Skip to content

Commit 93fcd02

Browse files
fix(cli)!: os cloud login --json 改为 NDJSON 事件流,授权前交出 verification URL (#6838)
* fix(cli): `os cloud login --json` 改为 NDJSON 事件流,授权前交出 verification URL (#6730) - 全部四个 --json 写点统一走 emitRecord() 单一写出口 - device flow 经 onDeviceCode 在轮询前发出 RFC 8628 记录 - --help / cli.mdx / deployment index.mdx 三处声明该例外 - PTY 驱动的 e2e,时序钉由端点扣住 token 直到测试读到记录 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx * docs(changeset): 给 #6730 的破坏性 changeset 补上 ADR-0087 处置标记 `check:adr-0087-registration`(pr-automation.yml)要求每条声明 BREAKING 的 changeset 在正文里写明它与 ADR-0087 台账的关系。本条变的是一个 CLI 命令 stdout 的流形状:没有可授权键、没有导出符号、没有存储值发生位移, `objectstack migrate meta` 无物可转换,故 not-required (no-migration-prescription),并写明真正触达消费者的三个渠道。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx * docs(changeset): 写明 minor 是本仓破坏性变更能取的最高档 `check-changeset-no-major.mjs`:所有可发布包锁步版本,launch window 期间 破坏性变更一律走 minor,major 被门禁拒。补这一句是为了让评审者不必再问 「为什么不是 major」,并点明真正承担警示职责的是正文披露而非版本号。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ca522e9 commit 93fcd02

6 files changed

Lines changed: 730 additions & 8 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
**BREAKING (`os cloud login --json` stdout wire shape):** it is now an NDJSON
6+
stream, one compact JSON document per line, and it emits a verification-URL
7+
record it never used to emit at all (#6730).
8+
9+
`os cloud login --json` passed `silent: true` into the device flow and nothing
10+
else. Formally that was impeccable — stdout carried exactly one JSON document
11+
and `JSON.parse(<entire stdout>)` read it. Measured against a live RFC 8628
12+
endpoint, the whole of stdout for an interactive `--json --no-browser` run was:
13+
14+
```
15+
{
16+
"success": true,
17+
"email": "user@example.com",
18+
"userId": "usr_…",
19+
"url": "https://cloud.objectos.ai"
20+
}
21+
```
22+
23+
The verification URL appeared nowhere — not on stdout, not on stderr. `silent`
24+
suppressed the human-readable print and put nothing in its place, so the one
25+
thing device flow exists to give a script (the URL, while there is still time to
26+
act on it) was withheld from the only caller that cannot ask a human for it. A
27+
consumer received a well-formed document describing an authorization it had no
28+
way to trigger.
29+
30+
`os cloud login --json` is now a **newline-delimited JSON stream**: one compact
31+
document per line, on every path — the device-authorization record, the
32+
`--email`/`--password` result, the already-logged-in notice, and the
33+
`{"success":false,"error":"…"}` failure record alike. The device record is
34+
field-identical to the one `os login --json` emits (#6531), so one consumer
35+
reads both commands.
36+
37+
This is the second and last of the CLI's **declared exceptions** to "`--json`
38+
means exactly one JSON document on stdout" (#6217) — `os login` is the other,
39+
and they are now the same exception rather than two answers to one question.
40+
Both are declared rather than silent: the `--json` flag's `--help` text says so,
41+
and so do the CLI reference page (`os cloud login --json` is NDJSON) and the
42+
cloud publish flow on the deployment page. **Parse this command's stdout line by
43+
line.**
44+
45+
### What breaks, and what to change
46+
47+
Unlike `os login`, whose device-flow output was unparseable in any shape and so
48+
had no consumers to break, `os cloud login --json` worked today. If you consume
49+
it:
50+
51+
- **Interactive/device-flow runs now emit two lines instead of one.**
52+
`JSON.parse(<entire stdout>)` throws on the second document. Read the stream a
53+
line at a time and act on the record you care about — the device record is the
54+
one carrying `verification_uri`, the result the one carrying `success`.
55+
- **Unattended runs are the safest migration and were already correct.**
56+
`os cloud login --email … --password …` never enters the device flow and still
57+
emits exactly one record; the only change there is that it is compact rather
58+
than 2-space indented, which `JSON.parse` reads identically.
59+
- **Exit codes are unchanged**: `1` on a login failure, `0` otherwise.
60+
61+
### Why `minor` and not `patch`
62+
63+
Deliberately not the `patch` #6531/PR #6727 took. That bump rested on "nothing
64+
that previously worked stops working", which was true there — the output was
65+
unreadable before. It is false here: a single-document reader of
66+
`os cloud login --json` works today and stops working on the device-flow path.
67+
The bump follows the wire shape, not the size of the diff.
68+
69+
`major` is not the alternative: every publishable package versions in lockstep,
70+
so during the launch window a breaking change ships as `minor` by convention and
71+
`scripts/check-changeset-no-major.mjs` enforces it. `minor` is therefore the
72+
highest bump this change can carry, and the disclosure above — not the number —
73+
is what has to do the work of warning a consumer.
74+
75+
<!-- adr-0087: not-required (no-migration-prescription) what changes is one CLI command's stdout STREAM shape. No authorable key, no exported symbol and no stored value moves: `packages/spec` is untouched, no metadata schema gains or loses a key, and nothing an app authored or persisted becomes invalid or unparseable — so `objectstack migrate meta` has nothing to convert and neither `spec-changes.json` nor the generated upgrade guide has anything to carry. The consumer action prescribed above is rewriting a SCRIPT that reads this command's stdout (parse line by line instead of one `JSON.parse`), which is a channel the ADR-0087 ledger does not serve at all; the channels that do reach those readers are this changeset's own CHANGELOG text, the `--json` `--help` line, and the CLI reference page — all three shipped with this change. -->
76+

content/docs/deployment/cli.mdx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,7 @@ os doctor -v # Show fix suggestions for warnings
10451045
| `os login` | Sign in and store credentials in `~/.objectstack/credentials.json` |
10461046
| `os whoami` | Show the current authenticated user |
10471047
| `os logout` | Revoke the server session and clear local credentials |
1048+
| `os cloud login` | Sign in to ObjectStack Cloud (the hosted package registry) and store credentials in `~/.objectstack/cloud.json` |
10481049

10491050
#### `os register`
10501051

@@ -1121,6 +1122,65 @@ the server-side session is revoked as well.
11211122
os logout
11221123
```
11231124

1125+
#### `os cloud login`
1126+
1127+
Signs you in to **ObjectStack Cloud** — the hosted package registry — rather
1128+
than to a runtime instance. It is the credential `os package publish` and the
1129+
marketplace commands use, and it lands in its own file
1130+
(`~/.objectstack/cloud.json`), separate from `os login`'s
1131+
`~/.objectstack/credentials.json`.
1132+
1133+
```bash
1134+
os cloud login
1135+
os cloud login --no-browser
1136+
os cloud login --url https://cloud.example.com # self-hosted control plane
1137+
os cloud login --email me@acme.com --password secret # CI
1138+
```
1139+
1140+
Like `os login`, in an interactive terminal it uses the browser-based device
1141+
flow: it prints a one-time verification URL and polls until you approve. If
1142+
cloud credentials already exist it exits successfully with "Already logged in";
1143+
pass `--force` to re-authenticate.
1144+
1145+
##### `os cloud login --json` is NDJSON — the same exception as `os login`
1146+
1147+
Every other ObjectStack command writes **exactly one JSON document** to stdout
1148+
under `--json`, so `JSON.parse(<entire stdout>)` is the way to read it. The two
1149+
device-flow login commands — `os login` and `os cloud login` — are the declared
1150+
exceptions, and they are the **same** exception: `--json` output is **NDJSON**,
1151+
one compact JSON document per line. **Parse it line by line.**
1152+
1153+
The reason is the device flow: it is two events at two points in time, and the
1154+
verification URL is only useful to a script *before* the user authorizes. So the
1155+
CLI emits it as its own record immediately, then a second record when the poll
1156+
resolves:
1157+
1158+
```console
1159+
$ os cloud login --json --no-browser
1160+
{"device_code":"…","user_code":"WXYZ-1234","verification_uri":"https://…/activate","verification_uri_complete":"https://…/activate?user_code=WXYZ-1234","expires_in":600}
1161+
{"success":true,"email":"user@example.com","userId":"usr_01H…","url":"https://cloud.objectos.ai"}
1162+
```
1163+
1164+
Read the first record, show the user the URL, then block on the next line:
1165+
1166+
```bash
1167+
os cloud login --json --no-browser | while IFS= read -r line; do
1168+
echo "$line" | jq -r 'if .verification_uri_complete then "Approve at: \(.verification_uri_complete)" else "Signed in as \(.email)" end'
1169+
done
1170+
```
1171+
1172+
Every record is one line, on every path — the `--email`/`--password` result, the
1173+
"already logged in" notice, and the failure payload
1174+
(`{"success":false,"error":"…"}`) included, since a failure can arrive *after*
1175+
the verification-URL record has already been written. Records that report a
1176+
login failure also set exit code `1`.
1177+
1178+
Before this was declared, `os cloud login --json` emitted a single document and
1179+
**never handed the verification URL to a consumer at all** — formally valid
1180+
JSON that withheld the one thing device flow exists to give a script. The
1181+
device-authorization record's fields are spelled exactly as `os login --json`
1182+
spells them, so one consumer reads both commands.
1183+
11241184
### Cloud Environments
11251185

11261186
| Command | Description |

content/docs/deployment/index.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ os package publish # → sys_package + immutable, checksummed sys_package_ve
7474
See the cloud [package](/docs/references/cloud/package) and
7575
[package-version](/docs/references/cloud/package-version) references.
7676

77+
> **Automating the login step.** `os cloud login` uses the browser device flow
78+
> in an interactive terminal, and under `--json` it is one of the CLI's two
79+
> declared **NDJSON** commands (`os login` is the other): stdout is a stream of
80+
> compact JSON documents, **one per line**, and the verification-URL record is
81+
> written *before* you authorize so a script can show it while it still
82+
> matters. Parse that stdout line by line, not with a single `JSON.parse`. See
83+
> [`os cloud login`](/docs/deployment/cli#os-cloud-login) in the CLI reference.
84+
> For an unattended pipeline, `os cloud login --email … --password …` skips the
85+
> device flow entirely and emits a single record.
86+
7787
Installing then happens **inside the target environment**: sign in to that
7888
environment's Console → **Marketplace** → pick the package → **Install**. The
7989
install is authorized by your environment login and applied to that environment

packages/cli/src/commands/cloud/login.ts

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,88 @@
88
* *runtime* ObjectOS instance. Cloud credentials are persisted to
99
* `~/.objectstack/cloud.json` and consumed by `os package publish`,
1010
* `os package install`, and any future marketplace commands.
11+
*
12+
* ## `--json` here is NDJSON — a declared exception, same as `os login` (#6730)
13+
*
14+
* Everywhere else in this CLI `--json` means "stdout is exactly one JSON
15+
* document" (#6217). Both device-flow login commands are declared exceptions to
16+
* that, and they are the SAME exception: one compact JSON document per line.
17+
*
18+
* ### What was broken
19+
*
20+
* This command used to pass `silent: flags.json` into the shared device flow
21+
* and nothing else. Formally that was impeccable — stdout carried a single
22+
* document and `JSON.parse` read it fine. Measured on `origin/main` against a
23+
* live RFC 8628 endpoint, `os cloud login --json --no-browser` emitted exactly
24+
* this and nothing more:
25+
*
26+
* ```
27+
* {
28+
* "success": true,
29+
* "email": "device@example.com",
30+
* "userId": "usr_6730",
31+
* "url": "http://127.0.0.1:<port>"
32+
* }
33+
* ```
34+
*
35+
* The verification URL never appeared — not on stdout, not on stderr. `silent`
36+
* suppressed the human-readable print and put nothing in its place, so the one
37+
* thing device flow exists to give a script was withheld from the only caller
38+
* that cannot ask a human for it. A consumer got a parseable document that
39+
* arrives *after* an authorization it had no way to trigger.
40+
*
41+
* ### Why a stream rather than one document
42+
*
43+
* Maintainer ruling, 2026-08-08 (#6730, extending #6531): device flow is two
44+
* events at two points in time, and emitting the verification URL **before**
45+
* the user authorizes is its entire value in automation. Buffering both halves
46+
* into one trailing document would keep stdout single-document by destroying
47+
* the thing the output exists for; putting the early record on stderr would
48+
* abuse the diagnostic stream for non-diagnostic content. And the ruling
49+
* refused to let the two sibling commands answer this differently: a script
50+
* author — human or AI — who learns the contract from the `os login` docs and
51+
* applies it here must be right.
52+
*
53+
* The ruling's binding condition is that the exception be *declared*: this
54+
* command's `--json` `--help` text says so, and so do
55+
* `content/docs/deployment/cli.mdx` and the cloud-deployment flow in
56+
* `content/docs/deployment/index.mdx`. An undocumented exception does the same
57+
* harm to a consumer as the bug it replaces.
58+
*
59+
* ### Why EVERY write, not just the device flow's
60+
*
61+
* The contract belongs to the command, not to one of its paths. The failure
62+
* record is reachable *after* the device record has already been written (a
63+
* denied approval, an expired code, a poll failure), so an indented payload
64+
* there would rebuild a two-document stream on the run a consumer can least
65+
* afford to misread. All four `--json` writes go through {@link emitRecord},
66+
* the only emitter in this file, which makes "one compact document per line" a
67+
* structural property of the command instead of four call sites that each have
68+
* to remember an option.
69+
* `packages/cli/test/cloud-login-json-ndjson.e2e.test.ts` pins both halves.
1170
*/
1271

1372
import * as readline from 'node:readline/promises';
1473
import { stdin as input, stdout as output } from 'node:process';
1574
import { Command, Flags } from '@oclif/core';
75+
import type { CliExitCode } from '../../utils/format.js';
1676
import { printHeader, printKV, printSuccess, printError, emitJson } from '../../utils/format.js';
1777
import { loginWithBrowser, loginWithPassword } from '../../utils/auth-flows.js';
1878
import { DEFAULT_CLOUD_URL, readCloudConfig, writeCloudConfig } from '../../utils/cloud-config.js';
1979

80+
/**
81+
* Emit ONE NDJSON record on stdout — the only `--json` writer in this command.
82+
*
83+
* Compact is not a formatting preference here, it is the contract: a record
84+
* that wrapped onto a second line would silently break every consumer reading
85+
* this command's stdout a line at a time. Routing all four call sites through
86+
* one helper is what makes that structural — see the file header for why the
87+
* whole command, and not only the device flow, has to hold it.
88+
*/
89+
async function emitRecord(payload: unknown, exitCode: CliExitCode = 0): Promise<void> {
90+
await emitJson(payload, exitCode, { compact: true });
91+
}
92+
2093
async function promptPassword(promptText: string): Promise<string> {
2194
if (!process.stdin.isTTY) {
2295
const rl = readline.createInterface({ input, output });
@@ -93,7 +166,10 @@ export default class CloudLogin extends Command {
93166
description: 'Re-authenticate even if cloud credentials already exist',
94167
default: false,
95168
}),
96-
json: Flags.boolean({ description: 'Output as JSON' }),
169+
json: Flags.boolean({
170+
description:
171+
'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. `os login --json` is the same exception with the same shape.',
172+
}),
97173
};
98174

99175
async run(): Promise<void> {
@@ -105,7 +181,7 @@ export default class CloudLogin extends Command {
105181
const existing = await readCloudConfig();
106182
if (existing?.token) {
107183
if (flags.json) {
108-
await emitJson({ success: false, error: 'Already logged in', email: existing.email, url: existing.url }, 0, { compact: true });
184+
await emitRecord({ success: false, error: 'Already logged in', email: existing.email, url: existing.url });
109185
} else {
110186
printSuccess(`Already logged in to ${existing.url} as ${existing.email || existing.userId}`);
111187
console.log('');
@@ -131,7 +207,16 @@ export default class CloudLogin extends Command {
131207
flags.email && flags.password
132208
? await loginWithPassword(url, flags.email, flags.password)
133209
: process.stdin.isTTY && !flags.email && !flags.password
134-
? await loginWithBrowser(url, { noBrowser: flags['no-browser'], silent: flags.json })
210+
? await loginWithBrowser(url, {
211+
noBrowser: flags['no-browser'],
212+
silent: flags.json,
213+
// Record 1 of 2, and deliberately written BEFORE the poll loop:
214+
// an automation consumer needs the verification URL while it can
215+
// still act on it, which is the reason this command is a stream
216+
// at all. `silent` alone is what made it vanish (#6730).
217+
// Undefined in human mode so the flow keeps its own printer.
218+
onDeviceCode: flags.json ? ({ record }) => emitRecord(record) : undefined,
219+
})
135220
: await this.fallbackPasswordPrompt(url, flags.email);
136221

137222
await writeCloudConfig({
@@ -143,7 +228,9 @@ export default class CloudLogin extends Command {
143228
});
144229

145230
if (flags.json) {
146-
await emitJson({ success: true, email: result.user?.email, userId: result.user?.id, url });
231+
// Record 2 of 2 on the device path, and the only record on the
232+
// --email/--password path — same line-per-document shape either way.
233+
await emitRecord({ success: true, email: result.user?.email, userId: result.user?.id, url });
147234
} else {
148235
printSuccess('Cloud authentication successful');
149236
if (result.user?.email) printKV('Email', result.user.email);
@@ -155,7 +242,11 @@ export default class CloudLogin extends Command {
155242
}
156243
} catch (error: any) {
157244
if (flags.json) {
158-
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 would recreate a two-document stream on the
248+
// path a consumer is least able to recover from.
249+
await emitRecord({ success: false, error: error.message });
159250
this.exit(1);
160251
}
161252
printError(error.message || String(error));

0 commit comments

Comments
 (0)