Skip to content
Merged
48 changes: 48 additions & 0 deletions .changeset/config-refusal-throws-so-json-faces-emit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/cli": patch
---

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)

Every `--json` face in this CLI declares that it answers an error path with a
payload. `resolveConfigPath()` was the one path that bypassed that declaration:
it wrote its refusal and then called `process.exit(1)` **directly**, so nothing
was thrown and the catch-all each command already carries — all of which sit
downstream of a throw — never ran. Ten published faces answered a missing config
file with an empty stdout.

Measured before this change on the published entry `packages/cli/bin/run.js`,
`NO_COLOR=1`, streams captured separately, exit read before any pipe — ten faces
(`build` · `compile` · `diff` · `i18n check` · `i18n extract` · `info` · `lint` ·
`migrate meta` · `validate` · `verify`) across both branches of the helper, 19
runs: **exit 1, stdout 0 bytes, stderr 296 B (explicit path) / 123 B
(auto-detect)** — and `JSON.parse` on that stdout throws in all 19. After: the
same 19 runs answer **exit 1 with a parseable document on stdout**, stderr
unchanged byte for byte.

The refusals now throw `ConfigRefusalError`. That is not a new contract — it is
this path being pulled back onto the one its callers had already published, so
it adds **zero** accept-set members and **zero** error codes.

Three properties hold it in place:

- **No face becomes a crash dump.** `os verify` had no `try` at all — measured,
a throw through it produced an oclif error line and no payload where every
sibling emitted an envelope — so it gains the catch-all its nine siblings
already had, in this same change rather than after it.
- **The text face does not narrow.** The refusal and both hint lines are still
written by the helper, to stderr, byte-identical: all 19 non-`--json` runs
compare equal before and after on stdout, on stderr and on exit status. The
catch-alls skip re-rendering the sentence a second time on stdout.
- **No error code is minted.** The thrown error carries neither `code` nor
`httpStatus`, so `errorCodeFields()` contributes nothing and each face emits
its own bare `{ error }`. Whether that shape is right is **#15549**'s open
question, and this change deliberately does not answer it.

The `--json` stdout-purity instrument is widened with the fix rather than after
it: the pre-boot family's discovery moves into a shared module, the pin that
drives it now demands a document (empty stdout no longer passes) and compares
the text face's stderr as a whole string, and `json-stdout-purity.e2e.test.ts`
— whose own discovery is `bootSchemaStack`-based and cannot see a command that
fails above the kernel — reconciles against that population so neither half can
be lost silently.
11 changes: 11 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
emitJson,
isExitSignal,
errorCodeFields,
isReportedError,
} from '../utils/format.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// [#14553] The compile-time half of the navigation-contribution group ruling.
Expand Down Expand Up @@ -915,6 +916,16 @@ export default class Compile extends Command {
await emitJson({ success: false, error: error.message, ...errorCodeFields(error), warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
this.exit(1);
}
// [#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
// 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);
console.log('');
printError(error.message || String(error));
this.error(error.message || String(error));
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/commands/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
createTimer,
emitJson,
errorCodeFields,
isReportedError,
} from '../utils/format.js';

// ─── Types ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -310,8 +311,13 @@ export default class Diff extends Command {
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
process.exit(1);
}
console.log('');
printError(error.message || String(error));
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
// lines to stderr before throwing; printing the sentence again here
// would put a second copy on stdout.
if (!isReportedError(error)) {
console.log('');
printError(error.message || String(error));
}
process.exit(1);
}
}
Expand Down
14 changes: 10 additions & 4 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { FieldType } from '@objectstack/spec/data';
// these, so the part that can be shared is shared and only the part that
// genuinely lives on `driver-sql` is mirrored.
import { isTenancyDisabled, isUniqueDeclared, numericColumnFor } from '@objectstack/spec/data';
import { printHeader, printSuccess, printError, printInfo, printStep, createTimer, CLI_ALIAS } from '../utils/format.js';
import { printHeader, printSuccess, printError, printInfo, printStep, createTimer, isReportedError, CLI_ALIAS } from '../utils/format.js';
import { metadataFileName } from '../utils/metadata-file-name.js';
import { findEmissionParseFailures } from '../utils/emitted-source-parses.js';

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

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

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

} catch (error: any) {
printError(error.message || String(error));
// [#15547] `resolveConfigPath()` already reported its refusal on stderr
// before throwing; a second copy on stdout is what this guards.
if (!isReportedError(error)) printError(error.message || String(error));
process.exit(1);
}
}
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/commands/i18n/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
emitJson,
isExitSignal,
errorCodeFields,
isReportedError,
} from '../../utils/format.js';
import { computeI18nCoverage, COVERAGE_SURFACE_PHRASE } from '../../utils/i18n-coverage.js';

Expand Down Expand Up @@ -186,8 +187,13 @@ export default class I18nCheck extends Command {
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
process.exit(1);
}
console.log('');
printError(error.message || String(error));
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
// lines to stderr before throwing; printing the sentence again here
// would put a second copy on stdout.
if (!isReportedError(error)) {
console.log('');
printError(error.message || String(error));
}
process.exit(1);
}
}
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/commands/i18n/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
emitJson,
isExitSignal,
errorCodeFields,
isReportedError,
} from '../../utils/format.js';
import {
extractTranslations,
Expand Down Expand Up @@ -807,8 +808,13 @@ export default class I18nExtract extends Command {
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
process.exit(1);
}
console.log('');
printError(error.message || String(error));
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
// lines to stderr before throwing; printing the sentence again here
// would put a second copy on stdout.
if (!isReportedError(error)) {
console.log('');
printError(error.message || String(error));
}
process.exit(1);
}
}
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
printMetadataStats,
emitJson,
errorCodeFields,
isReportedError,
} from '../utils/format.js';

export default class Info extends Command {
Expand Down Expand Up @@ -119,8 +120,13 @@ export default class Info extends Command {
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
process.exit(1);
}
console.log('');
printError(error.message || String(error));
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
// lines to stderr before throwing; printing the sentence again here
// would put a second copy on stdout.
if (!isReportedError(error)) {
console.log('');
printError(error.message || String(error));
}
process.exit(1);
}
}
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
emitJson,
isExitSignal,
errorCodeFields,
isReportedError,
} from '../utils/format.js';

// ─── Types ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -895,8 +896,13 @@ export default class Lint extends Command {
);
process.exit(1);
}
console.log('');
printError(error.message || String(error));
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
// lines to stderr before throwing; printing the sentence again here
// would put a second copy on stdout.
if (!isReportedError(error)) {
console.log('');
printError(error.message || String(error));
}
process.exit(1);
}
}
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/commands/migrate/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
createTimer,
emitJson,
errorCodeFields,
isReportedError,
} from '../../utils/format.js';
import { bootSchemaStack } from '../../utils/schema-migrate.js';
import { buildDataMigrationPlugins } from '../../utils/data-migration-plugins.js';
Expand Down Expand Up @@ -435,7 +436,10 @@ export default class MigrateMeta extends Command {
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
this.exit(1);
}
printError(error.message || String(error));
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
// lines to stderr before throwing; printing the sentence again here
// would put a second copy on stdout.
if (!isReportedError(error)) printError(error.message || String(error));
this.exit(1);
}
}
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
emitJson,
isExitSignal,
errorCodeFields,
isReportedError,
} from '../utils/format.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// [#14553] The navigation-contribution group check, shared with `os compile`.
Expand Down Expand Up @@ -636,8 +637,13 @@ export default class Validate extends Command {
});
this.exit(1);
}
console.log('');
printError(error.message || String(error));
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
// lines to stderr before throwing; printing the sentence again here
// would put a second copy on stdout.
if (!isReportedError(error)) {
console.log('');
printError(error.message || String(error));
}
this.exit(1);
}
}
Expand Down
57 changes: 57 additions & 0 deletions packages/cli/src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ import {
type RlsPositionPersonaInput,
} from '@objectstack/verify';
import { loadConfig } from '../utils/config.js';
import {
printError,
emitJson,
isExitSignal,
errorCodeFields,
isReportedError,
} from '../utils/format.js';

/**
* Should this `os verify` run boot an org-scoped (multi-tenant) stack?
Expand Down Expand Up @@ -86,9 +93,59 @@ export default class Verify extends Command {
json: Flags.boolean({ description: 'Emit the structured report as JSON', default: false }),
};

/**
* The catch-all this command did not have (#15547).
*
* Every one of its nine `--json` siblings wraps its whole body in one `try`
* and answers a throw with an envelope; `os verify` wrapped nothing, so a
* throw walked out of `run()` and oclif rendered it. Measured on the
* published entry before this landed, against a config module that throws at
* evaluation:
*
* os verify --json → exit 1, stdout 0 B, stderr ` Error: …`
* os validate --json → exit 1, stdout `{"valid":false,"error":…}`
* os info --json → exit 1, stdout `{"error":…}`
*
* That mattered the moment `resolveConfigPath()` started throwing instead of
* exiting: this face would have been the one command turned INTO a crash
* dump by a change that fixed the other nine. So the `try` lands with the
* throw, never after it.
*
* The body moves into {@link runVerification} verbatim rather than being
* re-indented under a `try` here — the guard is the change, and a 120-line
* whitespace diff would bury it.
*/
async run(): Promise<void> {
const { flags } = await this.parse(Verify);

try {
await this.runVerification(flags);
} catch (error: any) {
// `this.exit()` THROWS (see `isExitSignal`) — including the exit 0 this
// command's success path takes — so the signal is re-thrown before
// anything is described as a failure.
if (isExitSignal(error)) throw error;
if (flags.json) {
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
this.exit(1);
}
// [#15547] `resolveConfigPath()` already wrote its refusal and hint lines
// to stderr before throwing; printing the sentence again here would put a
// second copy on stdout.
if (!isReportedError(error)) {
console.log('');
printError(error.message || String(error));
}
this.exit(1);
}
}

private async runVerification(flags: {
app?: string;
rls: boolean;
'multi-tenant': boolean;
json: boolean;
}): Promise<void> {
const { config, absolutePath } = await loadConfig(flags.app);

const multiTenant = resolveVerifyMultiTenant(flags);
Expand Down
Loading
Loading