Skip to content

Commit fbfc0e4

Browse files
hyperpolymathclaude
andcommitted
feat(shim): cross-runtime (Deno/Bun/Node) + JSR fast-check types
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 `/// <reference types="./mod.d.ts" />` 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) <noreply@anthropic.com>
1 parent 686adc1 commit fbfc0e4

3 files changed

Lines changed: 241 additions & 26 deletions

File tree

.claude/CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ The "no new TypeScript" rule has seven approved exemptions in this repo. These p
7575
| Path | Files | Rationale | Unblock condition |
7676
|---|---|---|---|
7777
| `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). |
78+
| `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. |
7879
| `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). |
7980

8081
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
8586

8687
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.
8788

89+
### Runtime Exemptions (Approved)
90+
91+
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.
92+
93+
| Path | Banned thing(s) used | Rationale | Unblock condition |
94+
|---|---|---|---|
95+
| `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. |
96+
97+
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 ❌.
98+
8899
### Package Management
89100

90101
- **Primary**: Guix (guix.scm)

packages/affinescript-cli/mod.d.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
// Type declarations for mod.js — the @hyperpolymath/affinescript shim.
5+
// Lets JSR's fast-check publish without the "JavaScript entrypoint without
6+
// type declarations" warning, and gives consumers proper editor types.
7+
//
8+
// Per CLAUDE.md, .d.ts is an approved TypeScript carve-out (file format
9+
// for declaration files only); the implementation in mod.js remains JS
10+
// because every effect is a Deno.* host API, the documented "JS only
11+
// where ReScript cannot" exception.
12+
13+
/** ADR-019 release target triples this shim knows about. */
14+
export type Target = "linux-x64" | "macos-x64" | "macos-arm64";
15+
16+
/** A single per-target entry in the {@link Pins} table. */
17+
export interface PinEntry {
18+
/** Canonical Release asset URL — `affinescript-<target>` raw executable. */
19+
url: string;
20+
/** Lower-case hex SHA-256. Empty string ⇒ fail-closed for that target. */
21+
sha256: string;
22+
}
23+
24+
/**
25+
* The full pin table — ONE compiler version + ONE sha256 per target, per
26+
* shim release. Filled in `pins.js` when a `v*` tag is cut.
27+
*/
28+
export interface Pins {
29+
/** The pinned compiler tag (e.g. `"v0.1.1"`). */
30+
version: string;
31+
/** Per-target download + checksum entries. Targets without a sha256
32+
* refuse to resolve (fail-closed). */
33+
targets: Partial<Record<Target, PinEntry>>;
34+
}
35+
36+
/** Options accepted by {@link resolveCompiler} and {@link run}. */
37+
export interface ResolveOptions {
38+
/** Override the embedded pin table (test seam). */
39+
pins?: Pins;
40+
/** Override the global `fetch` (test seam). */
41+
fetchImpl?: typeof fetch;
42+
}
43+
44+
/**
45+
* Map a host OS/arch to one of the supported ADR-019 release targets.
46+
* Throws if the host isn't covered (e.g. windows-x64 is a tracked follow-up).
47+
*/
48+
export function hostTarget(os?: string, arch?: string): Target;
49+
50+
/** Lower-case hex of the SHA-256 of `bytes`. */
51+
export function sha256Hex(bytes: ArrayBuffer | Uint8Array): Promise<string>;
52+
53+
/**
54+
* Absolute path the shim caches a pinned binary at. Resolves
55+
* `AFFINESCRIPT_CACHE` → `XDG_CACHE_HOME` → `$HOME/.cache` → `TMPDIR` → `/tmp`.
56+
*/
57+
export function cachePath(version: string, target: Target): string;
58+
59+
/**
60+
* Resolve a runnable compiler binary path for the host. On a cache
61+
* miss, downloads the pinned Release asset, verifies its SHA-256 against
62+
* the embedded pin, writes it to {@link cachePath} with the executable
63+
* bit set, and returns the path. Throws on checksum mismatch (refuses
64+
* to cache or run the tampered bytes).
65+
*/
66+
export function resolveCompiler(opts?: ResolveOptions): Promise<string>;
67+
68+
/**
69+
* Resolve via {@link resolveCompiler}, then `Deno.Command`-spawn the
70+
* binary with `args`, inheriting stdio. Returns the child's exit code
71+
* (caller decides whether to `Deno.exit()`).
72+
*/
73+
export function run(args?: string[], opts?: ResolveOptions): Promise<number>;

packages/affinescript-cli/mod.js

Lines changed: 157 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,156 @@
66
// The AffineScript compiler is a native OCaml binary, not a JS package.
77
// Per ADR-019 the GitHub Release (cut by .github/workflows/release.yml,
88
// #260 S2) is the CANONICAL artifact: per-platform `affinescript-<target>`
9-
// binaries + a `SHA256SUMS` manifest. This package is the ergonomic Deno
9+
// binaries + a `SHA256SUMS` manifest. This package is the ergonomic
1010
// front door: it downloads the binary for the host triple from the
1111
// Release pinned by THIS package version, verifies it against the
1212
// checksum embedded here (no floating fetch — one version+checksum per
1313
// shim release, the ADR-019 supply-chain rule), caches it, and execs it
1414
// with the caller's argv.
1515
//
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.
16+
// Runtime support — Deno is canonical; Bun and Node.js are first-class
17+
// targets because the shim's consumers (LSP installers, IDE extensions,
18+
// build scripts) often run in those environments. All three branches
19+
// are guarded by runtime detection at module load; nothing dynamically
20+
// imports a foreign runtime's globals. Browsers and Cloudflare Workers
21+
// are NOT supported and never will be: the shim's purpose is "fetch,
22+
// save to disk, exec a native binary" — steps 2 and 3 are not possible
23+
// in a sandboxed JS runtime.
24+
//
25+
// JavaScript, not ReScript, because this is entirely host-API surface
26+
// (spawn / fetch / crypto.subtle / fs) — the documented "JS only where
27+
// ReScript cannot" carve-out, same as packages/affine-js.
28+
29+
/// <reference types="./mod.d.ts" />
2030

2131
import { PINS } from "./pins.js";
2232

33+
// ─── runtime detection ──────────────────────────────────────────────
34+
const isDeno = typeof Deno !== "undefined";
35+
const isBun = !isDeno && typeof Bun !== "undefined";
36+
const isNode = !isDeno && !isBun &&
37+
typeof process !== "undefined" && !!process.versions?.node;
38+
39+
if (!isDeno && !isBun && !isNode) {
40+
throw new Error(
41+
"@hyperpolymath/affinescript: unsupported runtime. " +
42+
"This package targets Deno (canonical), Bun, and Node.js. " +
43+
"Browsers and Workers cannot exec native binaries.",
44+
);
45+
}
46+
47+
// ─── host helpers ───────────────────────────────────────────────────
48+
// Each helper picks one branch at runtime; the others are dead code in
49+
// that process and never load. `await import("node:…")` in the Node
50+
// branch works under Deno too (Deno node-compat), so a Bun path that
51+
// shells out to node: APIs is still valid — but we keep Bun's native
52+
// APIs where they exist for fewer surprises and better diagnostics.
53+
54+
function hostOs() {
55+
// Deno: "linux"|"darwin"|"windows"|...
56+
if (isDeno) return Deno.build.os;
57+
// Bun and Node both populate process.platform identically.
58+
return process.platform;
59+
}
60+
61+
function hostArch() {
62+
// Deno reports "x86_64"|"aarch64"; Node/Bun report "x64"|"arm64".
63+
// Normalise to Deno's spelling — that's what hostTarget() matches.
64+
if (isDeno) return Deno.build.arch;
65+
if (process.arch === "x64") return "x86_64";
66+
if (process.arch === "arm64") return "aarch64";
67+
return process.arch;
68+
}
69+
70+
function envGet(name) {
71+
if (isDeno) return Deno.env.get(name);
72+
return process.env[name];
73+
}
74+
75+
async function readBytes(path) {
76+
if (isDeno) return await Deno.readFile(path);
77+
if (isBun) return new Uint8Array(await Bun.file(path).arrayBuffer());
78+
const { readFile } = await import("node:fs/promises");
79+
return new Uint8Array(await readFile(path));
80+
}
81+
82+
async function writeBytes(path, bytes, { mode } = {}) {
83+
if (isDeno) {
84+
return await Deno.writeFile(path, bytes, mode != null ? { mode } : {});
85+
}
86+
if (isBun) {
87+
await Bun.write(path, bytes);
88+
if (mode != null) {
89+
const { chmod } = await import("node:fs/promises");
90+
await chmod(path, mode).catch(() => {});
91+
}
92+
return;
93+
}
94+
const { writeFile, chmod } = await import("node:fs/promises");
95+
await writeFile(path, bytes);
96+
if (mode != null) await chmod(path, mode).catch(() => {});
97+
}
98+
99+
async function mkdirRecursive(path) {
100+
if (isDeno) return await Deno.mkdir(path, { recursive: true });
101+
const { mkdir } = await import("node:fs/promises");
102+
await mkdir(path, { recursive: true });
103+
}
104+
105+
async function chmodExec(path) {
106+
if (isDeno) return await Deno.chmod(path, 0o755).catch(() => {});
107+
const { chmod } = await import("node:fs/promises");
108+
await chmod(path, 0o755).catch(() => {});
109+
}
110+
111+
async function spawnInherit(bin, args) {
112+
if (isDeno) {
113+
const { code } = await new Deno.Command(bin, {
114+
args,
115+
stdin: "inherit",
116+
stdout: "inherit",
117+
stderr: "inherit",
118+
}).output();
119+
return code;
120+
}
121+
if (isBun) {
122+
// Bun.spawn returns a process whose `.exited` resolves to the code.
123+
const proc = Bun.spawn([bin, ...args], {
124+
stdin: "inherit",
125+
stdout: "inherit",
126+
stderr: "inherit",
127+
});
128+
return await proc.exited;
129+
}
130+
const { spawn } = await import("node:child_process");
131+
return await new Promise((resolve, reject) => {
132+
const child = spawn(bin, args, { stdio: "inherit" });
133+
child.on("close", (code) => resolve(code ?? 0));
134+
child.on("error", reject);
135+
});
136+
}
137+
138+
/** Does this process look like it was invoked as `<runtime> <thisfile>`? */
139+
function thisIsMain() {
140+
// Deno: import.meta.main; Bun also honours it. Node: compare URLs.
141+
if (isDeno || isBun) return import.meta.main === true;
142+
if (isNode) {
143+
const arg1 = process.argv[1];
144+
if (!arg1) return false;
145+
try {
146+
const here = new URL(import.meta.url).pathname;
147+
return here === arg1 || here.endsWith(arg1);
148+
} catch {
149+
return false;
150+
}
151+
}
152+
return false;
153+
}
154+
155+
// ─── public API ─────────────────────────────────────────────────────
156+
23157
/** Map the host to an ADR-019 release target triple. */
24-
export function hostTarget(os = Deno.build.os, arch = Deno.build.arch) {
158+
export function hostTarget(os = hostOs(), arch = hostArch()) {
25159
if (os === "linux" && arch === "x86_64") return "linux-x64";
26160
if (os === "darwin" && arch === "x86_64") return "macos-x64";
27161
if (os === "darwin" && arch === "aarch64") return "macos-arm64";
@@ -42,10 +176,10 @@ export async function sha256Hex(bytes) {
42176

43177
/** Cache path for a pinned binary (XDG, then HOME, then a temp dir). */
44178
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";
179+
const base = envGet("AFFINESCRIPT_CACHE") ??
180+
envGet("XDG_CACHE_HOME") ??
181+
(envGet("HOME") ? `${envGet("HOME")}/.cache` : null) ??
182+
envGet("TMPDIR") ?? "/tmp";
49183
return `${base}/affinescript/${version}/affinescript-${target}`;
50184
}
51185

@@ -74,7 +208,7 @@ export async function resolveCompiler(opts = {}) {
74208
// Cache hit only counts if the cached bytes still match the pin
75209
// (defends against a corrupted/tampered cache).
76210
try {
77-
const cached = await Deno.readFile(path);
211+
const cached = await readBytes(path);
78212
if ((await sha256Hex(cached)) === entry.sha256) return path;
79213
} catch { /* miss — fall through to download */ }
80214

@@ -94,29 +228,26 @@ export async function resolveCompiler(opts = {}) {
94228
`Release artifact does not match this shim version's pin).`,
95229
);
96230
}
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(() => {});
231+
await mkdirRecursive(path.slice(0, path.lastIndexOf("/")));
232+
await writeBytes(path, bytes, { mode: 0o755 });
233+
// writeBytes' mode is pre-umask on some FSes; ensure the executable
234+
// bit is set so the spawn doesn't fail with EACCES.
235+
await chmodExec(path);
101236
return path;
102237
}
103238

104239
/**
105240
* Resolve then exec the compiler with `args`, inheriting stdio.
106241
* Returns the child's exit code (caller decides whether to exit).
107242
*/
108-
export async function run(args = Deno.args, opts = {}) {
243+
export async function run(args = [], opts = {}) {
109244
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;
245+
return await spawnInherit(bin, args);
118246
}
119247

120-
if (import.meta.main) {
121-
Deno.exit(await run());
248+
if (thisIsMain()) {
249+
const argv = isDeno ? Deno.args : process.argv.slice(2);
250+
const code = await run(argv);
251+
if (isDeno) Deno.exit(code);
252+
else process.exit(code);
122253
}

0 commit comments

Comments
 (0)