///|
pub struct Options {
pkg : Package
directory : String?
dry_run : Bool
force : Bool
}
///|
pub fn usage() -> String {
let msg = #|moon-binstall — install checksum-verified MoonBit binaries from GitHub Releases
#|
#|Usage:
#| moon-binstall install [--bin-dir DIR] [--force] [--dry-run]
#| moon-binstall [--bin-dir DIR] [--force] [--dry-run]
#| moon-binstall --help
#| moon binstall [--bin-dir DIR] [--force] [--dry-run]
#| moon binstall --help
#|
#|Aliases: turtles, hotpath, dsh
#|Environment: MOON_BINSTALL_DIR (default $HOME/.local/bin)
#|
#|Requires: curl, macOS or Linux; SHA-256 digest in GitHub release metadata.
#|hotpath installs the optional hotpath-report example binary, not the library.
msg
}
///|
pub fn parse_options(argv : ArrayView[String]) -> Options raise {
let mut coordinate = ""
let mut directory : String? = None
let mut dry_run = false
let mut force = false
let mut idx = 0
while idx < argv.length() {
let arg = argv[idx]
match arg {
"install" if idx == 0 => ()
"--dry-run" => dry_run = true
"--force" => force = true
"--bin-dir" => {
idx = idx + 1
guard idx < argv.length() && argv[idx] != "" else {
fail("--bin-dir requires a directory")
}
directory = Some(argv[idx])
}
_ => {
guard !arg.has_prefix("-") && coordinate == "" else {
fail("unrecognized or duplicate argument: \{arg}")
}
coordinate = arg
}
}
idx = idx + 1
}
guard coordinate != "" else { fail("missing repository coordinate") }
{ pkg: parse_package(coordinate), directory, dry_run, force }
}
///|
async fn require_platform() -> String {
let (os_status, os_out) = @process.collect_stdout("uname", ["-s"])
guard os_status == 0 else { fail("uname -s failed") }
let (cpu_status, cpu_out) = @process.collect_stdout("uname", ["-m"])
guard cpu_status == 0 else { fail("uname -m failed") }
platform_key(os_out.text().trim().to_owned(), cpu_out.text().trim().to_owned())
}
///|
fn target_directory(dir_override : String?) -> String raise {
match dir_override {
Some(dir) => {
guard dir != "" && dir.has_prefix("/") else {
fail("--bin-dir must be an absolute path")
}
dir
}
None =>
match @env.get_env_var("MOON_BINSTALL_DIR") {
Some(dir) => {
guard dir != "" && dir.has_prefix("/") else {
fail("MOON_BINSTALL_DIR must be an absolute path")
}
dir
}
None =>
match @env.get_env_var("HOME") {
Some(home) => home + "/.local/bin"
None => fail("HOME is not set; provide --bin-dir")
}
}
}
}
///|
/// Fail if curl refuses the transfer, rather than treating a 404 body as a
/// binary. Keep every request HTTPS, redirect only to HTTPS, and bound time.
async fn download(url : String, path : String) -> Unit {
let args = [
"--fail", "--silent", "--show-error", "--location",
"--proto", "=https", "--proto-redir", "=https",
"--max-redirs", "5",
"--connect-timeout", "15", "--max-time", "300",
"--output", path, url,
]
let status = @process.run("curl", args)
guard status == 0 else { fail("curl download failed (exit \{status})") }
}
///|
async fn verify_digest(path : String, expected : String) -> Unit {
let bytes = @fs.read_file(path).binary()
let actual = "sha256:" + @crypto.bytes_to_hex_string(@crypto.sha256(bytes))
guard actual == expected else {
fail("SHA-256 verification failed: expected \{expected}, got \{actual}")
}
}
///|
pub async fn install(config : Options) -> Unit {
let target = require_platform()
let pkg = config.pkg
let api = release_url(pkg)
let (code, body) = @process.collect_stdout("curl", [
"--fail", "--silent", "--show-error", "--location",
"--proto", "=https", "--proto-redir", "=https",
"--header", "Accept: application/vnd.github+json",
"--header", "User-Agent: moon-binstall/0.1",
"--connect-timeout", "15", "--max-time", "30", api,
])
guard code == 0 else {
fail("GitHub release lookup failed (curl exit \{code}): \{api}")
}
let release = resolve_release(body.text(), pkg, target)
let bin_dir = target_directory(config.directory)
let destination = bin_dir + "/" + pkg.binary
println("Resolved \{pkg.owner}/\{pkg.repo}@\{release.tag}: \{release.name}")
if config.dry_run {
println("Would install \{release.url} -> \{destination}")
return
}
@fs.mkdir(bin_dir, recursive=true, allow_exist=true)
guard !@fs.exists(destination) || config.force else {
fail("already exists: \{destination} (use --force to replace)")
}
// Avoid redirecting writes through a symlink, even with --force.
if @fs.exists(destination) {
guard @fs.kind(destination, follow_symlink=false) is Regular else {
fail("refusing to replace a non-regular file: \{destination}")
}
}
let staging = bin_dir + "/.\{pkg.binary}.moon-binstall.part"
guard !@fs.exists(staging) else {
fail("staging file exists (concurrent or interrupted install): \{staging}")
}
defer (@fs.remove(staging) catch { _ => () })
download(release.url, staging)
verify_digest(staging, release.digest)
@fs.chmod(staging, 0o755)
@fs.rename(staging, destination, replace=config.force)
println("Installed \{destination}")
}
///|
pub async fn execute(argv : ArrayView[String]) -> Int {
if argv.length() == 0 || (argv.length() == 1 &&
(argv[0] == "--help" || argv[0] == "-h" || argv[0] == "help")) {
println(usage())
return 0
}
if argv.length() == 1 && argv[0] == "--version" {
println("moon-binstall 0.1.2")
return 0
}
let config = parse_options(argv)
install(config)
0
}