Skip to content

Commit df8a16d

Browse files
claude[bot]claude
andauthored
fix(cli): resolveConfigPath's refusals throw, so the ten --json faces emit their envelopes — and os verify gains the catch-all it never had (#17143)
* wip(cli): resolveConfigPath refusals throw; os verify gains its catch-all * test(cli): widen the pre-boot json purity population; pin the text face by bytes * test(cli): the two purity families overlap on migrate meta — pin the argv, not disjointness * fix(cli): write the ESC byte as an escape sequence, not the raw byte --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7026141 commit df8a16d

16 files changed

Lines changed: 638 additions & 230 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `resolveConfigPath` throws its two refusals so the ten `--json` faces emit their envelopes, and `os verify` gains the catch-all it never had (#15547)
6+
7+
Every `--json` face in this CLI declares that it answers an error path with a
8+
payload. `resolveConfigPath()` was the one path that bypassed that declaration:
9+
it wrote its refusal and then called `process.exit(1)` **directly**, so nothing
10+
was thrown and the catch-all each command already carries — all of which sit
11+
downstream of a throw — never ran. Ten published faces answered a missing config
12+
file with an empty stdout.
13+
14+
Measured before this change on the published entry `packages/cli/bin/run.js`,
15+
`NO_COLOR=1`, streams captured separately, exit read before any pipe — ten faces
16+
(`build` · `compile` · `diff` · `i18n check` · `i18n extract` · `info` · `lint` ·
17+
`migrate meta` · `validate` · `verify`) across both branches of the helper, 19
18+
runs: **exit 1, stdout 0 bytes, stderr 296 B (explicit path) / 123 B
19+
(auto-detect)** — and `JSON.parse` on that stdout throws in all 19. After: the
20+
same 19 runs answer **exit 1 with a parseable document on stdout**, stderr
21+
unchanged byte for byte.
22+
23+
The refusals now throw `ConfigRefusalError`. That is not a new contract — it is
24+
this path being pulled back onto the one its callers had already published, so
25+
it adds **zero** accept-set members and **zero** error codes.
26+
27+
Three properties hold it in place:
28+
29+
- **No face becomes a crash dump.** `os verify` had no `try` at all — measured,
30+
a throw through it produced an oclif error line and no payload where every
31+
sibling emitted an envelope — so it gains the catch-all its nine siblings
32+
already had, in this same change rather than after it.
33+
- **The text face does not narrow.** The refusal and both hint lines are still
34+
written by the helper, to stderr, byte-identical: all 19 non-`--json` runs
35+
compare equal before and after on stdout, on stderr and on exit status. The
36+
catch-alls skip re-rendering the sentence a second time on stdout.
37+
- **No error code is minted.** The thrown error carries neither `code` nor
38+
`httpStatus`, so `errorCodeFields()` contributes nothing and each face emits
39+
its own bare `{ error }`. Whether that shape is right is **#15549**'s open
40+
question, and this change deliberately does not answer it.
41+
42+
The `--json` stdout-purity instrument is widened with the fix rather than after
43+
it: the pre-boot family's discovery moves into a shared module, the pin that
44+
drives it now demands a document (empty stdout no longer passes) and compares
45+
the text face's stderr as a whole string, and `json-stdout-purity.e2e.test.ts`
46+
— whose own discovery is `bootSchemaStack`-based and cannot see a command that
47+
fails above the kernel — reconciles against that population so neither half can
48+
be lost silently.

packages/cli/src/commands/compile.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
emitJson,
4343
isExitSignal,
4444
errorCodeFields,
45+
isReportedError,
4546
} from '../utils/format.js';
4647
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
4748
// [#14553] The compile-time half of the navigation-contribution group ruling.
@@ -953,6 +954,16 @@ export default class Compile extends Command {
953954
await emitJson({ success: false, error: error.message, ...errorCodeFields(error), warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
954955
this.exit(1);
955956
}
957+
// [#15547] `resolveConfigPath()` already wrote its refusal and hint lines
958+
// to stderr before throwing, so this face has nothing left to render —
959+
// and `this.error()` below is NOT a no-op for it: it re-renders the same
960+
// sentence as an oclif `› Error:` block AND raises this face's exit
961+
// status from 1 to 2. Measured on the published entry, `os compile
962+
// ./missing.ts` (and `os build`, which inherits this catch): exit 2 with
963+
// 483 stderr bytes, where the other eight faces answer exit 1 with 296.
964+
// `this.exit(1)` throws the ExitError the `--json` branch already relies
965+
// on, so the status and the bytes both stay where they were.
966+
if (isReportedError(error)) this.exit(1);
956967
console.log('');
957968
printError(error.message || String(error));
958969
this.error(error.message || String(error));

packages/cli/src/commands/diff.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
createTimer,
1515
emitJson,
1616
errorCodeFields,
17+
isReportedError,
1718
} from '../utils/format.js';
1819

1920
// ─── Types ──────────────────────────────────────────────────────────
@@ -310,8 +311,13 @@ export default class Diff extends Command {
310311
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
311312
process.exit(1);
312313
}
313-
console.log('');
314-
printError(error.message || String(error));
314+
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
315+
// lines to stderr before throwing; printing the sentence again here
316+
// would put a second copy on stdout.
317+
if (!isReportedError(error)) {
318+
console.log('');
319+
printError(error.message || String(error));
320+
}
315321
process.exit(1);
316322
}
317323
}

packages/cli/src/commands/generate.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type { FieldType } from '@objectstack/spec/data';
1818
// these, so the part that can be shared is shared and only the part that
1919
// genuinely lives on `driver-sql` is mirrored.
2020
import { isTenancyDisabled, isUniqueDeclared, numericColumnFor } from '@objectstack/spec/data';
21-
import { printHeader, printSuccess, printError, printInfo, printStep, createTimer, CLI_ALIAS } from '../utils/format.js';
21+
import { printHeader, printSuccess, printError, printInfo, printStep, createTimer, isReportedError, CLI_ALIAS } from '../utils/format.js';
2222
import { metadataFileName } from '../utils/metadata-file-name.js';
2323
import { findEmissionParseFailures } from '../utils/emitted-source-parses.js';
2424

@@ -866,7 +866,9 @@ async function runTypesGeneration(configPath: string | undefined, flags: { outpu
866866
console.log('');
867867

868868
} catch (error: any) {
869-
printError(error.message || String(error));
869+
// [#15547] `resolveConfigPath()` already reported its refusal on stderr
870+
// before throwing; a second copy on stdout is what this guards.
871+
if (!isReportedError(error)) printError(error.message || String(error));
870872
process.exit(1);
871873
}
872874
}
@@ -1007,7 +1009,9 @@ async function runClientGeneration(configPath: string | undefined, flags: { outp
10071009
console.log('');
10081010

10091011
} catch (error: any) {
1010-
printError(error.message || String(error));
1012+
// [#15547] `resolveConfigPath()` already reported its refusal on stderr
1013+
// before throwing; a second copy on stdout is what this guards.
1014+
if (!isReportedError(error)) printError(error.message || String(error));
10111015
process.exit(1);
10121016
}
10131017
}
@@ -2143,7 +2147,9 @@ async function runMigrationGeneration(configPath: string | undefined, flags: { o
21432147
console.log('');
21442148

21452149
} catch (error: any) {
2146-
printError(error.message || String(error));
2150+
// [#15547] `resolveConfigPath()` already reported its refusal on stderr
2151+
// before throwing; a second copy on stdout is what this guards.
2152+
if (!isReportedError(error)) printError(error.message || String(error));
21472153
process.exit(1);
21482154
}
21492155
}

packages/cli/src/commands/i18n/check.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
emitJson,
1616
isExitSignal,
1717
errorCodeFields,
18+
isReportedError,
1819
} from '../../utils/format.js';
1920
import { computeI18nCoverage, COVERAGE_SURFACE_PHRASE } from '../../utils/i18n-coverage.js';
2021

@@ -186,8 +187,13 @@ export default class I18nCheck extends Command {
186187
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
187188
process.exit(1);
188189
}
189-
console.log('');
190-
printError(error.message || String(error));
190+
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
191+
// lines to stderr before throwing; printing the sentence again here
192+
// would put a second copy on stdout.
193+
if (!isReportedError(error)) {
194+
console.log('');
195+
printError(error.message || String(error));
196+
}
191197
process.exit(1);
192198
}
193199
}

packages/cli/src/commands/i18n/extract.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
emitJson,
1717
isExitSignal,
1818
errorCodeFields,
19+
isReportedError,
1920
} from '../../utils/format.js';
2021
import {
2122
extractTranslations,
@@ -807,8 +808,13 @@ export default class I18nExtract extends Command {
807808
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
808809
process.exit(1);
809810
}
810-
console.log('');
811-
printError(error.message || String(error));
811+
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
812+
// lines to stderr before throwing; printing the sentence again here
813+
// would put a second copy on stdout.
814+
if (!isReportedError(error)) {
815+
console.log('');
816+
printError(error.message || String(error));
817+
}
812818
process.exit(1);
813819
}
814820
}

packages/cli/src/commands/info.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
printMetadataStats,
1616
emitJson,
1717
errorCodeFields,
18+
isReportedError,
1819
} from '../utils/format.js';
1920

2021
export default class Info extends Command {
@@ -119,8 +120,13 @@ export default class Info extends Command {
119120
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
120121
process.exit(1);
121122
}
122-
console.log('');
123-
printError(error.message || String(error));
123+
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
124+
// lines to stderr before throwing; printing the sentence again here
125+
// would put a second copy on stdout.
126+
if (!isReportedError(error)) {
127+
console.log('');
128+
printError(error.message || String(error));
129+
}
124130
process.exit(1);
125131
}
126132
}

packages/cli/src/commands/lint.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
emitJson,
2929
isExitSignal,
3030
errorCodeFields,
31+
isReportedError,
3132
} from '../utils/format.js';
3233

3334
// ─── Types ──────────────────────────────────────────────────────────
@@ -895,8 +896,13 @@ export default class Lint extends Command {
895896
);
896897
process.exit(1);
897898
}
898-
console.log('');
899-
printError(error.message || String(error));
899+
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
900+
// lines to stderr before throwing; printing the sentence again here
901+
// would put a second copy on stdout.
902+
if (!isReportedError(error)) {
903+
console.log('');
904+
printError(error.message || String(error));
905+
}
900906
process.exit(1);
901907
}
902908
}

packages/cli/src/commands/migrate/meta.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
createTimer,
2727
emitJson,
2828
errorCodeFields,
29+
isReportedError,
2930
} from '../../utils/format.js';
3031
import { bootSchemaStack } from '../../utils/schema-migrate.js';
3132
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
@@ -435,7 +436,10 @@ export default class MigrateMeta extends Command {
435436
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
436437
this.exit(1);
437438
}
438-
printError(error.message || String(error));
439+
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
440+
// lines to stderr before throwing; printing the sentence again here
441+
// would put a second copy on stdout.
442+
if (!isReportedError(error)) printError(error.message || String(error));
439443
this.exit(1);
440444
}
441445
}

packages/cli/src/commands/validate.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
emitJson,
3838
isExitSignal,
3939
errorCodeFields,
40+
isReportedError,
4041
} from '../utils/format.js';
4142
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
4243
// [#14553] The navigation-contribution group check, shared with `os compile`.
@@ -636,8 +637,13 @@ export default class Validate extends Command {
636637
});
637638
this.exit(1);
638639
}
639-
console.log('');
640-
printError(error.message || String(error));
640+
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
641+
// lines to stderr before throwing; printing the sentence again here
642+
// would put a second copy on stdout.
643+
if (!isReportedError(error)) {
644+
console.log('');
645+
printError(error.message || String(error));
646+
}
641647
this.exit(1);
642648
}
643649
}

0 commit comments

Comments
 (0)