init: initial binlib logic and demo lune package

This commit is contained in:
Erica Marigold 2024-11-21 10:37:38 +00:00
commit b1892a8098
28 changed files with 1525 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
lune_packages/
luau_packages/
core/pesde.lock

3
.luaurc Normal file
View file

@ -0,0 +1,3 @@
{
"languageMode": "strict"
}

7
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"luau-lsp.require.mode": "relativeToFile",
"luau-lsp.require.directoryAliases": {
"@lune/": "~/.lune/.typedefs/0.8.9/"
},
"stylua.targetReleaseVersion": "latest"
}

0
core/README.md Normal file
View file

19
core/pesde.toml Normal file
View file

@ -0,0 +1,19 @@
name = "compeydev/binlib"
version = "0.1.0"
includes = ["src/", "pesde.toml", "LICENSE", "README.md"]
[target]
environment = "lune"
lib = "src/init.luau"
[dependencies]
pathfs = { name = "jiwonz/pathfs", version = "^0.1.0" }
dirs = { name = "jiwonz/dirs", version = "^0.1.1" }
semver = { name = "0x5eal/semver", version = "^0.1.0", target = "luau" }
[peer_dependencies]
result = { name = "lukadev_0/result", version = "^1.2.0" }
option = { name = "lukadev_0/option", version = "^1.2.0" }
[indices]
default = "https://github.com/daimond113/pesde-index"

97
core/src/compression.luau Normal file
View file

@ -0,0 +1,97 @@
local serde = require("@lune/serde")
local process = require("@lune/process")
local dirs = require("../lune_packages/dirs")
local pathfs = require("../lune_packages/pathfs")
local revTable = require("./utils/rev_table")
local CommandBuilder = require("./utils/exec")
local types = require("./utils/result_option_conv")
local Option = types.Option
type Option<T> = types.Option<T>
local Result = types.Result
type Result<T, E> = types.Result<T, E>
export type CompressionFormat = "TarGz" | "TarXz" | "Zip"
local function detectFormat(fileName: string): Option<CompressionFormat>
local fileNameParts = string.split(string.lower(fileName), ".")
revTable(fileNameParts)
if fileNameParts[1] == "zip" then
return Option.Some("Zip" :: CompressionFormat) :: Option<unknown>
end
if fileNameParts[2] == "tar" then
if fileNameParts[1] == "gz" then
return Option.Some("TarGz" :: CompressionFormat) :: Option<unknown>
end
if fileNameParts[1] == "xz" then
return Option.Some("TarXz" :: CompressionFormat) :: Option<unknown>
end
end
return Option.None :: Option<CompressionFormat>
end
-- TODO: Use a type function to make all CompressionFormat lowercase
local decompress: { [CompressionFormat]: (compressed: buffer) -> Result<pathfs.AsPath, string> } = {
Zip = function(compressed: buffer)
-- FIXME: remove any usage
return (Option.from(dirs.cacheDir()):map(function(cacheDir)
local progCacheDir = cacheDir:join("pesde-bin")
if not pathfs.isDir(progCacheDir) then
pathfs.writeDir(progCacheDir)
end
return progCacheDir :: pathfs.AsPath
end) :: Option<any>):match({
Some = function(dir)
-- Generate a unique file name and write the contents to the temporary file
local tmpFile = dir:join(`{serde.hash("blake3", compressed)}.zip`)
local tmpFilePath = tmpFile:toString()
pathfs.writeFile(tmpFile, compressed)
-- Create the directory to decompress into
local decompressedDir = pathfs.Path.from(tmpFile):withExtension("")
pathfs.writeDir(decompressedDir)
-- Run unzip to decompress the file
local child = CommandBuilder
.new("unzip")
:withArgs({ tmpFilePath, "-d", decompressedDir:toString() })
-- FIXME: remove unknown usage
:withStdioStrategy({
stdout = Option.Some("pipe" :: CommandBuilder.StdioStrategy) :: Option<unknown>,
stderr = Option.Some(if process.env.PESDE_LOG == "debug" then "forward" else "pipe" :: CommandBuilder.StdioStrategy) :: Option<unknown>,
} :: CommandBuilder.IoStrategyMapping)
:intoChildProcess()
child:start()
local status = child:waitForChild()
-- Cleanup temporary file and handle errors
pathfs.removeFile(tmpFile)
if not status.ok then
return Result.Err(
`DecompressError::CommandFailed(exitCode={status.code})`
) :: Result<pathfs.AsPath, string>
end
return Result.Ok(decompressedDir) :: Result<pathfs.AsPath, string>
end,
None = function()
return Result.Err("DecompressError::NoCacheDir") :: Result<pathfs.AsPath, string>
end,
})
end,
-- TODO: Other formats
}
-- local path = "pesde-0.5.0-rc.7-windows-x86_64.zip"
-- print(decompress[detectFormat(path):unwrap()](buffer.fromstring(pathfs.readFile(path))))
return { decompress = decompress, detectFormat = detectFormat }

91
core/src/github.luau Normal file
View file

@ -0,0 +1,91 @@
local net = require("@lune/net")
local copy = require("./utils/copy")
local types = require("./utils/result_option_conv")
local Option = types.Option
local Result = types.Result
type Option<T> = types.Option<T>
type Result<T, E> = types.Result<T, E>
local Github = {}
export type Github = typeof(setmetatable(Github :: GithubFields, { __index = Github }))
type GithubFields = {
req: net.FetchParams,
retries: number,
}
export type Config = {
authToken: Option<string>,
retries: Option<number>,
}
export type GithubOperation = "FetchReleases" | "GetMetadata" | "GetActionArtifacts"
local API_BASE_URL = "https://api.github.com"
local DEFAULT_MAX_RETRIES = 5
local DEFAULT_CONFIG: Config = {
authToken = Option.None :: Option<string>,
retries = Option.Some(DEFAULT_MAX_RETRIES) :: Option<number>,
}
function Github.new(repo: string, config: Option<Config>)
local configOrDefault = config:unwrapOr(DEFAULT_CONFIG)
return setmetatable(
{
req = {
url = API_BASE_URL .. "/repos/" .. repo,
headers = {
["Authorization"] = configOrDefault.authToken:mapOr("", function(token)
return `Bearer {token}`
end),
},
} :: net.FetchParams,
config = config,
retries = configOrDefault.retries:unwrapOr(DEFAULT_MAX_RETRIES),
} :: GithubFields,
{
__index = Github,
}
)
end
-- FIXME: Remove unknown usage here
function Github.queueTransactions(self: Github, operations: { GithubOperation }): { Result<unknown, string> }
local queue: { (retries: number) -> Result<unknown, string> } = table.create(#operations)
for _, operation: GithubOperation in operations do
local req: net.FetchParams = copy(self.req)
if operation == "FetchReleases" then
req.url ..= "/releases"
req.method = "GET"
end
-- TODO: Other methods
table.insert(queue, function(retries: number)
local lastCode: number
for _ = 1, retries do
local resp = net.request(req)
lastCode = resp.statusCode
if not resp.ok then
continue
end
return Result.Ok(net.jsonDecode(resp.body))
end
return Result.Err(`Github::RespErr(statusCode={lastCode})`)
end)
end
local results = {}
for _, req in queue do
local ok, respRes: Result<unknown, string> = pcall(req, self.retries)
table.insert(results, if ok then respRes else Result.Err("Github::IoError"))
end
return results
end
return Github

176
core/src/init.luau Normal file
View file

@ -0,0 +1,176 @@
--> ENTRYPOINT; The main logic is as follows:
--> * First, we fetch the artifacts
--> * We try going through the list and using pattern matching to figure out
--> which is the artifact to download
--> * If we get no matches, we try downloading all the artifacts
--> * The artifacts are all extracted and we try detecting the platform from
--> binary data
--> * We then install the artifact with the matching platform descriptor
local net = require("@lune/net")
local process = require("@lune/process")
local pathfs = require("../lune_packages/pathfs")
local dirs = require("../lune_packages/dirs")
local semver = require("../luau_packages/semver")
local types = require("./utils/result_option_conv")
local Option = types.Option
type Option<T> = types.Option<T>
local Github = require("./github")
local PlatformDescriptor = require("./platform/descriptor")
local compression = require("./compression")
local eq = require("./utils/eq")
export type ToolId = {
alias: Option<string>,
repo: string,
version: Option<semver.SemverImpl>,
}
export type GithubReleases = {
{
tag_name: string,
prerelease: boolean,
draft: boolean,
assets: {
{
name: string,
browser_download_url: string,
size: number,
content_type: string,
}
},
}
}
local function downloadAndDecompress(asset: {
name: string,
browser_download_url: string,
size: number,
content_type: string,
}): pathfs.Path
local contentsResp = net.request(asset.browser_download_url)
if not contentsResp.ok then
return error(`Failed to download asset {asset.name}: HTTP Code {contentsResp.statusCode}`)
end
local decompressedPath = compression.decompress
[compression.detectFormat(asset.name):unwrap()](buffer.fromstring(contentsResp.body))
:unwrap() :: pathfs.Path
return decompressedPath
end
local function runTool(path: pathfs.Path)
-- TODO: Fixup our wrapper and use that instead in the future
process.spawn(path:toString(), process.args, {
stdio = "forward",
shell = true,
})
end
local TOOL_INSTALL_DIR = (dirs.homeDir() or error("Couldn't get home dir :(")):join(".pesde"):join("bin")
function installTool(tool: ToolId)
local toolAlias = tool.alias:unwrapOr(string.split(tool.repo, "/")[2])
local toolInstallPath = TOOL_INSTALL_DIR:join(toolAlias)
-- TODO: This is a bit too much overhead on startup and takes about 2s
-- We can improve this by following what rokit does, where we symlink
-- the tool's path to this script, and check the file that we are being
-- invoked as, in order to figure out what tool to execute
-- We can create "linker" scripts for each tool at ~/.pesde/bins with
-- contents like so:
--[[
#!/bin/env -S lune run
local pathInfo = debug.info(1, "s")
local path = string.sub(pathInfo, 10, #pathInfo - 2))
-- Now we can use `path` to figure out the real tool to execute
-- ...
]]
if pathfs.isFile(toolInstallPath) then
runTool(toolInstallPath)
return
end
local client = Github.new(
tool.repo,
Option.Some({
-- TODO: Maybe use the `gh auth token` command to get the token
authToken = Option.from(process.env.GITHUB_TOKEN) :: Option<string>,
retries = Option.None :: Option<number>,
}) :: Option<Github.Config>
)
local releases = client:queueTransactions({ "FetchReleases" })[1]:unwrap() :: GithubReleases
local assets = tool.version:match({
Some = function(version: semver.SemverImpl)
for _, release in releases do
if semver.parse(release.tag_name):unwrap() :: semver.SemverImpl == version then
return release.assets
end
end
return error(`No release found for version {version}`)
end,
None = function()
return releases[0].assets
end,
})
-- TODO: Use index type fn in solver v2
local matchingAsset: {
name: string,
browser_download_url: string,
size: number,
content_type: string,
}
local currentDesc = PlatformDescriptor.currentSystem()
local aliasPath = pathfs.Path.from(toolAlias):withExtension(if currentDesc.os == "windows" then "exe" else "")
for _, asset in assets do
local desc = PlatformDescriptor.fromString(asset.name):unwrap()
if eq(currentDesc, desc) then
matchingAsset = asset
end
end
local binaryPath: pathfs.Path
if matchingAsset == nil then
warn("No matching asset found, downloading all assets")
for _, asset in assets do
local decompressedPath = downloadAndDecompress(asset)
for _, file in pathfs.readDir(decompressedPath) do
local filePath = decompressedPath:join(file)
local nativeDesc = PlatformDescriptor.fromExecutable(filePath:toString()):unwrap()
if eq(currentDesc, nativeDesc) then
binaryPath = filePath
break
end
end
end
else
local decompressedPath = downloadAndDecompress(matchingAsset)
binaryPath = decompressedPath:join(aliasPath)
if not pathfs.isFile(binaryPath) then
error(`No matching binary found in {decompressedPath}`)
end
end
pathfs.move(binaryPath, toolInstallPath)
runTool(toolInstallPath)
end
return {
installTool = installTool,
runTool = runTool
}

View file

@ -0,0 +1,38 @@
--> Mostly a recreation of rokit's detection logic in Luau
--> See https://github.com/rojo-rbx/rokit/blob/a6b84c/lib/descriptor/arch.rs
local process = require("@lune/process")
local detection = require("./detection")
local types = require("../utils/result_option_conv")
local Option = types.Option
type Option<T> = types.Option<T>
export type Arch = process.Arch | "arm" | "x86"
local ARCH_SUBSTRINGS: { [Arch]: { string } } = {
aarch64 = { "aarch64", "arm64", "armv9" },
x86_64 = { "x86-64", "x86_64", "amd64", "win64", "win-x64" },
arm = { "arm32", "armv7" },
x86 = { "i686", "i386", "win32", "win-x86" },
}
local ARCH_FULL_WORDS: { [Arch]: { string } } = {
aarch64 = {},
x86_64 = { "x64", "win" },
arm = { "arm" },
x86 = { "x86" },
}
return {
detect = function(str: string): Option<Arch>
return detection.detect(str, ARCH_SUBSTRINGS, ARCH_FULL_WORDS)
end,
detectFromExecutable = function(binaryContents: buffer)
return Option.from(detection.detectFromExecutable(binaryContents))
:map(function(inner: detection.ExecutableDetectionResult)
return inner.arch
end)
end,
}

View file

@ -0,0 +1,59 @@
local process = require("@lune/process")
local fs = require("@lune/fs")
local os = require("./os")
local arch = require("./arch")
local toolchain = require("./toolchain")
local result = require("./result")
local detectFromExecutable = require("./detection/executable")
local types = require("../utils/result_option_conv")
local Option = types.Option
local Result = types.Result
type Option<T> = types.Option<T>
type Result<T, E> = types.Result<T, E>
local PlatformDescriptor = {}
type PlatformDescriptor = {
os: process.OS,
arch: Option<arch.Arch>,
toolchain: Option<toolchain.Toolchain>,
}
function PlatformDescriptor.currentSystem(): PlatformDescriptor
return {
os = process.os,
arch = Option.Some(process.arch) :: Option<arch.Arch>,
toolchain = Option.None :: Option<toolchain.Toolchain>,
}
end
function PlatformDescriptor.fromString(str: string)
local detectedOs = os.detect(str)
if detectedOs:isNone() then
return Result.Err("NoPatternDetected" :: result.PlatformError)
end
return Result.Ok({
os = detectedOs:unwrap() :: process.OS,
arch = arch.detect(str),
toolchain = toolchain.detect(str),
} :: PlatformDescriptor)
end
function PlatformDescriptor.fromExecutable(path: string): result.PlatformResult<PlatformDescriptor>
local binaryContents = fs.readFile(path)
local detected =
Option.from(detectFromExecutable(buffer.fromstring(binaryContents))) :: Option<detectFromExecutable.ExecutableDetectionResult>
local platformDesc = detected:map(function(inner: detectFromExecutable.ExecutableDetectionResult)
local innerClone: PlatformDescriptor = table.clone(inner) :: any
innerClone.toolchain = Option.None :: Option<toolchain.Toolchain>
return innerClone
end) :: Option<PlatformDescriptor>
return platformDesc:okOr(
"NoExecutableDetected" :: result.PlatformError
) :: result.PlatformResult<PlatformDescriptor>
end
return PlatformDescriptor

View file

@ -0,0 +1,92 @@
local process = require("@lune/process")
type Arch = process.Arch | "arm" | "x86"
export type ExecutableDetectionResult = {
os: process.OS?,
arch: Arch?,
}
return function(binaryContents: buffer): ExecutableDetectionResult?
-- Windows PE
do
local DOS_HEADER = "MZ"
local PE_HEADER_OFFSET = 0x3c
local PE_MAGIC_SIGNATURE = "PE\x00\x00"
local PE_MACHINE_TYPES: { [number]: Arch } = {
[0x8664] = "x86_64",
[0x01c0] = "arm",
[0xaa64] = "aarch64",
[0x014c] = "x86",
}
if buffer.readstring(binaryContents, 0, 2) == DOS_HEADER then
-- File was a DOS executable, jump to PE header to get the magic offset
local signatureOffset = buffer.readu8(binaryContents, PE_HEADER_OFFSET)
-- Check if the value at the magic offset was the PE magic signature
if buffer.readstring(binaryContents, signatureOffset, 4) == PE_MAGIC_SIGNATURE then
-- After the first 4 magic bytes, the next 2 bytes are architecture information
local machineType = buffer.readu16(binaryContents, signatureOffset + 4)
return {
os = "windows",
arch = PE_MACHINE_TYPES[machineType],
}
end
end
end
-- Linux ELF
do
local ELF_MAGIC_START = 0x7f
local ELF_MAGIC_SIGNATURE = "ELF"
local ELF_MACHINE_TYPES: { [number]: Arch } = {
[0x3e] = "x86_64",
[0x28] = "arm",
[0xb7] = "aarch64",
[0x03] = "x86",
}
-- ELF files have a magic signature of [0x7f, 'E', 'L', 'F']
if
buffer.readu8(binaryContents, 0) == ELF_MAGIC_START
and buffer.readstring(binaryContents, 1, 3) == ELF_MAGIC_SIGNATURE
then
-- Machine type is located after 16 bytes of ident and 2 bytes of type
local machineType = buffer.readu16(binaryContents, 18)
return {
os = "linux",
arch = ELF_MACHINE_TYPES[machineType],
}
end
end
-- macOS Mach-O
do
local MACHO_MAGIC_32 = 0xFEEDFACE
local MACHO_MAGIC_64 = 0xFEEDFACF
local MACHO_CPU_TYPES = {
x86 = 0x7,
arm = 0xc,
}
-- First 4 bytes are the magic depending on 32 or 64 bit
-- Next 2 bytes are the CPU type
local headerStart = buffer.readu32(binaryContents, 0)
local cpuType = buffer.readu16(binaryContents, 4)
local is64bit = headerStart == MACHO_MAGIC_64
local is32bit = headerStart == MACHO_MAGIC_32
return {
os = "macos",
--stylua: ignore
arch = (
if is64bit and cpuType == MACHO_CPU_TYPES.x86 then "x86_64"
elseif is64bit and cpuType == MACHO_CPU_TYPES.arm then "aarch64"
elseif is32bit and cpuType == MACHO_CPU_TYPES.x86 then "x86"
elseif is32bit and cpuType == MACHO_CPU_TYPES.arm then "arm"
else nil
),
}
end
end

View file

@ -0,0 +1,7 @@
local executable = require("./executable")
export type ExecutableDetectionResult = executable.ExecutableDetectionResult
return {
detect = require("./pattern"),
detectFromExecutable = executable,
}

View file

@ -0,0 +1,34 @@
local String = require("../../utils/string")
local types = require("../../utils/result_option_conv")
local Option = types.Option
type Option<T> = types.Option<T>
local function charWordSep(char: string)
return char == " " or char == "-" or char == "_"
end
return function<T>(str: string, substrings: { [T]: { string } }, fullWords: { [T]: { string } }): Option<T>
local lowercased = string.lower(str)
-- Look for substring matches
for item: T, keywords in substrings do
for _, keyword in keywords do
if string.find(lowercased, keyword) then
return Option.Some(item) :: Option<T>
end
end
end
-- If no substring matches found, look for a full word as a component
local components = String.splitAtChar(lowercased, charWordSep)
for _, component in components do
for item, keywords in fullWords do
if table.find(keywords, component) then
return Option.Some(item) :: Option<T>
end
end
end
return Option.None :: Option<T>
end

34
core/src/platform/os.luau Normal file
View file

@ -0,0 +1,34 @@
--> Mostly a recreation of rokit's detection logic in Luau
--> See https://github.com/rojo-rbx/rokit/blob/a6b84c/lib/descriptor/os.rs
local process = require("@lune/process")
local detection = require("./detection")
local Option = require("../../lune_packages/option")
type Option<T> = Option.Option<T>
local OS_SUBSTRINGS: { [process.OS]: { string } } = {
windows = { "windows" },
macos = { "macos", "darwin", "apple" },
linux = { "linux", "ubuntu", "debian", "fedora" },
}
local OS_FULL_WORDS: { [process.OS]: { string } } = {
windows = { "win", "win32", "win64" },
macos = { "mac", "osx" },
linux = {},
}
return {
detect = function(str: string): Option<process.OS>
return detection.detect(str, OS_SUBSTRINGS, OS_FULL_WORDS)
end,
detectFromExecutable = function(binaryContents: buffer): Option<process.OS>
return Option.from(detection.detectFromExecutable(binaryContents))
:map(function(inner: detection.ExecutableDetectionResult)
return inner.os
end)
end,
}

View file

@ -0,0 +1,7 @@
local types = require("../utils/result_option_conv")
type Result<T, E> = types.Result<T, E>
export type PlatformError = "NoPatternDetected" | "NoExecutableDetected" | "UnknownExecutableField"
export type PlatformResult<T> = Result<T, PlatformError>
return "<PlatformResult>"

View file

@ -0,0 +1,19 @@
local types = require("../utils/result_option_conv")
local Option = types.Option
type Option<T> = types.Option<T>
local TOOLCHAINS: { Toolchain } = { "msvc", "gnu", "musl" }
export type Toolchain = "msvc" | "gnu" | "musl"
return {
detect = function(str: string): Option<Toolchain>
for _, toolchain: Toolchain in TOOLCHAINS do
if string.find(str, toolchain) then
-- FIXME: remove any usage
return Option.Some(toolchain :: any) :: Option<Toolchain>
end
end
return Option.None :: Option<Toolchain>
end,
}

15
core/src/utils/copy.luau Normal file
View file

@ -0,0 +1,15 @@
local function copy<T>(tab: T): T
if typeof(tab) == "table" then
local copied = {}
for k, v in tab do
copied[k] = copy(v)
end
return copied :: any
end
return tab
end
return copy

29
core/src/utils/eq.luau Normal file
View file

@ -0,0 +1,29 @@
-- !! FIXME: THIS IS BROKEN !!
local function eq(this: any, that: any): boolean
if type(this) ~= type(that) then
return false
end
if type(this) == "table" then
local visited = {}
for key, value in pairs(this) do
if not eq(value, that[key]) then
return false
end
visited[key] = true
end
for key, _ in pairs(that) do
if not visited[key] then
return false
end
end
return true
end
return this == that
end
return eq

193
core/src/utils/exec.luau Normal file
View file

@ -0,0 +1,193 @@
--> Builder pattern class to spawn, manage and kill child processes
local process = require("@lune/process")
local task = require("@lune/task")
local types = require("../utils/result_option_conv")
local Option = types.Option
type Option<T> = types.Option<T>
local CommandBuilder = {}
type CommandBuilderFields = {
program: string,
args: { string },
retries: Option<number>,
ignoreErrors: Option<boolean>,
stdioStrategy: Option<IoStrategyMapping>,
}
export type CommandBuilder = typeof(setmetatable({} :: CommandBuilderFields, { __index = CommandBuilder }))
export type StdioStrategy = "pipe" | "forward" | "none"
export type IoStrategyMapping = {
stdout: Option<StdioStrategy>,
stderr: Option<StdioStrategy>,
}
export type ChildProcess = {
_thread: thread,
_pid: string,
_status: ChildStatus,
start: (self: ChildProcess) -> (),
waitForChild: (self: ChildProcess) -> ChildStatus,
kill: (self: ChildProcess) -> (),
}
export type ChildStatus = { ok: boolean, code: number, io: {
stdout: string,
stderr: string,
} }
-- FIXME: remove unknown usage
local DEFAULT_STDIO_STRATEGY: IoStrategyMapping = {
stdout = Option.Some("pipe" :: StdioStrategy) :: Option<unknown>,
stderr = Option.Some("pipe" :: StdioStrategy) :: Option<unknown>,
}
local DEFAULT_RETRIES = 0
local DEFAULT_IGNORE_ERRORS = false
function CommandBuilder.new(program: string)
return setmetatable(
{
program = program,
args = {},
retries = Option.None,
ignoreErrors = Option.None,
stdioStrategy = Option.None :: Option<IoStrategyMapping>,
} :: CommandBuilderFields,
{
__index = CommandBuilder,
}
)
end
function CommandBuilder.withArg(self: CommandBuilder, arg: string): CommandBuilder
table.insert(self.args, arg)
return self
end
function CommandBuilder.withArgs(self: CommandBuilder, args: { string }): CommandBuilder
for _, arg in args do
self:withArg(arg)
end
return self
end
function CommandBuilder.withMaxRetries(self: CommandBuilder, retries: number): CommandBuilder
self.retries = Option.Some(retries) :: Option<number>
return self
end
function CommandBuilder.withIgnoreErrors(self: CommandBuilder, yes: boolean): CommandBuilder
self.ignoreErrors = Option.Some(yes) :: Option<boolean>
return self
end
function CommandBuilder.withStdioStrategy(
self: CommandBuilder,
strategy: StdioStrategy | IoStrategyMapping
): CommandBuilder
-- FIXME: remove unknown usage
self.stdioStrategy = Option.Some(if typeof(strategy) == "string"
then {
stdout = Option.Some(strategy) :: Option<unknown>,
stderr = Option.Some(strategy) :: Option<unknown>,
}
else strategy) :: Option<IoStrategyMapping>
return self
end
local function intoSpawnOptionsStdioKind(strategy: StdioStrategy): process.SpawnOptionsStdioKind
if strategy == "pipe" then
return "default"
end
if strategy == "forward" then
return "forward"
end
if strategy == "none" then
return "none"
end
error(`Non-strategy provided: {strategy}`)
end
function CommandBuilder.intoChildProcess(self: CommandBuilder): ChildProcess
local child = {
_thread = coroutine.create(function(this: ChildProcess)
local retries = self.retries:unwrapOr(DEFAULT_RETRIES)
local ignoreErrors = self.ignoreErrors:unwrapOr(DEFAULT_IGNORE_ERRORS)
local argsList = table.concat(self.args, " ")
for _ = 0, retries do
local spawned = process.spawn(
if process.os == "windows"
then `(Start-Process {self.program} -Passthru -NoNewWindow -ArgumentList \"{argsList}\").Id`
else `{self.program} {argsList} & echo $!`,
{},
{
stdio = self
.stdioStrategy
-- FIXME: remove unknown usage
:orOpt(
Option.Some(DEFAULT_STDIO_STRATEGY) :: Option<unknown>
)
:map(function(mappings: IoStrategyMapping)
local translatedMappings: process.SpawnOptionsStdio = {}
for field, value in mappings do
translatedMappings[field] =
intoSpawnOptionsStdioKind((value :: Option<StdioStrategy>):unwrap())
end
return translatedMappings
end)
:unwrap(),
shell = true,
}
)
if spawned.ok then
local lines = spawned.stdout:split("\n")
-- TODO: Abstract upvalues here into a channels primitive
this._pid = assert(table.remove(lines, 1), "Failed to get PID")
this._status = {
code = spawned.code,
ok = spawned.code == 0 and not ignoreErrors,
io = {
stdout = table.concat(lines, "\n"),
stderr = spawned.stderr,
},
}
break
end
end
end),
start = function(self: ChildProcess)
coroutine.resume(self._thread, self)
end,
waitForChild = function(self: ChildProcess): ChildStatus
while coroutine.status(self._thread) ~= "dead" or self._status == nil do
task.wait(0.1)
end
return self._status
end,
kill = function(self: ChildProcess)
coroutine.close(self._thread)
local killResult = process.spawn(
if process.os == "windows" then `Stop-Process -Id {self._pid} -Force` else `kill {self._pid}`,
{
shell = true,
}
)
assert(killResult.ok, `Failed to kill process with PID {self._pid}`)
end,
} :: ChildProcess
return child
end
return CommandBuilder

View file

@ -0,0 +1,28 @@
--> Non-exhaustive set of extensions for Option<->Result conversion
local OptionImpl = require("../../lune_packages/option")
local ResultImpl = require("../../lune_packages/result")
local Option = {}
local Result = {}
export type Option<T> = OptionImpl.Option<T> & typeof(Option)
export type Result<T, E> = ResultImpl.Result<T, E>
function Option.okOr<T, E>(self: Option<T>, err: E): Result<T, E>
return self:mapOrElse(function()
return ResultImpl.Err(err)
end, function(val)
return ResultImpl.Ok(val)
end)
end
return {
Option = setmetatable(OptionImpl, {
__index = Option,
}),
Result = setmetatable(ResultImpl, {
__index = Result,
}),
}

View file

@ -0,0 +1,6 @@
return function(tab)
local len = #tab
for pos = 1, len // 2 do
tab[pos], tab[len - pos + 1] = tab[len - pos + 1], tab[pos]
end
end

296
core/src/utils/semver.luau Normal file
View file

@ -0,0 +1,296 @@
local types = require("../utils/result_option_conv")
local Option = types.Option
type Option<T> = types.Option<T>
local Result = types.Result
local function stringStartsWith(str: string, sub: string)
return string.sub(str, 1, #sub) == sub
end
local function hasLeadingZeros(num: string)
return string.match(num, "^(0+)") ~= nil
end
local function doLeadingZeroesCheck(type: "major" | "minor" | "patch", component: string)
-- We only do this check if each component has more than 1 digit as an optimization
if #component > 1 and hasLeadingZeros(component) then
return Result.Err({
msg = Option.Some(`Found disallowed leading zeros in {string.upper(type)} component`),
kind = {
id = "LeadingZerosPresent",
component = type,
} :: ParseError,
} :: SemverError)
end
return Result.Ok(nil)
end
type InvalidTypes = "char" | "symbol"
local function getInvalidTypeName(invalidComponent: string): InvalidTypes
local typePatterns: { [InvalidTypes]: string } = {
char = "%a",
symbol = "%p",
}
local presentType: InvalidTypes
for type: InvalidTypes, pattern in typePatterns do
if string.match(invalidComponent, pattern) then
presentType = type
break
end
end
return presentType
end
local Semver = {}
export type SemverImpl = typeof(setmetatable({} :: Version, Semver))
export type PreleaseType = "alpha" | "beta" | "rc"
export type PrereleaseVersion = {
type: PreleaseType,
ordinal: Option<number>,
}
export type Version = {
major: number,
minor: number,
patch: number,
buildMetadata: Option<string>,
prerelease: Option<PrereleaseVersion>,
}
export type SemverResult<T, E = SemverError> = types.Result<T, E>
export type SemverError = {
msg: Option<string>,
kind: SemverErrorKind,
}
export type SemverErrorKind = ParseError | {}
export type ParseError =
{ id: "MandatoryComponentMissing", components: { string } }
| {
id: "InvalidComponentType",
component: "major" | "minor" | "patch",
expected: "number",
got: "char" | "symbol" | "unknown",
}
| { id: "LeadingZerosPresent", component: "major" | "minor" | "patch" }
| { id: "InvalidIdentifierCharacter", component: "prerelease" | "metadata" }
| { id: "InvalidPrereleaseType", component: "prerelease", type: string }
| { id: "InvalidPrereleaseOrdinalType", expected: "number", got: "char" | "symbol" }
local PRERELEASE_LEX_ORDER: { [PreleaseType]: number } = {
alpha = 1,
beta = 2,
rc = 3,
}
function Semver.parse(ver: string): SemverResult<SemverImpl>
local components = string.split(ver, ".")
if #components < 3 then
return Result.Err({
msg = Option.Some(`Expected MAJOR.MINOR.PATCH format, missing {#components} / 3 components`),
kind = {
id = "MandatoryComponentMissing",
components = components,
} :: ParseError,
} :: SemverError)
end
local patchStr, ext = string.match(components[3], "(%d)([-+]?.*)")
if patchStr == nil then
return Result.Err({
msg = Option.Some(`Expected patch to be only a number`),
kind = {
id = "InvalidComponentType",
expected = "number",
got = "unknown",
component = "patch",
} :: ParseError,
} :: SemverError)
end
local major, minor, patch = tonumber(components[1]), tonumber(components[2]), tonumber(patchStr)
if major == nil or minor == nil or patch == nil then
local invalidComponentType: "major" | "minor" | "patch", invalidComponent: string
if major == nil then
invalidComponentType, invalidComponent = "major", components[1]
elseif minor == nil then
invalidComponentType, invalidComponent = "minor", components[2]
elseif patch == nil then
invalidComponentType, invalidComponent = "patch", patchStr :: string
end
local presentType: InvalidTypes = getInvalidTypeName(invalidComponent)
return Result.Err({
msg = Option.Some(
`Expected {string.upper(invalidComponentType)} to be only a number, but got {presentType}`
),
kind = {
id = "InvalidComponentType",
expected = "number",
got = presentType,
component = invalidComponentType,
} :: ParseError,
} :: SemverError)
end
-- All components were valid numbers, but we check to see if they had any leading zeros
-- Leading zeros are invalid in semver
local majorLeadingZeros = doLeadingZeroesCheck("major", components[1])
if majorLeadingZeros:isErr() then
return majorLeadingZeros
end
local minorLeadingZeros = doLeadingZeroesCheck("minor", components[2])
if minorLeadingZeros:isErr() then
return minorLeadingZeros
end
local patchLeadingZeros = doLeadingZeroesCheck("patch", patchStr)
if patchLeadingZeros:isErr() then
return patchLeadingZeros
end
local parsed: Version = {
major = major :: number,
minor = minor :: number,
patch = patch :: number,
buildMetadata = Option.None :: Option<string>,
prerelease = Option.None :: Option<PrereleaseVersion>,
}
if ext ~= nil then
if stringStartsWith(ext, "-") then
-- Prerelease information
local prereleaseType = string.sub(ext, 2)
if prereleaseType ~= "alpha" and prereleaseType ~= "beta" and prereleaseType ~= "rc" then
return Result.Err({
msg = Option.Some(`Expected prerelease type to be alpha | beta | rc, but got {prereleaseType}`),
kind = {
id = "InvalidPrereleaseType",
component = "prerelease",
type = prereleaseType,
} :: ParseError,
} :: SemverError)
end
local function badPrereleaseType(prerelease: string): SemverResult<SemverImpl>
local invalidType: InvalidTypes = getInvalidTypeName(prerelease)
return Result.Err({
msg = Option.Some(`Expected PRERELEASE to only be a number, but got {invalidType}`),
kind = {
id = "InvalidPrereleaseOrdinalType",
expected = "number",
got = invalidType,
} :: ParseError,
} :: SemverError)
end
local prereleaseOrdinal = Option.None :: Option<number>
-- TODO: Cleanup this recovery logic of recomputing a bit more, maybe remove it entirely
if components[4] ~= nil then
local ordinalNum = tonumber(components[4])
if ordinalNum == nil then
-- If it wasn't a valid number for the ordinal, that must mean one of two things:
-- a) The PRERELEASE component was bad
-- b) The component has build metadata after prerelease info
-- Here, we handle both those cases
local potentialOrdinalNumber, potentialBuildMetadata = string.match(components[4], "(%d)+(.*)")
if potentialOrdinalNumber == nil then
return badPrereleaseType(components[4])
end
if potentialBuildMetadata ~= nil then
parsed.buildMetadata = Option.Some(potentialBuildMetadata :: string) :: Option<string>
end
if potentialOrdinalNumber ~= nil then
ordinalNum = tonumber(potentialOrdinalNumber)
if ordinalNum == nil then
return badPrereleaseType(components[4])
end
end
end
prereleaseOrdinal = Option.Some(ordinalNum :: number) :: Option<number>
end
parsed.prerelease = Option.Some({
type = prereleaseType,
ordinal = prereleaseOrdinal,
} :: PrereleaseVersion) :: Option<PrereleaseVersion>
end
if stringStartsWith(ext, "+") then
-- Build metadata information; we remove the leading `+` symbol
parsed.buildMetadata = Option.Some(string.sub(ext, 2)) :: Option<string>
end
end
return Result.Ok(setmetatable(parsed :: Version, Semver))
end
local function prereleaseEq(leftPrerelease: PrereleaseVersion?, rightPrerelease: PrereleaseVersion?): boolean
if leftPrerelease == nil or rightPrerelease == nil then
return true
end
return leftPrerelease.type == rightPrerelease.type and leftPrerelease.ordinal == rightPrerelease.ordinal
end
--- Returns whether a prerelease is lesser than the other
local function prereleaseLt(leftPrerelease: PrereleaseVersion?, rightPrerelase: PrereleaseVersion?): boolean
if not prereleaseEq(leftPrerelease, rightPrerelase) then
if leftPrerelease ~= nil and rightPrerelase ~= nil then
if leftPrerelease.type == rightPrerelase.type then
return leftPrerelease.ordinal:unwrapOr(0) < rightPrerelase.ordinal:unwrapOr(0)
end
return PRERELEASE_LEX_ORDER[leftPrerelease.type] < PRERELEASE_LEX_ORDER[rightPrerelase.type]
end
return not (leftPrerelease == nil and rightPrerelase ~= nil)
and (leftPrerelease ~= nil and rightPrerelase == nil)
end
return true
end
local function prereleaseLe(leftPrerelease: PrereleaseVersion?, rightPrerelase: PrereleaseVersion?): boolean
return prereleaseLt(leftPrerelease, rightPrerelase) or prereleaseEq(leftPrerelease, rightPrerelase)
end
function Semver.__eq(left: SemverImpl, right: SemverImpl): boolean
return left.major == right.major
and left.minor == right.minor
and left.patch == right.patch
and prereleaseEq(left.prerelease:unwrapOrNil(), right.prerelease:unwrapOrNil())
end
function Semver.__lt(left: SemverImpl, right: SemverImpl): boolean
return if left.major ~= right.major
then left.major < right.major
elseif left.minor ~= right.minor then left.minor < right.minor
elseif left.patch ~= right.patch then left.patch < right.patch
else prereleaseLt(left.prerelease:unwrapOrNil(), right.prerelease:unwrapOrNil())
end
function Semver.__le(left: SemverImpl, right: SemverImpl): boolean
return if left.major ~= right.major
then left.major <= right.major
elseif left.minor ~= right.minor then left.minor <= right.minor
elseif left.patch ~= right.patch then left.patch <= right.patch
else prereleaseLe(left.prerelease:unwrapOrNil(), right.prerelease:unwrapOrNil())
end
return Semver
-- TODO: Testing!

View file

@ -0,0 +1,51 @@
local String = {}
function String.splitAt(str: string, pos: number): { string }?
local left, right = string.sub(str, 1, pos - 1), string.sub(str, pos + 1)
if left == nil or right == nil then
return nil
end
return { left, right }
end
function String.splitAtChar(str: string, char: string | number | (c: string) -> boolean): { string }
local strChars = string.split(str, "")
local splitsPositions: { number } = {}
for pos, strChar in strChars do
local innerChar = if typeof(char) == "number"
then string.char(char)
elseif typeof(char) == "function" then char(strChar)
elseif typeof(char) == "string" and #char == 1 then char
else error(`Expected char, got {typeof(char)}`)
if innerChar == true or strChar == innerChar then
table.insert(splitsPositions, pos)
end
end
local splitted: { string } = {}
for idx, splitPos in splitsPositions do
local previous = splitted[#splitted]
local relativeSplit = if previous
then String.splitAt(previous, splitPos - splitsPositions[idx - 1])
else String.splitAt(str, splitPos)
for _, split in assert(relativeSplit) do
table.insert(splitted, split)
end
end
local result = {}
for idx, split in splitted do
if idx % 2 ~= 0 or idx == #splitted or idx == #splitted - 1 then
table.insert(result, split)
end
end
return result
end
return String

10
lune/init.luau Normal file
View file

@ -0,0 +1,10 @@
local core = require("./lune_packages/core")
local semver = require("./luau_packages/semver")
local Option = require("./lune_packages/option")
type Option<T> = Option.Option<T>
core.installTool({
alias = Option.Some("lune"),
repo = "lune-org/lune",
version = Option.Some(semver.parse("0.8.9"):unwrap()) :: Option<semver.SemverImpl>,
} :: core.ToolId)

170
lune/pesde.lock Normal file
View file

@ -0,0 +1,170 @@
name = "compeydev/lune"
version = "0.1.0"
target = "lune"
[graph."0x5eal/semver"."0.1.0 luau"]
direct = ["semver", { name = "0x5eal/semver", version = "^0.1.0", target = "luau" }]
ty = "standard"
[graph."0x5eal/semver"."0.1.0 luau".target]
environment = "luau"
lib = "lib/init.luau"
[graph."0x5eal/semver"."0.1.0 luau".dependencies]
"lukadev_0/option" = ["1.2.0 luau", "option"]
"lukadev_0/result" = ["1.2.0 luau", "result"]
[graph."0x5eal/semver"."0.1.0 luau".pkg_ref]
ref_ty = "pesde"
name = "0x5eal/semver"
version = "0.1.0"
index_url = "https://github.com/daimond113/pesde-index"
[graph."0x5eal/semver"."0.1.0 luau".pkg_ref.dependencies]
frktest = [{ name = "itsfrank/frktest", version = "^0.0.2", index = "https://github.com/daimond113/pesde-index", target = "lune" }, "dev"]
option = [{ name = "lukadev_0/option", version = "^1.2.0", index = "https://github.com/daimond113/pesde-index" }, "peer"]
result = [{ name = "lukadev_0/result", version = "^1.2.0", index = "https://github.com/daimond113/pesde-index" }, "peer"]
[graph."0x5eal/semver"."0.1.0 luau".pkg_ref.target]
environment = "luau"
lib = "lib/init.luau"
[graph."compeydev/binlib"."0.1.0 lune"]
direct = ["core", { workspace = "compeydev/binlib", version = "^" }]
ty = "standard"
[graph."compeydev/binlib"."0.1.0 lune".target]
environment = "lune"
lib = "src/init.luau"
[graph."compeydev/binlib"."0.1.0 lune".dependencies]
"0x5eal/semver" = ["0.1.0 luau", "semver"]
"jiwonz/dirs" = ["0.1.2 lune", "dirs"]
"jiwonz/pathfs" = ["0.1.0 lune", "pathfs"]
"lukadev_0/option" = ["1.2.0 lune", "option"]
"lukadev_0/result" = ["1.2.0 lune", "result"]
[graph."compeydev/binlib"."0.1.0 lune".pkg_ref]
ref_ty = "workspace"
path = "core"
[graph."compeydev/binlib"."0.1.0 lune".pkg_ref.dependencies]
dirs = [{ name = "jiwonz/dirs", version = "^0.1.1", index = "https://github.com/daimond113/pesde-index" }, "standard"]
option = [{ name = "lukadev_0/option", version = "^1.2.0", index = "https://github.com/daimond113/pesde-index" }, "peer"]
pathfs = [{ name = "jiwonz/pathfs", version = "^0.1.0", index = "https://github.com/daimond113/pesde-index" }, "standard"]
result = [{ name = "lukadev_0/result", version = "^1.2.0", index = "https://github.com/daimond113/pesde-index" }, "peer"]
semver = [{ name = "0x5eal/semver", version = "^0.1.0", index = "https://github.com/daimond113/pesde-index", target = "luau" }, "standard"]
[graph."compeydev/binlib"."0.1.0 lune".pkg_ref.target]
environment = "lune"
lib = "src/init.luau"
[graph."jiwonz/dirs"."0.1.2 lune"]
ty = "standard"
[graph."jiwonz/dirs"."0.1.2 lune".target]
environment = "lune"
lib = "src/init.luau"
[graph."jiwonz/dirs"."0.1.2 lune".dependencies]
"jiwonz/pathfs" = ["0.1.0 lune", "pathfs"]
[graph."jiwonz/dirs"."0.1.2 lune".pkg_ref]
ref_ty = "pesde"
name = "jiwonz/dirs"
version = "0.1.2"
index_url = "https://github.com/daimond113/pesde-index"
[graph."jiwonz/dirs"."0.1.2 lune".pkg_ref.dependencies]
pathfs = [{ name = "jiwonz/pathfs", version = "^0.1.0", index = "https://github.com/daimond113/pesde-index" }, "standard"]
[graph."jiwonz/dirs"."0.1.2 lune".pkg_ref.target]
environment = "lune"
lib = "src/init.luau"
[graph."jiwonz/pathfs"."0.1.0 lune"]
ty = "standard"
[graph."jiwonz/pathfs"."0.1.0 lune".target]
environment = "lune"
lib = "init.luau"
[graph."jiwonz/pathfs"."0.1.0 lune".pkg_ref]
ref_ty = "pesde"
name = "jiwonz/pathfs"
version = "0.1.0"
index_url = "https://github.com/daimond113/pesde-index"
[graph."jiwonz/pathfs"."0.1.0 lune".pkg_ref.target]
environment = "lune"
lib = "init.luau"
[graph."lukadev_0/option"."1.2.0 lune"]
direct = ["option", { name = "lukadev_0/option", version = "^1.2.0" }]
ty = "standard"
[graph."lukadev_0/option"."1.2.0 lune".target]
environment = "lune"
lib = "lib/init.luau"
[graph."lukadev_0/option"."1.2.0 lune".pkg_ref]
ref_ty = "pesde"
name = "lukadev_0/option"
version = "1.2.0"
index_url = "https://github.com/daimond113/pesde-index"
[graph."lukadev_0/option"."1.2.0 lune".pkg_ref.target]
environment = "lune"
lib = "lib/init.luau"
[graph."lukadev_0/option"."1.2.0 luau"]
ty = "peer"
[graph."lukadev_0/option"."1.2.0 luau".target]
environment = "luau"
lib = "lib/init.luau"
[graph."lukadev_0/option"."1.2.0 luau".pkg_ref]
ref_ty = "pesde"
name = "lukadev_0/option"
version = "1.2.0"
index_url = "https://github.com/daimond113/pesde-index"
[graph."lukadev_0/option"."1.2.0 luau".pkg_ref.target]
environment = "luau"
lib = "lib/init.luau"
[graph."lukadev_0/result"."1.2.0 lune"]
direct = ["result", { name = "lukadev_0/result", version = "^1.2.0" }]
ty = "standard"
[graph."lukadev_0/result"."1.2.0 lune".target]
environment = "lune"
lib = "lib/init.luau"
[graph."lukadev_0/result"."1.2.0 lune".pkg_ref]
ref_ty = "pesde"
name = "lukadev_0/result"
version = "1.2.0"
index_url = "https://github.com/daimond113/pesde-index"
[graph."lukadev_0/result"."1.2.0 lune".pkg_ref.target]
environment = "lune"
lib = "lib/init.luau"
[graph."lukadev_0/result"."1.2.0 luau"]
ty = "peer"
[graph."lukadev_0/result"."1.2.0 luau".target]
environment = "luau"
lib = "lib/init.luau"
[graph."lukadev_0/result"."1.2.0 luau".pkg_ref]
ref_ty = "pesde"
name = "lukadev_0/result"
version = "1.2.0"
index_url = "https://github.com/daimond113/pesde-index"
[graph."lukadev_0/result"."1.2.0 luau".pkg_ref.target]
environment = "luau"
lib = "lib/init.luau"

19
lune/pesde.toml Normal file
View file

@ -0,0 +1,19 @@
name = "compeydev/lune"
version = "0.1.0"
description = "A standalone Luau runtime"
authors = ["CompeyDev <hi@devcomp.xyz>", "Filip Tibell <filip.tibell@gmail.com>"]
repository = "https://github.com/CompeyDev/pesde-tooling/blob/main/lune"
license = "MIT"
[target]
environment = "lune"
bin = "init.luau"
[dependencies]
result = { name = "lukadev_0/result", version = "^1.2.0" }
option = { name = "lukadev_0/option", version = "^1.2.0" }
semver = { name = "0x5eal/semver", version = "^0.1.0", target = "luau" }
core = { workspace = "compeydev/binlib", version = "^" }
[indices]
default = "https://github.com/daimond113/pesde-index"

9
pesde.lock Normal file
View file

@ -0,0 +1,9 @@
name = "compeydev/pesde_bins"
version = "0.1.0"
target = "lune"
[workspace."compeydev/binlib"]
lune = "core"
[workspace."compeydev/lune"]
lune = "lune"

13
pesde.toml Normal file
View file

@ -0,0 +1,13 @@
name = "compeydev/pesde_bins"
version = "0.1.0"
private = true
workspace_members = ["lune", "core"]
[target]
environment = "lune"
[indices]
default = "https://github.com/daimond113/pesde-index"