Skip to content

fix(cli): package install, package publish and plugin sign print one error line per refusal; the exit-signal pin covers every command - #21522

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21496-text-face-exit-signal
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21496-text-face-exit-signal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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 a try, that try's own catch sees the signal first. In os package install, os package publish and os plugin sign, the catch reported 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.

command before (f9a8eb889e) after (2b562ed58c)
os package install ./does-not-exist.json ✗ Cannot read artifact: ENOENT …, then ✗ EEXIT: 1; exit 1 ✗ Cannot read artifact: ENOENT … only; exit 1
os package publish ./does-not-exist.json --token t --server URL ✗ Cannot read artifact: ENOENT …, then ✗ EEXIT: 1; exit 1 the first line only; exit 1
os package publish ./artifact.json --token t --server STUB --icon-file ./icon.bmp (a local stub answering the package registration with 200) THREE lines: ✗ Cannot infer image type from '…icon.bmp'…, then ✗ Cannot read --icon-file '…icon.bmp': EEXIT: 1, then ✗ EEXIT: 1; exit 1 the first line only; exit 1

All six runs wrote nothing to stderr.

os plugin sign has one exit inside a try: 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 through node:crypto directly; every signature verified against its own key. So that refusal is measured in-process, with verifyPayload replaced 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 affected catch opens with if (isExitSignal(error)) throw error;, the predicate in src/utils/format.ts. No second helper, and format.ts is not edited.

  • packages/cli/src/commands/package/install.ts: the outer catch (was :248).
  • packages/cli/src/commands/package/publish.ts: the icon step's catch (was :667) and the outer catch (was :796).
  • packages/cli/src/commands/plugin/sign.ts: the self-verification catch (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 json flag, or a flag whose options include 'json'". Now there is no member predicate: every module under src/commands, the src/ twin of oclif's pattern command 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.

member this.exit-in-try sites (site, swallowing catch) pairs
os package install 6 (:115, :123, :155, :161, :194, :210) 6, all in the catch at :248
os package publish 15 17: 15 in the catch at :796, plus :649 and :662 in the icon catch at :667 as well
os plugin sign 1 (:98) 1, the catch at :101

The 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-try sites: 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-signal returned 0 hits). The header now describes the widened population. Its account of how a new command enters is rewritten: the module exists under src/commands, whatever faces it has. The face is still read off static flags, but only to label each case (--json, --FLAG json or text).

Text-face pins

A fourth describe drives the three commands in-process through oclif, with five refusal cases:

  • package install: an unreadable artifact (refused inside a nested catch), and a runtime with no install-local endpoint (refused in the try itself, behind a stubbed fetch answering 404);
  • package publish: an unreadable artifact, and an --icon-file whose type it cannot infer (behind a stubbed fetch answering the registration);
  • plugin sign: a failed self-verification (verifyPayload replaced by a seam; signPayload stays real).

Each case asserts that the refusal is ONE ✗ line, about the path or URL the case chose; that EEXIT appears 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 f9a8eb889e by git restore --source, under a trap that restores them on exit. On HEAD 2b562ed58c, before the run, isExitSignal counted 2, 3 and 2 in the three files; after the restore it counted 0, 0 and 0, and each blob was compared against the f9a8eb889e blob 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 empty git diff HEAD. The pin imports the commands by relative src/ path, so no dist/ 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.
  • Every unit test that drives an edited command, plus plugin-publish-visibility: pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 over 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-store and plugin-publish-visibility: 9 files, 172 passed.
  • The cli integration tier for the edited package install: --project integration over package-install-local-boot-steps.integration.test.ts and package-install-local-handlers.integration.test.ts, which spawn os package install against a live runtime: 2 files, 24 passed.
  • pnpm --filter @objectstack/cli run typecheck (tsc --noEmit and check:test-typecheck, whose program includes test/**): exit 0. The test layer's ledger is unchanged (3 files, 28 errors, 6 pinned signatures).
  • Gates, run on HEAD adcc2d77f2: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 66 commands, and all 66 exited 0. Four of them first answered PREREQUISITE NOT MET (exit 3) because the tree had no dist/: check:dual-build-cjs-loads, check:i18n, check:i18n-coverage and check:i18n-walk-parity. After turbo 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.
  • Lint, delivered as a proven narrowing rather than a whole-repo pnpm lint, on HEAD adcc2d77f2. I ran eslint --no-inline-config --format json (the pnpm lint binary 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.mjs never enables type-aware linting (no parserOptions.project, no projectService, 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 a try is outside the analyzer, which is seeded with exit only. Over the whole population on f9a8eb889e, three such calls sit inside a try. compile.ts:1046 is in the same catch block as a this.exit(1) the pin already judges green. init.ts:1359 and init.ts:1388 sit under an outer catch that re-reports them. Measured at the public door: os init demo -p npm with an unreachable registry printed ✗ Project scaffolded, but dependency installation failed., then ✗ Dependency installation failed from that catch, then Error: Dependency installation failed on 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 sign accepts a non-Ed25519 key and labels the result ed25519:. With an RSA key it exits 0 and writes ed25519:default: followed by a 342-character signature; an Ed25519 key gives 86 characters. The contract in packages/core/src/security/plugin-artifact-signature.ts reads "This is the CANONICAL Ed25519 detached-signature contract". signPayload and verifyPayload both pass null as the algorithm and never check the key type. This is outside this card; it is reported, not fixed.
  • The public-door readings ran the CLI from source (bin/run-dev.js), not a built dist/.

Generated by Claude Code

claude added 3 commits October 3, 2026 02:07
… 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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 6 documentable anchor(s).

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/declarative-endpoints.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/concepts/metadata-lifecycle.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/deployment/cli.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/deployment/index.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/deployment/publish-and-preview.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/protocol/kernel/index.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/protocol/kernel/lifecycle.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os package publish (command, read off packages/cli/src/commands/package/publish.ts), os plugin sign (command, read off packages/cli/src/commands/plugin/sign.ts))
  • content/docs/protocol/kernel/metadata-service.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/protocol/kernel/plugin-spec.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os plugin sign (command, read off packages/cli/src/commands/plugin/sign.ts))
  • content/docs/protocol/objectql/index.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/protocol/objectql/schema.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts))
  • content/docs/releases/v17/17-5.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/releases/v17/17-6.mdx (via os package install (command, read off packages/cli/src/commands/package/install.ts), os package publish (command, read off packages/cli/src/commands/package/publish.ts))
  • content/docs/releases/v9.mdx (via os package publish (command, read off packages/cli/src/commands/package/publish.ts))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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): node scripts/docs-audit/affected-docs.mjs --json 88fb5e85a02009344e9e2cf1abc929ae694c051f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2a79c774c5651faf81c994790c56de3b077cf463 — the merge of head adcc2d77f246428bb471872b64f3d878f9bdfd7b into base 88fb5e85a02009344e9e2cf1abc929ae694c051f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 88fb5e85a02009344e9e2cf1abc929ae694c051f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 03:17
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 03:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 5895119 Oct 3, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21496-text-face-exit-signal branch October 3, 2026 03:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants