Onset: version control where nobody waits and nothing gets lost. The on command, its core and onset-server.
| 1 | #!/usr/bin/env node |
| 2 | // Onset's `on` command from npm. The first run fetches the release this |
| 3 | // package's version names for this machine (from downloads.coline.dev, as |
| 4 | // the install script does), checks it against the release's checksum, and |
| 5 | // keeps it in this package's folder (uninstalling removes it), or in the |
| 6 | // user's cache folder where this one can't be written (a global install |
| 7 | // made with sudo). Every run starts that binary with the same arguments and |
| 8 | // exits as it does. No install scripts, so it works where they're turned |
| 9 | // off (pnpm, npm --ignore-scripts). |
| 10 | "use strict"; |
| 11 | const fs = require("node:fs"); |
| 12 | const os = require("node:os"); |
| 13 | const path = require("node:path"); |
| 14 | const crypto = require("node:crypto"); |
| 15 | const { spawn, execFileSync } = require("node:child_process"); |
| 16 |
| 17 | const { version, platforms } = require("../manifest.json"); |
| 18 | const exe = process.platform === "win32" ? "on.exe" : "on"; |
| 19 |
| 20 | function platform() { |
| 21 | const p = { darwin: "darwin", linux: "linux", win32: "windows" }[process.platform]; |
| 22 | const a = { arm64: "arm64", x64: "x64" }[process.arch]; |
| 23 | return p && a ? `${p}-${a}` : `${process.platform}-${process.arch}`; |
| 24 | } |
| 25 |
| 26 | function fail(message) { |
| 27 | process.stderr.write(`on: ${message}\n`); |
| 28 | process.exit(1); |
| 29 | } |
| 30 |
| 31 | async function fetchBinary(dest) { |
| 32 | const name = platform(); |
| 33 | const release = platforms[name]; |
| 34 | if (!release) fail(`Onset ${version} has no build for ${name}; see https://upstream.codes/onset`); |
| 35 | const base = process.env.ONSET_DOWNLOADS; |
| 36 | const url = base ? `${base}/${version}/${path.basename(release.url)}` : release.url; |
| 37 | process.stderr.write(`Fetching Onset ${version} for ${name}...\n`); |
| 38 | let res; |
| 39 | try { |
| 40 | res = await fetch(url); |
| 41 | } catch (e) { |
| 42 | fail(`couldn't reach ${new URL(url).host} (${e.cause?.code ?? e.message}); nothing changed`); |
| 43 | } |
| 44 | if (!res.ok) fail(`${url} answered ${res.status}; nothing changed`); |
| 45 | const archive = Buffer.from(await res.arrayBuffer()); |
| 46 | const sha = crypto.createHash("sha256").update(archive).digest("hex"); |
| 47 | if (sha !== release.sha256) fail(`the download doesn't match Onset ${version}'s checksum; nothing changed`); |
| 48 | // Unpacked next to where it goes, then moved in whole, so two first runs at once each end with a whole binary. |
| 49 | fs.mkdirSync(path.dirname(dest), { recursive: true }); |
| 50 | const tmp = fs.mkdtempSync(path.join(path.dirname(dest), ".fetch-")); |
| 51 | try { |
| 52 | const file = path.join(tmp, path.basename(release.url)); |
| 53 | fs.writeFileSync(file, archive); |
| 54 | // tar is on macOS, Linux, and Windows 10 1803 and later (where it opens zips too). |
| 55 | execFileSync("tar", [file.endsWith(".zip") ? "-xf" : "-xzf", file, "-C", tmp], { stdio: "ignore" }); |
| 56 | fs.chmodSync(path.join(tmp, exe), 0o755); |
| 57 | fs.renameSync(path.join(tmp, exe), dest); |
| 58 | } finally { |
| 59 | fs.rmSync(tmp, { recursive: true, force: true }); |
| 60 | } |
| 61 | } |
| 62 |
| 63 | function cache() { |
| 64 | const home = os.homedir(); |
| 65 | if (process.platform === "win32") return path.join(process.env.LOCALAPPDATA || home, "onset", "npm"); |
| 66 | if (process.platform === "darwin") return path.join(home, "Library", "Caches", "onset", "npm"); |
| 67 | return path.join(process.env.XDG_CACHE_HOME || path.join(home, ".cache"), "onset", "npm"); |
| 68 | } |
| 69 |
| 70 | function writable(dir) { |
| 71 | try { |
| 72 | fs.accessSync(dir, fs.constants.W_OK); |
| 73 | return true; |
| 74 | } catch { |
| 75 | return false; |
| 76 | } |
| 77 | } |
| 78 |
| 79 | async function main() { |
| 80 | const here = path.join(__dirname, "..", "native", exe); |
| 81 | const cached = path.join(cache(), version, exe); |
| 82 | let bin = [here, cached].find((p) => fs.existsSync(p)); |
| 83 | if (!bin) { |
| 84 | bin = writable(path.join(__dirname, "..")) ? here : cached; |
| 85 | await fetchBinary(bin); |
| 86 | } |
| 87 | const child = spawn(bin, process.argv.slice(2), { stdio: "inherit" }); |
| 88 | // Ctrl-C already reaches the whole process group; a signal sent to this process alone goes on. |
| 89 | process.on("SIGINT", () => {}); |
| 90 | for (const s of ["SIGTERM", "SIGHUP"]) process.on(s, () => child.kill(s)); |
| 91 | child.on("error", (e) => fail(`couldn't start ${bin} (${e.code ?? e.message})`)); |
| 92 | child.on("exit", (code, signal) => { |
| 93 | if (signal) { |
| 94 | process.removeAllListeners(signal); |
| 95 | process.kill(process.pid, signal); |
| 96 | } else process.exit(code ?? 1); |
| 97 | }); |
| 98 | } |
| 99 |
| 100 | main(); |
| 101 |