fix(cli): package install, package publish and plugin sign print one error line per refusal; the exit-signal pin covers every command - #21522
Conversation
… through their catch A `this.exit(1)` inside a `try` throws oclif's exit signal, and these commands' catches reported it as a second error line (`✗ EEXIT: 1`). Each affected catch now opens with the `isExitSignal` rethrow, and the exit-signal pin's population is widened from JSON-capable commands to every command. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…xt face The population no longer filters on a JSON face: every module under src/commands is a member, so a command of any face enters by existing. Floors move to the widened population (65 commands, 127 sites), and a fourth describe drives package install, package publish and plugin sign through a refusal each: one error line, no EEXIT, exit status 1. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2a79c774c5651faf81c994790c56de3b077cf463 && git checkout 2a79c774c5651faf81c994790c56de3b077cf463
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 88fb5e85a02009344e9e2cf1abc929ae694c051f adcc2d77f246428bb471872b64f3d878f9bdfd7b && git checkout -B drift-repro 88fb5e85a02009344e9e2cf1abc929ae694c051f && git merge --no-ff adcc2d77f246428bb471872b64f3d878f9bdfd7b
node scripts/docs-audit/affected-docs.mjs --json 88fb5e85a02009344e9e2cf1abc929ae694c051f
|
Fixes #21496
Clause-②: no
This is the TEXT-face half of the exit-signal family. The JSON-face half landed as #21495 (
2ee8383f4e); its card, #21434, is done.What was wrong
this.exit(1)does not end the process. It throws oclif's exit signal (code: 'EEXIT'). When the call sits inside atry, thattry's owncatchsees the signal first. Inos package install,os package publishandos plugin sign, thecatchreported whatever it caught, so the signal came back out as a second error line. The exit status was right every time; the extra line was the defect.Measured at the public door: the CLI run from source through
bin/run-dev.js, from a scratch directory.f9a8eb889e)2b562ed58c)os package install ./does-not-exist.json✗ Cannot read artifact: ENOENT …, then✗ EEXIT: 1; exit 1✗ Cannot read artifact: ENOENT …only; exit 1os package publish ./does-not-exist.json --token t --server URL✗ Cannot read artifact: ENOENT …, then✗ EEXIT: 1; exit 1os package publish ./artifact.json --token t --server STUB --icon-file ./icon.bmp(a local stub answering the package registration with 200)✗ Cannot infer image type from '…icon.bmp'…, then✗ Cannot read --icon-file '…icon.bmp': EEXIT: 1, then✗ EEXIT: 1; exit 1All six runs wrote nothing to stderr.
os plugin signhas one exit inside atry: the self-verification refusal. No real key reaches it at the public door. I signed with RSA and Ed25519 keys through the CLI, and with Ed25519, Ed448, RSA, RSA-PSS, EC and DSA keys throughnode:cryptodirectly; every signature verified against its own key. So that refusal is measured in-process, withverifyPayloadreplaced by a seam (below). Before the fix it printed✗ Self-verification of the produced signature failed.and then✗ Self-verification error: EEXIT: 1. After the fix it prints the first line only. The exit status is 1 both times.The fix
The ruled idiom (#21434,
5957176280): each affectedcatchopens withif (isExitSignal(error)) throw error;, the predicate insrc/utils/format.ts. No second helper, andformat.tsis not edited.packages/cli/src/commands/package/install.ts: the outercatch(was:248).packages/cli/src/commands/package/publish.ts: the icon step'scatch(was:667) and the outercatch(was:796).packages/cli/src/commands/plugin/sign.ts: the self-verificationcatch(was:101).Closing the class: the pin's population is now every command
The pin's analyzer is shape-based. Before this PR its population was "declares a boolean
jsonflag, or a flag whoseoptionsinclude'json'". Now there is no member predicate: every module undersrc/commands, thesrc/twin of oclif'spatterncommand table, is a member. A later command of any face enters by existing. A new assertion holds the population equal to the walk, so a filter that comes back goes red.The widened population's red list, measured BEFORE the fix (the widened pin run against the unfixed commands): 3 members, exactly the three the card named. No further command was flagged.
this.exit-in-trysitesos package install:115,:123,:155,:161,:194,:210):248os package publish:796, plus:649and:662in the icon catch at:667as wellos plugin sign:98):101The site counts match the card's 6, 15 and 1. The widened population is 65 commands: the 46 JSON-capable ones from the first population, and 19 with a text face only. It holds 127
this.exit-in-trysites: 105 from before, and 22 in the three commands above. The floors move to 65 and 127. The first population's 46 is kept as a separate floor on the face labels.Name (A4): renamed.
git mv packages/cli/test/json-exit-signal.pin.test.ts packages/cli/test/exit-signal.pin.test.ts. The population is no longer JSON-only, so the old name would have described a filter that no longer exists. Nothing in the tree referenced the old path (git grep json-exit-signalreturned 0 hits). The header now describes the widened population. Its account of how a new command enters is rewritten: the module exists undersrc/commands, whatever faces it has. The face is still read offstatic flags, but only to label each case (--json,--FLAG jsonortext).Text-face pins
A fourth
describedrives the three commands in-process through oclif, with five refusal cases:package install: an unreadable artifact (refused inside a nestedcatch), and a runtime with no install-local endpoint (refused in thetryitself, behind a stubbedfetchanswering 404);package publish: an unreadable artifact, and an--icon-filewhose type it cannot infer (behind a stubbedfetchanswering the registration);plugin sign: a failed self-verification (verifyPayloadreplaced by a seam;signPayloadstays real).Each case asserts that the refusal is ONE
✗line, about the path or URL the case chose; thatEEXITappears nowhere in the output; and that the exit status is 1. Message wording is not pinned. The cases stay in the unit tier: nothing is spawned, no kernel boots, and every file they read is written at module scope.Reverse verification. All three command files were restored to
f9a8eb889ebygit restore --source, under a trap that restores them on exit. On HEAD2b562ed58c, before the run,isExitSignalcounted 2, 3 and 2 in the three files; after the restore it counted 0, 0 and 0, and each blob was compared against thef9a8eb889eblob to prove the restore landed. The pin went red as expected: 8 failed, 93 passed of 101. Three were structural members and five were the driven cases, which printed 2, 2, 2, 3 and 2 error lines. The files were then restored to HEAD and proven by blob hash and an emptygit diff HEAD. The pin imports the commands by relativesrc/path, so nodist/build was involved.Verification
pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/exit-signal.pin.test.ts: 101 passed. That is 15 fixtures, 3 population checks, 65 members, 13 JSON-face driven cases and 5 text-face driven cases.plugin-publish-visibility:pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2over the pin,package-install-storage-dir,package-publish-error-envelope,package-publish-manifest-id,package-publish-namespace,package-publish-visibility,plugin-sign,publish-active-environment-storeandplugin-publish-visibility: 9 files, 172 passed.package install:--project integrationoverpackage-install-local-boot-steps.integration.test.tsandpackage-install-local-handlers.integration.test.ts, which spawnos package installagainst a live runtime: 2 files, 24 passed.pnpm --filter @objectstack/cli run typecheck(tsc --noEmitandcheck:test-typecheck, whose program includestest/**): exit 0. The test layer's ledger is unchanged (3 files, 28 errors, 6 pinned signatures).adcc2d77f2:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 66 commands, and all 66 exited 0. Four of them first answeredPREREQUISITE NOT MET(exit 3) because the tree had nodist/:check:dual-build-cjs-loads,check:i18n,check:i18n-coverageandcheck:i18n-walk-parity. Afterturbo run build --filter='!@objectstack/docs'they ran again and exited 0.dispatch-gates --ran:66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. I also ran four roster gates whose roster sits in a directory this diff touches; all four exited 0 (check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity). The tool's own outside-the-list blocks are NOT MEASURED here and are left to CI: the 5 path-scheduled CI jobs, the 4 type-check lanes, the 6 workflow-valued families, the 11 wide-population families, and the other 50 artifact-roster families.pnpm lint, on HEADadcc2d77f2. I raneslint --no-inline-config --format json(thepnpm lintbinary and flags) over the five paths this diff adds or modifies. The checked population comes from eslint's own answer: the JSON has 5 entries. The 4 TypeScript files are linted with 0 errors and 0 warnings, and the changeset is reported "File ignored because no matching configuration was supplied". The only deleted path is the renamed pin. Narrowing to those files cannot change any other file's verdict.eslint.config.mjsnever enables type-aware linting (noparserOptions.project, noprojectService, no typed rules, as its own header states and a grep confirms). Its plugins are inline AST rules, and the only files it reads at load are two baselines this diff does not touch. Each verdict therefore depends on the file's own text and the config alone.Acceptance notes
this.error(…)inside atryis outside the analyzer, which is seeded withexitonly. Over the whole population onf9a8eb889e, three such calls sit inside atry.compile.ts:1046is in the samecatchblock as athis.exit(1)the pin already judges green.init.ts:1359andinit.ts:1388sit under an outercatchthat re-reports them. Measured at the public door:os init demo -p npmwith an unreachable registry printed✗ Project scaffolded, but dependency installation failed., then✗ Dependency installation failedfrom thatcatch, thenError: Dependency installation failedon stderr, and exited 2. That is the same family through a different signal. Widening the analyzer's seed would reshape it, so it is reported here and in the report, not fixed. The header's "does NOT cover" section states it.os plugin signaccepts a non-Ed25519 key and labels the resulted25519:. With an RSA key it exits 0 and writesed25519:default:followed by a 342-character signature; an Ed25519 key gives 86 characters. The contract inpackages/core/src/security/plugin-artifact-signature.tsreads "This is the CANONICAL Ed25519 detached-signature contract".signPayloadandverifyPayloadboth passnullas the algorithm and never check the key type. This is outside this card; it is reported, not fixed.bin/run-dev.js), not a builtdist/.Generated by Claude Code