fix(cli): os init prints its in-try refusals once; the exit-signal pin is seeded with this.error - #21541
Conversation
…n is seeded with this.error os init's outer catch re-reported the CLIError its own this.error calls raise inside the try (the dependency-install and scaffold-validation refusals): a second ✗ line under each refusal. The catch now opens with the isExitSignal rethrow. The exit-signal pin's analyzer is seeded with this.error as well as this.exit over the same every-command population, and drives both os init refusals in-process. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 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 e736f3bd700847ed4b1a705a92a02a89fcb876cb && git checkout e736f3bd700847ed4b1a705a92a02a89fcb876cb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 49161683fbc9ab0ef15993d9f201f490ca71a2db 549f3704e4e96c860f28b5f8dd302aca4dc79ca0 && git checkout -B drift-repro 49161683fbc9ab0ef15993d9f201f490ca71a2db && git merge --no-ff 549f3704e4e96c860f28b5f8dd302aca4dc79ca0
node scripts/docs-audit/affected-docs.mjs --json 49161683fbc9ab0ef15993d9f201f490ca71a2db
|
Fixes #21523
Clause-②: no
This is the
this.errorface of the exit-signal family. Itsthis.exittext face (#21496) landed as PR #21522 (5895119c35), which renamed and widened the pin this PR extends.What was wrong
this.error(msg)does not end the process. It throws oclif'sCLIError, which carriesoclif.exit(2 by default), soisExitSignal(packages/cli/src/utils/format.ts) already recognises it. Inos init, twothis.errorcalls sit insiderun()'s outertry: the scaffold self-test refusal and the dependency-install refusal. Thattry'scatchprinted the message again withprintError, then raised a secondthis.errorwith the same message.Measured at the public door: the published entry
packages/cli/bin/run.jsover a freshly builtdist/, run from a scratch directory, with npm pointed at a closed local port (npm_config_registry=http://127.0.0.1:9/,npm_config_fetch_retries=0).os init demo -p npmbee8d1c62c)549f3704e4)✗ Project scaffolded, but dependency installation failed., then✗ Dependency installation failed✗ Project scaffolded, but dependency installation failed.only› Error: Dependency installation failed› Error: Dependency installation failedThe scaffold self-test refusal had the same shape, measured in-process (below):
✗ Scaffold validation failed: …, then a second✗ Scaffold validation failed.The fix
The outer
catchofpackages/cli/src/commands/init.tsnow opens withif (isExitSignal(error)) throw error;, imported fromutils/format.js. This is the ruled idiom: no second helper, andformat.tsis not edited. The catch-all still prints and refuses for every other error it catches.Closing the class: the analyzer is seeded with
this.errorThe analyzer in
packages/cli/test/exit-signal.pin.test.tswas seeded withexitonly. It is now seeded with both members (SIGNAL_SEEDS = ['exit', 'error']), over the same every-command population discovered from oclif's command table. A later command that throws either signal inside atryenters by existing.Red list of the reseeded analyzer, measured BEFORE the fix on
bee8d1c62c: 1 member, 2 sites.os init:src/commands/init.ts:1359 this.error('Scaffold validation failed')andsrc/commands/init.ts:1388 this.error('Dependency installation failed'), both swallowed by the catch at line 1391 ("its first statement is not the isExitSignal rethrow").Both are in scope and both are repaired by the one catch above. The reseed finds 4
this.errorsites inside atryover the population, 3 distinct in source:init.ts's two, above;compile.ts:1046(this.error(err.message)), judged once foros compileand once foros build, which inherits it. It shares its enclosing catch with athis.exit(1)the pin already judged. That catch opens with the rethrow, so it stays green.No flagged catch handles the signal on purpose. The only red catch,
init.ts's, re-reports what it caught. No catch converts athis.errorinto something else, so the two seeds have the same shape.One more census, taken on
bee8d1c62cover every command source: the only other oclifCommandmember that throws the signal,this.parse, is called inside atryby no command. The header records this, along with the rule that a command which does so adds a seed.Header and floors. The header now names what seeds the analyzer, and says a new command enters by either call. The population floor (65) and the JSON-face floor (46) are unchanged; both were re-measured on
bee8d1c62c. The site floor rises from 127 to 131: the 127this.exitsites plus the 4this.errorsites.Fixtures. Five new fixtures pin the new seed:
os initdefect shape (red);this.erroroutside everytry(not a site);this.errorreached through a same-class helper (red);os compile's shape: athis.errorsharing a guarded catch with athis.exit(green).this.error(msg, { exit: false })throws nothing, and no command writes it. The header says the analyzer judges it like any otherthis.error. That can give a false red (the guard is harmless there), never a false green.Text-face pins for
os initTwo cases join the driven text-face
describe.os initruns in-process through oclif. Three things are replaced:execSync, throughvi.mock('child_process')) by a seam;validateScaffold) by a seam;process.cwdspy, restored after each case).The cases:
✗line and exit 2. The install ran once, in the target directory. The self-test never ran.✗line, naming the rejection the case chose, and exit 2.expectOneRefusalnow takes the expected status: 1 for thethis.exit(1)refusals, as before, and 2 foros init'sthis.errorones. The tier staysunit: nothing is spawned and nothing is bundled.Verification
Everything below ran on the final commit
549f3704e4unless it names another commit.pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/exit-signal.pin.test.ts: 109 passed. Baseline onbee8d1c62c, before any edit: 101 passed.init.tsuntouched: 3 failed, 106 passed. The failures areos init (text), with the two leaks above, and both drivenos initcases, each printing 2✗lines.node scripts/ablation-replace.mjs --file packages/cli/src/commands/init.ts --anchor 'if (isExitSignal(error)) throw error;' --delete -- pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/exit-signal.pin.test.ts.4930c989f594→d811c1c322b6.os init (text)namesinit.ts:1360and:1389swallowed by the catch at:1392. Each driven case reports "expected [ …(2) ] to have a length of 1 but got 2".4930c989f594) andgit diff HEADis empty.init.tsfromsrc/by a relative import, so this leg needed nodist/rebuild.packages/cliunit tier.pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 251 files, 3672 tests passed. The integration tier is declared to CI.os init. These areinit-created-files-summary,starter-field-consumers,scaffold-emission-policy,generate-object-namespace-prefix,generate-scaffolds-reach-stackandcreate-refuses-invalid-project-name. Command:OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2over those files. Result: 6 files, 53 tests passed.init-created-files-summarydrives the failed-install path through a fake package manager onPATH.pnpm --filter @objectstack/cli typecheckexits 0.check:test-typecheckreports OK: the debt ledger holds 28 errors, and none is in the pin file.--listFilesconfirms the pin is intsconfig.test.json's program.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 66 commands for this diff, and all 66 exit 0.check:dual-build-cjs-loadsandcheck:i18n-coverage, first answered exit 3 (PREREQUISITE NOT MET: nodist/for packages outside the cli closure). They were re-run after a fullturbo run buildand then exited 0.--ranreconciliation: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED".pnpm lintrun.eslint --print-configshows 5 rules in effect on each. The changeset is outside everyfilesglob.--format json:node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-config --format json packages/cli/src/commands/init.ts packages/cli/test/exit-signal.pin.test.tsreports 2 files, 0 errors, 0 warnings, exit 0.--print-configshowsparserOptions.projectandprojectServicenull. The config states it never enables type-aware linting. Every rule in effect is single-file (no-restricted-syntax,no-restricted-imports,slot-lookup/no-any-assignment,query-options/no-any-erasure,verify-stand-in/no-asserted-driver-argument,comment-swallow/no-code-inside-block-comment). This diff touches neithereslint.config.mjsnor the baselines it reads, so no untouched file's verdict can move.Acceptance notes
os init's refusal is still followed by oclif's› Error: …block on stderr. The entry point renders that block from the thrownCLIErrorafterrun()exits, and it printed exactly once before the fix too. What the fix removed is the catch's second✗line. Every otheros initrefusal has the same✗+Error:pair:os init demo -t bogusprints✗ Unknown template: bogusand then› Error: Unknown template: bogus, exit 2, measured on549f3704e4. That pairing comes fromprintErrorfollowed bythis.error, not from this mechanism. The pin counts the command's✗lines, as the family's other text-face pins do. Whether the pairing is itself a second report of one refusal is outside this card, and is reported to the seat rather than changed here.dev.ts,start.ts,serve.ts,packages/runtime/orpackages/metadata-protocol/was touched.serve.tschanged onmainsince this branch's base (550f4cc2fd), adding atrywith no signal call inside it, andgit merge-treeof this head withmainis clean.Generated by Claude Code