Skip to content

Commit 24dc7c1

Browse files
fix(cli): os init prints its in-try refusals once; the exit-signal pin is seeded with this.error (#21541)
Fixes #21523 Clause-②: no This is the `this.error` face of the exit-signal family. Its `this.exit` text 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's `CLIError`, which carries `oclif.exit` (2 by default), so `isExitSignal` (`packages/cli/src/utils/format.ts`) already recognises it. In `os init`, two `this.error` calls sit inside `run()`'s outer `try`: the scaffold self-test refusal and the dependency-install refusal. That `try`'s `catch` printed the message again with `printError`, then raised a second `this.error` with the same message. Measured at the public door: the published entry `packages/cli/bin/run.js` over a freshly built `dist/`, 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 npm` | before (`bee8d1c62c`) | after (`549f3704e4`) | |---|---|---| | stdout | `✗ Project scaffolded, but dependency installation failed.`, then `✗ Dependency installation failed` | `✗ Project scaffolded, but dependency installation failed.` only | | stderr (besides npm's own output) | `› Error: Dependency installation failed` | `› Error: Dependency installation failed` | | exit status | 2 | 2 | The 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 `catch` of `packages/cli/src/commands/init.ts` now opens with `if (isExitSignal(error)) throw error;`, imported from `utils/format.js`. This is the ruled idiom: no second helper, and `format.ts` is not edited. The catch-all still prints and refuses for every other error it catches. ## Closing the class: the analyzer is seeded with `this.error` The analyzer in `packages/cli/test/exit-signal.pin.test.ts` was seeded with `exit` only. 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 a `try` enters 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')` and `src/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.error` sites inside a `try` over the population, 3 distinct in source: - `init.ts`'s two, above; - `compile.ts:1046` (`this.error(err.message)`), judged once for `os compile` and once for `os build`, which inherits it. It shares its enclosing catch with a `this.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 a `this.error` into something else, so the two seeds have the same shape. One more census, taken on `bee8d1c62c` over every command source: the only other oclif `Command` member that throws the signal, `this.parse`, is called inside a `try` by 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 127 `this.exit` sites plus the 4 `this.error` sites. **Fixtures.** Five new fixtures pin the new seed: - the `os init` defect shape (red); - the same shape under the idiom (green); - a `this.error` outside every `try` (not a site); - a `this.error` reached through a same-class helper (red); - `os compile`'s shape: a `this.error` sharing a guarded catch with a `this.exit` (green). `this.error(msg, { exit: false })` throws nothing, and no command writes it. The header says the analyzer judges it like any other `this.error`. That can give a false red (the guard is harmless there), never a false green. ## Text-face pins for `os init` Two cases join the driven text-face `describe`. `os init` runs in-process through oclif. Three things are replaced: - its package-manager install (`execSync`, through `vi.mock('child_process')`) by a seam; - its scaffold self-test (`validateScaffold`) by a seam; - its working directory, by a scratch directory (a `process.cwd` spy, restored after each case). The cases: - **A failed dependency install.** ONE `✗` line and exit 2. The install ran once, in the target directory. The self-test never ran. - **A scaffold its own self-test rejects.** ONE `✗` line, naming the rejection the case chose, and exit 2. `expectOneRefusal` now takes the expected status: 1 for the `this.exit(1)` refusals, as before, and 2 for `os init`'s `this.error` ones. The tier stays `unit`: nothing is spawned and nothing is bundled. ## Verification Everything below ran on the final commit `549f3704e4` unless it names another commit. - **The pin.** `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/exit-signal.pin.test.ts`: **109 passed**. Baseline on `bee8d1c62c`, before any edit: 101 passed. - **Before the fix**, with the pin edited and `init.ts` untouched: 3 failed, 106 passed. The failures are `os init (text)`, with the two leaks above, and both driven `os init` cases, each printing 2 `✗` lines. - **Reverse verification, after the fix was committed.** - Command: `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`. - The mutation landed: anchor count 1 → 0, blob `4930c989f594` → `d811c1c322b6`. - Result: **3 failed, 106 passed**, exactly the three predicted. `os init (text)` names `init.ts:1360` and `:1389` swallowed by the catch at `:1392`. Each driven case reports "expected [ …(2) ] to have a length of 1 but got 2". - The restore was proven: blob == HEAD (`4930c989f594`) and `git diff HEAD` is empty. - The pin imports `init.ts` from `src/` by a relative import, so this leg needed no `dist/` rebuild. - **`packages/cli` unit tier.** `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 251 files, 3672 tests passed. The integration tier is declared to CI. - **The six nightly-tier e2e files that drive `os init`.** These are `init-created-files-summary`, `starter-field-consumers`, `scaffold-emission-policy`, `generate-object-namespace-prefix`, `generate-scaffolds-reach-stack` and `create-refuses-invalid-project-name`. Command: `OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2` over those files. Result: 6 files, 53 tests passed. `init-created-files-summary` drives the failed-install path through a fake package manager on `PATH`. - **Typecheck.** `pnpm --filter @objectstack/cli typecheck` exits 0. `check:test-typecheck` reports OK: the debt ledger holds 28 errors, and none is in the pin file. `--listFiles` confirms the pin is in `tsconfig.test.json`'s program. - **Gates.** - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 66 commands for this diff, and all 66 exit 0. - Two of them, `check:dual-build-cjs-loads` and `check:i18n-coverage`, first answered exit 3 (PREREQUISITE NOT MET: no `dist/` for packages outside the cli closure). They were re-run after a full `turbo run build` and then exited 0. - `--ran` reconciliation: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED". - **Lint: a proven narrowing, not a `pnpm lint` run.** - ① The population, read from eslint's own config: both touched TypeScript files are linted. `eslint --print-config` shows 5 rules in effect on each. The changeset is outside every `files` glob. - ② The file count, from `--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.ts` reports 2 files, 0 errors, 0 warnings, exit 0. - ③ Invariance: on both files `--print-config` shows `parserOptions.project` and `projectService` null. 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 neither `eslint.config.mjs` nor the baselines it reads, so no untouched file's verdict can move. ## Acceptance notes - **oclif's own error block remains.** After the fix, `os init`'s refusal is still followed by oclif's `› Error: …` block on stderr. The entry point renders that block from the thrown `CLIError` after `run()` exits, and it printed exactly once before the fix too. What the fix removed is the catch's second `✗` line. Every other `os init` refusal has the same `✗` + `Error:` pair: `os init demo -t bogus` prints `✗ Unknown template: bogus` and then `› Error: Unknown template: bogus`, exit 2, measured on `549f3704e4`. That pairing comes from `printError` followed by `this.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. - **No changes outside the claimed surface.** Nothing under `dev.ts`, `start.ts`, `serve.ts`, `packages/runtime/` or `packages/metadata-protocol/` was touched. `serve.ts` changed on `main` since this branch's base (`550f4cc2fd`), adding a `try` with no signal call inside it, and `git merge-tree` of this head with `main` is clean. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 44defd4 commit 24dc7c1

3 files changed

Lines changed: 168 additions & 40 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os init` prints its dependency-install and scaffold-validation refusals once (#21523)
6+
7+
Clause-②: no
8+
9+
`os init demo -p npm` with an unreachable package registry printed `✗ Project scaffolded, but dependency installation failed.`, then a second `✗ Dependency installation failed`, then oclif's `Error: Dependency installation failed`, and exited 2. The second `✗` line came from the command's outer `catch`: the `this.error(…)` inside its `try` throws oclif's exit signal, and the `catch` reported it again. A scaffold that failed its own validation got a second `✗ Scaffold validation failed` line under its refusal the same way.
10+
11+
The `catch` now lets the signal through. Each refusal prints its `✗` line once, followed by oclif's `Error:` line as before, and the exit status is still 2.

‎packages/cli/src/commands/init.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
printAuthoringAdvisories,
1818
printAuthoringRuleErrors,
1919
AUTHORING_ADVISORY_PRINT_LIMIT,
20+
isExitSignal,
2021
} from '../utils/format.js';
2122
import { validateScaffold } from '../utils/scaffold-validate.js';
2223
import { summarizeTree, describeEntry } from 'create-objectstack/created-summary';
@@ -1389,6 +1390,10 @@ export default class Init extends Command {
13891390
}
13901391

13911392
} catch (error: any) {
1393+
// The two refusals above (scaffold self-test, dependency install) already
1394+
// printed their `✗` line and raised the exit signal with `this.error`.
1395+
// Re-reporting it here printed the refusal a second time.
1396+
if (isExitSignal(error)) throw error;
13921397
printError(error.message || String(error));
13931398
this.error(error.message || String(error));
13941399
}

0 commit comments

Comments
 (0)