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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .changeset/cli-json-pipe-truncation-sweep.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions .changeset/platform-objects-app-i18n-phantom-debt.md
Original file line number Diff line number Diff line change
@@ -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 `<locale>.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 `<locale>.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.
50 changes: 50 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 4 additions & 6 deletions packages/cli/src/commands/cloud/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -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);
Expand All @@ -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));
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/cloud/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { Command, Flags } from '@oclif/core';
import { ObjectStackClient } from '@objectstack/client';
import { printHeader, printSuccess, printError } 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 {
Expand Down Expand Up @@ -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));
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/commands/cloud/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import { Command, Flags } from '@oclif/core';
import { printHeader, printKV, printSuccess, printError } 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 {
Expand All @@ -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.');
}
Expand Down Expand Up @@ -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);
Expand All @@ -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));
Expand Down
Loading
Loading