Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/21542-refusal-renders-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/cli': patch
---

fix(cli): `os init` and `os compile` render each refusal once, not once on stdout and again as oclif's `Error:` block on stderr (#21542)

Clause-②: no

`os init demo -t bogus` printed `✗ Unknown template: bogus` on stdout, then the same sentence as oclif's `Error:` block on stderr, and exited 2. Ten refusals did it: the five `os init` makes before it writes anything (an unknown template, a project name that is not valid, a target directory that is not empty, a current directory whose name is not a valid project name, an `objectstack.config.ts` that already exists), its scaffold self-test and dependency install, its catch-all, and `os compile`'s runtime-bundle refusal and catch-all (`os build` inherits both). Each printed its own `✗` line and then handed the sentence to `this.error`, which has oclif's entry point render it again.

Each now prints its `✗` line and the hint under it once, and ends in `this.exit(2)`: the status `this.error` raised, with nothing rendered by the entry point. Stdout carries the same lines as before; stderr no longer repeats them. Exit statuses are unchanged: 2 for all ten.

A script that read the sentence from stderr, from the `Error:` block, now finds it on stdout, on the `✗` line, which is where the full wording and the hint always were.
16 changes: 12 additions & 4 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1041,9 +1041,14 @@ export default class Compile extends Command {
await emitJson({ success: false, error: `runtime bundle failed: ${err.message}`, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
this.exit(1);
}
// The `✗` line below is this refusal's one rendering. It used to end
// in `this.error(err.message)`, which has oclif's entry point render
// the same message again as an `Error:` block on stderr; `this.exit(2)`
// raises the same signal with the status `this.error` raised, and
// renders nothing.
console.log('');
printError(`Runtime bundle failed: ${err.message}`);
this.error(err.message);
this.exit(2);
}
}
}
Expand Down Expand Up @@ -1204,17 +1209,20 @@ export default class Compile extends Command {
printAdvisoriesOnce();
// [#15547] `resolveConfigPath()` already wrote its refusal and hint lines
// to stderr before throwing, so this face has nothing left to render —
// and `this.error()` below is NOT a no-op for it: it re-renders the same
// sentence as an oclif `› Error:` block AND raises this face's exit
// and an oclif `this.error()` here is NOT a no-op for it: it re-renders
// the same sentence as an `› Error:` block AND raises this face's exit
// status from 1 to 2. Measured on the published entry, `os compile
// ./missing.ts` (and `os build`, which inherits this catch): exit 2 with
// 483 stderr bytes, where the other eight faces answer exit 1 with 296.
// `this.exit(1)` throws the ExitError the `--json` branch already relies
// on, so the status and the bytes both stay where they were.
if (isReportedError(error)) this.exit(1);
// Any other failure is rendered here, once, by `printError`, and ends in
// `this.exit(2)`: the status the `this.error()` that stood here raised
// (its entry-point `Error:` block was the same sentence a second time).
console.log('');
printError(error.message || String(error));
this.error(error.message || String(error));
this.exit(2);
}
}
}
26 changes: 17 additions & 9 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,10 +1148,18 @@ export default class Init extends Command {
const startCwd = process.cwd();
const template = TEMPLATES[flags.template];

// Every refusal in this command renders its sentence ONCE: the `✗` line it
// prints itself, with the hint under it, and then `this.exit(2)`. It used to
// end in `this.error(<the same sentence>)`, which hands oclif's entry point
// the sentence to render a second time as an `Error:` block on stderr — one
// refusal read twice across two streams. `this.exit(n)` raises the same
// signal and renders nothing, and `2` is the status `this.error` raised, so
// the exit status is unchanged. That is the split `isReportedError` guards
// (`utils/format.ts`): one rendering per refusal.
if (!template) {
printError(`Unknown template: ${flags.template}`);
console.log(chalk.dim(` Available: ${Object.keys(TEMPLATES).join(', ')}`));
this.error(`Unknown template: ${flags.template}`);
this.exit(2);
}

// Resolve target directory + project name.
Expand All @@ -1169,7 +1177,7 @@ export default class Init extends Command {
const nameError = validateProjectName(args.name);
if (nameError) {
printError(nameError);
this.error(nameError);
this.exit(2);
}
projectName = args.name;
targetDir = path.resolve(startCwd, args.name);
Expand All @@ -1179,7 +1187,7 @@ export default class Init extends Command {
const msg = `Target directory ${targetDir} is not empty`;
printError(msg);
console.log(chalk.dim(' Choose a different name or remove the existing directory first.'));
this.error(msg);
this.exit(2);
}
} else {
fs.mkdirSync(targetDir, { recursive: true });
Expand All @@ -1191,15 +1199,15 @@ export default class Init extends Command {
if (nameError) {
printError(`Current directory name "${projectName}" is not a valid project name. ${nameError}`);
console.log(chalk.dim(' Re-run with an explicit name: `objectstack init my-app`'));
this.error(nameError);
this.exit(2);
}
}

// Check for existing config
if (fs.existsSync(path.join(targetDir, 'objectstack.config.ts'))) {
printError(`objectstack.config.ts already exists in ${targetDir}`);
console.log(chalk.dim(' Use `objectstack generate` to add metadata to an existing project'));
this.error('objectstack.config.ts already exists');
this.exit(2);
}

// Convert the npm-name (which allows hyphens, dots, scopes) into a
Expand Down Expand Up @@ -1357,7 +1365,7 @@ export default class Init extends Command {

if (scaffoldRejected) {
console.log(chalk.dim(' This is a CLI bug — please report it at https://github.com/objectstack-ai/objectstack/issues'));
this.error('Scaffold validation failed');
this.exit(2);
}
}

Expand Down Expand Up @@ -1386,16 +1394,16 @@ export default class Init extends Command {
}
console.log(chalk.dim(` ${chosenPm} install`));
console.log('');
this.error('Dependency installation failed');
this.exit(2);
}

} catch (error: any) {
// The two refusals above (scaffold self-test, dependency install) already
// printed their `✗` line and raised the exit signal with `this.error`.
// printed their `✗` line and raised the exit signal with `this.exit(2)`.
// Re-reporting it here printed the refusal a second time.
if (isExitSignal(error)) throw error;
printError(error.message || String(error));
this.error(error.message || String(error));
this.exit(2);
}
}
}
25 changes: 14 additions & 11 deletions packages/cli/test/exit-signal.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@
* directory a scratch directory. Each case asserts the two things an operator
* reads: the refusal is ONE `✗` line with no `EEXIT` anywhere in the output,
* and the exit status, unchanged by the repair — 1 for the `this.exit(1)`
* refusals, 2 for `os init`'s `this.error` ones.
* refusals, 2 for `os init`'s `this.exit(2)` ones (the status its `this.error`
* refusals raised, until they rendered their sentence once:
* `refusal-renders-once.test.ts` and its `.e2e` twin).
*
* ## Tier
*
Expand Down Expand Up @@ -480,7 +482,9 @@ const FLOW = new Map(
* `bee8d1c62c` over the same 65 commands: the 127 `this.exit` sites, plus
* four `this.error` sites — `compile.ts`'s bundling refusal, counted for
* `os compile` and again for `os build` through it, and `os init`'s two
* refusals inside its outer `try`.
* refusals inside its outer `try`. Those four sites now spell `this.exit(2)`
* (the status `this.error` raised, with the sentence rendered once): both
* spellings are seeds, so the floor is unchanged.
*/
const POPULATION_FLOOR = 65;
const SITE_FLOOR = 131;
Expand Down Expand Up @@ -648,14 +652,13 @@ describe('every command lets the exit signal through', () => {
}
});

it("the scan reaches the `this.error` seed — `os init`'s two refusals inside its outer try, and `os compile`'s bundling refusal", () => {
const errorCalls = (id: string) => FLOW.get(id)?.sites.map((s) => s.call).filter((call) => call.startsWith('this.error(')) ?? [];
expect(errorCalls('init')).toEqual(expect.arrayContaining([
"this.error('Scaffold validation failed')",
"this.error('Dependency installation failed')",
]));
it("the scan reaches `os init`'s two refusals inside its outer try, and `os compile`'s bundling refusal — each ends in `this.exit(2)`", () => {
const refusalCalls = (id: string) => FLOW.get(id)?.sites.map((s) => s.call).filter((call) => call === 'this.exit(2)') ?? [];
// Scaffold self-test and dependency install: both sit inside the outer `try`
// whose `catch` must let the signal through, so both are sites.
expect(refusalCalls('init'), 'os init').toHaveLength(2);
// Inherited: `os build` is judged on `compile.ts`'s site as well.
for (const id of ['compile', 'build']) expect(errorCalls(id), `os ${id}`).toContain('this.error(err.message)');
for (const id of ['compile', 'build']) expect(refusalCalls(id), `os ${id}`).toHaveLength(1);
});

it.each(POPULATION.map((c) => [c.id, c.faces.join(' | ')]))('os %s (%s)', (id) => {
Expand Down Expand Up @@ -961,8 +964,8 @@ async function driveText(cmd: Runnable, argv: string[], cwd?: string): Promise<D

/**
* The refusal is reported ONCE, the signal is never named, and the status is
* the one it always was: 1 for a `this.exit(1)` refusal, 2 for a `this.error`
* one (oclif's default, which `os init` never overrides). `subject` is what
* the one it always was: 1 for a `this.exit(1)` refusal, 2 for `os init`'s
* `this.exit(2)` ones (what its `this.error` refusals raised). `subject` is what
* the one line must be about — a path, a URL or an error the case chose, never
* the refusal's wording.
*/
Expand Down
Loading
Loading