///|
/// Prebuilt release artifacts.
///
/// `mb tool install` prefers a binary published on the module's GitHub release
/// over compiling from source. This module locates the repository behind a
/// mooncakes module, queries the GitHub releases API, picks an asset that
/// matches the host platform and installs it, unpacking archives when needed.
/// When nothing suitable is found the caller falls back to building from
/// source.
///|
/// Extract the `owner/repo` slug from a GitHub repository URL. Returns `None`
/// for empty values and for hosts other than github.com.
fn parse_github_repo(url : String) -> String? {
let mut s = url.trim()
if s == "" {
return None
}
while s.has_suffix("/") {
s = s[:s.length() - 1].to_owned()
}
if s.has_suffix(".git") {
s = s[:s.length() - 4].to_owned()
}
let prefixes = [
"https://github.com/", "http://github.com/", "git://github.com/", "ssh://git@github.com/",
"git@github.com:",
]
let mut rest = ""
let mut found = false
for p in prefixes {
if s.has_prefix(p) {
rest = s[p.length():].to_owned()
found = true
break
}
}
if !found {
// Also accept a bare `owner/repo` slug, which is how a package that is
// not on mooncakes.io is named on the command line. Reject anything that
// looks like a URL for another host.
if s.contains("://") || s.contains(":") {
return None
}
rest = s.to_owned()
}
let parts = rest.split("/").to_array()
if parts.length() < 2 {
return None
}
let owner = parts[0].to_owned()
let repo = parts[1].to_owned()
if owner == "" || repo == "" || (!found && owner.contains(".")) {
None
} else {
Some(owner + "/" + repo)
}
}
///|
/// Split a `owner/repo@version` spec (or URL) into its repository part and the
/// optional version. A `@` that belongs to a `git@github.com:` remote is left
/// alone.
fn split_github_version(spec : String) -> (String, String?) {
let s = spec.trim()
match s.rev_find("@") {
Some(i) => {
let after = s[i + 1:].to_owned()
if after.contains("/") {
(s.to_owned(), None)
} else {
(s[:i].to_owned(), Some(after))
}
}
None => (s.to_owned(), None)
}
}
///|
/// Parse a command-line package spec as a GitHub repository, returning
/// `(owner/repo, version?)`. Accepts `owner/repo`, `owner/repo@v`, full URLs
/// and `git@github.com:` remotes.
fn parse_github_spec(spec : String) -> (String, String?)? {
let (base, version) = split_github_version(spec)
match parse_github_repo(base) {
Some(repo) => Some((repo, version))
None => None
}
}
///|
/// CPU-architecture aliases that identify the host `plat` triple.
fn arch_aliases(plat : String) -> Array[String] {
if plat.contains("x86_64") {
["x86_64", "amd64", "x64", "x86-64"]
} else if plat.contains("aarch64") || plat.contains("arm64") {
["aarch64", "arm64"]
} else {
[]
}
}
///|
/// Operating-system aliases that identify the host `plat` triple.
fn os_aliases(plat : String) -> Array[String] {
if plat.has_prefix("linux") {
["linux"]
} else if plat.has_prefix("darwin") {
["darwin", "macos", "mac", "osx"]
} else {
[]
}
}
///|
/// True when an asset name mentions both the host OS and CPU architecture.
///
/// Matching is case-insensitive and accepts common aliases (`amd64`/`x64` for
/// `x86_64`, `arm64` for `aarch64`, `macos` for `darwin`). Requiring both
/// avoids installing a binary built for a different platform.
fn asset_matches_platform(name : String, plat : String) -> Bool {
let lower = name.to_lower()
let mut os_ok = false
for a in os_aliases(plat) {
if lower.contains(a) {
os_ok = true
}
}
if !os_ok {
return false
}
let mut arch_ok = false
for a in arch_aliases(plat) {
if lower.contains(a) {
arch_ok = true
}
}
arch_ok
}
///|
/// True for sidecar files that carry a checksum, signature or metadata rather
/// than an installable program.
fn asset_is_checksum(name : String) -> Bool {
let lower = name.to_lower()
let suffixes = [
".sha256", ".sha256sum", ".sha512", ".sha512sum", ".sha1", ".md5", ".sig", ".asc",
".pem", ".crt", ".cert", ".txt", ".json", ".sbom",
]
for s in suffixes {
if lower.has_suffix(s) {
return true
}
}
false
}
///|
/// True for assets that can plausibly become an executable on this host: not a
/// checksum, not a native package installer and not a Windows binary.
fn is_installable_asset(name : String) -> Bool {
if asset_is_checksum(name) {
return false
}
let lower = name.to_lower()
let excluded = [
".deb", ".rpm", ".msi", ".dmg", ".pkg", ".apk", ".exe", ".msix", ".nupkg", ".jar",
]
for s in excluded {
if lower.has_suffix(s) {
return false
}
}
true
}
///|
/// True when a name is a platform token rather than part of a tool's name.
fn is_platform_token(token : String) -> Bool {
match token.to_lower() {
"linux"
| "linux64"
| "darwin"
| "macos"
| "macosx"
| "mac"
| "osx"
| "windows"
| "win"
| "win32"
| "win64"
| "freebsd"
| "unix"
| "unknown"
| "gnu"
| "musl"
| "musl64"
| "msvc"
| "pc"
| "apple"
| "none"
| "static"
| "x86"
| "x86_64"
| "x86-64"
| "amd64"
| "x64"
| "aarch64"
| "arm64"
| "arm"
| "i386"
| "i686"
| "universal"
| "all"
| "noarch"
| "bin"
| "binary"
| "release"
| "dist" => true
_ => false
}
}
///|
/// True when a token looks like a version (`1.2.3`, `v1.2.3`).
fn is_version_token(token : String) -> Bool {
if token == "" {
return false
}
let c0 = token[0]
if c0 >= '0' && c0 <= '9' {
return true
}
if (c0 == 'v' || c0 == 'V') && token.length() > 1 {
let c1 = token[1]
return c1 >= '0' && c1 <= '9'
}
false
}
///|
/// The last path segment of a module name (`author/mod` -> `mod`).
fn short_name(name : String) -> String {
match name.rev_find("/") {
Some(i) => name[i + 1:].to_owned()
None => name
}
}
///|
/// Derive an executable name from a raw (non-archive) asset name by removing
/// archive extensions, version tokens and platform tokens. Returns `""` when
/// nothing meaningful is left, in which case the caller falls back to the
/// module name.
fn binary_name_from_asset(name : String) -> String {
let mut stem = name
for i = 0; i < 2; i = i + 1 {
match strip_archive_ext(stem) {
Some(s) => stem = s
None => ()
}
}
let parts = stem
.replace(old="_", new="-")
.replace(old=".", new="-")
.split("-")
.to_array()
let kept : Array[String] = []
for p in parts {
let tok = p.to_owned()
if tok == "" || is_platform_token(tok) || is_version_token(tok) {
continue
}
kept.push(tok)
}
kept.join("-")
}
///|
/// True for files that are not programs: shared/static libraries, debug
/// symbols and documentation that may live next to an executable.
fn is_library_name(name : String) -> Bool {
let lower = name.to_lower()
if lower.contains(".so") {
return true
}
let suffixes = [
".dylib", ".dll", ".a", ".o", ".lib", ".rlib", ".pdb", ".dsym", ".h", ".hpp",
".txt", ".md", ".json", ".toml", ".html", ".wasm", ".gz", ".zip", ".1", ".pc",
]
for s in suffixes {
if lower.has_suffix(s) {
return true
}
}
false
}
///|
/// Request headers for the GitHub API. An authentication token from
/// `GITHUB_TOKEN` or `GH_TOKEN` is included when present, which raises the
/// rate limit from 60 to 5000 requests per hour.
fn github_headers() -> Array[String] {
let headers = [
"User-Agent: mb/" + MB_VERSION,
"Accept: application/vnd.github+json",
"X-GitHub-Api-Version: 2022-11-28",
]
let token = env_or("GITHUB_TOKEN", env_or("GH_TOKEN", ""))
if token != "" {
headers.push("Authorization: Bearer " + token)
}
headers
}
///|
/// Fetch a GitHub API document, returning `None` on any error (including a
/// 404 for a missing release) so callers can fall back gracefully.
fn github_json(url : String) -> Json? {
let text = fetch_text_headers(url, github_headers()) catch { _ => "" }
if text == "" {
return None
}
let parsed = @json.parse(text) catch { _ => Json::null() }
match parsed {
Null => None
other => Some(other)
}
}
///|
/// Look up a GitHub release. The pinned version is tried as both `v`
/// and `` tags; when the caller did not pin a version the repository's
/// `latest` release is used as a fallback.
fn fetch_release(repo : String, version : String, allow_latest : Bool) -> Json? {
let base = "https://api.github.com/repos/" + repo + "/releases/tags/"
for tag in ["v" + version, version] {
match github_json(base + tag) {
Some(j) => return Some(j)
None => ()
}
}
if allow_latest {
match
github_json("https://api.github.com/repos/" + repo + "/releases/latest") {
Some(j) => return Some(j)
None => ()
}
}
None
}
///|
fn asset_candidate(asset : Json) -> (String, String, String?)? {
let name = match json_string(asset, "name") {
Some(n) => n
None => return None
}
let url = match json_string(asset, "browser_download_url") {
Some(u) => u
None => return None
}
Some((name, url, json_string(asset, "digest")))
}
///|
/// Choose the asset to install: the first platform-matching archive, else the
/// first platform-matching raw binary. Returns `(name, url, digest?)`.
fn pick_release_asset(
release : Json,
plat : String,
) -> (String, String, String?)? {
let assets = match json_array(release, "assets") {
Some(a) => a
None => []
}
let mut raw : (String, String, String?)? = None
for asset in assets {
match asset_candidate(asset) {
None => ()
Some((name, url, digest)) => {
if !is_installable_asset(name) || !asset_matches_platform(name, plat) {
continue
}
let candidate = (name, url, digest)
if is_archive_name(name) {
return Some(candidate)
} else if raw is None {
raw = Some(candidate)
}
}
}
}
raw
}
///|
/// Normalise a GitHub asset digest (`sha256:`) to its hex part.
fn sha256_of_digest(digest : String) -> String? {
let lower = digest.to_lower()
if lower.has_prefix("sha256:") {
Some(lower[7:].to_owned())
} else {
None
}
}
///|
/// Executables found inside an unpacked archive, as `(name, path)` pairs. Names
/// are deduplicated so a file that appears twice is installed once.
fn collect_executables(dir : String) -> Array[(String, String)] raise MbError {
let out : Array[(String, String)] = []
for rel in walk_files(dir) {
let abs = path_join(dir, rel)
if !is_executable_file(abs) {
continue
}
let raw = base_name(rel)
if is_library_name(raw) {
continue
}
let name = if raw.has_suffix(".exe") {
raw[:raw.length() - 4].to_owned()
} else {
raw
}
let mut dup = false
for existing in out {
if existing.0 == name {
dup = true
}
}
if !dup {
out.push((name, abs))
}
}
out
}
///|
/// Try to install `name` `version` from a prebuilt GitHub release asset.
///
/// Returns the installed executable names on success, or `None` when the
/// module has no usable release for this platform, in which case the caller
/// should build from source. `requested` is the version the user pinned, if
/// any; it gates the `/releases/latest` fallback.
fn try_install_release(
name : String,
version : String,
repository : String,
requested : String?,
bin : String,
dir : String,
force : Bool,
) -> Array[String]? raise MbError {
let repo = match parse_github_repo(repository) {
Some(r) => r
None => return None
}
let plat = platform()
let release = match fetch_release(repo, version, requested is None) {
Some(r) => r
None => return None
}
let (asset_name, url, digest) = match pick_release_asset(release, plat) {
Some(p) => p
None => return None
}
println("Found release asset " + asset_name + " for " + plat)
Some(
download_and_install_asset(
asset_name,
url,
digest,
short_name(name),
bin,
dir,
force,
),
)
}
///|
/// Download one release asset and install its executable(s) into `bin`.
///
/// Archives are unpacked and every executable inside is installed; a plain file
/// is installed under a name derived from the asset, falling back to
/// `fallback_name` when the asset name carries no usable name.
fn download_and_install_asset(
asset_name : String,
url : String,
digest : String?,
fallback_name : String,
bin : String,
dir : String,
force : Bool,
) -> Array[String] raise MbError {
println("Downloading " + url + " ...")
let tmp = temp_path()
download(url, tmp, true)
match digest {
Some(d) =>
match sha256_of_digest(d) {
Some(expected) => {
let actual = sha256_hex(read_bytes(tmp))
if actual != expected {
rm_rf(tmp) catch {
_ => ()
}
raise MbError("checksum mismatch for " + asset_name)
}
}
None => ()
}
None => ()
}
let installed : Array[String] = []
if is_archive_name(asset_name) {
let unpack = temp_path() + ".d"
extract_archive(tmp, asset_name, unpack)
let found = collect_executables(unpack)
let names : Array[String] = []
for (n, _) in found {
names.push(n)
}
ensure_bin_free(bin, names, dir, force)
for entry in found {
let (bin_name, path) = entry
stage_binary(dir, bin_name, path)
installed.push(bin_name)
}
rm_rf(unpack) catch {
_ => ()
}
} else {
let derived = binary_name_from_asset(asset_name)
let bin_name = if derived == "" { fallback_name } else { derived }
ensure_bin_free(bin, [bin_name], dir, force)
stage_binary(dir, bin_name, tmp)
installed.push(bin_name)
}
rm_rf(tmp) catch {
_ => ()
}
if installed.is_empty() {
raise MbError("no executable found in release asset " + asset_name)
}
link_binaries(bin, dir, installed)
report_installed(bin, installed)
installed
}
///|
/// Latest release tag of a GitHub repository, or `None` when there is none or
/// the API could not be reached.
fn latest_github_tag(repo : String) -> String? {
match
github_json("https://api.github.com/repos/" + repo + "/releases/latest") {
Some(j) => json_string(j, "tag_name")
None => None
}
}
///|
/// Install a tool that is not on mooncakes.io straight from a GitHub release.
///
/// `spec` is `owner/repo`, `owner/repo@version` or a GitHub URL. The recorded
/// tool name is the repository slug and the version is the release tag.
fn install_github_tool(
spec : String,
bin : String,
force : Bool,
) -> Unit raise MbError {
let (repo, requested) = match parse_github_spec(spec) {
Some(p) => p
None =>
raise MbError(
"'" +
spec +
"' is neither a mooncakes.io module nor a GitHub repository",
)
}
let version = match requested {
Some(v) => v
None => ""
}
println("Trying GitHub releases for " + repo + " ...")
let release = match fetch_release(repo, version, requested is None) {
Some(r) => r
None => raise MbError("no GitHub release found for " + repo)
}
let tag = match json_string(release, "tag_name") {
Some(t) => t
None => if version == "" { "latest" } else { version }
}
let dir = tool_dir(repo, tag)
if file_exists(path_join(dir, "tool.json")) && !force {
println(
repo + " " + tag + " is already installed; use --force to reinstall",
)
return
}
let plat = platform()
let (asset_name, url, digest) = match pick_release_asset(release, plat) {
Some(p) => p
None =>
raise MbError("no release asset for " + plat + " in " + repo + " " + tag)
}
println("Found release asset " + asset_name + " for " + plat)
let bins = download_and_install_asset(
asset_name,
url,
digest,
short_name(repo),
bin,
dir,
force,
)
let record : ToolMeta = { name: repo, version: tag, bins, bin: Some(bin), }
write_text(
path_join(dir, "tool.json"),
@json.to_json(record).stringify(indent=2),
)
println("Installed " + repo + " " + tag + " (prebuilt)")
}