From fbfc0e4c2fdae9e62af83436919231a3dc5ea1ad Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Wed, 20 May 2026 08:24:08 +0100
Subject: [PATCH] feat(shim): cross-runtime (Deno/Bun/Node) + JSR fast-check
types
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two bundled changes that both feed the @hyperpolymath/affinescript
0.1.2 publish path.
JSR fast-check: typed entrypoint
--------------------------------
JSR's `deno publish` emits an `unsupported-javascript-entrypoint`
warning whenever a JS module has no associated `.d.ts`. Without the
declaration, JSR falls back to type inference and consumers see no
editor types on the package page. Add `mod.d.ts` (TypeScript carve-
out, same precedent as `packages/affine-js/types.d.ts`) and a
`/// ` in `mod.js` so JSR picks it up.
Dry-run is now clean and the published files grow by one 2.87 KB type
file.
Cross-runtime: Deno, Bun, Node
------------------------------
The shim's consumers — LSP installers, IDE extensions, build scripts
wiring AffineScript into a CI pipeline — overwhelmingly live in Node
and Bun ecosystems, not Deno. Forcing them to install Deno solely to
fetch+verify+exec a binary defeats the "ergonomic install" purpose of
the shim. Refactor `mod.js` to detect the runtime once at module
load (`isDeno`/`isBun`/`isNode`) and branch every host effect through
a small helper layer:
hostOs() / hostArch() / envGet() — process.* on Bun+Node, Deno.*
on Deno; arch normalised to
Deno's spelling ("x86_64"/"aarch64")
so hostTarget() stays unchanged.
readBytes() / writeBytes() / mkdirRecursive() / chmodExec()
— Deno.* on Deno, Bun.file/Bun.write
on Bun, node:fs/promises on Node.
spawnInherit() — Deno.Command / Bun.spawn /
node:child_process.spawn.
thisIsMain() — import.meta.main on Deno+Bun;
URL comparison on Node.
The public API (hostTarget, sha256Hex, cachePath, resolveCompiler,
run) is unchanged; mod.d.ts captures the contract.
Browsers and Cloudflare Workers are explicitly NOT supported: the
shim's job is fetch+save+exec, and steps 2–3 are not possible in a
sandboxed JS runtime. Documented inline in the module header.
CLAUDE.md
---------
Add two carve-outs:
- TypeScript Exemptions: `packages/affinescript-cli/mod.d.ts`.
- Runtime Exemptions (NEW section): `packages/affinescript-cli/mod.js`
is the one approved Node+Bun exemption in the repo. Same
"explicit user approval" gate as the TS exemptions.
Verification
------------
- `deno test` (packages/affinescript-cli): 6/6 green (unchanged).
- `deno publish --dry-run`: 4 files including mod.d.ts, no warnings.
- Cross-runtime smoke (hermetic fake fetch + fake binary; same
contract as the Deno suite — download + checksum + cache + exec +
argv passthrough + exit-code):
Deno: OK
Bun: OK
Node: OK
Run under deno 2.x, bun 1.3.14, node 22.11.0.
Refs the @hyperpolymath/affinescript JSR-publish path (post-#295 follow-up).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.claude/CLAUDE.md | 11 ++
packages/affinescript-cli/mod.d.ts | 73 ++++++++++++
packages/affinescript-cli/mod.js | 183 +++++++++++++++++++++++++----
3 files changed, 241 insertions(+), 26 deletions(-)
create mode 100644 packages/affinescript-cli/mod.d.ts
diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 0f00c2ed..e8ef2c06 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -75,6 +75,7 @@ The "no new TypeScript" rule has seven approved exemptions in this repo. These p
| Path | Files | Rationale | Unblock condition |
|---|---|---|---|
| `packages/affine-js/types.d.ts` | 1 | TypeScript declaration file — the public API contract by which JS callers consume AffineScript-compiled artefacts. `.d.ts` is TS by definition. | Generate from canonical compiler output (issue: see ROADMAP). |
+| `packages/affinescript-cli/mod.d.ts` | 1 | TypeScript declaration file for the JSR shim's public API. Required by JSR's "fast type-check" — without it, the published package emits a `unsupported-javascript-entrypoint` warning and consumers get no editor types. `.d.ts` is TS by definition. | Same as `affine-js`: generate or rewrite the shim entry once stdlib + bindings exist. |
| `affinescript-deno-test/*.ts` | 6 | Deno-based test harness for AffineScript itself: `cli.ts`, `mod.ts`, `lib/{compile,discover,runner}.ts`, `example/smoke_driver.ts`. Deno test runner is TS-native. | AffineScript stdlib + Deno bindings (no scheduled issue). |
Adding to this list requires explicit user approval and an unblock condition. New TypeScript files outside this list are still banned per the policy table above.
@@ -85,6 +86,16 @@ Adding to this list requires explicit user approval and an unblock condition. Ne
The 5 external references to `affinescript-deno-test/` (CI workflow, status docs, history docs) and the references to `packages/affine-js/` (status docs, Deno config) are why physical relocation into a `vendor/` subtree was rejected — the relocation cost exceeded the visibility benefit when the directories are already named clearly.
+### Runtime Exemptions (Approved)
+
+The "no Node.js / no Bun" rules in the language policy table have one approved exemption in this repo. Adding to this list requires explicit user approval — same gate as the TypeScript exemptions above.
+
+| Path | Banned thing(s) used | Rationale | Unblock condition |
+|---|---|---|---|
+| `packages/affinescript-cli/mod.js` | `process.platform`/`process.arch`/`process.env`, `node:fs/promises`, `node:child_process`, `Bun.spawn`, `Bun.file`, `Bun.write` | The shim is the **compiler-distribution front door**. Its consumers — LSP installers, IDE extensions, CI scripts wiring AffineScript into a build pipeline — overwhelmingly live in Node and Bun ecosystems, not Deno. Forcing them to install Deno solely to fetch+verify+exec a binary defeats the shim's "ergonomic install" purpose. The branches are guarded by single-line runtime detection at module load; nothing else in the repo depends on this pattern. | None — this is the intended steady state. The shim's whole job is to be runtime-agnostic. |
+
+Browsers and Cloudflare Workers are NOT supported and never will be (the shim's purpose — fetch, save to disk, exec a native binary — cannot be done in a sandboxed JS runtime). The JSR runtime-compatibility checkboxes for this package should be: Deno ✅, Bun ✅, Node ✅, Workers ❌, Browsers ❌.
+
### Package Management
- **Primary**: Guix (guix.scm)
diff --git a/packages/affinescript-cli/mod.d.ts b/packages/affinescript-cli/mod.d.ts
new file mode 100644
index 00000000..f3079562
--- /dev/null
+++ b/packages/affinescript-cli/mod.d.ts
@@ -0,0 +1,73 @@
+// SPDX-License-Identifier: PMPL-1.0-or-later
+// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)
+//
+// Type declarations for mod.js — the @hyperpolymath/affinescript shim.
+// Lets JSR's fast-check publish without the "JavaScript entrypoint without
+// type declarations" warning, and gives consumers proper editor types.
+//
+// Per CLAUDE.md, .d.ts is an approved TypeScript carve-out (file format
+// for declaration files only); the implementation in mod.js remains JS
+// because every effect is a Deno.* host API, the documented "JS only
+// where ReScript cannot" exception.
+
+/** ADR-019 release target triples this shim knows about. */
+export type Target = "linux-x64" | "macos-x64" | "macos-arm64";
+
+/** A single per-target entry in the {@link Pins} table. */
+export interface PinEntry {
+ /** Canonical Release asset URL — `affinescript-` raw executable. */
+ url: string;
+ /** Lower-case hex SHA-256. Empty string ⇒ fail-closed for that target. */
+ sha256: string;
+}
+
+/**
+ * The full pin table — ONE compiler version + ONE sha256 per target, per
+ * shim release. Filled in `pins.js` when a `v*` tag is cut.
+ */
+export interface Pins {
+ /** The pinned compiler tag (e.g. `"v0.1.1"`). */
+ version: string;
+ /** Per-target download + checksum entries. Targets without a sha256
+ * refuse to resolve (fail-closed). */
+ targets: Partial>;
+}
+
+/** Options accepted by {@link resolveCompiler} and {@link run}. */
+export interface ResolveOptions {
+ /** Override the embedded pin table (test seam). */
+ pins?: Pins;
+ /** Override the global `fetch` (test seam). */
+ fetchImpl?: typeof fetch;
+}
+
+/**
+ * Map a host OS/arch to one of the supported ADR-019 release targets.
+ * Throws if the host isn't covered (e.g. windows-x64 is a tracked follow-up).
+ */
+export function hostTarget(os?: string, arch?: string): Target;
+
+/** Lower-case hex of the SHA-256 of `bytes`. */
+export function sha256Hex(bytes: ArrayBuffer | Uint8Array): Promise;
+
+/**
+ * Absolute path the shim caches a pinned binary at. Resolves
+ * `AFFINESCRIPT_CACHE` → `XDG_CACHE_HOME` → `$HOME/.cache` → `TMPDIR` → `/tmp`.
+ */
+export function cachePath(version: string, target: Target): string;
+
+/**
+ * Resolve a runnable compiler binary path for the host. On a cache
+ * miss, downloads the pinned Release asset, verifies its SHA-256 against
+ * the embedded pin, writes it to {@link cachePath} with the executable
+ * bit set, and returns the path. Throws on checksum mismatch (refuses
+ * to cache or run the tampered bytes).
+ */
+export function resolveCompiler(opts?: ResolveOptions): Promise;
+
+/**
+ * Resolve via {@link resolveCompiler}, then `Deno.Command`-spawn the
+ * binary with `args`, inheriting stdio. Returns the child's exit code
+ * (caller decides whether to `Deno.exit()`).
+ */
+export function run(args?: string[], opts?: ResolveOptions): Promise;
diff --git a/packages/affinescript-cli/mod.js b/packages/affinescript-cli/mod.js
index a07c3e6f..20c82d40 100644
--- a/packages/affinescript-cli/mod.js
+++ b/packages/affinescript-cli/mod.js
@@ -6,22 +6,156 @@
// The AffineScript compiler is a native OCaml binary, not a JS package.
// Per ADR-019 the GitHub Release (cut by .github/workflows/release.yml,
// #260 S2) is the CANONICAL artifact: per-platform `affinescript-`
-// binaries + a `SHA256SUMS` manifest. This package is the ergonomic Deno
+// binaries + a `SHA256SUMS` manifest. This package is the ergonomic
// front door: it downloads the binary for the host triple from the
// Release pinned by THIS package version, verifies it against the
// checksum embedded here (no floating fetch — one version+checksum per
// shim release, the ADR-019 supply-chain rule), caches it, and execs it
// with the caller's argv.
//
-// Deno-first (CLAUDE.md). JavaScript, not ReScript, because this is
-// entirely Deno host APIs (Deno.Command / fetch / crypto.subtle / fs) —
-// the documented "JS only where ReScript cannot" carve-out, same as
-// packages/affine-js.
+// Runtime support — Deno is canonical; Bun and Node.js are first-class
+// targets because the shim's consumers (LSP installers, IDE extensions,
+// build scripts) often run in those environments. All three branches
+// are guarded by runtime detection at module load; nothing dynamically
+// imports a foreign runtime's globals. Browsers and Cloudflare Workers
+// are NOT supported and never will be: the shim's purpose is "fetch,
+// save to disk, exec a native binary" — steps 2 and 3 are not possible
+// in a sandboxed JS runtime.
+//
+// JavaScript, not ReScript, because this is entirely host-API surface
+// (spawn / fetch / crypto.subtle / fs) — the documented "JS only where
+// ReScript cannot" carve-out, same as packages/affine-js.
+
+///
import { PINS } from "./pins.js";
+// ─── runtime detection ──────────────────────────────────────────────
+const isDeno = typeof Deno !== "undefined";
+const isBun = !isDeno && typeof Bun !== "undefined";
+const isNode = !isDeno && !isBun &&
+ typeof process !== "undefined" && !!process.versions?.node;
+
+if (!isDeno && !isBun && !isNode) {
+ throw new Error(
+ "@hyperpolymath/affinescript: unsupported runtime. " +
+ "This package targets Deno (canonical), Bun, and Node.js. " +
+ "Browsers and Workers cannot exec native binaries.",
+ );
+}
+
+// ─── host helpers ───────────────────────────────────────────────────
+// Each helper picks one branch at runtime; the others are dead code in
+// that process and never load. `await import("node:…")` in the Node
+// branch works under Deno too (Deno node-compat), so a Bun path that
+// shells out to node: APIs is still valid — but we keep Bun's native
+// APIs where they exist for fewer surprises and better diagnostics.
+
+function hostOs() {
+ // Deno: "linux"|"darwin"|"windows"|...
+ if (isDeno) return Deno.build.os;
+ // Bun and Node both populate process.platform identically.
+ return process.platform;
+}
+
+function hostArch() {
+ // Deno reports "x86_64"|"aarch64"; Node/Bun report "x64"|"arm64".
+ // Normalise to Deno's spelling — that's what hostTarget() matches.
+ if (isDeno) return Deno.build.arch;
+ if (process.arch === "x64") return "x86_64";
+ if (process.arch === "arm64") return "aarch64";
+ return process.arch;
+}
+
+function envGet(name) {
+ if (isDeno) return Deno.env.get(name);
+ return process.env[name];
+}
+
+async function readBytes(path) {
+ if (isDeno) return await Deno.readFile(path);
+ if (isBun) return new Uint8Array(await Bun.file(path).arrayBuffer());
+ const { readFile } = await import("node:fs/promises");
+ return new Uint8Array(await readFile(path));
+}
+
+async function writeBytes(path, bytes, { mode } = {}) {
+ if (isDeno) {
+ return await Deno.writeFile(path, bytes, mode != null ? { mode } : {});
+ }
+ if (isBun) {
+ await Bun.write(path, bytes);
+ if (mode != null) {
+ const { chmod } = await import("node:fs/promises");
+ await chmod(path, mode).catch(() => {});
+ }
+ return;
+ }
+ const { writeFile, chmod } = await import("node:fs/promises");
+ await writeFile(path, bytes);
+ if (mode != null) await chmod(path, mode).catch(() => {});
+}
+
+async function mkdirRecursive(path) {
+ if (isDeno) return await Deno.mkdir(path, { recursive: true });
+ const { mkdir } = await import("node:fs/promises");
+ await mkdir(path, { recursive: true });
+}
+
+async function chmodExec(path) {
+ if (isDeno) return await Deno.chmod(path, 0o755).catch(() => {});
+ const { chmod } = await import("node:fs/promises");
+ await chmod(path, 0o755).catch(() => {});
+}
+
+async function spawnInherit(bin, args) {
+ if (isDeno) {
+ const { code } = await new Deno.Command(bin, {
+ args,
+ stdin: "inherit",
+ stdout: "inherit",
+ stderr: "inherit",
+ }).output();
+ return code;
+ }
+ if (isBun) {
+ // Bun.spawn returns a process whose `.exited` resolves to the code.
+ const proc = Bun.spawn([bin, ...args], {
+ stdin: "inherit",
+ stdout: "inherit",
+ stderr: "inherit",
+ });
+ return await proc.exited;
+ }
+ const { spawn } = await import("node:child_process");
+ return await new Promise((resolve, reject) => {
+ const child = spawn(bin, args, { stdio: "inherit" });
+ child.on("close", (code) => resolve(code ?? 0));
+ child.on("error", reject);
+ });
+}
+
+/** Does this process look like it was invoked as ` `? */
+function thisIsMain() {
+ // Deno: import.meta.main; Bun also honours it. Node: compare URLs.
+ if (isDeno || isBun) return import.meta.main === true;
+ if (isNode) {
+ const arg1 = process.argv[1];
+ if (!arg1) return false;
+ try {
+ const here = new URL(import.meta.url).pathname;
+ return here === arg1 || here.endsWith(arg1);
+ } catch {
+ return false;
+ }
+ }
+ return false;
+}
+
+// ─── public API ─────────────────────────────────────────────────────
+
/** Map the host to an ADR-019 release target triple. */
-export function hostTarget(os = Deno.build.os, arch = Deno.build.arch) {
+export function hostTarget(os = hostOs(), arch = hostArch()) {
if (os === "linux" && arch === "x86_64") return "linux-x64";
if (os === "darwin" && arch === "x86_64") return "macos-x64";
if (os === "darwin" && arch === "aarch64") return "macos-arm64";
@@ -42,10 +176,10 @@ export async function sha256Hex(bytes) {
/** Cache path for a pinned binary (XDG, then HOME, then a temp dir). */
export function cachePath(version, target) {
- const base = Deno.env.get("AFFINESCRIPT_CACHE") ??
- Deno.env.get("XDG_CACHE_HOME") ??
- (Deno.env.get("HOME") ? `${Deno.env.get("HOME")}/.cache` : null) ??
- Deno.env.get("TMPDIR") ?? "/tmp";
+ const base = envGet("AFFINESCRIPT_CACHE") ??
+ envGet("XDG_CACHE_HOME") ??
+ (envGet("HOME") ? `${envGet("HOME")}/.cache` : null) ??
+ envGet("TMPDIR") ?? "/tmp";
return `${base}/affinescript/${version}/affinescript-${target}`;
}
@@ -74,7 +208,7 @@ export async function resolveCompiler(opts = {}) {
// Cache hit only counts if the cached bytes still match the pin
// (defends against a corrupted/tampered cache).
try {
- const cached = await Deno.readFile(path);
+ const cached = await readBytes(path);
if ((await sha256Hex(cached)) === entry.sha256) return path;
} catch { /* miss — fall through to download */ }
@@ -94,10 +228,11 @@ export async function resolveCompiler(opts = {}) {
`Release artifact does not match this shim version's pin).`,
);
}
- await Deno.mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true });
- await Deno.writeFile(path, bytes, { mode: 0o755 });
- // writeFile mode is pre-umask; ensure it is executable.
- await Deno.chmod(path, 0o755).catch(() => {});
+ await mkdirRecursive(path.slice(0, path.lastIndexOf("/")));
+ await writeBytes(path, bytes, { mode: 0o755 });
+ // writeBytes' mode is pre-umask on some FSes; ensure the executable
+ // bit is set so the spawn doesn't fail with EACCES.
+ await chmodExec(path);
return path;
}
@@ -105,18 +240,14 @@ export async function resolveCompiler(opts = {}) {
* Resolve then exec the compiler with `args`, inheriting stdio.
* Returns the child's exit code (caller decides whether to exit).
*/
-export async function run(args = Deno.args, opts = {}) {
+export async function run(args = [], opts = {}) {
const bin = await resolveCompiler(opts);
- const cmd = new Deno.Command(bin, {
- args,
- stdin: "inherit",
- stdout: "inherit",
- stderr: "inherit",
- });
- const { code } = await cmd.output();
- return code;
+ return await spawnInherit(bin, args);
}
-if (import.meta.main) {
- Deno.exit(await run());
+if (thisIsMain()) {
+ const argv = isDeno ? Deno.args : process.argv.slice(2);
+ const code = await run(argv);
+ if (isDeno) Deno.exit(code);
+ else process.exit(code);
}