Conversation
…clearly A global install on Node 16 died with a raw ERR_UNKNOWN_FILE_EXTENSION trace from inside Node's ESM loader. Two defects produced it: - The bin entry had no file extension. Under "type": "module" Node's ESM loader refuses an extensionless file, so the crash happened before any project code ran and nothing could report it. Measured in containers: Node 16 refuses the extensionless form, 18/20/22 accept it, and every release accepts `.mjs`. Not a platform defect — it reproduces on Linux too. - No Node floor was declared or enforced. There was no `engines` field, so npm installed silently on an end-of-life runtime, and no `process.version` check anywhere. bin/clad -> bin/clad.mjs, with the floor check ahead of the bundle import (the bundle is what would otherwise throw, so a later check would never run). The floor is 20: bundled commander declares >=20 and the esbuild target is node20. `engines` is declared too, but it only warns — the runtime refusal is what the user sees. This adds no support for Node 16. It turns a crash into an instruction. CI gains a smoke-only entry job that installs the packed tarball through npm's own bin shim on ubuntu 20/22, windows 22, and ubuntu 18 for the refusal — the two axes (platform, below-floor Node) that were unexercised. Verified through the real install path: Node 16 and 18 print the floor message and exit 1; 20 and 22 print the version. The originally reported `clad setup` on Node 16 now prints the same message. F-5fc112ad Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The previous commit declared a floor of Node 20 on the strength of a
dependency's `engines` field. Measured against running releases, that floor
was never real: the shipped bundle already ran on Node 18, and exactly four
platform surfaces stood between it and Node 16 —
execa → node:util aborted
execa → node:stream getDefaultHighWaterMark
execa → node:events addAbortListener
ours → node:readline/promises
Because the engine ships as one bundled file, execa's floor had silently
become the whole tool's floor. A user could not run `clad --version` on
Node 16 for want of a spawn helper.
So the floor drops by removing what reached above it, not by relaxing a
number. `src/core/run-sync.ts` replaces execaSync at all thirteen call sites
and in the conformance harness, so execa leaves the tree entirely. Four
behaviours are reproduced deliberately:
- cross-spawn keeps the Windows `.cmd` resolution execa was delegating; a
raw spawn cannot find `npm`, and `shell: true` would let cmd.exe mangle
the architecture stage's exclusion regex
- the exit status is absent whenever the spawn never started, on every
platform — Windows reports 1 through the command processor AND
synthesizes ENOENT, so the error, not the status, decides
- one trailing newline is stripped from each captured stream
- the capture limit stays 100 MB, against the platform's 1 MB that
truncates silently
Our own blockers: one interactive prompt statically imported the promise
flavour of readline, which exists from Node 17 and made the whole bundle
unloadable on 16 — now the callback form. Built-in fetch arrives in Node 18
and only the direct model lanes use it, so those name the missing capability
and the release that provides it while every other command keeps working.
scripts/check-node-surface.mjs is the regression guard: it reads every
platform-module import out of the bundle and resolves each against the
running release, so a dependency upgrade that reaches above the floor fails
loudly. CI runs it in the build job and again on the floor release, and the
entry matrix becomes Linux 16/20/22 plus Windows 22, with Node 14 proving
the refusal still speaks below the floor. The Windows cell runs the runner's
own tests, the only place that spawn path executes.
Verified through the real install path: 14 refuses and exits 1; 16, 18, 20
and 22 print the version. On Node 16 the originally reported `clad setup`
completes, and `sync` (306 entries), `status` and the gate all run.
The conformance tests, authored in a separate context, caught a real defect:
cross-spawn assigns its error field to null on a healthy run, so an identity
check against undefined reported every success as failed.
Node 14 and below stay out of reach deliberately — 14 needs our own
recursive copy and 12 two more of our own calls, for runtimes three years
past end of life.
F-203a3114
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Eleven version sites plus the six README record rows and the honesty pins that hold them. Release notes describe the Node floor drop in user terms: a global install on Node 16 used to die inside Node's module loader before printing anything, and the floor stood at 20 only because a bundled dependency declared it. Verified at this version through the real install path — Node 14 refuses with one sentence and exits 1; 16, 18, 20, 22 and 24 print 0.10.1. Full suite 3855/3855, conformance 33/33 at L4, strict pre-push gate green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`test.skipIf(cond)` with a true condition removes a test from COLLECTION, not just from the run. Two of the new runtime tests keyed on `dist/clad.js`, which is gitignored, so a fresh clone collected 3853 where the committed claim said 3855. The public count guard then refused the build, and because `npm ci` runs it through `prepare`, every CI job failed at its first step. The bundle is resolved once — `dist/clad.js` when present, else the committed `plugins/claude-code/dist/clad.js` mirror, which exists in any checkout — and every test is a plain `test(...)`, with existence asserted inside the test so a missing bundle fails loudly instead of disappearing from the count. The npm resolution case loses its PATH gate for the same reason and keeps its meaning: an unresolvable npm fails with the portable-resolution message. Collection is now identical with and without build output: 3855 either way, verified in a clone carrying no dist/. F-203a3114 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Windows cell failed, and the cause was not Windows-specific code: each entry cell ran `npm ci && npm run build`, and the build validates the public test count. That count legitimately varies by platform — one transaction test cannot run on Windows — so Windows collects 3854 against a claim of 3855 and the build refuses before anything is exercised. A per-cell build was the wrong shape anyway. One pack job now builds on a platform and release that can run the full toolchain, uploads the archive, and every cell installs the SAME bytes — which is a stronger claim than each cell building its own. The surface check reads the shipped bundle out of the unpacked archive rather than a local build. The Windows cell still needs dev dependencies for the runner tests, installed with --ignore-scripts so the prepare hook does not drag the count guard back in. That guard keeps running where it belongs: the verify job, on one known platform. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`npm pack --pack-destination` does not create its destination, so the pack job failed with ENOENT after the build had already succeeded. Reproduced in a clean clone, fixed with the mkdir, and re-verified there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`npm install -g archive/<file>.tgz` is not a path to npm — an argument with a slash and no leading `./` is read as a GitHub owner/repo shorthand, so every entry cell tried `ssh://git@github.com/archive/cladding-0.10.1.tgz.git` and exited 128. My local runs used absolute paths and sailed past it. Reproduced in a container both ways: the bare form spawns git, the `./` form installs and prints the version. The unpack step takes the same prefix. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tool now runs on Node 16 instead of refusing below 20
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 0.10.1 — runs on Node 16
2026-09-11 · develop → mainOne user-facing fix shipped as a patch: a global install on Node 16 crashed inside Node's own module loader before printing anything, and the supported floor stood at Node 20 only because a bundled dependency declared it.
What ships
▸ The published entry point loads on every release. It had no file extension, which the module loader refuses in a package that declares modules, so the crash happened before any project code ran and nothing could explain it.
▸ The floor drops from Node 20 to Node 16, by removing what reached above it rather than by relaxing a number. Measured against running releases: the engine already worked on Node 18, and exactly four runtime surfaces separated it from Node 16 — three from one dependency used only to launch external commands, one a promise-flavoured import of our own. A single bundled file means that dependency's requirement was the whole tool's requirement.
▸ One launcher of our own replaces it, reproducing the four behaviours whose loss would change a check's verdict silently: Windows resolution of commands that are really batch files, one trailing newline trimmed, a capture limit that does not truncate a verbose tool, and an absent exit status when a command never started.
▸ A guard so the floor cannot rise again unnoticed. Every runtime module the bundle imports is resolved against the release under test, in the build job and again on the floor release itself.
Verified at this version
Through a real install from the packed archive, not a direct file invocation:
On Node 16 the originally reported command completes, and so does real work — spec validation over 306 entries, the status matrix, and the gate. Full suite 3855 of 3855, conformance 33 of 33 at the top assurance level, strict pre-push gate green, rebuild on the commit produces no diff, and the tree stays clean after the gate.
Known limits, stated plainly
▸ On Node 16 a check that delegates to this project's own linter fails, because that linter needs Node 18. It is reported as a tool finding rather than a crash, and it is the toolchain's requirement rather than cladding's floor.
▸ Windows has no runner in the previous workflow, so its cell here is the first real execution on that platform. The mechanism is identical across platforms and the platform-specific hazards carry source-level guards, but nobody has run project activation end to end on Windows.
▸ Node 14 and below stay out of reach deliberately: 14 additionally needs our own recursive copy and 12 two more of our own calls, for runtimes three years past end of life. Below the floor, one sentence naming the required version is the honest answer.