From 86c766e0be2790fb1c9e531833a0a5fb41d66e18 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 05:44:12 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(cli):=20finish=20the=20--json=20truncat?= =?UTF-8?q?ion=20fix=20=E2=80=94=20every=20command,=20and=20the=20second?= =?UTF-8?q?=20document=20it=20was=20hiding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3780 routed three commands through `emitJson`. The other ~100 emission sites still wrote machine output with `console.log`, which on a PIPE is cut off at one 64 KiB buffer when the command exits right after. It is invisible to whoever writes it: stdout to a TTY is synchronous, so every interactive run looks perfect while every scripted consumer — the only audience `--json` has — gets invalid JSON. The exit need not be explicit. oclif ends failing commands with `handle()` → `Exit.exit()` → `process.exit()` and flushes nothing on that path (`flush()` runs only on `execute()`'s success path), so a plain `this.exit(1)` or any thrown error truncates identically — 73 of the 104 sites had that shape. `lint` was only half fixed by #3780: `--eval --json` writes a whole corpus report and was still on `console.log`. - All 104 sites go through `emitJson`; `formatOutput`'s json/yaml branches through the same drain-aware `emitText` (`--format yaml` truncated too, and it is now async — 32 call sites await it). - Control flow is untouched. A following `this.exit(1)` stays; it is simply safe once the buffer has drained. - Output bytes are unchanged. Roughly half the sites emitted compact and half indented; `{ compact: true }` preserves whichever each had, so this is a truncation fix and not an output-format change. Unifying that split is worth doing as its own decision. - An ESLint rule (`--no-inline-config`, so no per-site opt-out) rejects `console.log(JSON.stringify(…))` under packages/cli/src and un-awaited `formatOutput`. Draining the write exposed a second defect underneath it. Because `this.exit(1)` THROWS, a command whose body sits in one `try` unwinds its inner "report and stop" into the outer `catch`, which reports again: `os validate --json` on a failing config printed TWO JSON documents — neither valid JSON nor valid JSONL. Truncation had been hiding the second one. Nine commands had this shape; their catches now re-throw the exit signal (`isExitSignal`) rather than describing it as a failure. Measured on `os validate --json`, 900 schema errors, piped: before 131072 bytes (exactly two buffers, cut mid-string) unparseable truncation fix only 1514711 bytes (two documents) unparseable both fixes 1514648 bytes (one document, 900 errors) parses Verified both directions rather than only the green one: the integration test goes red against origin/main sources, and a control case asserts the `console.log` pattern it replaced genuinely truncates, so the gate cannot quietly stop testing anything. Deliberately NOT fixed by forcing stdout into blocking mode process-wide, which would be one line and cover every command including future ones: the same binary runs `os serve` / `os dev`, and a blocking write to a pipe with a slow reader blocks the event loop — trading truncated JSON for a server that stalls on its own logs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BQwnjqgCyAhSQjhNriW3BW --- .changeset/cli-json-pipe-truncation-sweep.md | 55 +++++ eslint.config.mjs | 50 +++++ packages/cli/src/commands/cloud/login.ts | 10 +- packages/cli/src/commands/cloud/logout.ts | 6 +- packages/cli/src/commands/cloud/whoami.ts | 9 +- packages/cli/src/commands/compile.ts | 45 ++-- packages/cli/src/commands/data/create.ts | 12 +- packages/cli/src/commands/data/delete.ts | 12 +- packages/cli/src/commands/data/get.ts | 12 +- packages/cli/src/commands/data/query.ts | 12 +- packages/cli/src/commands/data/update.ts | 12 +- packages/cli/src/commands/diff.ts | 7 +- .../cli/src/commands/environments/bind.ts | 9 +- .../cli/src/commands/environments/create.ts | 9 +- .../cli/src/commands/environments/list.ts | 8 +- .../cli/src/commands/environments/show.ts | 8 +- packages/cli/src/commands/explain.ts | 9 +- packages/cli/src/commands/i18n/check.ts | 4 +- packages/cli/src/commands/i18n/extract.ts | 4 +- packages/cli/src/commands/info.ts | 7 +- packages/cli/src/commands/lint.ts | 8 +- packages/cli/src/commands/login.ts | 12 +- packages/cli/src/commands/logout.ts | 10 +- packages/cli/src/commands/meta/delete.ts | 10 +- packages/cli/src/commands/meta/get.ts | 8 +- packages/cli/src/commands/meta/list.ts | 14 +- packages/cli/src/commands/meta/register.ts | 10 +- packages/cli/src/commands/meta/resync.ts | 13 +- packages/cli/src/commands/migrate/apply.ts | 17 +- .../commands/migrate/files-to-references.ts | 13 +- packages/cli/src/commands/migrate/meta.ts | 15 +- packages/cli/src/commands/migrate/plan.ts | 11 +- packages/cli/src/commands/register.ts | 6 +- packages/cli/src/commands/validate.ts | 71 ++++--- packages/cli/src/commands/whoami.ts | 10 +- packages/cli/src/utils/format.ts | 85 +++++++- packages/cli/src/utils/output-formatter.ts | 18 +- packages/cli/test/emit-json-pipe.test.ts | 194 ++++++++++++++++++ packages/cli/test/remote-api-utils.test.ts | 50 +++-- 39 files changed, 648 insertions(+), 227 deletions(-) create mode 100644 .changeset/cli-json-pipe-truncation-sweep.md create mode 100644 packages/cli/test/emit-json-pipe.test.ts diff --git a/.changeset/cli-json-pipe-truncation-sweep.md b/.changeset/cli-json-pipe-truncation-sweep.md new file mode 100644 index 0000000000..0e7a86c82b --- /dev/null +++ b/.changeset/cli-json-pipe-truncation-sweep.md @@ -0,0 +1,55 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): finish the `--json` truncation fix — every command, and the second document it was hiding (#3780 follow-up) + +#3780 routed three commands through `emitJson`. The other ~100 emission sites +still wrote machine output with `console.log`, which on a **pipe** is cut off +at one 64 KiB buffer when the command exits right after: Node buffers pipe +writes asynchronously and the exit tears the process down mid-drain. It is +invisible to whoever writes it — stdout to a TTY is synchronous, so every +interactive run looks perfect while every scripted consumer, the only audience +`--json` has, gets invalid JSON. + +The exit does not have to be an explicit `process.exit`. oclif ends failing +commands with `handle()` → `Exit.exit()` → `process.exit()` and flushes +nothing on that path (`flush()` runs only on `execute()`'s success path), so a +plain `this.exit(1)` — or any thrown error — truncates identically. 73 of the +104 sites had exactly that shape. Even `lint` was only half fixed: `--eval +--json` writes a whole corpus report and was still on `console.log`. + +All 104 sites now go through `emitJson`, `formatOutput`'s `json`/`yaml` +branches through the same drain-aware write (`--format yaml` truncated too), +and an ESLint rule keeps the pattern from growing back one command at a time. +Control flow is untouched — a following `this.exit(1)` stays, it is simply +safe once the buffer has drained. Output bytes are unchanged: roughly half the +sites emitted compact and half indented, and each keeps whichever it had. + +**Draining the write exposed a second defect underneath it.** Because +`this.exit(1)` *throws*, a command whose body sits in one `try` unwinds its +inner "report and stop" into the outer `catch`, which reports again — so +`os validate --json` on a failing config printed **two** JSON documents, which +is neither valid JSON nor valid JSONL. Truncation had been hiding the second +one. Nine commands had this shape; their catch clauses now re-throw the exit +signal (`isExitSignal`) instead of describing it as a failure. + +Measured on `os validate --json` against a config with 900 schema errors, +piped: + +| | bytes | parses | +|---|---:|---| +| before | 131072 (exactly two buffers, cut mid-string) | no | +| truncation fix alone | 1514711 (two documents) | no | +| both fixes | 1514648 (one document, 900 errors) | **yes** | + +Pinned end to end: a real command, a real pipe, a payload past several +buffers — plus a control case asserting the `console.log` pattern it replaced +genuinely truncates, so the gate cannot quietly stop testing anything. + +Not covered: the ~30 human-facing `console.log` paths, which are unaffected, +and `os serve` / `os dev` logging. This is deliberately not fixed by forcing +stdout into blocking mode process-wide, which would be one line and cover +everything — the same binary runs the dev server, and a blocking write to a +pipe with a slow reader blocks the event loop, trading truncated JSON for a +server that stalls on its own logs. diff --git a/eslint.config.mjs b/eslint.config.mjs index 1f69115195..d0f9bf3b7e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -78,6 +78,56 @@ export default [ }], }, }, + // Machine output must not be written with `console.log`. + // + // `console.log(big)` followed by an exit hands a PIPE reader a payload cut + // off at one 64 KiB buffer: Node writes stdout asynchronously to a pipe and + // the exit tears the process down mid-drain. `os lint … --json` shipped that + // for months at exactly 65536 bytes, and it is invisible to whoever writes + // it — stdout to a TTY is synchronous, so every interactive run looks right + // while every scripted consumer, the only audience `--json` has, gets + // invalid JSON. The exit need not be explicit: oclif ends failing commands + // with `handle()` → `Exit.exit()` → `process.exit()` and flushes nothing on + // that path, so a plain `this.exit(1)` truncates the same way. + // + // `emitJson` / `emitText` (packages/cli/src/utils/format.ts) await the write + // callback first. The whole CLI was swept onto them; this keeps the pattern + // from growing back one command at a time. Note the root lint script runs + // with `--no-inline-config`, so there is no per-site opt-out — which is the + // point: every past instance of this was written by someone who had no + // reason to suspect it. + { + files: ['packages/cli/src/**/*.{ts,tsx,mts,cts}'], + ignores: ['**/node_modules/**', '**/dist/**', '**/*.test.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + }, + rules: { + 'no-restricted-syntax': ['error', + { + selector: + "CallExpression[callee.object.name='console'][callee.property.name='log']" + + " > CallExpression[callee.object.name='JSON'][callee.property.name='stringify']", + message: + 'Write machine output with `await emitJson(payload)` from utils/format.js, not ' + + 'console.log(JSON.stringify(…)). On a pipe, console.log followed by an exit ' + + '(including oclif\'s this.exit / any thrown error) truncates the payload at 64 KiB. ' + + 'Pass `{ compact: true }` as the third argument to keep single-line output.', + }, + { + // `formatOutput` became async for the same reason — its json and yaml + // branches go through emitText. An un-awaited call at statement + // position silently reopens the hole. (An awaited one nests under an + // AwaitExpression and does not match.) + selector: "ExpressionStatement > CallExpression[callee.name='formatOutput']", + message: + '`formatOutput` is async — await it. Its json/yaml branches drain stdout before ' + + 'the command can exit; dropping the await reintroduces the 64 KiB pipe truncation.', + }, + ], + }, + }, // issue #2035 — authoring-entry guard. Flags exported consts in metadata // files that are annotated with a spec domain type (simple `Page` or qualified // `UI.Page`) instead of being wrapped in the `defineX` factory. AST-only (no diff --git a/packages/cli/src/commands/cloud/login.ts b/packages/cli/src/commands/cloud/login.ts index 108a14f510..d0bc93de68 100644 --- a/packages/cli/src/commands/cloud/login.ts +++ b/packages/cli/src/commands/cloud/login.ts @@ -13,7 +13,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 { printHeader, printKV, printSuccess, printError } from '../../utils/format.js'; +import { printHeader, printKV, printSuccess, printError, emitJson } from '../../utils/format.js'; import { loginWithBrowser, loginWithPassword } from '../../utils/auth-flows.js'; import { DEFAULT_CLOUD_URL, readCloudConfig, writeCloudConfig } from '../../utils/cloud-config.js'; @@ -105,9 +105,7 @@ export default class CloudLogin extends Command { const existing = await readCloudConfig(); if (existing?.token) { if (flags.json) { - console.log( - JSON.stringify({ success: false, error: 'Already logged in', email: existing.email, url: existing.url }), - ); + await emitJson({ success: false, error: 'Already logged in', email: existing.email, url: existing.url }, 0, { compact: true }); } else { printSuccess(`Already logged in to ${existing.url} as ${existing.email || existing.userId}`); console.log(''); @@ -145,7 +143,7 @@ export default class CloudLogin extends Command { }); if (flags.json) { - console.log(JSON.stringify({ success: true, email: result.user?.email, userId: result.user?.id, url }, null, 2)); + await emitJson({ success: true, email: result.user?.email, userId: result.user?.id, url }); } else { printSuccess('Cloud authentication successful'); if (result.user?.email) printKV('Email', result.user.email); @@ -157,7 +155,7 @@ export default class CloudLogin extends Command { } } catch (error: any) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: error.message }, null, 2)); + await emitJson({ success: false, error: error.message }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/cloud/logout.ts b/packages/cli/src/commands/cloud/logout.ts index 334bc028e0..8e964ecaf4 100644 --- a/packages/cli/src/commands/cloud/logout.ts +++ b/packages/cli/src/commands/cloud/logout.ts @@ -8,7 +8,7 @@ import { Command, Flags } from '@oclif/core'; import { ObjectStackClient } from '@objectstack/client'; -import { printHeader, printSuccess, printError } from '../../utils/format.js'; +import { printHeader, printSuccess, printError, emitJson } from '../../utils/format.js'; import { deleteCloudConfig, tryReadCloudConfig } from '../../utils/cloud-config.js'; export default class CloudLogout extends Command { @@ -39,14 +39,14 @@ export default class CloudLogout extends Command { await deleteCloudConfig(); if (flags.json) { - console.log(JSON.stringify({ success: true, message: 'Cloud credentials cleared' }, null, 2)); + await emitJson({ success: true, message: 'Cloud credentials cleared' }); } else { printSuccess('Cloud credentials cleared'); console.log(''); } } catch (error: any) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: error.message }, null, 2)); + await emitJson({ success: false, error: error.message }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/cloud/whoami.ts b/packages/cli/src/commands/cloud/whoami.ts index ffb0bf6e8c..1a29f5db52 100644 --- a/packages/cli/src/commands/cloud/whoami.ts +++ b/packages/cli/src/commands/cloud/whoami.ts @@ -7,7 +7,7 @@ */ import { Command, Flags } from '@oclif/core'; -import { printHeader, printKV, printSuccess, printError } from '../../utils/format.js'; +import { printHeader, printKV, printSuccess, printError, emitJson, isExitSignal } from '../../utils/format.js'; import { tryReadCloudConfig } from '../../utils/cloud-config.js'; export default class CloudWhoami extends Command { @@ -26,7 +26,7 @@ export default class CloudWhoami extends Command { const config = await tryReadCloudConfig(); if (!config?.token) { if (flags.json) { - console.log(JSON.stringify({ logged_in: false }, null, 2)); + await emitJson({ logged_in: false }); } else { printError('Not logged in to ObjectStack Cloud. Run `os cloud login` first.'); } @@ -54,7 +54,7 @@ export default class CloudWhoami extends Command { const valid = !!sessionUser; if (flags.json) { - console.log(JSON.stringify({ logged_in: true, valid, url: config.url, email, userId }, null, 2)); + await emitJson({ logged_in: true, valid, url: config.url, email, userId }); } else { printHeader('ObjectStack Cloud Identity'); printKV('Server', config.url); @@ -70,8 +70,9 @@ export default class CloudWhoami extends Command { console.log(''); } } catch (error: any) { + if (isExitSignal(error)) throw error; if (flags.json) { - console.log(JSON.stringify({ success: false, error: error.message }, null, 2)); + await emitJson({ success: false, error: error.message }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 6691935d67..8abd41f606 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -37,6 +37,8 @@ import { formatZodErrors, collectMetadataStats, printMetadataStats, + emitJson, + isExitSignal, } from '../utils/format.js'; import { checkSpecVersionGap } from '../utils/spec-version.js'; @@ -119,7 +121,7 @@ export default class Compile extends Command { ]; if (issues.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'strict-body: missing body', issues })); + await emitJson({ success: false, error: 'strict-body: missing body', issues }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -137,7 +139,7 @@ export default class Compile extends Command { if (!result.success) { if (flags.json) { - console.log(JSON.stringify({ success: false, errors: (result.error as unknown as ZodError).issues })); + await emitJson({ success: false, errors: (result.error as unknown as ZodError).issues }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -157,7 +159,7 @@ export default class Compile extends Command { const exprWarnings = exprIssues.filter((i) => i.severity === 'warning'); if (exprErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'expression validation failed', issues: exprErrors, warnings: exprWarnings })); + await emitJson({ success: false, error: 'expression validation failed', issues: exprErrors, warnings: exprWarnings }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -195,11 +197,11 @@ export default class Compile extends Command { }); if (capPreflight.errors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ success: false, error: 'capability provider preflight failed', issues: capPreflight.errors.map((c) => ({ token: c.token, message: renderCapabilityMessage(c) })), - })); + }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -243,7 +245,7 @@ export default class Compile extends Command { const widgetWarnings = widgetFindings.filter((f) => f.severity === 'warning'); if (widgetErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'widget binding validation failed', issues: widgetErrors })); + await emitJson({ success: false, error: 'widget binding validation failed', issues: widgetErrors }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -276,7 +278,7 @@ export default class Compile extends Command { const actionRefWarnings = actionRefFindings.filter((f) => f.severity === 'warning'); if (actionRefErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'dashboard action reference validation failed', issues: actionRefErrors })); + await emitJson({ success: false, error: 'dashboard action reference validation failed', issues: actionRefErrors }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -309,7 +311,7 @@ export default class Compile extends Command { const filterTokenErrors = filterTokenFindings.filter((f) => f.severity === 'error'); if (filterTokenErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'filter placeholder validation failed', issues: filterTokenErrors })); + await emitJson({ success: false, error: 'filter placeholder validation failed', issues: filterTokenErrors }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -347,7 +349,7 @@ export default class Compile extends Command { const refWarnings = refFindings.filter((f) => f.severity === 'warning'); if (refErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'reference integrity validation failed', issues: refErrors })); + await emitJson({ success: false, error: 'reference integrity validation failed', issues: refErrors }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -376,7 +378,7 @@ export default class Compile extends Command { const styleWarnings = styleFindings.filter((f) => f.severity === 'warning'); if (styleErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'SDUI styling validation failed', issues: styleErrors })); + await emitJson({ success: false, error: 'SDUI styling validation failed', issues: styleErrors }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -461,7 +463,7 @@ export default class Compile extends Command { const autonumberWarnings = autonumberLint.filter((f) => f.severity === 'warning'); if (autonumberErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'autonumber format validation failed', issues: autonumberErrors })); + await emitJson({ success: false, error: 'autonumber format validation failed', issues: autonumberErrors }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -493,7 +495,7 @@ export default class Compile extends Command { const viewRefWarnings = viewRefLint.filter((f) => f.severity === 'warning'); if (viewRefErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'view reference validation failed', issues: viewRefErrors })); + await emitJson({ success: false, error: 'view reference validation failed', issues: viewRefErrors }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -533,7 +535,7 @@ export default class Compile extends Command { const securityAdvisories = securityFindings.filter((f) => f.severity !== 'error'); if (securityErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'security posture validation failed', issues: securityErrors })); + await emitJson({ success: false, error: 'security posture validation failed', issues: securityErrors }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -566,7 +568,7 @@ export default class Compile extends Command { const readonlyWriteAdvisories = readonlyWriteFindings.filter((f) => f.severity !== 'error'); if (readonlyWriteErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'readonly flow-write validation failed', issues: readonlyWriteErrors })); + await emitJson({ success: false, error: 'readonly flow-write validation failed', issues: readonlyWriteErrors }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -614,7 +616,7 @@ export default class Compile extends Command { const drift = diffAccessMatrix(committed, currentMatrix); if (drift.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'access matrix drift', changes: drift })); + await emitJson({ success: false, error: 'access matrix drift', changes: drift }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -638,7 +640,7 @@ export default class Compile extends Command { const docWarnings = docsResult.issues.filter((i) => i.severity === 'warning'); if (docErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'docs validation failed', issues: docErrors })); + await emitJson({ success: false, error: 'docs validation failed', issues: docErrors }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -694,7 +696,7 @@ export default class Compile extends Command { // pipelines can guard against accidental regressions. const msg = `--no-runtime-bundle requires every callable to have a metadata body (${stillNeeded} missing, ${lowering.bodyExtractionWarnings.length} extraction warning(s)). Re-run with --strict-body to see details, or omit --no-runtime-bundle.`; if (flags.json) { - console.log(JSON.stringify({ success: false, error: msg })); + await emitJson({ success: false, error: msg }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -718,7 +720,7 @@ export default class Compile extends Command { cleanupOldRuntimeBundles(artifactDir, runtimeBundle.outputFileName); } catch (err: any) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: `runtime bundle failed: ${err.message}` })); + await emitJson({ success: false, error: `runtime bundle failed: ${err.message}` }, 0, { compact: true }); this.exit(1); } console.log(''); @@ -739,7 +741,7 @@ export default class Compile extends Command { const specGap = checkSpecVersionGap((config as { manifest?: { specVersion?: unknown } }).manifest); if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ success: true, output: artifactPath, size: jsonContent.length, @@ -750,7 +752,7 @@ export default class Compile extends Command { specVersionGap: specGap, stats, duration: timer.elapsed(), - })); + }, 0, { compact: true }); return; } @@ -779,8 +781,9 @@ export default class Compile extends Command { console.log(''); } catch (error: any) { + if (isExitSignal(error)) throw error; if (flags.json) { - console.log(JSON.stringify({ success: false, error: error.message })); + await emitJson({ success: false, error: error.message }, 0, { compact: true }); this.exit(1); } console.log(''); diff --git a/packages/cli/src/commands/data/create.ts b/packages/cli/src/commands/data/create.ts index 4475657655..75c3d0c0cd 100644 --- a/packages/cli/src/commands/data/create.ts +++ b/packages/cli/src/commands/data/create.ts @@ -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 } from '../../utils/format.js'; +import { printError, printSuccess, emitJson } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; @@ -85,22 +85,22 @@ export default class DataCreate extends Command { const result = await client.data.create(args.object, recordData); if (flags.format === 'json') { - formatOutput(result, 'json'); + await formatOutput(result, 'json'); } else if (flags.format === 'yaml') { - formatOutput(result, 'yaml'); + await formatOutput(result, 'yaml'); } else { printSuccess(`Record created: ${result.id}`); if (result.record) { console.log(''); - formatOutput(result.record, 'table'); + await formatOutput(result.record, 'table'); } } } catch (error: any) { if (flags.format === 'json') { - console.log(JSON.stringify({ + await emitJson({ success: false, error: error.message, - }, null, 2)); + }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/data/delete.ts b/packages/cli/src/commands/data/delete.ts index ee7f0a7536..15a94fedde 100644 --- a/packages/cli/src/commands/data/delete.ts +++ b/packages/cli/src/commands/data/delete.ts @@ -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 } from '../../utils/format.js'; +import { printError, printSuccess, emitJson } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; @@ -58,23 +58,23 @@ export default class DataDelete extends Command { const result = await client.data.delete(args.object, args.id); if (flags.format === 'json') { - console.log(JSON.stringify({ + await emitJson({ success: true, object: result.object, id: result.id, deleted: result.deleted, - }, null, 2)); + }); } else if (flags.format === 'yaml') { - formatOutput({ success: true, object: result.object, id: result.id, deleted: result.deleted }, 'yaml'); + await formatOutput({ success: true, object: result.object, id: result.id, deleted: result.deleted }, 'yaml'); } else { printSuccess(`Record deleted: ${result.id}`); } } catch (error: any) { if (flags.format === 'json') { - console.log(JSON.stringify({ + await emitJson({ success: false, error: error.message, - }, null, 2)); + }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/data/get.ts b/packages/cli/src/commands/data/get.ts index e06532468a..05583d2389 100644 --- a/packages/cli/src/commands/data/get.ts +++ b/packages/cli/src/commands/data/get.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Args, Command, Flags } from '@oclif/core'; -import { printError } from '../../utils/format.js'; +import { printError, emitJson } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; @@ -58,23 +58,23 @@ export default class DataGet extends Command { const result = await client.data.get(args.object, args.id); if (flags.format === 'json') { - formatOutput(result, 'json'); + await formatOutput(result, 'json'); } else if (flags.format === 'yaml') { - formatOutput(result, 'yaml'); + await formatOutput(result, 'yaml'); } else { // Table format - show the record if (result.record) { - formatOutput(result.record, 'table'); + await formatOutput(result.record, 'table'); } else { console.log('Record not found.'); } } } catch (error: any) { if (flags.format === 'json') { - console.log(JSON.stringify({ + await emitJson({ success: false, error: error.message, - }, null, 2)); + }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/data/query.ts b/packages/cli/src/commands/data/query.ts index 9a84c1f722..9e1065dfcc 100644 --- a/packages/cli/src/commands/data/query.ts +++ b/packages/cli/src/commands/data/query.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Args, Command, Flags } from '@oclif/core'; -import { printError } from '../../utils/format.js'; +import { printError, emitJson } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; @@ -97,13 +97,13 @@ export default class DataQuery extends Command { const result = await client.data.query(args.object, queryOptions); if (flags.format === 'json') { - formatOutput(result, 'json'); + await formatOutput(result, 'json'); } else if (flags.format === 'yaml') { - formatOutput(result, 'yaml'); + await formatOutput(result, 'yaml'); } else { // Table format if (result.records && result.records.length > 0) { - formatOutput(result.records, 'table'); + await formatOutput(result.records, 'table'); } else { console.log('No records found.'); } @@ -114,10 +114,10 @@ export default class DataQuery extends Command { } } catch (error: any) { if (flags.format === 'json') { - console.log(JSON.stringify({ + await emitJson({ success: false, error: error.message, - }, null, 2)); + }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/data/update.ts b/packages/cli/src/commands/data/update.ts index d1e5fb3905..2efcacdad1 100644 --- a/packages/cli/src/commands/data/update.ts +++ b/packages/cli/src/commands/data/update.ts @@ -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 } from '../../utils/format.js'; +import { printError, printSuccess, emitJson } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; @@ -89,22 +89,22 @@ export default class DataUpdate extends Command { const result = await client.data.update(args.object, args.id, updateData); if (flags.format === 'json') { - formatOutput(result, 'json'); + await formatOutput(result, 'json'); } else if (flags.format === 'yaml') { - formatOutput(result, 'yaml'); + await formatOutput(result, 'yaml'); } else { printSuccess(`Record updated: ${result.id}`); if (result.record) { console.log(''); - formatOutput(result.record, 'table'); + await formatOutput(result.record, 'table'); } } } catch (error: any) { if (flags.format === 'json') { - console.log(JSON.stringify({ + await emitJson({ success: false, error: error.message, - }, null, 2)); + }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index 31719111d2..4f1771daad 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -11,6 +11,7 @@ import { printInfo, printStep, createTimer, + emitJson, } from '../utils/format.js'; // ─── Types ────────────────────────────────────────────────────────── @@ -231,14 +232,14 @@ export default class Diff extends Command { // ── Output ── if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ before: beforePath, after: afterPath, total: diffs.length, breaking: breakingCount, changes: diffs, duration: timer.elapsed(), - }, null, 2)); + }); return; } @@ -283,7 +284,7 @@ export default class Diff extends Command { } catch (error: any) { if (flags.json) { - console.log(JSON.stringify({ error: error.message })); + await emitJson({ error: error.message }, 0, { compact: true }); process.exit(1); } console.log(''); diff --git a/packages/cli/src/commands/environments/bind.ts b/packages/cli/src/commands/environments/bind.ts index 916ddd5867..c0ac102ddb 100644 --- a/packages/cli/src/commands/environments/bind.ts +++ b/packages/cli/src/commands/environments/bind.ts @@ -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 } from '../../utils/format.js'; +import { printError, printStep, printKV, emitJson, isExitSignal } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; @@ -127,9 +127,9 @@ export default class ProjectsBind extends Command { } if (flags.format === 'json') { - formatOutput(res, 'json'); + await formatOutput(res, 'json'); } else if (flags.format === 'yaml') { - formatOutput(res, 'yaml'); + await formatOutput(res, 'yaml'); } else { console.log(`\n✓ Project bound to artifact`); console.log(` ${args.environmentId} → ${artifactAbs}`); @@ -137,8 +137,9 @@ export default class ProjectsBind extends Command { console.log(''); } } catch (error: any) { + if (isExitSignal(error)) throw error; if (flags.format === 'json') { - console.log(JSON.stringify({ success: false, error: error.message }, null, 2)); + await emitJson({ success: false, error: error.message }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/environments/create.ts b/packages/cli/src/commands/environments/create.ts index f84ce1d823..7dc50c25f9 100644 --- a/packages/cli/src/commands/environments/create.ts +++ b/packages/cli/src/commands/environments/create.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Command, Flags } from '@oclif/core'; -import { printError } from '../../utils/format.js'; +import { printError, emitJson, isExitSignal } 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'; @@ -106,9 +106,9 @@ export default class ProjectsCreate extends Command { } if (flags.format === 'json') { - formatOutput(res, 'json'); + await formatOutput(res, 'json'); } else if (flags.format === 'yaml') { - formatOutput(res, 'yaml'); + await formatOutput(res, 'yaml'); } else { const p = res?.project ?? {}; console.log(`\n✓ Project created: ${p.display_name ?? p.id} (${p.id})`); @@ -118,8 +118,9 @@ export default class ProjectsCreate extends Command { console.log(''); } } catch (error: any) { + if (isExitSignal(error)) throw error; if (flags.format === 'json') { - console.log(JSON.stringify({ success: false, error: error.message }, null, 2)); + await emitJson({ success: false, error: error.message }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/environments/list.ts b/packages/cli/src/commands/environments/list.ts index be029bc466..165102f827 100644 --- a/packages/cli/src/commands/environments/list.ts +++ b/packages/cli/src/commands/environments/list.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Command, Flags } from '@oclif/core'; -import { printError } from '../../utils/format.js'; +import { printError, emitJson } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; @@ -53,9 +53,9 @@ export default class ProjectsList extends Command { const projects = res?.projects ?? []; if (flags.format === 'json') { - formatOutput(res, 'json'); + await formatOutput(res, 'json'); } else if (flags.format === 'yaml') { - formatOutput(res, 'yaml'); + await formatOutput(res, 'yaml'); } else { console.log(`\nProjects (${projects.length}):\n`); if (projects.length === 0) { @@ -76,7 +76,7 @@ export default class ProjectsList extends Command { } } catch (error: any) { if (flags.format === 'json') { - console.log(JSON.stringify({ success: false, error: error.message }, null, 2)); + await emitJson({ success: false, error: error.message }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/environments/show.ts b/packages/cli/src/commands/environments/show.ts index a2a55bc87a..758cbde498 100644 --- a/packages/cli/src/commands/environments/show.ts +++ b/packages/cli/src/commands/environments/show.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Args, Command, Flags } from '@oclif/core'; -import { printError } from '../../utils/format.js'; +import { printError, emitJson } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; @@ -44,9 +44,9 @@ export default class ProjectsShow extends Command { const res = await client.projects.get(args.id); if (flags.format === 'json') { - formatOutput(res, 'json'); + await formatOutput(res, 'json'); } else if (flags.format === 'yaml') { - formatOutput(res, 'yaml'); + await formatOutput(res, 'yaml'); } else { const p = res?.project ?? {}; console.log(`\nProject: ${p.display_name ?? p.id}`); @@ -67,7 +67,7 @@ export default class ProjectsShow extends Command { } } catch (error: any) { if (flags.format === 'json') { - console.log(JSON.stringify({ success: false, error: error.message }, null, 2)); + await emitJson({ success: false, error: error.message }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index 2834d39923..03ba3f6042 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -8,6 +8,7 @@ import { printError, printInfo, printKV, + emitJson, } from '../utils/format.js'; // ─── Schema Catalog ───────────────────────────────────────────────── @@ -332,12 +333,12 @@ export default class Explain extends Command { // ── No argument: list all schemas ── if (!schemaName) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ schemas: Object.entries(SCHEMAS).map(([key, s]) => ({ name: key, description: s.description, })), - }, null, 2)); + }); return; } @@ -357,7 +358,7 @@ export default class Explain extends Command { const schema = SCHEMAS[schemaName.toLowerCase()]; if (!schema) { if (flags.json) { - console.log(JSON.stringify({ error: `Unknown schema: ${schemaName}` })); + await emitJson({ error: `Unknown schema: ${schemaName}` }, 0, { compact: true }); process.exit(1); } printError(`Unknown schema: "${schemaName}"`); @@ -369,7 +370,7 @@ export default class Explain extends Command { // ── JSON output ── if (flags.json) { - console.log(JSON.stringify(schema, null, 2)); + await emitJson(schema); return; } diff --git a/packages/cli/src/commands/i18n/check.ts b/packages/cli/src/commands/i18n/check.ts index 63b8720ec5..8a89d29d3e 100644 --- a/packages/cli/src/commands/i18n/check.ts +++ b/packages/cli/src/commands/i18n/check.ts @@ -13,6 +13,7 @@ import { printStep, createTimer, emitJson, + isExitSignal, } from '../../utils/format.js'; import { computeI18nCoverage } from '../../utils/i18n-coverage.js'; @@ -155,8 +156,9 @@ export default class I18nCheck extends Command { if (report.totals.errors > 0 || thresholdViolations.length > 0) process.exit(1); } catch (error: any) { + if (isExitSignal(error)) throw error; if (flags.json) { - console.log(JSON.stringify({ error: error.message })); + await emitJson({ error: error.message }, 0, { compact: true }); process.exit(1); } console.log(''); diff --git a/packages/cli/src/commands/i18n/extract.ts b/packages/cli/src/commands/i18n/extract.ts index 32fcbcfe35..0f327faee1 100644 --- a/packages/cli/src/commands/i18n/extract.ts +++ b/packages/cli/src/commands/i18n/extract.ts @@ -14,6 +14,7 @@ import { printStep, createTimer, emitJson, + isExitSignal, } from '../../utils/format.js'; import { extractTranslations, renderTranslationModule, type FillStrategy } from '../../utils/i18n-extract.js'; @@ -271,8 +272,9 @@ export default class I18nExtract extends Command { console.log(''); printSuccess(`Generated ${written} file(s) ${chalk.dim(`(${timer.display()})`)}`); } catch (error: any) { + if (isExitSignal(error)) throw error; if (flags.json) { - console.log(JSON.stringify({ error: error.message })); + await emitJson({ error: error.message }, 0, { compact: true }); process.exit(1); } console.log(''); diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index 5062260d73..8ecaea3301 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -13,6 +13,7 @@ import { createTimer, collectMetadataStats, printMetadataStats, + emitJson, } from '../utils/format.js'; export default class Info extends Command { @@ -40,7 +41,7 @@ export default class Info extends Command { const stats = collectMetadataStats(config); if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ config: absolutePath, manifest: config.manifest || null, stats, @@ -50,7 +51,7 @@ export default class Info extends Command { fields: o.fields ? Object.keys(o.fields).length : 0, })), loadTime: duration, - }, null, 2)); + }); return; } @@ -114,7 +115,7 @@ export default class Info extends Command { } catch (error: any) { if (flags.json) { - console.log(JSON.stringify({ error: error.message })); + await emitJson({ error: error.message }, 0, { compact: true }); process.exit(1); } console.log(''); diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index e19968c2f7..e6dc27bb22 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -26,6 +26,7 @@ import { printStep, createTimer, emitJson, + isExitSignal, } from '../utils/format.js'; // ─── Types ────────────────────────────────────────────────────────── @@ -767,8 +768,9 @@ export default class Lint extends Command { if (errors.length > 0) process.exit(1); } catch (error: any) { + if (isExitSignal(error)) throw error; if (flags.json) { - console.log(JSON.stringify({ error: error.message })); + await emitJson({ error: error.message }, 0, { compact: true }); process.exit(1); } console.log(''); @@ -814,7 +816,7 @@ export default class Lint extends Command { generate = fn; } catch (error: any) { const msg = `Failed to load generator "${flags.generator}": ${error?.message || error}`; - if (flags.json) console.log(JSON.stringify({ error: msg })); + if (flags.json) await emitJson({ error: msg }, 0, { compact: true }); else printError(msg); process.exit(1); } @@ -826,7 +828,7 @@ export default class Lint extends Command { }); if (flags.json) { - console.log(JSON.stringify({ ...report, duration: timer.elapsed() }, null, 2)); + await emitJson({ ...report, duration: timer.elapsed() }); if (!report.ok) process.exit(1); return; } diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index 80e6005c63..2c3ee1ccfc 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Command, Flags } from '@oclif/core'; -import { printHeader, printSuccess, printError, printKV } from '../utils/format.js'; +import { printHeader, printSuccess, printError, printKV, emitJson } from '../utils/format.js'; import { writeAuthConfig, readAuthConfig } from '../utils/auth-config.js'; import { ObjectStackClient } from '@objectstack/client'; import * as readline from 'node:readline/promises'; @@ -122,7 +122,7 @@ export default class AuthLogin extends Command { const existing = await readAuthConfig(); if (existing?.token) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: 'Already logged in', email: existing.email })); + await emitJson({ success: false, error: 'Already logged in', email: existing.email }, 0, { compact: true }); } else { printSuccess(`Already logged in as ${existing.email || existing.userId}`); console.log(''); @@ -171,7 +171,7 @@ export default class AuthLogin extends Command { await this.loginWithPassword(client, flags.url, email, password, flags.json); } catch (error: any) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: error.message }, null, 2)); + await emitJson({ success: false, error: error.message }); this.exit(1); } printError(error.message || String(error)); @@ -206,7 +206,7 @@ export default class AuthLogin extends Command { }); if (jsonOutput) { - console.log(JSON.stringify({ success: true, email: user?.email || email, userId: user?.id }, null, 2)); + await emitJson({ success: true, email: user?.email || email, userId: user?.id }); } else { printSuccess('Authentication successful'); printKV('Email', user?.email || email); @@ -252,7 +252,7 @@ export default class AuthLogin extends Command { const verificationUrl = verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`; if (jsonOutput) { - console.log(JSON.stringify({ device_code, user_code, verification_uri, verification_uri_complete, expires_in })); + await emitJson({ device_code, user_code, verification_uri, verification_uri_complete, expires_in }, 0, { compact: true }); } else { console.log(' To authorize this CLI, visit:'); console.log(''); @@ -318,7 +318,7 @@ export default class AuthLogin extends Command { }); if (jsonOutput) { - console.log(JSON.stringify({ success: true, email: user?.email, userId: user?.id }, null, 2)); + await emitJson({ success: true, email: user?.email, userId: user?.id }); } else { printSuccess('Authentication successful'); if (user?.email) printKV('Email', user.email); diff --git a/packages/cli/src/commands/logout.ts b/packages/cli/src/commands/logout.ts index 8310700ac4..25888add92 100644 --- a/packages/cli/src/commands/logout.ts +++ b/packages/cli/src/commands/logout.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Command, Flags } from '@oclif/core'; -import { printHeader, printSuccess, printError } from '../utils/format.js'; +import { printHeader, printSuccess, printError, emitJson } from '../utils/format.js'; import { deleteAuthConfig, readAuthConfig } from '../utils/auth-config.js'; import { ObjectStackClient } from '@objectstack/client'; @@ -40,20 +40,20 @@ export default class AuthLogout extends Command { await deleteAuthConfig(); if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ success: true, message: 'Credentials cleared', - }, null, 2)); + }); } else { printSuccess('Credentials cleared'); console.log(''); } } catch (error: any) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ success: false, error: error.message, - }, null, 2)); + }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/meta/delete.ts b/packages/cli/src/commands/meta/delete.ts index 51b14db27a..436cd638ec 100644 --- a/packages/cli/src/commands/meta/delete.ts +++ b/packages/cli/src/commands/meta/delete.ts @@ -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 } from '../../utils/format.js'; +import { printError, printSuccess, emitJson } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; @@ -58,18 +58,18 @@ export default class MetaDelete extends Command { const result = await client.meta.deleteItem(args.type, args.name); if (flags.format === 'json') { - formatOutput({ success: true, type: args.type, name: args.name, deleted: result.deleted }, 'json'); + await formatOutput({ success: true, type: args.type, name: args.name, deleted: result.deleted }, 'json'); } else if (flags.format === 'yaml') { - formatOutput({ success: true, type: args.type, name: args.name, deleted: result.deleted }, 'yaml'); + await formatOutput({ success: true, type: args.type, name: args.name, deleted: result.deleted }, 'yaml'); } else { printSuccess(`Metadata deleted: ${args.type}/${args.name}`); } } catch (error: any) { if (flags.format === 'json') { - console.log(JSON.stringify({ + await emitJson({ success: false, error: error.message, - }, null, 2)); + }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/meta/get.ts b/packages/cli/src/commands/meta/get.ts index 95a669e1df..029cb9a650 100644 --- a/packages/cli/src/commands/meta/get.ts +++ b/packages/cli/src/commands/meta/get.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Args, Command, Flags } from '@oclif/core'; -import { printError } from '../../utils/format.js'; +import { printError, emitJson } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; @@ -57,13 +57,13 @@ export default class MetaGet extends Command { // Get the metadata item const item = await client.meta.getItem(args.type, args.name); - formatOutput(item, flags.format as any); + await formatOutput(item, flags.format as any); } catch (error: any) { if (flags.format === 'json') { - console.log(JSON.stringify({ + await emitJson({ success: false, error: error.message, - }, null, 2)); + }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/meta/list.ts b/packages/cli/src/commands/meta/list.ts index 5a0ee3246f..521be90f06 100644 --- a/packages/cli/src/commands/meta/list.ts +++ b/packages/cli/src/commands/meta/list.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Args, Command, Flags } from '@oclif/core'; -import { printError } from '../../utils/format.js'; +import { printError, emitJson } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; @@ -55,9 +55,9 @@ export default class MetaList extends Command { const types = await client.meta.getTypes(); if (flags.format === 'json') { - formatOutput(types, 'json'); + await formatOutput(types, 'json'); } else if (flags.format === 'yaml') { - formatOutput(types, 'yaml'); + await formatOutput(types, 'yaml'); } else { console.log('\nAvailable metadata types:\n'); if (Array.isArray(types)) { @@ -72,9 +72,9 @@ export default class MetaList extends Command { const items = await client.meta.getItems(args.type); if (flags.format === 'json') { - formatOutput(items, 'json'); + await formatOutput(items, 'json'); } else if (flags.format === 'yaml') { - formatOutput(items, 'yaml'); + await formatOutput(items, 'yaml'); } else { console.log(`\n${args.type} items:\n`); if (Array.isArray(items)) { @@ -92,10 +92,10 @@ export default class MetaList extends Command { } } catch (error: any) { if (flags.format === 'json') { - console.log(JSON.stringify({ + await emitJson({ success: false, error: error.message, - }, null, 2)); + }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/meta/register.ts b/packages/cli/src/commands/meta/register.ts index 202c19f786..ac4c867d53 100644 --- a/packages/cli/src/commands/meta/register.ts +++ b/packages/cli/src/commands/meta/register.ts @@ -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 } from '../../utils/format.js'; +import { printError, printSuccess, emitJson } from '../../utils/format.js'; import { createApiClient, requireAuth } from '../../utils/api-client.js'; import { formatOutput } from '../../utils/output-formatter.js'; @@ -76,18 +76,18 @@ export default class MetaRegister extends Command { const result = await client.meta.saveItem(args.type, name, metadata); if (flags.format === 'json') { - formatOutput(result, 'json'); + await formatOutput(result, 'json'); } else if (flags.format === 'yaml') { - formatOutput(result, 'yaml'); + await formatOutput(result, 'yaml'); } else { printSuccess(`Metadata registered: ${args.type}/${name}`); } } catch (error: any) { if (flags.format === 'json') { - console.log(JSON.stringify({ + await emitJson({ success: false, error: error.message, - }, null, 2)); + }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/meta/resync.ts b/packages/cli/src/commands/meta/resync.ts index 016c25006e..f648fcc77f 100644 --- a/packages/cli/src/commands/meta/resync.ts +++ b/packages/cli/src/commands/meta/resync.ts @@ -11,6 +11,7 @@ import { printInfo, printStep, createTimer, + emitJson, } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { bootstrapPlatformAdmin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; @@ -84,7 +85,7 @@ export default class MetaResync extends Command { try { stack = await bootSchemaStack({ databaseUrl: flags['database-url'] }); } catch (error: any) { - if (flags.json) console.log(JSON.stringify({ error: error.message })); + if (flags.json) await emitJson({ error: error.message }, 0, { compact: true }); else printError(error.message || String(error)); process.exit(1); } @@ -100,7 +101,7 @@ export default class MetaResync extends Command { if (!ql) { if (flags.json) { - console.log(JSON.stringify({ error: 'objectql_unavailable' })); + await emitJson({ error: 'objectql_unavailable' }, 0, { compact: true }); return; } printError('ObjectQL service is not available — cannot resync.'); @@ -108,7 +109,7 @@ export default class MetaResync extends Command { } if (!Array.isArray(sets) || sets.length === 0) { if (flags.json) { - console.log(JSON.stringify({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'no_default_permission_sets' })); + await emitJson({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'no_default_permission_sets' }, 0, { compact: true }); return; } printWarning('No default permission sets are available to resync.'); @@ -121,7 +122,7 @@ export default class MetaResync extends Command { if (!flags.yes) { if (flags.json || !process.stdin.isTTY) { if (flags.json) { - console.log(JSON.stringify({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'confirmation_required', hint: 'pass --yes' })); + await emitJson({ resynced: 0, resyncSkipped: 0, inserted: 0, message: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true }); return; } printWarning('Confirmation required. Re-run with --yes to resync.'); @@ -150,7 +151,7 @@ export default class MetaResync extends Command { const inserted = Math.max(0, (report.seeded ?? 0) - resynced - resyncSkipped); if (flags.json) { - console.log(JSON.stringify({ database: stack.dbLabel, resynced, resyncSkipped, inserted, duration: timer.elapsed() }, null, 2)); + await emitJson({ database: stack.dbLabel, resynced, resyncSkipped, inserted, duration: timer.elapsed() }); return; } @@ -167,7 +168,7 @@ export default class MetaResync extends Command { console.log(''); } catch (error: any) { exitCode = 1; - if (flags.json) console.log(JSON.stringify({ error: error.message })); + if (flags.json) await emitJson({ error: error.message }, 0, { compact: true }); else printError(error.message || String(error)); } finally { await stack.shutdown(); diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index 2004ad4932..f082e3010f 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -11,6 +11,7 @@ import { printInfo, printStep, createTimer, + emitJson, } from '../../utils/format.js'; import { bootSchemaStack, @@ -74,7 +75,7 @@ export default class MigrateApply extends Command { try { stack = await bootSchemaStack({ databaseUrl: flags['database-url'] }); } catch (error: any) { - if (flags.json) { console.log(JSON.stringify({ error: error.message })); this.exit(1); } + if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); } printError(error.message || String(error)); this.exit(1); return; @@ -82,7 +83,7 @@ export default class MigrateApply extends Command { try { if (!stack.driver) { - if (flags.json) { console.log(JSON.stringify({ error: 'no_sql_driver' })); return; } + if (flags.json) { await emitJson({ error: 'no_sql_driver' }, 0, { compact: true }); return; } printWarning('Schema migration is only supported on SQL drivers (SQLite / Postgres). No SQL driver is active.'); return; } @@ -91,7 +92,7 @@ export default class MigrateApply extends Command { const grouped = groupByCategory(drift); if (drift.length === 0) { - if (flags.json) { console.log(JSON.stringify({ applied: [], skipped: [], message: 'in_sync' })); return; } + if (flags.json) { await emitJson({ applied: [], skipped: [], message: 'in_sync' }, 0, { compact: true }); return; } printSuccess('Physical schema is already in sync with metadata — nothing to apply.'); return; } @@ -114,7 +115,7 @@ export default class MigrateApply extends Command { } if (intended.length === 0) { - if (flags.json) { console.log(JSON.stringify({ applied: [], skipped: deferred, message: 'nothing_safe_to_apply' })); return; } + if (flags.json) { await emitJson({ applied: [], skipped: deferred, message: 'nothing_safe_to_apply' }, 0, { compact: true }); return; } printWarning('No changes to apply without --allow-destructive.'); return; } @@ -122,7 +123,7 @@ export default class MigrateApply extends Command { // Confirmation gate. if (!flags.yes) { if (flags.json || !process.stdin.isTTY) { - if (flags.json) { console.log(JSON.stringify({ applied: [], skipped: drift, message: 'confirmation_required', hint: 'pass --yes' })); return; } + if (flags.json) { await emitJson({ applied: [], skipped: drift, message: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true }); return; } printWarning('Confirmation required. Re-run with --yes to apply, or use "os migrate plan" to preview.'); return; } @@ -133,12 +134,12 @@ export default class MigrateApply extends Command { const { applied, skipped } = await stack.driver.applyMigrationEntries(drift, { allowDestructive }); if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ database: stack.dbLabel, applied, skipped, duration: timer.elapsed(), - }, null, 2)); + }); return; } @@ -150,7 +151,7 @@ export default class MigrateApply extends Command { console.log(chalk.dim(` ${timer.display()}`)); console.log(''); } catch (error: any) { - if (flags.json) { console.log(JSON.stringify({ error: error.message })); this.exit(1); } + if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); } printError(error.message || String(error)); this.exit(1); } finally { diff --git a/packages/cli/src/commands/migrate/files-to-references.ts b/packages/cli/src/commands/migrate/files-to-references.ts index cade4777bd..fa82c90950 100644 --- a/packages/cli/src/commands/migrate/files-to-references.ts +++ b/packages/cli/src/commands/migrate/files-to-references.ts @@ -11,6 +11,8 @@ import { printInfo, printStep, createTimer, + emitJson, + isExitSignal, } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { loadConfig } from '../../utils/config.js'; @@ -128,7 +130,7 @@ export default class MigrateFilesToReferences extends Command { if (apply && !flags.yes) { if (flags.json || !process.stdin.isTTY) { if (flags.json) { - console.log(JSON.stringify({ error: 'confirmation_required', hint: 'pass --yes' })); + await emitJson({ error: 'confirmation_required', hint: 'pass --yes' }, 0, { compact: true }); this.exit(1); } printWarning('Apply mode rewrites record data. Re-run with --yes to confirm, or run without --apply to preview.'); @@ -155,7 +157,7 @@ export default class MigrateFilesToReferences extends Command { extraPlugins: await buildDataMigrationPlugins(), }); } catch (error: any) { - if (flags.json) { console.log(JSON.stringify({ error: error.message })); this.exit(1); } + if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); } printError(error.message || String(error)); this.exit(1); return; @@ -208,7 +210,7 @@ export default class MigrateFilesToReferences extends Command { }); if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ database: stack.dbLabel, apply, backfill: { @@ -237,7 +239,7 @@ export default class MigrateFilesToReferences extends Command { gateFailures: result.gateFailures, flag: result.flag, duration: timer.elapsed(), - }, null, 2)); + }); if (!result.gatePassed) this.exit(1); return; } @@ -281,7 +283,8 @@ export default class MigrateFilesToReferences extends Command { console.log(''); if (!result.gatePassed) this.exit(1); } catch (error: any) { - if (flags.json) { console.log(JSON.stringify({ error: error.message })); this.exit(1); } + if (isExitSignal(error)) throw error; + if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); } printError(error.message || String(error)); this.exit(1); } finally { diff --git a/packages/cli/src/commands/migrate/meta.ts b/packages/cli/src/commands/migrate/meta.ts index 6e4e30fd5d..7d74570e73 100644 --- a/packages/cli/src/commands/migrate/meta.ts +++ b/packages/cli/src/commands/migrate/meta.ts @@ -21,6 +21,7 @@ import { printInfo, printStep, createTimer, + emitJson, } from '../../utils/format.js'; /** @@ -95,9 +96,7 @@ export default class MigrateMeta extends Command { const specChanges = composeSpecChanges(flags.from, toMajor); if (flags.json) { - console.log( - JSON.stringify( - { + await emitJson({ from: result.fromMajor, to: result.toMajor, runtime: PROTOCOL_VERSION, @@ -114,11 +113,7 @@ export default class MigrateMeta extends Command { specChanges, schemaValid: parsed.success, duration: timer.elapsed(), - }, - null, - 2, - ), - ); + }); if (flags.out) writeFileSync(resolve(flags.out), JSON.stringify(result.stack, null, 2)); return; } @@ -179,7 +174,7 @@ export default class MigrateMeta extends Command { } catch (error: any) { if (error instanceof MigrationFloorError) { if (flags.json) { - console.log(JSON.stringify({ error: 'unsupported_from_major', message: error.message })); + await emitJson({ error: 'unsupported_from_major', message: error.message }, 0, { compact: true }); this.exit(1); } printError(error.message); @@ -187,7 +182,7 @@ export default class MigrateMeta extends Command { return; } if (flags.json) { - console.log(JSON.stringify({ error: error.message })); + await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/migrate/plan.ts b/packages/cli/src/commands/migrate/plan.ts index 911482b964..1e8fa79c1f 100644 --- a/packages/cli/src/commands/migrate/plan.ts +++ b/packages/cli/src/commands/migrate/plan.ts @@ -10,6 +10,7 @@ import { printInfo, printStep, createTimer, + emitJson, } from '../../utils/format.js'; import { bootSchemaStack, renderPlan, summarize } from '../../utils/schema-migrate.js'; @@ -49,7 +50,7 @@ export default class MigratePlan extends Command { try { stack = await bootSchemaStack({ databaseUrl: flags['database-url'] }); } catch (error: any) { - if (flags.json) { console.log(JSON.stringify({ error: error.message })); this.exit(1); } + if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); } printError(error.message || String(error)); this.exit(1); return; @@ -57,7 +58,7 @@ export default class MigratePlan extends Command { try { if (!stack.driver) { - if (flags.json) { console.log(JSON.stringify({ error: 'no_sql_driver', changes: [] })); return; } + if (flags.json) { await emitJson({ error: 'no_sql_driver', changes: [] }, 0, { compact: true }); return; } printWarning('Schema migration is only supported on SQL drivers (SQLite / Postgres). No SQL driver is active.'); return; } @@ -65,13 +66,13 @@ export default class MigratePlan extends Command { const drift = await stack.driver.detectManagedDrift(); if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ database: stack.dbLabel, managedTables: stack.managedTableCount, total: drift.length, changes: drift, duration: timer.elapsed(), - }, null, 2)); + }); return; } @@ -92,7 +93,7 @@ export default class MigratePlan extends Command { console.log(chalk.dim(` ${timer.display()}`)); console.log(''); } catch (error: any) { - if (flags.json) { console.log(JSON.stringify({ error: error.message })); this.exit(1); } + if (flags.json) { await emitJson({ error: error.message }, 0, { compact: true }); this.exit(1); } printError(error.message || String(error)); this.exit(1); } finally { diff --git a/packages/cli/src/commands/register.ts b/packages/cli/src/commands/register.ts index 6d07a845d3..9ed15c4f70 100644 --- a/packages/cli/src/commands/register.ts +++ b/packages/cli/src/commands/register.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Command, Flags } from '@oclif/core'; -import { printHeader, printSuccess, printError, printKV } from '../utils/format.js'; +import { printHeader, printSuccess, printError, printKV, emitJson } from '../utils/format.js'; import { writeAuthConfig } from '../utils/auth-config.js'; import { ObjectStackClient } from '@objectstack/client'; import * as readline from 'node:readline/promises'; @@ -141,7 +141,7 @@ export default class AuthRegister extends Command { }); if (flags.json) { - console.log(JSON.stringify({ success: true, email: user?.email || email, userId: user?.id }, null, 2)); + await emitJson({ success: true, email: user?.email || email, userId: user?.id }); } else { printSuccess('Account created and logged in'); printKV('Email', user?.email || email); @@ -152,7 +152,7 @@ export default class AuthRegister extends Command { } } catch (error: any) { if (flags.json) { - console.log(JSON.stringify({ success: false, error: error.message }, null, 2)); + await emitJson({ success: false, error: error.message }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index a0c75ba440..cc2a9ac7b0 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -36,6 +36,8 @@ import { formatZodErrors, collectMetadataStats, printMetadataStats, + emitJson, + isExitSignal, } from '../utils/format.js'; import { checkSpecVersionGap } from '../utils/spec-version.js'; @@ -85,11 +87,11 @@ export default class Validate extends Command { if (!result.success) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: (result.error as unknown as ZodError).issues, duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } @@ -113,12 +115,12 @@ export default class Validate extends Command { if (exprErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: exprErrors, warnings: exprWarnings, duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -142,11 +144,11 @@ export default class Validate extends Command { if (listViewErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: listViewErrors, duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -170,11 +172,11 @@ export default class Validate extends Command { if (viewContainerErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: viewContainerErrors, duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -199,12 +201,12 @@ export default class Validate extends Command { if (widgetErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: widgetErrors, warnings: widgetWarnings, duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -230,12 +232,12 @@ export default class Validate extends Command { const actionRefWarnings = actionRefFindings.filter((f) => f.severity === 'warning'); if (actionRefErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: actionRefErrors, warnings: [...widgetWarnings, ...actionRefWarnings], duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -266,12 +268,12 @@ export default class Validate extends Command { const filterTokenErrors = filterTokenFindings.filter((f) => f.severity === 'error'); if (filterTokenErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: filterTokenErrors, warnings: [...widgetWarnings, ...actionRefWarnings], duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -309,12 +311,12 @@ export default class Validate extends Command { const refWarnings = refFindings.filter((f) => f.severity === 'warning'); if (refErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: refErrors, warnings: [...widgetWarnings, ...actionRefWarnings, ...refWarnings], duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -345,12 +347,12 @@ export default class Validate extends Command { if (styleErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: styleErrors, warnings: [...widgetWarnings, ...styleWarnings], duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -391,12 +393,12 @@ export default class Validate extends Command { if (jsxErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: jsxErrors, warnings: [...widgetWarnings, ...styleWarnings, ...jsxWarnings], duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -417,12 +419,12 @@ export default class Validate extends Command { const reactErrors = reactFindings.filter((f) => f.severity === 'error'); if (reactErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: reactErrors, warnings: [...widgetWarnings, ...styleWarnings, ...jsxWarnings], duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -450,12 +452,12 @@ export default class Validate extends Command { } if (reactPropErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: reactPropErrors, warnings: [...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...reactPropWarnings], duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -540,11 +542,11 @@ export default class Validate extends Command { const readonlyWriteWarnings = readonlyWriteFindings.filter((f) => f.severity === 'warning'); if (readonlyWriteErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: readonlyWriteErrors, duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -581,11 +583,11 @@ export default class Validate extends Command { const securityAdvisories = securityFindings.filter((f) => f.severity !== 'error'); if (securityErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: securityErrors, duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -625,11 +627,11 @@ export default class Validate extends Command { })); if (capProviderErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: capProviderErrors.map((c) => ({ token: c.token, message: renderCapabilityMessage(c) })), duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); @@ -648,7 +650,7 @@ export default class Validate extends Command { const specGap = checkSpecVersionGap(config.manifest); if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: true, manifest: config.manifest, stats, @@ -656,7 +658,7 @@ export default class Validate extends Command { conversions: conversionNotices, specVersionGap: specGap, duration: timer.elapsed(), - }, null, 2)); + }); return; } @@ -745,12 +747,13 @@ export default class Validate extends Command { console.log(''); } catch (error: any) { + if (isExitSignal(error)) throw error; if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, error: error.message, duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); diff --git a/packages/cli/src/commands/whoami.ts b/packages/cli/src/commands/whoami.ts index 90d6d5bacd..d6d8a8ea6d 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Command, Flags } from '@oclif/core'; -import { printHeader, printError, printKV } from '../utils/format.js'; +import { printHeader, printError, printKV, emitJson } from '../utils/format.js'; import { createApiClient, requireAuth } from '../utils/api-client.js'; import { formatOutput } from '../utils/output-formatter.js'; @@ -51,9 +51,9 @@ export default class AuthWhoami extends Command { const sessionData = response.data || response; if (flags.format === 'json') { - formatOutput(sessionData, 'json'); + await formatOutput(sessionData, 'json'); } else if (flags.format === 'yaml') { - formatOutput(sessionData, 'yaml'); + await formatOutput(sessionData, 'yaml'); } else { printHeader('Current Session'); @@ -72,10 +72,10 @@ export default class AuthWhoami extends Command { } } catch (error: any) { if (flags.format === 'json') { - console.log(JSON.stringify({ + await emitJson({ success: false, error: error.message, - }, null, 2)); + }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 7799e174ee..d0035a49a3 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -9,6 +9,23 @@ export const CLI_ALIAS = 'os'; // ─── Machine-readable output ──────────────────────────────────────── +export interface EmitJsonOptions { + /** + * Emit on a single line instead of 2-space-indented. + * + * Exists so the sweep onto `emitJson` could be a pure truncation fix with no + * observable output change: roughly half the CLI's `--json` sites were + * already compact and half indented, and this preserves whichever each one + * emitted. The split is accidental rather than designed — `os login --json` + * prints a compact payload and then an indented one in the same run — so + * unifying it is worth doing, but as its own decision, not as a side effect + * of fixing truncated pipes. + * + * New code should use the default. + */ + compact?: boolean; +} + /** * Emit a `--json` payload and record the exit code, without truncating. * @@ -20,14 +37,70 @@ export const CLI_ALIAS = 'os'; * scripted consumer got invalid JSON while an interactive run (stdout is a TTY, * written synchronously) looked perfect. Silent, and invisible to the author. * - * Waiting for the write callback drains the buffer first, and setting - * `process.exitCode` instead of calling `process.exit` lets Node exit on its - * own once nothing is pending. + * The exit does not have to be an explicit `process.exit` to bite. oclif's + * `handle()` ends every failing command with `Exit.exit()` → `process.exit()` + * and performs no flush on that path, so a plain `this.exit(1)` — or any + * thrown error — truncates exactly the same way. `flush()` runs only on the + * SUCCESS path of `execute()`. That is why the fix belongs at the write, not + * at the exit: awaiting the write callback drains the buffer before any of + * those paths can tear the process down. + * + * Deliberately NOT fixed by forcing stdout into blocking mode process-wide + * (`process.stdout._handle.setBlocking(true)`), which would cover every + * command in one line: the same binary runs `os serve` / `os dev`, and a + * blocking write to a pipe whose reader is slow blocks the event loop — it + * would trade truncated JSON for a server that stalls on its own logs. + * + * Setting `process.exitCode` rather than calling `process.exit` lets Node exit + * on its own once nothing is pending. Callers that must unwind immediately can + * still `this.exit(n)` after awaiting this — the buffer is already drained by + * then. + */ +export async function emitJson( + payload: unknown, + exitCode = 0, + opts: EmitJsonOptions = {}, +): Promise { + const text = opts.compact ? JSON.stringify(payload) : JSON.stringify(payload, null, 2); + await emitText(text, exitCode); +} + +/** + * True for the `ExitError` oclif's `this.exit(n)` throws. + * + * A command whose whole body sits in one `try` has a problem the truncation + * masked: `this.exit(1)` does not exit, it THROWS, so an inner "report the + * failure and stop" unwinds into the outer `catch`, which reports a second + * time. `os validate --json` on a bad config emitted two JSON documents back + * to back — unparseable as either one document or as JSONL. Nobody noticed + * because the payload was being cut off at 64 KiB before the second one could + * appear; draining the write is what made it visible. + * + * A catch that reports failures must re-throw this first — it is a + * control-flow signal from our own code, not a failure to describe: + * + * } catch (error: any) { + * if (isExitSignal(error)) throw error; + * … + * } + */ +export function isExitSignal(error: unknown): boolean { + const e = error as { code?: unknown; oclif?: { exit?: unknown } } | null | undefined; + return e?.code === 'EEXIT' || typeof e?.oclif?.exit === 'number'; +} + +/** + * The drain-aware write `emitJson` is built on, for machine payloads that are + * not JSON — `formatOutput`'s `--format yaml` truncates on a pipe exactly like + * the JSON one, and for the same reason. + * + * Appends the trailing newline. Everything in `emitJson`'s doc comment about + * why this cannot be fixed at the exit, or globally via blocking stdout, + * applies here too. */ -export async function emitJson(payload: unknown, exitCode = 0): Promise { - const text = JSON.stringify(payload, null, 2) + '\n'; +export async function emitText(text: string, exitCode = 0): Promise { await new Promise((resolve, reject) => { - process.stdout.write(text, (err) => (err ? reject(err) : resolve())); + process.stdout.write(text + '\n', (err) => (err ? reject(err) : resolve())); }); if (exitCode !== 0) process.exitCode = exitCode; } diff --git a/packages/cli/src/utils/output-formatter.ts b/packages/cli/src/utils/output-formatter.ts index 0788b50c69..8d51891bee 100644 --- a/packages/cli/src/utils/output-formatter.ts +++ b/packages/cli/src/utils/output-formatter.ts @@ -3,22 +3,30 @@ import chalk from 'chalk'; import yaml from 'yaml'; +import { emitText } from './format.js'; + /** * Output format options for CLI commands */ export type OutputFormat = 'json' | 'table' | 'yaml'; /** - * Format and output data according to the specified format + * Format and output data according to the specified format. + * + * `async` because the two MACHINE formats must drain stdout before the command + * can exit — `os data query --format json` piped to a consumer is exactly the + * unbounded payload that truncates at one pipe buffer when a `this.exit(1)` + * follows. See `emitJson` in ./format.ts for the full mechanism. The `table` + * format is human-facing and stays on plain `console.log`. */ -export function formatOutput(data: any, format: OutputFormat = 'json'): void { +export async function formatOutput(data: any, format: OutputFormat = 'json'): Promise { switch (format) { case 'json': - console.log(JSON.stringify(data, null, 2)); + await emitText(JSON.stringify(data, null, 2)); break; case 'yaml': - console.log(yaml.stringify(data)); + await emitText(yaml.stringify(data)); break; case 'table': @@ -34,7 +42,7 @@ export function formatOutput(data: any, format: OutputFormat = 'json'): void { break; default: - console.log(JSON.stringify(data, null, 2)); + await emitText(JSON.stringify(data, null, 2)); } } diff --git a/packages/cli/test/emit-json-pipe.test.ts b/packages/cli/test/emit-json-pipe.test.ts new file mode 100644 index 0000000000..21c56ade8e --- /dev/null +++ b/packages/cli/test/emit-json-pipe.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileP = promisify(execFile); + +/** + * `emitJson` exists because a `--json` payload written with `console.log` and + * followed by an exit is silently CUT OFF at one 64 KiB pipe buffer. + * + * The bug is invisible in every interactive run: stdout to a TTY is written + * synchronously, so an author sees perfect output while every scripted + * consumer — the only audience `--json` has — gets invalid JSON. It is also + * not limited to an explicit `process.exit`: oclif ends failing commands with + * `handle()` → `Exit.exit()` → `process.exit()` and flushes nothing on that + * path, so a plain `this.exit(1)` truncates identically. + * + * So this spawns a REAL child process with stdout on a PIPE (the condition + * that triggers it) and asserts both directions — the fix holds, and the + * pattern it replaced genuinely fails. A gate that cannot go red is a comment. + */ + +const REPO_CLI = resolve(__dirname, '..'); +const FORMAT_MODULE = resolve(REPO_CLI, 'src/utils/format.ts'); +const TSX = resolve(REPO_CLI, '../../node_modules/.bin/tsx'); + +/** Comfortably past one 64 KiB pipe buffer, and past two, so a partial drain still fails. */ +const PAYLOAD_BYTES = 300_000; + +let dir: string; +let expectedLength: number; + +function harness(body: string): string { + // `.mts`, not `.ts`: the temp dir has no package.json, so tsx would treat a + // bare `.ts` as CJS and reject the top-level await these harnesses need. + const file = join(dir, `harness-${Math.abs(hash(body))}.mts`); + writeFileSync( + file, + `import { emitJson } from ${JSON.stringify(FORMAT_MODULE)};\n` + + `const payload = { blob: 'x'.repeat(${PAYLOAD_BYTES}) };\n` + + body + + '\n', + ); + return file; +} + +// Deterministic file naming without Date.now()/Math.random(). +function hash(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; + return h; +} + +/** Run a harness with stdout on a pipe; return stdout even when the exit code is non-zero. */ +async function runPiped(file: string): Promise<{ stdout: string; code: number }> { + try { + const { stdout } = await execFileP(TSX, [file], { maxBuffer: 64 * 1024 * 1024 }); + return { stdout, code: 0 }; + } catch (err: any) { + return { stdout: String(err.stdout ?? ''), code: err.code ?? 1 }; + } +} + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'os-emitjson-')); + expectedLength = JSON.stringify({ blob: 'x'.repeat(PAYLOAD_BYTES) }, null, 2).length + 1; +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('emitJson over a pipe', () => { + it('delivers the whole payload when the process exits non-zero right after writing', async () => { + const file = harness("await emitJson(payload); process.exit(1);"); + const { stdout, code } = await runPiped(file); + + expect(code).toBe(1); + expect(stdout.length).toBe(expectedLength); + expect(stdout.length).toBeGreaterThan(65_536); + // The assertion that actually matters to a consumer. + expect(() => JSON.parse(stdout)).not.toThrow(); + expect(JSON.parse(stdout).blob).toHaveLength(PAYLOAD_BYTES); + }, 60_000); + + it('records the exit code without tearing down the write', async () => { + const file = harness('await emitJson(payload, 1);'); + const { stdout, code } = await runPiped(file); + + expect(code).toBe(1); + expect(JSON.parse(stdout).blob).toHaveLength(PAYLOAD_BYTES); + }, 60_000); + + it('survives a thrown error after the write — oclif turns those into process.exit too', async () => { + const file = harness("await emitJson(payload); throw new Error('boom');"); + const { stdout } = await runPiped(file); + + expect(JSON.parse(stdout).blob).toHaveLength(PAYLOAD_BYTES); + }, 60_000); + + it('compact mode is a formatting choice, not a truncation escape hatch', async () => { + const file = harness('await emitJson(payload, 0, { compact: true }); process.exit(1);'); + const { stdout } = await runPiped(file); + + expect(stdout).not.toContain('\n '); + expect(JSON.parse(stdout).blob).toHaveLength(PAYLOAD_BYTES); + }, 60_000); + + it('CONTROL: the console.log pattern it replaced really does truncate', async () => { + // Pins that the three assertions above are testing something real. If a + // future Node makes pipe writes synchronous this goes green-to-red — which + // is the correct signal that the hazard is gone, not a flake to silence. + const file = harness( + 'console.log(JSON.stringify(payload, null, 2));\nprocess.exit(1);', + ); + const { stdout } = await runPiped(file); + + expect(stdout.length).toBeLessThan(expectedLength); + expect(() => JSON.parse(stdout)).toThrow(); + }, 60_000); +}); + +/** + * The end-to-end shape both fixes exist for: a real command, a real pipe, a + * failing config big enough to cross several 64 KiB buffers. + * + * Before, piping this gave 131072 bytes — exactly two buffers, cut mid-string. + * Draining the write fixed the size and exposed the second defect underneath: + * `this.exit(1)` THROWS, so the inner report unwound into the outer catch and + * the command printed a SECOND JSON document. Truncation had been hiding it. + * Both have to hold for `--json` to mean anything. + */ +describe('os validate --json over a pipe', () => { + const CONFIG_OBJECTS = 900; + let configDir: string; + + beforeAll(() => { + configDir = mkdtempSync(join(tmpdir(), 'os-validate-')); + const objects: Record = {}; + for (let i = 0; i < CONFIG_OBJECTS; i++) { + // `type` must be a string — each object contributes a schema issue, and + // 900 of them put the payload comfortably past several pipe buffers. + objects[`obj_${i}`] = { name: `obj_${i}`, fields: { f: { type: 12345 } } }; + } + writeFileSync( + join(configDir, 'objectstack.config.ts'), + `export default ${JSON.stringify({ name: 'trunc', objects }, null, 2)};\n`, + ); + }); + + afterAll(() => { + rmSync(configDir, { recursive: true, force: true }); + }); + + it('delivers exactly one parseable document, whole', async () => { + const { stdout, code } = await (async () => { + try { + const { stdout } = await execFileP( + TSX, + [resolve(REPO_CLI, 'bin/run-dev.js'), 'validate', join(configDir, 'objectstack.config.ts'), '--json'], + { maxBuffer: 64 * 1024 * 1024, cwd: REPO_CLI }, + ); + return { stdout, code: 0 }; + } catch (err: any) { + return { stdout: String(err.stdout ?? ''), code: err.code ?? 1 }; + } + })(); + + expect(code).toBe(1); + expect(stdout.length).toBeGreaterThan(131_072); // was capped here by two pipe buffers + // One document — not two concatenated, which is neither valid JSON nor JSONL. + const parsed = JSON.parse(stdout); + expect(parsed.valid).toBe(false); + expect(parsed.errors).toHaveLength(CONFIG_OBJECTS); + }, 120_000); +}); + +describe('isExitSignal', () => { + it('recognises what this.exit() throws, and nothing else', async () => { + const { isExitSignal } = await import('../src/utils/format.js'); + expect(isExitSignal({ code: 'EEXIT', oclif: { exit: 1 } })).toBe(true); + expect(isExitSignal({ oclif: { exit: 0 } })).toBe(true); + expect(isExitSignal(new Error('a real failure'))).toBe(false); + expect(isExitSignal({ code: 'ENOENT' })).toBe(false); + expect(isExitSignal(undefined)).toBe(false); + expect(isExitSignal(null)).toBe(false); + }); +}); + diff --git a/packages/cli/test/remote-api-utils.test.ts b/packages/cli/test/remote-api-utils.test.ts index a20dfea411..60e3b1494d 100644 --- a/packages/cli/test/remote-api-utils.test.ts +++ b/packages/cli/test/remote-api-utils.test.ts @@ -154,42 +154,66 @@ describe('Output Formatter Utilities', () => { vi.spyOn(console, 'log').mockImplementation(() => {}); }); - it('should format JSON output', () => { + // The two MACHINE formats deliberately bypass console.log: they go through + // `emitText`, which awaits the stdout write callback so the payload cannot be + // cut off at one 64 KiB pipe buffer when the command exits right after. See + // packages/cli/test/emit-json-pipe.test.ts for the end-to-end proof. + it('should format JSON output', async () => { + const write = vi.spyOn(process.stdout, 'write').mockImplementation((( + _chunk: any, + cb?: any, + ) => { + if (typeof cb === 'function') cb(); + return true; + }) as any); const data = { name: 'test', value: 123 }; - formatOutput(data, 'json'); + await formatOutput(data, 'json'); - expect(console.log).toHaveBeenCalledWith( - expect.stringContaining('"name": "test"') + expect(write).toHaveBeenCalledWith( + expect.stringContaining('"name": "test"'), + expect.any(Function) ); + write.mockRestore(); }); - it('should format YAML output', () => { + it('should format YAML output', async () => { + const write = vi.spyOn(process.stdout, 'write').mockImplementation((( + _chunk: any, + cb?: any, + ) => { + if (typeof cb === 'function') cb(); + return true; + }) as any); const data = { name: 'test', value: 123 }; - formatOutput(data, 'yaml'); + await formatOutput(data, 'yaml'); - expect(console.log).toHaveBeenCalled(); + expect(write).toHaveBeenCalledWith( + expect.stringContaining('name: test'), + expect.any(Function) + ); + write.mockRestore(); }); - it('should format table output for arrays', () => { + it('should format table output for arrays', async () => { const data = [ { name: 'item1', value: 1 }, { name: 'item2', value: 2 }, ]; - formatOutput(data, 'table'); + await formatOutput(data, 'table'); expect(console.log).toHaveBeenCalled(); }); - it('should format table output for single object', () => { + it('should format table output for single object', async () => { const data = { name: 'test', value: 123 }; - formatOutput(data, 'table'); + await formatOutput(data, 'table'); expect(console.log).toHaveBeenCalled(); }); - it('should handle empty arrays', () => { + it('should handle empty arrays', async () => { const data: any[] = []; - formatOutput(data, 'table'); + await formatOutput(data, 'table'); expect(console.log).toHaveBeenCalled(); }); From 59107eed33fb005d817be8cee1845f339c87088e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 05:59:09 +0000 Subject: [PATCH 2/3] fix(i18n): platform-objects' 231 untranslated strings were 1 (#3762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the rest of #3762, recorded as "platform-objects is 77 strings short per locale in apps.*/dashboards.*, and its --objects-only extract cannot scaffold them — needs an emit decision (drop --objects-only, or a companion .apps.generated.ts) before any translating." Measured, the premise did not hold. Of the 77 declared keys per locale, 76 were ALREADY translated in the hand-authored .ts files and had been for months. Exactly one was genuinely missing — apps.studio.navigation.nav_app_builder.label, absent in all four locales including en. The 231 was a measurement artifact: this config declares SETUP_APP / STUDIO_APP / ACCOUNT_APP and SystemOverviewDashboard, but its `translations` merge baseline listed only the two GENERATED subtrees, so coverage counted every hand-authored app/dashboard key as untranslated. Neither proposed emit is right, and the second would have caused damage. The Setup app is a shell of empty group anchors; its ~25 menu entries are contributed at RUNTIME by SETUP_NAV_CONTRIBUTIONS and by capability plugins (ADR-0029 D7), so a bundle generated from a static walk of SETUP_APP is structurally incomplete — regenerating over the hand-authored files would have DELETED 40 live nav translations per locale. Dropping --objects-only fails differently: kind:'full' folds all 803 metadata-form keys into .objects.generated.ts and renames the export the baseline imports. The split is correct as it stands, and is now written down in the config: objects/metadataForms are generated and gated by the bundle-drift check; apps/dashboards/pages are hand-authored and gated by the coverage ratchet. What was wrong was only that the baseline omitted the hand-authored half. - Extract config's `translations` carries the per-locale assemblers, with objects/metadataForms still pinned to the committed generated files. Safe for the emit — --objects-only writes data.objects alone, so nothing added here can reach a generated bundle; check:i18n stays in sync across all nine packages. - nav_app_builder translated in all four locales, wording harvested from the repo's own precedent for "builder" (构建器 / ビルダー / generador). - nav_workflows removed from all four: its entry is gone from STUDIO_APP and nothing contributes to that app, so the translation was dead. - Coverage ratchet baselined 231 -> 0 (repo total 996 -> 765), making platform-objects the ninth package where the ratchet is a strict gate. Verified red on a single removed translation, green on restore. - A local, CLI-independent parity test walks the statically declared Studio and Account navigation plus the dashboard's widgets and asserts a translation in every locale — and the reverse, that no translation outlives its nav item. Both directions verified to fail before they pass. An untranslated nav id is invisible in the UI: it falls back to the app's own English label, so a Chinese Studio menu shows one English entry among thirty. That is why this needs a gate and not a one-time sweep. Setup's ~25 runtime-contributed entries stay out of scope. A static gate for them needs either an objectql dependency in this package (it depends only on spec and metadata-core) or extractor support for navigationContributions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BQwnjqgCyAhSQjhNriW3BW --- .../platform-objects-app-i18n-phantom-debt.md | 62 ++++++++++++ .../scripts/i18n-extract.config.ts | 70 ++++++++++--- .../app-nav-translation-parity.test.ts | 99 +++++++++++++++++++ .../src/apps/translations/en.ts | 2 +- .../src/apps/translations/es-ES.ts | 2 +- .../src/apps/translations/ja-JP.ts | 2 +- .../src/apps/translations/zh-CN.ts | 2 +- scripts/i18n-coverage-baseline.json | 2 +- 8 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 .changeset/platform-objects-app-i18n-phantom-debt.md create mode 100644 packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts diff --git a/.changeset/platform-objects-app-i18n-phantom-debt.md b/.changeset/platform-objects-app-i18n-phantom-debt.md new file mode 100644 index 0000000000..b1b41a5452 --- /dev/null +++ b/.changeset/platform-objects-app-i18n-phantom-debt.md @@ -0,0 +1,62 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(i18n): platform-objects' 231 untranslated strings were 1 — close the real gap and stop the phantom (#3762) + +Closes the rest of #3762. The remaining item was recorded as "platform-objects +is 77 strings short per locale, in `apps.*` / `dashboards.*`, and its +`--objects-only` extract cannot scaffold them — needs an emit decision (drop +`--objects-only`, or a companion `.apps.generated.ts`) before any translating." + +Measured, the premise did not hold. Of the 77 declared keys per locale, **76 +were already translated** in the hand-authored `.ts` files and had been +for months. Exactly one was genuinely missing — +`apps.studio.navigation.nav_app_builder.label`, absent in all four locales +including `en`. The 231 was a measurement artifact: this config declares +SETUP_APP / STUDIO_APP / ACCOUNT_APP and SystemOverviewDashboard, but its +`translations` merge baseline listed only the two GENERATED subtrees +(`objects`, `metadataForms`), so coverage counted every hand-authored +app/dashboard key as untranslated. + +**Neither proposed emit is right, and the second would have caused damage.** +The Setup app is a shell of empty group anchors; its ~25 menu entries are +contributed at runtime by `SETUP_NAV_CONTRIBUTIONS` and by capability plugins +(ADR-0029 D7). A bundle generated from a static walk of `SETUP_APP` is +therefore structurally incomplete, and regenerating over the hand-authored +files would have **deleted 40 live nav translations per locale**. Dropping +`--objects-only` fails differently: `kind: 'full'` folds all 803 metadata-form +keys into `.objects.generated.ts` and renames the export the baseline +imports. + +The split is correct as it stands and is now written down: `objects` / +`metadataForms` are generated and gated by the bundle-drift check; `apps` / +`dashboards` / `pages` are hand-authored and gated by the coverage ratchet. +What was wrong was only that the baseline omitted the hand-authored half. + +- Extract config's `translations` now carries the per-locale assemblers, with + `objects`/`metadataForms` still pinned to the committed generated files. + Safe for the emit — `--objects-only` writes `data.objects` alone, so nothing + added here can reach a generated bundle, and `check:i18n` stays in sync + across all nine packages. +- `nav_app_builder` translated in all four locales, wording taken from the + repo's own precedent for "builder" (`构建器` / `ビルダー` / `generador`). +- `nav_workflows` removed from all four: its menu entry is gone from + `STUDIO_APP` and nothing contributes to that app, so the translation was + dead. +- Coverage ratchet baselined 231 → **0**, making platform-objects the ninth + package where the ratchet is a strict gate — verified to go red on a single + removed translation. +- A local, CLI-independent parity test walks the statically declared Studio and + Account navigation plus the dashboard's widgets and asserts a translation in + every locale — and the reverse, that no translation survives its nav item. + Both directions verified to fail before passing. + +An untranslated nav id is invisible in the UI — it falls back to the app's +English label, so a Chinese Studio menu just shows one English entry among +thirty. That is why this needed a gate rather than a one-time sweep. + +Still out of scope: the ~25 Setup entries contributed at runtime. Bringing them +under a static gate needs either an objectql dependency in this package (it +depends only on spec and metadata-core) or extractor support for +`navigationContributions` — a real follow-up, not something to half-do here. diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index 24e065bea5..809dca195d 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -22,13 +22,35 @@ * The config is **build-time only** — it is not deployed and not used at * runtime. The Setup App still ships its own bundle via plugin-auth. * - * NOTE: `translations` lists the *currently committed* generated files - * (plus the curated zh-CN overlay) as merge baselines so that re-running - * `os i18n extract --merge` preserves every existing translation and only - * fills in newly-added schema keys per `--fill`. Do NOT add - * `SetupAppTranslations` or `MetadataFormsTranslations` here — those - * bundles re-export the same generated files and importing them through - * the wrapper risks pulling in unrelated hand-edits. + * NOTE: `translations` is the merge baseline — what a re-run treats as + * ALREADY TRANSLATED. It must therefore carry everything this config + * declares, from both halves of this package's i18n: + * + * - `objects` / `metadataForms` — GENERATED. Emitted by this command into + * `.objects.generated.ts` / `.metadata-forms.generated.ts` + * and gated by `pnpm check:i18n`, which fails when the committed bundle + * differs from a fresh extract. + * - `apps` / `dashboards` / `pages` — HAND-AUTHORED in `.ts`, and + * they have to stay that way. The Setup app is a shell of empty group + * anchors (ADR-0029 D7); its ~25 menu entries are contributed at RUNTIME + * by `SETUP_NAV_CONTRIBUTIONS` and by capability plugins, so a bundle + * generated from a static walk of `SETUP_APP` would be structurally + * incomplete — regenerating over it would DELETE 40 live nav + * translations per locale. Their gate is the coverage ratchet + * (`scripts/check-i18n-coverage.mjs`), baselined at 0 for this package, + * not the bundle-drift gate. + * + * Omitting the hand-authored half was a measurable bug, not a style choice: + * this config declares SETUP_APP / STUDIO_APP / ACCOUNT_APP and + * SystemOverviewDashboard, so coverage counted all 77 `apps.*`/`dashboards.*` + * keys as untranslated in every locale — 231 strings of phantom debt — while + * 76 of the 77 had been translated for months. Only + * `apps.studio.navigation.nav_app_builder.label` was genuinely missing. + * + * Still do NOT add `SetupAppTranslations` or `MetadataFormsTranslations`: + * those are the outer wrappers. Import the per-locale assembler (`en.js`, + * `zh-CN.js`, …) and pin `objects`/`metadataForms` to the generated files + * explicitly, as below. */ import { defineStack } from '@objectstack/spec'; @@ -133,6 +155,28 @@ import { zhCNMetadataForms } from '../src/apps/translations/zh-CN.metadata-forms import { jaJPMetadataForms } from '../src/apps/translations/ja-JP.metadata-forms.generated.js'; import { esESMetadataForms } from '../src/apps/translations/es-ES.metadata-forms.generated.js'; +// ── Existing hand-authored app/dashboard/page translations ──────────────── +// The per-locale assemblers, NOT the `SetupAppTranslations` wrapper. Each one +// already re-exports the generated `objects` bundle above and adds the +// hand-written `apps` / `dashboards` / `pages` subtrees. +// +// These must be in the baseline for the same reason the generated bundles are, +// and the omission was measurable: `apps.*` and `dashboards.*` are declared by +// this config (it lists SETUP_APP / STUDIO_APP / ACCOUNT_APP and +// SystemOverviewDashboard) but their translations were not, so coverage +// reported all 77 as untranslated. 76 of them had been translated for months — +// only `apps.studio.navigation.nav_app_builder.label` was genuinely missing. +// The whole 231 in the ratchet baseline was that artifact, not a real debt. +// +// Safe for the emit, which is the reason the split existed: `--objects-only` +// writes `data.objects` alone, so nothing here can reach +// `.objects.generated.ts`. It only changes what MERGE and COVERAGE +// consider already-translated, which is exactly what was wrong. +import { en } from '../src/apps/translations/en.js'; +import { zhCN } from '../src/apps/translations/zh-CN.js'; +import { jaJP } from '../src/apps/translations/ja-JP.js'; +import { esES } from '../src/apps/translations/es-ES.js'; + export default defineStack({ name: 'platform-objects-i18n-extract', @@ -201,9 +245,13 @@ export default defineStack({ dashboards: [SystemOverviewDashboard] as any, translations: [ - { en: { objects: enObjects, metadataForms: enMetadataForms } }, - { 'zh-CN': { objects: zhCNObjects, metadataForms: zhCNMetadataForms } }, - { 'ja-JP': { objects: jaJPObjects, metadataForms: jaJPMetadataForms } }, - { 'es-ES': { objects: esESObjects, metadataForms: esESMetadataForms } }, + // `...en` supplies the hand-authored apps/dashboards/pages; `objects` and + // `metadataForms` are then pinned to the generated files explicitly, so the + // merge baseline is the committed artifact rather than whatever the + // assembler happens to re-export. + { en: { ...en, objects: enObjects, metadataForms: enMetadataForms } }, + { 'zh-CN': { ...zhCN, objects: zhCNObjects, metadataForms: zhCNMetadataForms } }, + { 'ja-JP': { ...jaJP, objects: jaJPObjects, metadataForms: jaJPMetadataForms } }, + { 'es-ES': { ...esES, objects: esESObjects, metadataForms: esESMetadataForms } }, ], }); diff --git a/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts b/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts new file mode 100644 index 0000000000..68f6f64d33 --- /dev/null +++ b/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// App/dashboard translation parity (#3762). +// +// The `apps.*` / `dashboards.*` half of this package's i18n is HAND-AUTHORED in +// `.ts` — it cannot be generated, because the Setup app is a shell of +// empty group anchors whose entries are contributed at runtime (ADR-0029 D7), +// so a bundle built from a static walk would be structurally incomplete. +// +// Being hand-authored, nothing regenerates it when a nav item is added, and the +// gap is invisible in the UI: an untranslated nav id falls back to the app's own +// English label, so a Chinese Studio menu simply shows one English entry among +// thirty. `apps.studio.navigation.nav_app_builder.label` sat missing in ALL FOUR +// locales that way. +// +// It went unnoticed for a second reason worth pinning against: the extract +// config's merge baseline listed only the GENERATED subtrees, so coverage +// reported all 77 declared app/dashboard keys as untranslated in every locale — +// 231 strings of phantom debt that drowned the one real miss. With the baseline +// complete and the ratchet at 0, that tool now reports the truth; this test is +// the local, CLI-independent version of the same invariant. +// +// Setup is deliberately NOT covered here: its nav ids do not exist on the app +// object at all until the runtime merges contributions in, so this file would +// have nothing to walk. Those labels are gated by the coverage ratchet. + +import { describe, it, expect } from 'vitest'; +import { STUDIO_APP } from '../studio.app.js'; +import { ACCOUNT_APP } from '../account.app.js'; +import { SystemOverviewDashboard } from '../dashboards/index.js'; +import { en } from './en.js'; +import { zhCN } from './zh-CN.js'; +import { jaJP } from './ja-JP.js'; +import { esES } from './es-ES.js'; + +const LOCALES = { en, 'zh-CN': zhCN, 'ja-JP': jaJP, 'es-ES': esES } as const; + +/** Every nav id in an app's statically declared navigation tree, depth-first. */ +function navIds(app: { navigation?: unknown[] }): string[] { + const out: string[] = []; + const walk = (items: unknown[]) => { + for (const raw of items ?? []) { + const item = raw as { id?: string; children?: unknown[] }; + if (item?.id) out.push(item.id); + if (Array.isArray(item?.children)) walk(item.children); + } + }; + walk(app.navigation ?? []); + return out; +} + +describe('statically declared app navigation is translated in every locale', () => { + for (const app of [STUDIO_APP, ACCOUNT_APP] as Array<{ name: string; navigation?: unknown[] }>) { + for (const [locale, data] of Object.entries(LOCALES)) { + it(`${app.name} — ${locale}`, () => { + const nav = (data.apps?.[app.name]?.navigation ?? {}) as Record; + const missing = navIds(app).filter((id) => !nav[id]?.label); + expect(missing, `untranslated nav ids in apps.${app.name}.navigation`).toEqual([]); + }); + } + } + + // The reverse direction. A translation for an id the app no longer declares is + // dead weight that reads as coverage — `nav_workflows` outlived its menu entry + // in all four locales and nothing said so. + for (const app of [STUDIO_APP] as Array<{ name: string; navigation?: unknown[] }>) { + for (const [locale, data] of Object.entries(LOCALES)) { + it(`${app.name} — ${locale} carries no translation for a removed nav id`, () => { + const declared = new Set(navIds(app)); + const translated = Object.keys(data.apps?.[app.name]?.navigation ?? {}); + expect( + translated.filter((id) => !declared.has(id)), + `apps.${app.name}.navigation keys with no declaring nav item`, + ).toEqual([]); + }); + } + } +}); + +describe('dashboard widgets are translated in every locale', () => { + const dashboard = SystemOverviewDashboard as unknown as { + name: string; + widgets?: Array<{ id?: string; title?: string }>; + }; + + for (const [locale, data] of Object.entries(LOCALES)) { + it(`${dashboard.name} — ${locale}`, () => { + const entry = data.dashboards?.[dashboard.name]; + expect(entry?.label, `dashboards.${dashboard.name}.label`).toBeTruthy(); + + const widgets = (entry?.widgets ?? {}) as Record; + const missing = (dashboard.widgets ?? []) + .map((w) => w.id) + .filter((id): id is string => !!id) + .filter((id) => !widgets[id]?.title); + expect(missing, `untranslated widget titles in dashboards.${dashboard.name}`).toEqual([]); + }); + } +}); diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 39435ac0a9..1a113fd1ff 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -117,6 +117,7 @@ export const en: TranslationData = { description: 'Metadata workbench for developers, analysts, and implementers', navigation: { group_overview: { label: 'Overview' }, + nav_app_builder: { label: 'App Builder' }, nav_metadata_directory: { label: 'All Metadata Types' }, nav_packages: { label: 'Packages' }, group_data_model: { label: 'Data Model' }, @@ -134,7 +135,6 @@ export const en: TranslationData = { nav_hooks: { label: 'Hooks' }, group_automation: { label: 'Automation' }, nav_flows: { label: 'Flows' }, - nav_workflows: { label: 'Workflow Rules' }, group_ai: { label: 'AI' }, nav_agents: { label: 'Agents' }, nav_tools: { label: 'Tools' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index c2c1aed425..accec65e09 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -93,6 +93,7 @@ export const esES: TranslationData = { description: 'Banco de trabajo de metadatos para desarrolladores, analistas e implementadores', navigation: { group_overview: { label: 'Resumen' }, + nav_app_builder: { label: 'Generador de aplicaciones' }, nav_metadata_directory: { label: 'Todos los tipos de metadatos' }, nav_packages: { label: 'Paquetes' }, group_data_model: { label: 'Modelo de datos' }, @@ -110,7 +111,6 @@ export const esES: TranslationData = { nav_hooks: { label: 'Hooks' }, group_automation: { label: 'Automatización' }, nav_flows: { label: 'Flujos' }, - nav_workflows: { label: 'Reglas de flujo de trabajo' }, group_ai: { label: 'IA' }, nav_agents: { label: 'Agentes' }, nav_tools: { label: 'Herramientas' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 55fd3d9341..2d884cea13 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -93,6 +93,7 @@ export const jaJP: TranslationData = { description: '開発者・アナリスト・実装担当者向けのメタデータワークベンチ', navigation: { group_overview: { label: '概要' }, + nav_app_builder: { label: 'アプリビルダー' }, nav_metadata_directory: { label: 'すべてのメタデータタイプ' }, nav_packages: { label: 'パッケージ' }, group_data_model: { label: 'データモデル' }, @@ -110,7 +111,6 @@ export const jaJP: TranslationData = { nav_hooks: { label: 'フック' }, group_automation: { label: '自動化' }, nav_flows: { label: 'フロー' }, - nav_workflows: { label: 'ワークフロールール' }, group_ai: { label: 'AI' }, nav_agents: { label: 'エージェント' }, nav_tools: { label: 'ツール' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 0421487e68..d344d64ac3 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -96,6 +96,7 @@ export const zhCN: TranslationData = { description: '面向开发者、分析师与实施者的元数据工作台', navigation: { group_overview: { label: '总览' }, + nav_app_builder: { label: '应用构建器' }, nav_metadata_directory: { label: '全部元数据类型' }, nav_packages: { label: '软件包' }, group_data_model: { label: '数据模型' }, @@ -113,7 +114,6 @@ export const zhCN: TranslationData = { nav_hooks: { label: '钩子' }, group_automation: { label: '自动化' }, nav_flows: { label: '流程' }, - nav_workflows: { label: '工作流规则' }, group_ai: { label: 'AI' }, nav_agents: { label: '智能体' }, nav_tools: { label: '工具' }, diff --git a/scripts/i18n-coverage-baseline.json b/scripts/i18n-coverage-baseline.json index 2622fe39ac..d97f47561a 100644 --- a/scripts/i18n-coverage-baseline.json +++ b/scripts/i18n-coverage-baseline.json @@ -2,7 +2,7 @@ "examples/app-crm/objectstack.config.ts": 97, "examples/app-showcase/objectstack.config.ts": 456, "examples/app-todo/objectstack.config.ts": 212, - "packages/platform-objects/scripts/i18n-extract.config.ts": 231, + "packages/platform-objects/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-approvals/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-audit/scripts/i18n-extract.config.ts": 0, "packages/plugins/plugin-security/scripts/i18n-extract.config.ts": 0, From ad7c276c3d1a65f1bcef857fa2a978e8775b08ac Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 06:14:52 +0000 Subject: [PATCH 3/3] fix(cli): make the truncation control test deterministic; sweep validate's new lint path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI failures on #3803, both real. 1. ESLint — #3789 landed `os validate`'s four authoring lints on main after this branch forked, and its `--json` branch was written with `console.log(JSON.stringify(…))`. The new rule caught it on the merge, which is exactly the case the rule exists for: the pattern coming back one command at a time, written by someone with no reason to suspect it. Converted to `await emitJson(…)`. 2. Test Core — the CONTROL case in emit-json-pipe.test.ts failed with "expected 300017 to be less than 300017". Whether a run truncates is a RACE between the writer's exit and the reader's drain: it truncated at 65536 locally and delivered the whole payload on a runner whose reader kept up. A gate that goes red by machine speed is no better than one that cannot go red, so this was my defect, not a flake to retry. Rewritten to remove the race rather than widen the tolerance: never read the pipe until the child has exited, so the kernel buffer caps at its capacity and everything still queued in userspace dies with process.exit. Payload raised to 4 MB, ~50x margin over even a 1 MiB pipe-max-size; the exact byte count is allowed to vary because only the shortfall is the claim. Measured 80640-117184 bytes delivered across runs, and stable over repeated runs. The technique is valid ONLY for the broken pattern, and the comment now says so — pointing it at `emitJson` hangs forever, because awaiting the write callback is what the fix does and with no reader the write never completes. Verified rather than assumed: still running after 8s. That deadlock is the fix working. Merges origin/main (c7f4417). Full check after the merge: eslint clean, cli tsc clean, cli 682 tests, platform-objects 239 tests, i18n bundle gate 9/9 in sync, coverage ratchet none-new. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BQwnjqgCyAhSQjhNriW3BW --- packages/cli/src/commands/validate.ts | 4 +- packages/cli/test/emit-json-pipe.test.ts | 47 +++++++++++++++++++----- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 318c7f46b3..6aeac8e226 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -613,11 +613,11 @@ export default class Validate extends Command { ]; if (authoringLintErrors.length > 0) { if (flags.json) { - console.log(JSON.stringify({ + await emitJson({ valid: false, errors: authoringLintErrors, duration: timer.elapsed(), - }, null, 2)); + }); this.exit(1); } console.log(''); diff --git a/packages/cli/test/emit-json-pipe.test.ts b/packages/cli/test/emit-json-pipe.test.ts index 21c56ade8e..b0e25f6985 100644 --- a/packages/cli/test/emit-json-pipe.test.ts +++ b/packages/cli/test/emit-json-pipe.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { execFile } from 'node:child_process'; +import { execFile, spawn } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -112,16 +112,45 @@ describe('emitJson over a pipe', () => { }, 60_000); it('CONTROL: the console.log pattern it replaced really does truncate', async () => { - // Pins that the three assertions above are testing something real. If a - // future Node makes pipe writes synchronous this goes green-to-red — which - // is the correct signal that the hazard is gone, not a flake to silence. - const file = harness( - 'console.log(JSON.stringify(payload, null, 2));\nprocess.exit(1);', + // Pins that the assertions above are testing something real. + // + // Whether a given run truncates is a RACE between the writer's exit and the + // reader's drain, so this must not be written the obvious way. A first + // version used the same 300 KB payload and `execFileP` (which reads + // continuously): it truncated at 65536 locally and delivered all 300017 + // bytes on a CI runner whose reader kept up — a gate that goes red by + // machine speed, which is no better than one that cannot go red at all. + // + // Removing the race: never read the pipe until the child has exited. The + // kernel buffer then caps at its capacity (64 KiB by default) and + // everything still queued in userspace dies with `process.exit`. The + // payload is 4 MB so the margin over any plausible capacity — even a + // 1 MiB `pipe-max-size` — is ~50x, and the exact byte count is allowed to + // vary because only the shortfall is the claim. + // + // This technique is valid ONLY for the broken pattern. Pointing it at + // `emitJson` would hang forever, because awaiting the write callback is + // precisely what the fix does: with no reader, the write never completes. + // That deadlock IS the fix working. + const CONTROL_BYTES = 4_000_000; + const file = join(dir, 'control.mts'); + writeFileSync( + file, + `const payload = { blob: 'x'.repeat(${CONTROL_BYTES}) };\n` + + 'console.log(JSON.stringify(payload, null, 2));\n' + + 'process.exit(1);\n', ); - const { stdout } = await runPiped(file); - expect(stdout.length).toBeLessThan(expectedLength); - expect(() => JSON.parse(stdout)).toThrow(); + const child = spawn(TSX, [file], { stdio: ['ignore', 'pipe', 'ignore'] }); + child.stdout.pause(); + await new Promise((resolve) => child.on('exit', () => resolve())); + const chunks: Buffer[] = []; + for await (const c of child.stdout) chunks.push(c as Buffer); + const delivered = Buffer.concat(chunks).toString('utf8'); + + const whole = JSON.stringify({ blob: 'x'.repeat(CONTROL_BYTES) }, null, 2).length + 1; + expect(delivered.length).toBeLessThan(whole); + expect(() => JSON.parse(delivered)).toThrow(); }, 60_000); });