#!/usr/bin/env node import { execFileSync } from "node:child_process"; import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; const org = "storytold"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const config = JSON.parse(await readFile(join(scriptDir, "config.json"), "utf8")); const installDir = resolve(scriptDir, (config.installDir ?? "bin").replaceAll("__HOME__", homedir())); // Use the token of the logged in gh, if any. let token = null; try { token = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null; } catch {} console.log(token ? "Using gh token" : "No gh token available, using anonymous access"); const headers = (accept) => ({ Accept: accept, "X-GitHub-Api-Version": "2022-11-28", ...(token && { Authorization: `Bearer ${token}` }), }); const platforms = { macos: /-macos-universal\.zip$/, linux: /-linux-x86_64\.tar\.gz$/, windows: /-windows-x64-portable\.zip$/, }; const hostPlatform = { darwin: "macos", linux: "linux", win32: "windows" }[process.platform]; const { values: { os: targetOs = hostPlatform, "dry-run": dryRun } } = parseArgs({ options: { os: { type: "string" }, "dry-run": { type: "boolean" } }, }); if (!platforms[targetOs]) { console.error(`Usage: get-clis.mjs [--os ${Object.keys(platforms).join("|")}] [--dry-run]`); process.exit(2); } const matchesPlatform = platforms[targetOs]; const archiveRe = /\.(tar\.gz|tgz|tar|zip)$/i; async function findFiles(dir) { const out = []; for (const e of await readdir(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isDirectory()) out.push(...(await findFiles(p))); else if (e.isFile()) out.push(p); } return out; } async function install(name) { const res = await fetch(`https://api.github.com/repos/${org}/${name}/releases/latest`, { headers: headers("application/vnd.github+json"), }); if (!res.ok) throw new Error(`release lookup failed: ${res.status} ${res.statusText}`); const release = await res.json(); const matching = release.assets.filter((a) => a.name.startsWith(`${name}-`) && matchesPlatform.test(a.name)); const asset = matching.find((a) => a.name.startsWith(`${name}-cli-`)) ?? matching[0]; if (!asset) throw new Error(`no ${targetOs} asset in release ${release.tag_name}`); console.log(`${name}: ${release.tag_name} -> ${asset.name}`); if (dryRun) { console.log(`${name}: dry run, would download ${asset.size} bytes into ${installDir}`); return; } const dl = await fetch(asset.url, { headers: headers("application/octet-stream") }); if (!dl.ok) throw new Error(`download failed: ${dl.status} ${dl.statusText}`); const work = await mkdtemp(join(tmpdir(), `${name}-`)); try { const file = join(work, asset.name); await writeFile(file, Buffer.from(await dl.arrayBuffer())); let binary = file; if (archiveRe.test(asset.name)) { const out = join(work, "extracted"); await mkdir(out); execFileSync("tar", ["-xf", file, "-C", out]); const files = await findFiles(out); binary = files.find((f) => [`${name}-cli`, `${name}-cli.exe`].includes(basename(f))); if (!binary) throw new Error(`no ${name}-cli executable in ${asset.name}`); } await mkdir(installDir, { recursive: true }); const target = join(installDir, basename(binary) === asset.name ? name : basename(binary)); await copyFile(binary, target); await chmod(target, 0o755); console.log(`${name}: installed ${target}`); } finally { await rm(work, { recursive: true, force: true }); } } let failed = 0; for (const name of config.clis) { try { await install(name); } catch (err) { failed++; console.error(`${name}: ${err.message}`); } } process.exit(failed ? 1 : 0);