Skip to content

Release 0.10.1 — runs on Node 16 - #266

Merged
qwerfunch merged 8 commits into
mainfrom
develop
Sep 11, 2026
Merged

qwerfunch merged 8 commits into
mainfrom
develop

Conversation

@qwerfunch

Copy link
Copy Markdown
Owner

Release 0.10.1 — runs on Node 16

2026-09-11 · develop → main

One 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.

Heads-up for the merge: use Create a merge commit. A squash puts the release commit outside develop's ancestry and the next release branch reports every file touched since as conflicting. After the tag, back-merge main into develop so develop stays a clean superset.

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:

node:14  →  cladding requires Node 16 or newer. This is Node 14.21.3.   exit=1
node:16  →  0.10.1                                                      exit=0
node:18  →  0.10.1                                                      exit=0
node:20  →  0.10.1                                                      exit=0
node:22  →  0.10.1                                                      exit=0
node:24  →  0.10.1                                                      exit=0

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.

qwerfunch and others added 8 commits September 10, 2026 16:18
…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
@qwerfunch
qwerfunch merged commit 2d456db into main Sep 11, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant