Skip to content
Open
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
81 changes: 81 additions & 0 deletions .changeset/cli-json-error-envelope-adr-0112-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
"@objectstack/cli": minor
---

feat(cli): `--format json` failure envelopes carry the ADR-0112 `code` and `httpStatus` (#13347)

Every machine-readable failure this CLI emits was built the same way — 48 sites
under `packages/cli/src/commands/`:

```ts
await emitJson({ success: false, error: error.message });
```

The payload carried the human sentence and nothing else. The error reaching
those `catch` blocks from `@objectstack/client` is not a bare `Error`: the SDK's
`fetch` wrapper attaches `err.code` (the semantic ADR-0112 string, normalized to
the same spelling across the flat `@objectstack/rest` envelope and the wrapped
runtime-dispatcher one) and `err.httpStatus`. Both were discarded at the CLI
boundary, so the one outcome a script most needs to branch on — *someone else
edited it, re-read and retry* vs *you are not allowed* vs *the server is down* —
was separable only by substring-matching an English sentence that no contract
pins.

A stale-pin refusal from `os meta delete --if-match` used to read:

```json
{
"success": false,
"error": "[metadata_conflict] view/race_probe has been modified since you loaded it. …"
}
```

and now reads:

```json
{
"success": false,
"error": "[metadata_conflict] view/race_probe has been modified since you loaded it. …",
"code": "METADATA_CONFLICT",
"httpStatus": 409
}
```

Maintainer ruling 2026-08-30 (option **A** of three):

- The payload stays **FLAT**. Nesting into `{ error: { code, message, httpStatus } }`
was considered and declined as breaking.
- `success` and `error` keep their current meaning **and spelling**.
- The two keys are emitted **only when the thrown error carries them**, and are
**absent** — not `undefined` — otherwise. No fallback code is invented for a
locally-thrown plain `Error`: this CLI's own input refusals get no code,
deliberately, because ADR-0112's ledger is the authority on who may mint one.

One value-space note, stated so the declaration matches what ships: `code` is a
**pass-through**, never minted or filtered. On wire failures it is the semantic
ADR-0112 string the SDK attached; on local I/O failures inside the same `try`
(`os validate` reading a `src/docs` that is a file, say) it is the Node errno
(`ENOENT`, `ENOTDIR`, …). The two vocabularies are disjoint — errnos are
`E`-prefixed OS names — so a consumer branching on ADR-0112 codes cannot
false-match an errno, but not every emitted `code` is ledger-owned.

Human (`table`) output is untouched.

**Why `minor` and not `patch`.** Maintainer-set, and it overrides the obvious
reading: this is a shape change to an **already-published error envelope**, and
that makes it minor even though it is purely additive.

**Migration.** Nothing is required — no key is removed, renamed or re-typed, and
every payload this CLI emitted before is still emitted, byte-for-byte, minus the
two new keys. Two things are worth knowing before you rely on the new ones:

- **The envelope is polymorphic, by design.** `code` and `httpStatus` are absent
whenever the failure did not carry them, which a consumer cannot distinguish
from an older CLI. Branch on presence (`if (payload.code === 'METADATA_CONFLICT')`),
never on absence meaning "success" or "unsupported version". This cost was
weighed against breaking every existing consumer, and the non-breaking side won.
- **Stop substring-matching the sentence.** `error` is prose and no contract pins
its wording; the bracketed `[metadata_conflict]` tag some messages carry today
is a property of one producer, not a contract, and a separate card argues for
removing it. Code that reads the sentence to classify a failure should move to
`code` (with `httpStatus` as the coarse fallback).
4 changes: 2 additions & 2 deletions packages/cli/src/commands/cloud/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import { Command, Flags } from '@oclif/core';
import type { CliExitCode } from '../../utils/format.js';
import { printHeader, printKV, printSuccess, printError, emitJson } from '../../utils/format.js';
import { printHeader, printKV, printSuccess, printError, emitJson, errorCodeFields } from '../../utils/format.js';
import { loginWithBrowser, loginWithPassword } from '../../utils/auth-flows.js';
import { DEFAULT_CLOUD_URL, readCloudConfig, writeCloudConfig } from '../../utils/cloud-config.js';

Expand Down Expand Up @@ -246,7 +246,7 @@ export default class CloudLogin extends Command {
// written (an expired code, a denied approval, a poll failure), so an
// indented payload here would recreate a two-document stream on the
// path a consumer is least able to recover from.
await emitRecord({ success: false, error: error.message });
await emitRecord({ success: false, error: error.message, ...errorCodeFields(error) });
this.exit(1);
}
printError(error.message || String(error));
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/cloud/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { Command, Flags } from '@oclif/core';
import { ObjectStackClient } from '@objectstack/client';
import { printHeader, printSuccess, printError, emitJson } from '../../utils/format.js';
import { printHeader, printSuccess, printError, emitJson, errorCodeFields } from '../../utils/format.js';
import { deleteCloudConfig, tryReadCloudConfig } from '../../utils/cloud-config.js';

export default class CloudLogout extends Command {
Expand Down Expand Up @@ -46,7 +46,7 @@ export default class CloudLogout extends Command {
}
} catch (error: any) {
if (flags.json) {
await emitJson({ success: false, error: error.message });
await emitJson({ success: false, error: error.message, ...errorCodeFields(error) });
this.exit(1);
}
printError(error.message || String(error));
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/cloud/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { Command, Flags } from '@oclif/core';
import { printHeader, printKV, printSuccess, printError, emitJson, isExitSignal } from '../../utils/format.js';
import { printHeader, printKV, printSuccess, printError, emitJson, isExitSignal, errorCodeFields } from '../../utils/format.js';
import { tryReadCloudConfig } from '../../utils/cloud-config.js';

export default class CloudWhoami extends Command {
Expand Down Expand Up @@ -72,7 +72,7 @@ export default class CloudWhoami extends Command {
} catch (error: any) {
if (isExitSignal(error)) throw error;
if (flags.json) {
await emitJson({ success: false, error: error.message });
await emitJson({ success: false, error: error.message, ...errorCodeFields(error) });
this.exit(1);
}
printError(error.message || String(error));
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
printMetadataStats,
emitJson,
isExitSignal,
errorCodeFields,
} from '../utils/format.js';
import { checkSpecVersionGap } from '../utils/spec-version.js';

Expand Down Expand Up @@ -720,7 +721,7 @@ export default class Compile extends Command {
} catch (error: any) {
if (isExitSignal(error)) throw error;
if (flags.json) {
await emitJson({ success: false, error: error.message, warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
await emitJson({ success: false, error: error.message, ...errorCodeFields(error), warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
this.exit(1);
}
console.log('');
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/data/create.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { Args, Command, Flags } from '@oclif/core';
import { printError, printSuccess, emitJson } from '../../utils/format.js';
import { printError, printSuccess, emitJson, errorCodeFields } from '../../utils/format.js';
import { createApiClient, requireAuth } from '../../utils/api-client.js';
import { formatOutput } from '../../utils/output-formatter.js';

Expand Down Expand Up @@ -100,6 +100,7 @@ export default class DataCreate extends Command {
await emitJson({
success: false,
error: error.message,
...errorCodeFields(error),
});
this.exit(1);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/data/delete.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { Args, Command, Flags } from '@oclif/core';
import { printError, printSuccess, emitJson } from '../../utils/format.js';
import { printError, printSuccess, emitJson, errorCodeFields } from '../../utils/format.js';
import { createApiClient, requireAuth } from '../../utils/api-client.js';
import { formatOutput } from '../../utils/output-formatter.js';

Expand Down Expand Up @@ -83,6 +83,7 @@ export default class DataDelete extends Command {
await emitJson({
success: false,
error: error.message,
...errorCodeFields(error),
});
this.exit(1);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/data/get.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { Args, Command, Flags } from '@oclif/core';
import { printError, emitJson } from '../../utils/format.js';
import { printError, emitJson, errorCodeFields } from '../../utils/format.js';
import { createApiClient, requireAuth } from '../../utils/api-client.js';
import { formatOutput } from '../../utils/output-formatter.js';

Expand Down Expand Up @@ -74,6 +74,7 @@ export default class DataGet extends Command {
await emitJson({
success: false,
error: error.message,
...errorCodeFields(error),
});
this.exit(1);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/data/query.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { Args, Command, Flags } from '@oclif/core';
import { printError, emitJson } from '../../utils/format.js';
import { printError, emitJson, errorCodeFields } from '../../utils/format.js';
import { createApiClient, requireAuth } from '../../utils/api-client.js';
import { formatOutput } from '../../utils/output-formatter.js';

Expand Down Expand Up @@ -117,6 +117,7 @@ export default class DataQuery extends Command {
await emitJson({
success: false,
error: error.message,
...errorCodeFields(error),
});
this.exit(1);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/data/update.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { Args, Command, Flags } from '@oclif/core';
import { printError, printSuccess, emitJson } from '../../utils/format.js';
import { printError, printSuccess, emitJson, errorCodeFields } from '../../utils/format.js';
import { createApiClient, requireAuth } from '../../utils/api-client.js';
import { formatOutput } from '../../utils/output-formatter.js';

Expand Down Expand Up @@ -104,6 +104,7 @@ export default class DataUpdate extends Command {
await emitJson({
success: false,
error: error.message,
...errorCodeFields(error),
});
this.exit(1);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
printStep,
createTimer,
emitJson,
errorCodeFields,
} from '../utils/format.js';

// ─── Types ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -284,7 +285,7 @@ export default class Diff extends Command {

} catch (error: any) {
if (flags.json) {
await emitJson({ error: error.message }, 0, { compact: true });
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
process.exit(1);
}
console.log('');
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/environments/bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Command, Flags, Args } from '@oclif/core';
import path from 'node:path';
import fs from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import { printError, printStep, printKV, emitJson, isExitSignal } from '../../utils/format.js';
import { printError, printStep, printKV, emitJson, isExitSignal, errorCodeFields } from '../../utils/format.js';
import { createApiClient, requireAuth } from '../../utils/api-client.js';
import { formatOutput } from '../../utils/output-formatter.js';

Expand Down Expand Up @@ -147,7 +147,7 @@ export default class EnvironmentsBind extends Command {
} catch (error: any) {
if (isExitSignal(error)) throw error;
if (flags.format === 'json') {
await emitJson({ success: false, error: error.message });
await emitJson({ success: false, error: error.message, ...errorCodeFields(error) });
this.exit(1);
}
printError(error.message || String(error));
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/environments/create.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { Command, Flags } from '@oclif/core';
import { printError, emitJson, isExitSignal } from '../../utils/format.js';
import { printError, emitJson, isExitSignal, errorCodeFields } from '../../utils/format.js';
import { createApiClient, requireAuth } from '../../utils/api-client.js';
import { formatOutput } from '../../utils/output-formatter.js';
import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js';
Expand Down Expand Up @@ -127,7 +127,7 @@ export default class EnvironmentsCreate extends Command {
} catch (error: any) {
if (isExitSignal(error)) throw error;
if (flags.format === 'json') {
await emitJson({ success: false, error: error.message });
await emitJson({ success: false, error: error.message, ...errorCodeFields(error) });
this.exit(1);
}
printError(error.message || String(error));
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/environments/list.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { Command, Flags } from '@oclif/core';
import { printError, emitJson } from '../../utils/format.js';
import { printError, emitJson, errorCodeFields } from '../../utils/format.js';
import { createApiClient, requireAuth } from '../../utils/api-client.js';
import { formatOutput } from '../../utils/output-formatter.js';

Expand Down Expand Up @@ -76,7 +76,7 @@ export default class EnvironmentsList extends Command {
}
} catch (error: any) {
if (flags.format === 'json') {
await emitJson({ success: false, error: error.message });
await emitJson({ success: false, error: error.message, ...errorCodeFields(error) });
this.exit(1);
}
printError(error.message || String(error));
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/environments/show.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { Args, Command, Flags } from '@oclif/core';
import { printError, emitJson } from '../../utils/format.js';
import { printError, emitJson, errorCodeFields } from '../../utils/format.js';
import { createApiClient, requireAuth } from '../../utils/api-client.js';
import { formatOutput } from '../../utils/output-formatter.js';

Expand Down Expand Up @@ -67,7 +67,7 @@ export default class EnvironmentsShow extends Command {
}
} catch (error: any) {
if (flags.format === 'json') {
await emitJson({ success: false, error: error.message });
await emitJson({ success: false, error: error.message, ...errorCodeFields(error) });
this.exit(1);
}
printError(error.message || String(error));
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/i18n/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
createTimer,
emitJson,
isExitSignal,
errorCodeFields,
} from '../../utils/format.js';
import { computeI18nCoverage } from '../../utils/i18n-coverage.js';

Expand Down Expand Up @@ -158,7 +159,7 @@ export default class I18nCheck extends Command {
} catch (error: any) {
if (isExitSignal(error)) throw error;
if (flags.json) {
await emitJson({ error: error.message }, 0, { compact: true });
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
process.exit(1);
}
console.log('');
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/i18n/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
createTimer,
emitJson,
isExitSignal,
errorCodeFields,
} from '../../utils/format.js';
import {
extractTranslations,
Expand Down Expand Up @@ -371,7 +372,7 @@ export default class I18nExtract extends Command {
} catch (error: any) {
if (isExitSignal(error)) throw error;
if (flags.json) {
await emitJson({ error: error.message }, 0, { compact: true });
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
process.exit(1);
}
console.log('');
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
collectMetadataStats,
printMetadataStats,
emitJson,
errorCodeFields,
} from '../utils/format.js';

export default class Info extends Command {
Expand Down Expand Up @@ -115,7 +116,7 @@ export default class Info extends Command {

} catch (error: any) {
if (flags.json) {
await emitJson({ error: error.message }, 0, { compact: true });
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
process.exit(1);
}
console.log('');
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
createTimer,
emitJson,
isExitSignal,
errorCodeFields,
} from '../utils/format.js';

// ─── Types ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -636,7 +637,7 @@ export default class Lint extends Command {
} catch (error: any) {
if (isExitSignal(error)) throw error;
if (flags.json) {
await emitJson({ error: error.message }, 0, { compact: true });
await emitJson({ error: error.message, ...errorCodeFields(error) }, 0, { compact: true });
process.exit(1);
}
console.log('');
Expand Down
Loading
Loading