tooling/toolchainlib/src/init.luau
Erica Marigold 0242ab6968 feat: move all install path logic into __call
Also does this following:
* Properly types metatable return
* Runs tool after installation, previously the tool would not get
  executed post installation
* Further removal of manual alias handling
2024-11-23 17:30:46 +00:00

267 lines
7.9 KiB
Text

--> 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 stdio = require("@lune/stdio")
local serde = require("@lune/serde")
local Semver = require("../luau_packages/semver")
local pathfs = require("../lune_packages/pathfs")
local dirs = require("../lune_packages/dirs")
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 toolAliasOrDefault(tool: ToolId): string
return tool.alias:unwrapOr(string.split((tool :: ToolId).repo, "/")[2])
end
local LINK_INSTALL_DIR = (dirs.homeDir() or error("Couldn't get home dir :(")):join(".pesde"):join("bin")
local TOOL_STORAGE_DIR = LINK_INSTALL_DIR:join("tool_storage")
function runTool(tool: ToolId | pathfs.Path): number
-- FIXME: `process.spawn` has a bug where interactive features don't
-- forward properly
local toolId = tool :: ToolId
local path = if toolId.alias ~= nil then LINK_INSTALL_DIR:join(toolAliasOrDefault(toolId)) else tool :: pathfs.Path
return process.spawn(path:toString(), process.args, {
cwd = process.cwd,
env = process.env,
stdio = "forward",
}).code
end
function installTool(tool: ToolId, installPath: pathfs.Path)
local toolAlias = toolAliasOrDefault(tool)
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
-- Maintain multiple versions of a tool, and avoid downloading
-- the binary for a version again if it's already there
local toolDir = Option.from(installPath:parent()):unwrap()
if not pathfs.isFile(toolDir) then
pathfs.writeDir(toolDir)
end
pathfs.move(binaryPath, installPath)
-- In order to eliminate fs read overhead on startup and to disallow
-- the use of the tool binary when outside a package where it is installed,
-- 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
-- First off, we check whether the tool is installed in pesde.toml
-- if we're being run as a symlink, and not a `pesde x` bin
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
-- ...
]]
runTool(installPath)
end
type LibExports = {
runTool: (pathfs.Path | ToolId) -> number,
installTool: (ToolId, pathfs.Path) -> never,
}
type LibExportsImpl = typeof(setmetatable(
{} :: LibExports,
{ __call = function(lib: LibExportsImpl, tool: string, pesdeRoot: string?) end }
))
return setmetatable(
{
runTool = runTool,
installTool = installTool,
} :: LibExports,
{
__call = function(lib: LibExportsImpl, tool: string, pesdeRoot: string?)
-- TODO: Progress bar maybe? :D
local ERROR_PREFIX = `{stdio.color("red")}{stdio.style("bold")}error{stdio.color("reset")}:`
local repo, version = string.match(tool, "([^@]+)@?(.*)")
if repo == nil then
stdio.ewrite(`{ERROR_PREFIX} Invalid tool provided\n`)
process.exit(1)
end
local function manifestVersion(): string
if pesdeRoot == nil then
stdio.ewrite(`{ERROR_PREFIX} Failed to discover pesde package root\n`)
process.exit(1)
end
-- Use _G.PESDE_ROOT to get the install directory, then decode the
-- pesde manifest to get the version of the tool dynamically
local manifestContents = pathfs.readFile(pathfs.Path.from(pesdeRoot :: string):join("pesde.toml"))
-- TODO: Create a pesde manifest type in toolchainlib, and use that here
local ok, manifest = pcall(serde.decode, "toml" :: "toml", manifestContents)
if not ok then
stdio.ewrite(
`{ERROR_PREFIX} Failed to decode bundled manifest. This is probably a bug.\n\n{manifest}`
)
process.exit(1)
end
return manifest.version
end
local toolId = string.gsub(repo :: string, "/", "+")
local toolAlias = string.split(toolId, "+")[2]
local toolInstallPath = TOOL_STORAGE_DIR:join(toolId):join(
`{toolAlias}-` .. if version ~= "" then version :: string else manifestVersion()
)
print(toolInstallPath)
if pathfs.isFile(toolInstallPath) then
process.exit(lib.runTool(toolInstallPath))
end
local ok, err = pcall(
lib.installTool,
{
alias = Option.None,
repo = repo,
version = Option.Some(
Semver.parse(if version ~= "" then version :: string else manifestVersion()):unwrap()
) :: Option<Semver.SemverImpl>,
} :: ToolId,
toolInstallPath
)
if not ok then
stdio.ewrite(`{ERROR_PREFIX} Failed to install {tool}\n`)
stdio.ewrite(` - {err}\n`)
end
end,
}
)