Skip to content

Commit be91adf

Browse files
fix(cli): os login --json 声明为 NDJSON 事件流,每行一份可解析文档 (#6531) (#6727)
* wip(cli): os login --json NDJSON + declared-exception docs (#6531) * test(cli): pin os login --json NDJSON stream + declared exception (#6531) * fix(cli): write the ESC byte as escape text, not a raw control byte (#6531) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 087af44 commit be91adf

6 files changed

Lines changed: 555 additions & 10 deletions

File tree

.changeset/eighty-donuts-tickle.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os login --json` is a parseable NDJSON stream (#6531)
6+
7+
`os login --json` produced output that no consumer could read in any shape. The
8+
device flow wrote its RFC 8628 device-authorization payload compact and, once
9+
the token poll resolved, the result payload 2-space indented — two JSON
10+
documents on one stdout. Driven against a live device endpoint, that stream
11+
failed `JSON.parse(<entire stdout>)` with `Unexpected non-whitespace character
12+
after JSON at position 200`, and read as NDJSON it failed on 5 of its 6 lines,
13+
because the second document spanned five of them. The same two-document shape
14+
appeared on the failure path, where an error payload could follow a
15+
device-authorization record that had already been written.
16+
17+
`os login --json` is now a **newline-delimited JSON stream**: one compact
18+
document per line, on every path — the device-authorization record, the
19+
`--email`/`--password` result, the already-logged-in notice, and the
20+
`{"success":false,"error":"…"}` failure record alike. Every line parses on its
21+
own, and the verification-URL record still arrives *before* the user
22+
authorizes, which is what makes the device flow usable from a script at all.
23+
24+
This is the CLI's **one declared exception** to "`--json` means exactly one JSON
25+
document on stdout" (#6217), and it is declared rather than silent: the
26+
`--json` flag's `--help` text says so, and so do the CLI reference page and the
27+
device-flow section of the authentication docs. Parse this command's stdout
28+
line by line.
29+
30+
Bumped as a patch: no interface is added or removed and nothing that previously
31+
worked stops working. The device-flow output was unparseable before, so it had
32+
no consumers to break; the only other observable change is that the
33+
email/password result is compact rather than indented, which `JSON.parse` reads
34+
identically. Human-mode output is untouched.

content/docs/deployment/cli.mdx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,40 @@ For CI and other non-interactive contexts, pass email/password directly:
10781078
os login --email user@example.com --password secret
10791079
```
10801080

1081+
##### `os login --json` is NDJSON — the one exception
1082+
1083+
Every other ObjectStack command writes **exactly one JSON document** to stdout
1084+
under `--json`, so `JSON.parse(<entire stdout>)` is the way to read it.
1085+
`os login` is the single declared exception: its `--json` output is **NDJSON**,
1086+
one compact JSON document per line. **Parse it line by line.**
1087+
1088+
The reason is the device flow: it is two events at two points in time, and the
1089+
verification URL is only useful to a script *before* the user authorizes. So the
1090+
CLI emits it as its own record immediately, then a second record when the poll
1091+
resolves:
1092+
1093+
```console
1094+
$ os login --json --no-browser
1095+
{"device_code":"…","user_code":"WXYZ-1234","verification_uri":"https://…/activate","verification_uri_complete":"https://…/activate?user_code=WXYZ-1234","expires_in":600}
1096+
{"success":true,"email":"user@example.com","userId":"usr_01H…"}
1097+
```
1098+
1099+
Read the first record, show the user the URL, then block on the next line:
1100+
1101+
```bash
1102+
os login --json --no-browser | while IFS= read -r line; do
1103+
echo "$line" | jq -r 'if .verification_uri_complete then "Approve at: \(.verification_uri_complete)" else "Signed in as \(.email)" end'
1104+
done
1105+
```
1106+
1107+
Every record is one line, on every path — the `--email`/`--password` result and
1108+
the failure payload (`{"success":false,"error":"…"}`) included, since a failure
1109+
can arrive *after* the verification-URL record has already been written. Records
1110+
that report failure also set exit code `1`.
1111+
1112+
Before this was declared, `os login --json` wrote a compact record followed by a
1113+
pretty-printed one, which parsed as neither a single document nor as NDJSON.
1114+
10811115
#### `os logout`
10821116

10831117
Logout calls `POST /api/v1/auth/sign-out` before deleting local credentials, so

content/docs/permissions/authentication.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ expire after the server-configured TTL (the CLI assumes a 10-minute / 600s
6868
default). The device flow requires `plugins: { deviceAuthorization: true }` in
6969
your `AuthPlugin` configuration.
7070

71+
Under `--json` this command is the CLI's **one declared NDJSON exception**: it
72+
emits the verification-URL record before you authorize and the result record
73+
afterwards, one compact JSON document per line, so stdout must be parsed line by
74+
line rather than with a single `JSON.parse`. See
75+
[the CLI reference](/docs/deployment/cli#os-login--json-is-ndjson--the-one-exception).
76+
7177
The email/password path is still supported for CI and non-interactive shells:
7278

7379
```bash

packages/cli/src/commands/login.ts

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,82 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
/**
4+
* `os login --json` is NDJSON — the CLI's ONE declared exception (#6531).
5+
*
6+
* ## What was broken
7+
*
8+
* Everywhere else in this CLI `--json` means "stdout is exactly one JSON
9+
* document" (#6217). The device-flow path could not honour that and did not
10+
* try: it wrote the RFC 8628 device-authorization payload compact, and then,
11+
* after the token poll succeeded, the result payload 2-space indented. Measured
12+
* against a live device endpoint, stdout came out as
13+
*
14+
* ```
15+
* {"device_code":"…","user_code":"…","verification_uri":"…","expires_in":600}
16+
* {
17+
* "success": true,
18+
* …
19+
* }
20+
* ```
21+
*
22+
* — which `JSON.parse` rejects (`Unexpected non-whitespace character after JSON
23+
* at position 200`) *and* which is not NDJSON either, because the second
24+
* document spans five lines: 5 of its 6 lines fail an independent parse. A
25+
* consumer had no shape to read it in at all. The same two-document stream
26+
* appeared on the failure path too — device record, then an indented error
27+
* payload when the poll timed out or was denied.
28+
*
29+
* ## Why a stream rather than one document
30+
*
31+
* Maintainer ruling, 2026-08-08 (#6531): this flow genuinely IS two events at
32+
* two points in time, and emitting the verification URL **before** the user
33+
* authorizes is the entire value of device flow in automation. Buffering both
34+
* halves into one trailing document would make stdout parseable by destroying
35+
* the thing the output exists for; putting the early record on stderr would
36+
* abuse the diagnostic stream for non-diagnostic content. So `os login --json`
37+
* is declared a newline-delimited stream, and — the ruling's binding condition
38+
* — declared *explicitly*: in this command's `--help` text and in the command
39+
* documentation (`content/docs/deployment/cli.mdx`, and the device-flow section
40+
* of `content/docs/permissions/authentication.mdx`). An undocumented exception
41+
* does the same harm to a consumer as the bug it replaces.
42+
*
43+
* ## Why EVERY write, not just the device flow's two
44+
*
45+
* The contract belongs to the command, not to one of its paths. If the
46+
* `--email`/`--password` result or the error payload stayed indented, a
47+
* consumer that read this command line-by-line — exactly what the docs now
48+
* tell it to do — would break on the first run that took another path, and the
49+
* failure path is reachable *after* the device record has already been written.
50+
* So every `--json` write goes through {@link emitRecord}, which is the only
51+
* emitter in this file; that makes "one compact document per line" a property
52+
* of the command instead of four call sites that each have to remember an
53+
* option. `packages/cli/test/login-json-ndjson.e2e.test.ts` holds both halves:
54+
* the stream contract, driven through a real child process against a real
55+
* device endpoint, and the source pin that keeps a future write from bypassing
56+
* the helper.
57+
*/
58+
359
import { Command, Flags } from '@oclif/core';
60+
import type { CliExitCode } from '../utils/format.js';
461
import { printHeader, printSuccess, printError, printKV, emitJson } from '../utils/format.js';
562
import { writeAuthConfig, readAuthConfig } from '../utils/auth-config.js';
663
import { ObjectStackClient } from '@objectstack/client';
764
import * as readline from 'node:readline/promises';
865
import { stdin as input, stdout as output } from 'node:process';
966

67+
/**
68+
* Emit ONE NDJSON record on stdout — the only `--json` writer in this command.
69+
*
70+
* Compact is not a formatting preference here, it is the contract: a record
71+
* that wrapped onto a second line would silently break every consumer reading
72+
* this command's stdout a line at a time. Routing all four call sites through
73+
* one helper is what makes that structural — see the file header for why the
74+
* whole command, and not only the device flow's two writes, has to hold it.
75+
*/
76+
async function emitRecord(payload: unknown, exitCode: CliExitCode = 0): Promise<void> {
77+
await emitJson(payload, exitCode, { compact: true });
78+
}
79+
1080
/**
1181
* Prompt for a password with masked input (shows * per character).
1282
* Falls back to plain readline.question() in non-TTY environments.
@@ -108,7 +178,8 @@ export default class AuthLogin extends Command {
108178
default: false,
109179
}),
110180
json: Flags.boolean({
111-
description: 'Output as JSON',
181+
description:
182+
'Machine-readable output as NDJSON — one compact JSON document per line. Unlike every other ObjectStack command, whose --json stdout is a single document, this one is a stream: the device flow reports the verification URL as its own record BEFORE you authorize, then the result as a second record. Parse stdout line by line.',
112183
}),
113184
};
114185

@@ -122,7 +193,7 @@ export default class AuthLogin extends Command {
122193
const existing = await readAuthConfig();
123194
if (existing?.token) {
124195
if (flags.json) {
125-
await emitJson({ success: false, error: 'Already logged in', email: existing.email }, 0, { compact: true });
196+
await emitRecord({ success: false, error: 'Already logged in', email: existing.email });
126197
} else {
127198
printSuccess(`Already logged in as ${existing.email || existing.userId}`);
128199
console.log('');
@@ -171,7 +242,11 @@ export default class AuthLogin extends Command {
171242
await this.loginWithPassword(client, flags.url, email, password, flags.json);
172243
} catch (error: any) {
173244
if (flags.json) {
174-
await emitJson({ success: false, error: error.message });
245+
// Reachable AFTER the device-authorization record has already been
246+
// written (an expired code, a denied approval, a poll failure), so an
247+
// indented payload here recreated the exact two-document stream #6531
248+
// is about — on the path a consumer is least able to recover from.
249+
await emitRecord({ success: false, error: error.message });
175250
this.exit(1);
176251
}
177252
printError(error.message || String(error));
@@ -206,7 +281,7 @@ export default class AuthLogin extends Command {
206281
});
207282

208283
if (jsonOutput) {
209-
await emitJson({ success: true, email: user?.email || email, userId: user?.id });
284+
await emitRecord({ success: true, email: user?.email || email, userId: user?.id });
210285
} else {
211286
printSuccess('Authentication successful');
212287
printKV('Email', user?.email || email);
@@ -252,7 +327,10 @@ export default class AuthLogin extends Command {
252327
const verificationUrl = verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`;
253328

254329
if (jsonOutput) {
255-
await emitJson({ device_code, user_code, verification_uri, verification_uri_complete, expires_in }, 0, { compact: true });
330+
// Record 1 of 2, and deliberately written BEFORE the poll loop: an
331+
// automation consumer needs the verification URL while it can still act
332+
// on it, which is the reason this command is a stream at all.
333+
await emitRecord({ device_code, user_code, verification_uri, verification_uri_complete, expires_in });
256334
} else {
257335
console.log(' To authorize this CLI, visit:');
258336
console.log('');
@@ -318,7 +396,8 @@ export default class AuthLogin extends Command {
318396
});
319397

320398
if (jsonOutput) {
321-
await emitJson({ success: true, email: user?.email, userId: user?.id });
399+
// Record 2 of 2 — same line-per-document shape as record 1.
400+
await emitRecord({ success: true, email: user?.email, userId: user?.id });
322401
} else {
323402
printSuccess('Authentication successful');
324403
if (user?.email) printKV('Email', user.email);

packages/cli/src/utils/format.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,22 @@ export interface EmitJsonOptions {
4747
* Exists so the sweep onto `emitJson` could be a pure truncation fix with no
4848
* observable output change: roughly half the CLI's `--json` sites were
4949
* already compact and half indented, and this preserves whichever each one
50-
* emitted. The split is accidental rather than designed — `os login --json`
51-
* prints a compact payload and then an indented one in the same run — so
52-
* unifying it is worth doing, but as its own decision, not as a side effect
53-
* of fixing truncated pipes.
50+
* emitted.
5451
*
52+
* This comment used to cite `os login --json` — a compact payload followed by
53+
* an indented one in the same run — as proof the split was accidental. That
54+
* was true, and worse than a formatting inconsistency: two documents on one
55+
* stdout parse as neither a single document nor as NDJSON. #6531 fixed it,
56+
* and in doing so gave `compact` its one *designed* use. `os login` is the
57+
* CLI's sole declared NDJSON command, because its device flow is genuinely
58+
* two events over time and the first one has to reach an automation consumer
59+
* before the user authorizes; there, one line per document IS the contract,
60+
* enforced through a single emitter in `commands/login.ts` and pinned by
61+
* `test/login-json-ndjson.e2e.test.ts`.
62+
*
63+
* Everywhere else `--json` still means exactly one JSON document on stdout
64+
* (#6217), so the remaining compact call sites are still only preserving
65+
* historical formatting and unifying them stays worth doing on its own.
5566
* New code should use the default.
5667
*/
5768
compact?: boolean;

0 commit comments

Comments
 (0)