|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | +// |
| 4 | +// @hyperpolymath/affinescript — the thin compiler shim (ADR-019 / #260 S3). |
| 5 | +// |
| 6 | +// The AffineScript compiler is a native OCaml binary, not a JS package. |
| 7 | +// Per ADR-019 the GitHub Release (cut by .github/workflows/release.yml, |
| 8 | +// #260 S2) is the CANONICAL artifact: per-platform `affinescript-<target>` |
| 9 | +// binaries + a `SHA256SUMS` manifest. This package is the ergonomic Deno |
| 10 | +// front door: it downloads the binary for the host triple from the |
| 11 | +// Release pinned by THIS package version, verifies it against the |
| 12 | +// checksum embedded here (no floating fetch — one version+checksum per |
| 13 | +// shim release, the ADR-019 supply-chain rule), caches it, and execs it |
| 14 | +// with the caller's argv. |
| 15 | +// |
| 16 | +// Deno-first (CLAUDE.md). JavaScript, not ReScript, because this is |
| 17 | +// entirely Deno host APIs (Deno.Command / fetch / crypto.subtle / fs) — |
| 18 | +// the documented "JS only where ReScript cannot" carve-out, same as |
| 19 | +// packages/affine-js. |
| 20 | + |
| 21 | +import { PINS } from "./pins.js"; |
| 22 | + |
| 23 | +/** Map the host to an ADR-019 release target triple. */ |
| 24 | +export function hostTarget(os = Deno.build.os, arch = Deno.build.arch) { |
| 25 | + if (os === "linux" && arch === "x86_64") return "linux-x64"; |
| 26 | + if (os === "darwin" && arch === "x86_64") return "macos-x64"; |
| 27 | + if (os === "darwin" && arch === "aarch64") return "macos-arm64"; |
| 28 | + throw new Error( |
| 29 | + `@hyperpolymath/affinescript: unsupported host ${os}/${arch}. ` + |
| 30 | + `Supported: linux-x64, macos-x64, macos-arm64 (windows-x64 is a ` + |
| 31 | + `tracked follow-up). Build from source: hyperpolymath/affinescript.`, |
| 32 | + ); |
| 33 | +} |
| 34 | + |
| 35 | +/** Lower-case hex of the SHA-256 of `bytes`. */ |
| 36 | +export async function sha256Hex(bytes) { |
| 37 | + const digest = await crypto.subtle.digest("SHA-256", bytes); |
| 38 | + return Array.from(new Uint8Array(digest)) |
| 39 | + .map((b) => b.toString(16).padStart(2, "0")) |
| 40 | + .join(""); |
| 41 | +} |
| 42 | + |
| 43 | +/** Cache path for a pinned binary (XDG, then HOME, then a temp dir). */ |
| 44 | +export function cachePath(version, target) { |
| 45 | + const base = Deno.env.get("AFFINESCRIPT_CACHE") ?? |
| 46 | + Deno.env.get("XDG_CACHE_HOME") ?? |
| 47 | + (Deno.env.get("HOME") ? `${Deno.env.get("HOME")}/.cache` : null) ?? |
| 48 | + Deno.env.get("TMPDIR") ?? "/tmp"; |
| 49 | + return `${base}/affinescript/${version}/affinescript-${target}`; |
| 50 | +} |
| 51 | + |
| 52 | +/** |
| 53 | + * Resolve a runnable compiler binary path for the host, downloading + |
| 54 | + * checksum-verifying from the pinned Release on a cache miss. |
| 55 | + * |
| 56 | + * @param {{ pins?: object, fetchImpl?: typeof fetch }} [opts] |
| 57 | + * `pins`/`fetchImpl` are test seams; production uses the embedded |
| 58 | + * PINS and global fetch. |
| 59 | + * @returns {Promise<string>} absolute path to the verified binary |
| 60 | + */ |
| 61 | +export async function resolveCompiler(opts = {}) { |
| 62 | + const pins = opts.pins ?? PINS; |
| 63 | + const doFetch = opts.fetchImpl ?? fetch; |
| 64 | + const target = hostTarget(); |
| 65 | + const entry = pins.targets?.[target]; |
| 66 | + if (!entry || !entry.sha256 || !entry.url) { |
| 67 | + throw new Error( |
| 68 | + `@hyperpolymath/affinescript: no pinned binary for ${target} at ` + |
| 69 | + `version ${pins.version} (pins.js not finalised for this release).`, |
| 70 | + ); |
| 71 | + } |
| 72 | + const path = cachePath(pins.version, target); |
| 73 | + |
| 74 | + // Cache hit only counts if the cached bytes still match the pin |
| 75 | + // (defends against a corrupted/tampered cache). |
| 76 | + try { |
| 77 | + const cached = await Deno.readFile(path); |
| 78 | + if ((await sha256Hex(cached)) === entry.sha256) return path; |
| 79 | + } catch { /* miss — fall through to download */ } |
| 80 | + |
| 81 | + const res = await doFetch(entry.url); |
| 82 | + if (!res.ok) { |
| 83 | + throw new Error( |
| 84 | + `@hyperpolymath/affinescript: download failed for ${target} ` + |
| 85 | + `(${entry.url}): HTTP ${res.status}`, |
| 86 | + ); |
| 87 | + } |
| 88 | + const bytes = new Uint8Array(await res.arrayBuffer()); |
| 89 | + const got = await sha256Hex(bytes); |
| 90 | + if (got !== entry.sha256) { |
| 91 | + throw new Error( |
| 92 | + `@hyperpolymath/affinescript: checksum mismatch for ${target} — ` + |
| 93 | + `expected ${entry.sha256}, got ${got}. Refusing to run (the ` + |
| 94 | + `Release artifact does not match this shim version's pin).`, |
| 95 | + ); |
| 96 | + } |
| 97 | + await Deno.mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true }); |
| 98 | + await Deno.writeFile(path, bytes, { mode: 0o755 }); |
| 99 | + // writeFile mode is pre-umask; ensure it is executable. |
| 100 | + await Deno.chmod(path, 0o755).catch(() => {}); |
| 101 | + return path; |
| 102 | +} |
| 103 | + |
| 104 | +/** |
| 105 | + * Resolve then exec the compiler with `args`, inheriting stdio. |
| 106 | + * Returns the child's exit code (caller decides whether to exit). |
| 107 | + */ |
| 108 | +export async function run(args = Deno.args, opts = {}) { |
| 109 | + const bin = await resolveCompiler(opts); |
| 110 | + const cmd = new Deno.Command(bin, { |
| 111 | + args, |
| 112 | + stdin: "inherit", |
| 113 | + stdout: "inherit", |
| 114 | + stderr: "inherit", |
| 115 | + }); |
| 116 | + const { code } = await cmd.output(); |
| 117 | + return code; |
| 118 | +} |
| 119 | + |
| 120 | +if (import.meta.main) { |
| 121 | + Deno.exit(await run()); |
| 122 | +} |
0 commit comments