From f35f02c17d84adce62fc9e937f36b9a83950e74d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 14:30:33 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(cli):=20`os=20cloud=20login=20--json`?= =?UTF-8?q?=20=E6=94=B9=E4=B8=BA=20NDJSON=20=E4=BA=8B=E4=BB=B6=E6=B5=81?= =?UTF-8?q?=EF=BC=8C=E6=8E=88=E6=9D=83=E5=89=8D=E4=BA=A4=E5=87=BA=20verifi?= =?UTF-8?q?cation=20URL=20(#6730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 全部四个 --json 写点统一走 emitRecord() 单一写出口 - device flow 经 onDeviceCode 在轮询前发出 RFC 8628 记录 - --help / cli.mdx / deployment index.mdx 三处声明该例外 - PTY 驱动的 e2e,时序钉由端点扣住 token 直到测试读到记录 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .changeset/cloud-login-json-ndjson.md | 67 +++ content/docs/deployment/cli.mdx | 60 +++ content/docs/deployment/index.mdx | 10 + packages/cli/src/commands/cloud/login.ts | 101 +++- packages/cli/src/utils/auth-flows.ts | 51 +- .../test/cloud-login-json-ndjson.e2e.test.ts | 440 ++++++++++++++++++ 6 files changed, 721 insertions(+), 8 deletions(-) create mode 100644 .changeset/cloud-login-json-ndjson.md create mode 100644 packages/cli/test/cloud-login-json-ndjson.e2e.test.ts diff --git a/.changeset/cloud-login-json-ndjson.md b/.changeset/cloud-login-json-ndjson.md new file mode 100644 index 0000000000..20e7bd03db --- /dev/null +++ b/.changeset/cloud-login-json-ndjson.md @@ -0,0 +1,67 @@ +--- +'@objectstack/cli': minor +--- + +**BREAKING (`os cloud login --json` stdout wire shape):** it is now an NDJSON +stream, one compact JSON document per line, and it emits a verification-URL +record it never used to emit at all (#6730). + +`os cloud login --json` passed `silent: true` into the device flow and nothing +else. Formally that was impeccable — stdout carried exactly one JSON document +and `JSON.parse()` read it. Measured against a live RFC 8628 +endpoint, the whole of stdout for an interactive `--json --no-browser` run was: + +``` +{ + "success": true, + "email": "user@example.com", + "userId": "usr_…", + "url": "https://cloud.objectos.ai" +} +``` + +The verification URL appeared nowhere — not on stdout, not on stderr. `silent` +suppressed the human-readable print and put nothing in its place, so the one +thing device flow exists to give a script (the URL, while there is still time to +act on it) was withheld from the only caller that cannot ask a human for it. A +consumer received a well-formed document describing an authorization it had no +way to trigger. + +`os cloud login --json` is now a **newline-delimited JSON stream**: one compact +document per line, on every path — the device-authorization record, the +`--email`/`--password` result, the already-logged-in notice, and the +`{"success":false,"error":"…"}` failure record alike. The device record is +field-identical to the one `os login --json` emits (#6531), so one consumer +reads both commands. + +This is the second and last of the CLI's **declared exceptions** to "`--json` +means exactly one JSON document on stdout" (#6217) — `os login` is the other, +and they are now the same exception rather than two answers to one question. +Both are declared rather than silent: the `--json` flag's `--help` text says so, +and so do the CLI reference page (`os cloud login --json` is NDJSON) and the +cloud publish flow on the deployment page. **Parse this command's stdout line by +line.** + +### What breaks, and what to change + +Unlike `os login`, whose device-flow output was unparseable in any shape and so +had no consumers to break, `os cloud login --json` worked today. If you consume +it: + +- **Interactive/device-flow runs now emit two lines instead of one.** + `JSON.parse()` throws on the second document. Read the stream a + line at a time and act on the record you care about — the device record is the + one carrying `verification_uri`, the result the one carrying `success`. +- **Unattended runs are the safest migration and were already correct.** + `os cloud login --email … --password …` never enters the device flow and still + emits exactly one record; the only change there is that it is compact rather + than 2-space indented, which `JSON.parse` reads identically. +- **Exit codes are unchanged**: `1` on a login failure, `0` otherwise. + +### Why `minor` and not `patch` + +Deliberately not the `patch` #6531/PR #6727 took. That bump rested on "nothing +that previously worked stops working", which was true there — the output was +unreadable before. It is false here: a single-document reader of +`os cloud login --json` works today and stops working on the device-flow path. +The bump follows the wire shape, not the size of the diff. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 9100dc0ce6..a652ce94ad 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1045,6 +1045,7 @@ os doctor -v # Show fix suggestions for warnings | `os login` | Sign in and store credentials in `~/.objectstack/credentials.json` | | `os whoami` | Show the current authenticated user | | `os logout` | Revoke the server session and clear local credentials | +| `os cloud login` | Sign in to ObjectStack Cloud (the hosted package registry) and store credentials in `~/.objectstack/cloud.json` | #### `os register` @@ -1087,6 +1088,65 @@ the server-side session is revoked as well. os logout ``` +#### `os cloud login` + +Signs you in to **ObjectStack Cloud** — the hosted package registry — rather +than to a runtime instance. It is the credential `os package publish` and the +marketplace commands use, and it lands in its own file +(`~/.objectstack/cloud.json`), separate from `os login`'s +`~/.objectstack/credentials.json`. + +```bash +os cloud login +os cloud login --no-browser +os cloud login --url https://cloud.example.com # self-hosted control plane +os cloud login --email me@acme.com --password secret # CI +``` + +Like `os login`, in an interactive terminal it uses the browser-based device +flow: it prints a one-time verification URL and polls until you approve. If +cloud credentials already exist it exits successfully with "Already logged in"; +pass `--force` to re-authenticate. + +##### `os cloud login --json` is NDJSON — the same exception as `os login` + +Every other ObjectStack command writes **exactly one JSON document** to stdout +under `--json`, so `JSON.parse()` is the way to read it. The two +device-flow login commands — `os login` and `os cloud login` — are the declared +exceptions, and they are the **same** exception: `--json` output is **NDJSON**, +one compact JSON document per line. **Parse it line by line.** + +The reason is the device flow: it is two events at two points in time, and the +verification URL is only useful to a script *before* the user authorizes. So the +CLI emits it as its own record immediately, then a second record when the poll +resolves: + +```console +$ os cloud login --json --no-browser +{"device_code":"…","user_code":"WXYZ-1234","verification_uri":"https://…/activate","verification_uri_complete":"https://…/activate?user_code=WXYZ-1234","expires_in":600} +{"success":true,"email":"user@example.com","userId":"usr_01H…","url":"https://cloud.objectos.ai"} +``` + +Read the first record, show the user the URL, then block on the next line: + +```bash +os cloud login --json --no-browser | while IFS= read -r line; do + echo "$line" | jq -r 'if .verification_uri_complete then "Approve at: \(.verification_uri_complete)" else "Signed in as \(.email)" end' +done +``` + +Every record is one line, on every path — the `--email`/`--password` result, the +"already logged in" notice, and the failure payload +(`{"success":false,"error":"…"}`) included, since a failure can arrive *after* +the verification-URL record has already been written. Records that report a +login failure also set exit code `1`. + +Before this was declared, `os cloud login --json` emitted a single document and +**never handed the verification URL to a consumer at all** — formally valid +JSON that withheld the one thing device flow exists to give a script. The +device-authorization record's fields are spelled exactly as `os login --json` +spells them, so one consumer reads both commands. + ### Cloud Environments | Command | Description | diff --git a/content/docs/deployment/index.mdx b/content/docs/deployment/index.mdx index 06d9c3e008..6f115865d2 100644 --- a/content/docs/deployment/index.mdx +++ b/content/docs/deployment/index.mdx @@ -74,6 +74,16 @@ os package publish # → sys_package + immutable, checksummed sys_package_ve See the cloud [package](/docs/references/cloud/package) and [package-version](/docs/references/cloud/package-version) references. +> **Automating the login step.** `os cloud login` uses the browser device flow +> in an interactive terminal, and under `--json` it is one of the CLI's two +> declared **NDJSON** commands (`os login` is the other): stdout is a stream of +> compact JSON documents, **one per line**, and the verification-URL record is +> written *before* you authorize so a script can show it while it still +> matters. Parse that stdout line by line, not with a single `JSON.parse`. See +> [`os cloud login`](/docs/deployment/cli#os-cloud-login) in the CLI reference. +> For an unattended pipeline, `os cloud login --email … --password …` skips the +> device flow entirely and emits a single record. + Installing then happens **inside the target environment**: sign in to that environment's Console → **Marketplace** → pick the package → **Install**. The install is authorized by your environment login and applied to that environment diff --git a/packages/cli/src/commands/cloud/login.ts b/packages/cli/src/commands/cloud/login.ts index d0bc93de68..28da4201ab 100644 --- a/packages/cli/src/commands/cloud/login.ts +++ b/packages/cli/src/commands/cloud/login.ts @@ -8,15 +8,88 @@ * *runtime* ObjectOS instance. Cloud credentials are persisted to * `~/.objectstack/cloud.json` and consumed by `os package publish`, * `os package install`, and any future marketplace commands. + * + * ## `--json` here is NDJSON — a declared exception, same as `os login` (#6730) + * + * Everywhere else in this CLI `--json` means "stdout is exactly one JSON + * document" (#6217). Both device-flow login commands are declared exceptions to + * that, and they are the SAME exception: one compact JSON document per line. + * + * ### What was broken + * + * This command used to pass `silent: flags.json` into the shared device flow + * and nothing else. Formally that was impeccable — stdout carried a single + * document and `JSON.parse` read it fine. Measured on `origin/main` against a + * live RFC 8628 endpoint, `os cloud login --json --no-browser` emitted exactly + * this and nothing more: + * + * ``` + * { + * "success": true, + * "email": "device@example.com", + * "userId": "usr_6730", + * "url": "http://127.0.0.1:" + * } + * ``` + * + * The verification URL never appeared — not on stdout, not on stderr. `silent` + * suppressed the human-readable print and put nothing in its place, so the one + * thing device flow exists to give a script was withheld from the only caller + * that cannot ask a human for it. A consumer got a parseable document that + * arrives *after* an authorization it had no way to trigger. + * + * ### Why a stream rather than one document + * + * Maintainer ruling, 2026-08-08 (#6730, extending #6531): device flow is two + * events at two points in time, and emitting the verification URL **before** + * the user authorizes is its entire value in automation. Buffering both halves + * into one trailing document would keep stdout single-document by destroying + * the thing the output exists for; putting the early record on stderr would + * abuse the diagnostic stream for non-diagnostic content. And the ruling + * refused to let the two sibling commands answer this differently: a script + * author — human or AI — who learns the contract from the `os login` docs and + * applies it here must be right. + * + * The ruling's binding condition is that the exception be *declared*: this + * command's `--json` `--help` text says so, and so do + * `content/docs/deployment/cli.mdx` and the cloud-deployment flow in + * `content/docs/deployment/index.mdx`. An undocumented exception does the same + * harm to a consumer as the bug it replaces. + * + * ### Why EVERY write, not just the device flow's + * + * The contract belongs to the command, not to one of its paths. The failure + * record is reachable *after* the device record has already been written (a + * denied approval, an expired code, a poll failure), so an indented payload + * there would rebuild a two-document stream on the run a consumer can least + * afford to misread. All four `--json` writes go through {@link emitRecord}, + * the only emitter in this file, which makes "one compact document per line" a + * structural property of the command instead of four call sites that each have + * to remember an option. + * `packages/cli/test/cloud-login-json-ndjson.e2e.test.ts` pins both halves. */ import * as readline from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; import { Command, Flags } from '@oclif/core'; +import type { CliExitCode } from '../../utils/format.js'; import { printHeader, printKV, printSuccess, printError, emitJson } from '../../utils/format.js'; import { loginWithBrowser, loginWithPassword } from '../../utils/auth-flows.js'; import { DEFAULT_CLOUD_URL, readCloudConfig, writeCloudConfig } from '../../utils/cloud-config.js'; +/** + * Emit ONE NDJSON record on stdout — the only `--json` writer in this command. + * + * Compact is not a formatting preference here, it is the contract: a record + * that wrapped onto a second line would silently break every consumer reading + * this command's stdout a line at a time. Routing all four call sites through + * one helper is what makes that structural — see the file header for why the + * whole command, and not only the device flow, has to hold it. + */ +async function emitRecord(payload: unknown, exitCode: CliExitCode = 0): Promise { + await emitJson(payload, exitCode, { compact: true }); +} + async function promptPassword(promptText: string): Promise { if (!process.stdin.isTTY) { const rl = readline.createInterface({ input, output }); @@ -93,7 +166,10 @@ export default class CloudLogin extends Command { description: 'Re-authenticate even if cloud credentials already exist', default: false, }), - json: Flags.boolean({ description: 'Output as JSON' }), + json: Flags.boolean({ + description: + '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.', + }), }; async run(): Promise { @@ -105,7 +181,7 @@ export default class CloudLogin extends Command { const existing = await readCloudConfig(); if (existing?.token) { if (flags.json) { - await emitJson({ success: false, error: 'Already logged in', email: existing.email, url: existing.url }, 0, { compact: true }); + await emitRecord({ success: false, error: 'Already logged in', email: existing.email, url: existing.url }); } else { printSuccess(`Already logged in to ${existing.url} as ${existing.email || existing.userId}`); console.log(''); @@ -131,7 +207,16 @@ export default class CloudLogin extends Command { flags.email && flags.password ? await loginWithPassword(url, flags.email, flags.password) : process.stdin.isTTY && !flags.email && !flags.password - ? await loginWithBrowser(url, { noBrowser: flags['no-browser'], silent: flags.json }) + ? await loginWithBrowser(url, { + noBrowser: flags['no-browser'], + silent: flags.json, + // Record 1 of 2, and deliberately written BEFORE the poll loop: + // an automation consumer needs the verification URL while it can + // still act on it, which is the reason this command is a stream + // at all. `silent` alone is what made it vanish (#6730). + // Undefined in human mode so the flow keeps its own printer. + onDeviceCode: flags.json ? ({ record }) => emitRecord(record) : undefined, + }) : await this.fallbackPasswordPrompt(url, flags.email); await writeCloudConfig({ @@ -143,7 +228,9 @@ export default class CloudLogin extends Command { }); if (flags.json) { - await emitJson({ success: true, email: result.user?.email, userId: result.user?.id, url }); + // Record 2 of 2 on the device path, and the only record on the + // --email/--password path — same line-per-document shape either way. + await emitRecord({ 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); @@ -155,7 +242,11 @@ export default class CloudLogin extends Command { } } catch (error: any) { if (flags.json) { - await emitJson({ success: false, error: error.message }); + // Reachable AFTER the device-authorization record has already been + // written (an expired code, a denied approval, a poll failure), so an + // indented payload here would recreate a two-document stream on the + // path a consumer is least able to recover from. + await emitRecord({ success: false, error: error.message }); this.exit(1); } printError(error.message || String(error)); diff --git a/packages/cli/src/utils/auth-flows.ts b/packages/cli/src/utils/auth-flows.ts index 607eceecfe..9d7b7c716a 100644 --- a/packages/cli/src/utils/auth-flows.ts +++ b/packages/cli/src/utils/auth-flows.ts @@ -15,6 +15,15 @@ * I/O so callers can persist the resulting token wherever they need to. * They DO write to stdout for the interactive device-flow UX; pass * `silent: true` to suppress all human-readable output (used by `--json`). + * + * ⚠ `silent: true` on its own is NOT a complete `--json` story, and #6730 is + * what that costs. Silence removes the human-readable verification URL and + * puts nothing in its place, so `os cloud login --json` reached stdout with a + * single well-formed document and never handed automation the URL at all — + * switching off the half of device flow that a script exists to use. A + * `--json` caller therefore passes `silent: true` **and** + * {@link BrowserFlowOptions.onDeviceCode}, which re-emits the same event in + * machine-readable form, before the user authorizes. */ import { ObjectStackClient } from '@objectstack/client'; @@ -70,6 +79,27 @@ async function openBrowser(url: string): Promise { }); } +/** + * RFC 8628 §3.2 device-authorization fields, spelled exactly as the server + * sends them. + * + * Handed to {@link BrowserFlowOptions.onDeviceCode} verbatim rather than + * re-derived by the caller, because for a `--json` caller these field names + * ARE the wire contract: `os cloud login --json` emits this object as its + * first NDJSON record (#6730), and it has to be field-identical to the record + * `os login --json` emits (#6531) — a consumer written against the documented + * stream must not have to special-case which of the two commands produced it. + * The camelCase conveniences beside it exist for human-facing UI, where the + * spelling is nobody's contract. + */ +export interface DeviceAuthorizationRecord { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete?: string; + expires_in?: number; +} + export interface BrowserFlowOptions { /** OAuth client id; defaults to `OS_CLI_CLIENT_ID` env or `objectstack-cli`. */ clientId?: string; @@ -77,12 +107,22 @@ export interface BrowserFlowOptions { noBrowser?: boolean; /** Suppress all human-readable stdout (callers using --json). */ silent?: boolean; - /** Override the spinner / verification-URL printer for custom UI. */ + /** + * Override the spinner / verification-URL printer for custom UI. + * + * Awaited: the `--json` caller writes an NDJSON record here, and + * {@link emitJson} only resolves once the stdout write has actually drained + * (a pipe buffers asynchronously — see `utils/format.ts`). Firing it and + * walking straight into the poll loop would leave that guarantee to luck on + * exactly the record automation needs first. + */ onDeviceCode?: (info: { verificationUrl: string; userCode: string; expiresIn: number; - }) => void; + /** The server's own fields, for callers whose output shape is a contract. */ + record: DeviceAuthorizationRecord; + }) => void | Promise; } /** @@ -121,7 +161,12 @@ export async function loginWithBrowser( verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`; if (opts.onDeviceCode) { - opts.onDeviceCode({ verificationUrl, userCode: user_code, expiresIn: expires_in ?? 600 }); + await opts.onDeviceCode({ + verificationUrl, + userCode: user_code, + expiresIn: expires_in ?? 600, + record: { device_code, user_code, verification_uri, verification_uri_complete, expires_in }, + }); } else if (!silent) { console.log(' To authorize this CLI, visit:'); console.log(''); diff --git a/packages/cli/test/cloud-login-json-ndjson.e2e.test.ts b/packages/cli/test/cloud-login-json-ndjson.e2e.test.ts new file mode 100644 index 0000000000..fb4e6cffe0 --- /dev/null +++ b/packages/cli/test/cloud-login-json-ndjson.e2e.test.ts @@ -0,0 +1,440 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os cloud login --json` is NDJSON, and the URL comes FIRST (#6730). + * + * ## The defect this pins shut + * + * This command passed `silent: flags.json` into the shared device flow and + * nothing else. Formally that was impeccable: stdout carried one JSON document + * and `JSON.parse` read it. Measured on `origin/main` against a live RFC 8628 + * endpoint, `os cloud login --json --no-browser` produced exactly this, and + * nothing else — on stdout or stderr: + * + * ``` + * { + * "success": true, + * "email": "device@example.com", + * "userId": "usr_6730", + * "url": "http://127.0.0.1:" + * } + * ``` + * + * The verification URL never reached a consumer. `silent` suppressed the + * human-readable print and put nothing in its place, so the single thing device + * flow exists to give a script — the URL, while there is still time to act on + * it — was withheld from the only caller that cannot ask a human for it. That + * is the defect: not a malformed document, an absent event. + * + * The maintainer ruled (2026-08-08, #6730, extending #6531) that both login + * commands carry one contract: a **stream**, every line parseable on its own, + * verification-URL record before authorization. + * + * ## Why the ordering assertion is TEMPORAL, not an array index + * + * The ruling picked NDJSON over "buffer both halves and emit one document at + * the end" for exactly one reason: an automation consumer needs the + * verification URL *while it can still act on it*. Asserting `records[0]` is + * the device record would not catch a regression to the buffered shape at all, + * because a trailing merged emit also puts the URL fields first. + * + * So the fake endpoint here withholds the token until THIS TEST has seen the + * device record land on the child's stdout. The release is driven by the + * observation, which makes "URL before authorization" a fact about the run + * rather than a property of the array afterwards. A buffered implementation + * never gets released by the watcher, falls through to the escape-hatch timer + * ({@link RELEASE_DEADLINE_MS}, so the suite fails instead of hanging), and + * lands with `urlSeenAt === null` — red, naming the actual cause. + * + * ## Why a PTY, and why not a silent skip + * + * `cloud/login.ts` takes the device flow only when `process.stdin.isTTY` — a + * child with a piped stdin falls through to the email/password prompt and never + * reaches the emission points at all. A plain `execFile` therefore cannot reach + * the defect. `script(1)` allocates the pty; the command it runs redirects + * stdout and stderr to files, so fd 0 is a TTY while fd 1 and fd 2 stay + * separate, capturable and non-TTY — which is also the shape a real user gets + * when they pipe an interactive `os cloud login --json` into a consumer. + * + * If `script(1)` is missing this file FAILS rather than skips. A contract test + * that quietly opts out on the machine that runs it is the same green-for-the- + * wrong-reason hazard the fixtures in #5046 were replaced for; every CI runner + * in this repo is `ubuntu-latest`, where `script(1)` is part of the base image. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { execFile, execFileSync } from 'node:child_process'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); +const CLOUD_LOGIN_SRC = resolve(HERE, '../src/commands/cloud/login.ts'); +const REPO_ROOT = resolve(HERE, '../../..'); +const CLI_DOCS = resolve(REPO_ROOT, 'content/docs/deployment/cli.mdx'); +const DEPLOY_DOCS = resolve(REPO_ROOT, 'content/docs/deployment/index.mdx'); + +/** + * How long the endpoint waits for the device record to appear before releasing + * anyway. Only reached when the record never arrives early — i.e. when the + * contract is broken — and exists so that failure is an assertion rather than a + * suite that hangs until the runner kills it. + */ +const RELEASE_DEADLINE_MS = 20_000; + +/** Poll interval for watching the child's stdout file. */ +const WATCH_MS = 25; + +interface DeviceRun { + code: number; + stdout: string; + stderr: string; + /** When the verification URL was first readable on stdout. */ + urlSeenAt: number | null; + /** When the endpoint first answered the poll with something other than pending. */ + authorizedAt: number | null; + /** Whether the endpoint had to fall back to the deadline instead of the watcher. */ + releasedByDeadline: boolean; +} + +/** + * A minimal RFC 8628 endpoint whose token poll stays `authorization_pending` + * until {@link release} is called — see the header for why the test, not the + * clock, decides when authorization happens. + */ +function startDeviceEndpoint(outcome: 'token' | 'access_denied') { + let released = false; + let authorizedAt: number | null = null; + + const server: Server = createServer((req, res) => { + req.resume(); + req.on('end', () => { + const send = (code: number, obj: unknown) => { + res.writeHead(code, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(obj)); + }; + const { pathname } = new URL(req.url ?? '/', 'http://placeholder'); + + if (pathname === '/api/v1/auth/device/code') { + return send(200, { + device_code: 'DEV-CODE-6730', + user_code: 'WXYZ-6730', + verification_uri: 'http://127.0.0.1:9/activate', + verification_uri_complete: 'http://127.0.0.1:9/activate?user_code=WXYZ-6730', + expires_in: 600, + // 1s: the CLI floors anything falsy back to 5s (`interval || 5`). + interval: 1, + }); + } + + if (pathname === '/api/v1/auth/device/token') { + if (!released) return send(400, { error: 'authorization_pending' }); + authorizedAt ??= Date.now(); + return outcome === 'token' + ? send(200, { access_token: 'ACCESS-TOKEN-6730', token_type: 'Bearer' }) + : send(400, { error: 'access_denied' }); + } + + if (pathname === '/api/v1/auth/get-session') { + return send(200, { user: { id: 'usr_6730', email: 'device@example.com' } }); + } + + return send(404, { error: 'not_found' }); + }); + }); + + return { + server, + release: () => { + released = true; + }, + authorizedAt: () => authorizedAt, + listen: () => + new Promise((res) => { + server.listen(0, '127.0.0.1', () => res((server.address() as AddressInfo).port)); + }), + close: () => new Promise((res) => server.close(() => res())), + }; +} + +/** Every non-empty line of a captured stdout. */ +function lines(stdout: string): string[] { + return stdout.split('\n').filter((l) => l.length > 0); +} + +/** The device-authorization record, if one is already readable on stdout. */ +function findDeviceRecord(text: string): Record | null { + for (const line of lines(text)) { + try { + const rec = JSON.parse(line) as Record; + if (rec && typeof rec === 'object' && 'verification_uri' in rec) return rec; + } catch { + // A partially-flushed line is not a failure here — the contract + // assertions below judge the finished stream. + } + } + return null; +} + +/** + * Drive one full cloud device-flow login through a real child process on a PTY. + * + * `sawUrl` is what the watcher waits for before letting the endpoint authorize; + * it differs between the `--json` runs (a parseable record) and the human run + * (printed prose), which is the whole point — the human path must keep printing + * the URL it always printed. + */ +async function runCloudDeviceLogin(opts: { + outcome: 'token' | 'access_denied'; + json: boolean; + sawUrl?: (stdout: string) => boolean; +}): Promise { + const sawUrl = opts.sawUrl ?? ((text: string) => findDeviceRecord(text) !== null); + const dir = mkdtempSync(join(tmpdir(), 'os-cloud-login-ndjson-')); + const outFile = join(dir, 'stdout.txt'); + const errFile = join(dir, 'stderr.txt'); + // HOME is the credentials root (`cloud-config.ts` builds every path from + // `os.homedir()`), so pointing it at the temp dir keeps the run from reading + // — or overwriting — the developer's real ~/.objectstack/cloud.json. + const home = join(dir, 'home'); + + const endpoint = startDeviceEndpoint(opts.outcome); + const port = await endpoint.listen(); + + let urlSeenAt: number | null = null; + let releasedByDeadline = false; + + const watcher = setInterval(() => { + if (urlSeenAt !== null) return; + if (!existsSync(outFile)) return; + if (sawUrl(readFileSync(outFile, 'utf-8'))) { + urlSeenAt = Date.now(); + endpoint.release(); + } + }, WATCH_MS); + + const deadline = setTimeout(() => { + if (urlSeenAt === null) { + releasedByDeadline = true; + endpoint.release(); + } + }, RELEASE_DEADLINE_MS); + + const shell = [ + `'${TSX}' '${CLI}' cloud login${opts.json ? ' --json' : ''} --no-browser`, + `--url 'http://127.0.0.1:${port}'`, + `> '${outFile}' 2> '${errFile}'`, + ].join(' '); + + const code = await new Promise((res) => { + execFile( + 'script', + // -q quiet, -e propagate the child's exit status, -c the command. + ['-qec', shell, '/dev/null'], + { env: { ...process.env, HOME: home, NO_COLOR: '1' }, maxBuffer: 32 * 1024 * 1024 }, + (err) => res(err ? Number((err as { code?: unknown }).code ?? 1) : 0), + ); + }); + + clearInterval(watcher); + clearTimeout(deadline); + await endpoint.close(); + + const read = (f: string) => (existsSync(f) ? readFileSync(f, 'utf-8') : ''); + const run: DeviceRun = { + code, + stdout: read(outFile), + stderr: read(errFile), + urlSeenAt, + authorizedAt: endpoint.authorizedAt(), + releasedByDeadline, + }; + rmSync(dir, { recursive: true, force: true }); + return run; +} + +describe('os cloud login --json — the declared NDJSON stream (#6730)', () => { + let ok: DeviceRun; + let denied: DeviceRun; + let human: DeviceRun; + + beforeAll(async () => { + try { + execFileSync('script', ['--version'], { stdio: 'ignore' }); + } catch { + throw new Error( + 'script(1) is required to drive the TTY-gated device flow — see this file’s header for why this fails instead of skipping.', + ); + } + // Sequential: each run owns a port, a HOME and a poll clock, and running + // them together would interleave three children's timing for no benefit. + ok = await runCloudDeviceLogin({ outcome: 'token', json: true }); + denied = await runCloudDeviceLogin({ outcome: 'access_denied', json: true }); + human = await runCloudDeviceLogin({ + outcome: 'token', + json: false, + sawUrl: (text) => text.includes('/activate?user_code=WXYZ-6730'), + }); + }, 240_000); + + describe('the successful flow', () => { + it('emits stdout every line of which parses on its own — the NDJSON contract', () => { + const all = lines(ok.stdout); + expect(all.length).toBeGreaterThan(0); + for (const [i, line] of all.entries()) { + // Named per line so a regression says WHICH record broke, rather than + // only that some parse failed. + expect(() => JSON.parse(line), `stdout line ${i + 1}: ${JSON.stringify(line)}`).not.toThrow(); + } + }); + + it('hands the verification URL to the consumer at all — the #6730 defect itself', () => { + // On origin/main this was the whole bug: a single valid document, and the + // URL nowhere on stdout or stderr. Asserted before any ordering claim, + // because "absent" and "late" are different failures and this names the + // first one. + expect(ok.stdout).toContain('verification_uri'); + expect(ok.stdout).toContain('http://127.0.0.1:9/activate?user_code=WXYZ-6730'); + }); + + it('is exactly two records — device authorization, then the result', () => { + const records = lines(ok.stdout).map((l) => JSON.parse(l) as Record); + expect(records).toHaveLength(2); + // Field-identical to what `os login --json` emits (#6531): a consumer + // written against the documented stream must not have to ask which of the + // two sibling commands produced the record. + expect(records[0]).toMatchObject({ + device_code: 'DEV-CODE-6730', + user_code: 'WXYZ-6730', + verification_uri: 'http://127.0.0.1:9/activate', + verification_uri_complete: 'http://127.0.0.1:9/activate?user_code=WXYZ-6730', + expires_in: 600, + }); + expect(records[1]).toMatchObject({ + success: true, + email: 'device@example.com', + userId: 'usr_6730', + }); + // `url` is this command's own field — it records WHICH control plane the + // credential now belongs to, which a cloud consumer needs and the runtime + // login has no equivalent of. + expect(String(records[1].url)).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(ok.code).toBe(0); + }); + + it('hands over the verification URL BEFORE authorization — the reason this route was chosen', () => { + // The endpoint only authorized because the watcher had already read the + // record off stdout, so these two facts are the run's own history. + expect(ok.releasedByDeadline, 'the device record never reached stdout early').toBe(false); + expect(ok.urlSeenAt).not.toBeNull(); + expect(ok.authorizedAt).not.toBeNull(); + expect(ok.urlSeenAt!).toBeLessThanOrEqual(ok.authorizedAt!); + }); + + it('lets nothing but the payload onto stdout — no banner, no spinner, no prompt', () => { + // Two records, two lines: any human-mode output would raise the count, so + // this subsumes "nothing else was written" rather than listing banners a + // future edit could add to. + expect(lines(ok.stdout)).toHaveLength(2); + expect(ok.stdout).not.toContain('ObjectStack Cloud Login'); + expect(ok.stdout).not.toContain('To authorize this CLI'); + expect(ok.stdout).not.toContain('Waiting for browser approval'); + expect(ok.stdout).not.toContain('Credentials stored in'); + // The spinner's carriage returns and its erase sequence would corrupt a + // line-oriented reader even though they carry no visible text. Both are + // written here as escape TEXT, never as the bytes themselves. + expect(ok.stdout).not.toMatch(/[\r\u001b]/); + }); + }); + + describe('the failure that arrives AFTER the first record', () => { + it('keeps every line parseable when the poll is denied', () => { + // The path the card never named: device record already written, then an + // indented error payload — a two-document stream on the run a consumer + // can least afford to misread. + const all = lines(denied.stdout); + for (const [i, line] of all.entries()) { + expect(() => JSON.parse(line), `stdout line ${i + 1}: ${JSON.stringify(line)}`).not.toThrow(); + } + const records = all.map((l) => JSON.parse(l) as Record); + expect(records).toHaveLength(2); + expect(records[0]).toMatchObject({ user_code: 'WXYZ-6730' }); + expect(records[1]).toEqual({ success: false, error: 'Login denied by user.' }); + }); + + it('reports the refusal through the exit code as well as the record', () => { + expect(denied.code).toBe(1); + }); + }); + + describe('human mode is untouched', () => { + it('still prints the verification URL as prose, and no JSON record', () => { + // The emitter is wired only under `--json`; without it the shared flow + // keeps its own printer. Pinned because the obvious wrong fix — always + // passing `onDeviceCode` — would silently replace the human UX with a + // machine record. + expect(human.stdout).toContain('To authorize this CLI'); + expect(human.stdout).toContain('http://127.0.0.1:9/activate?user_code=WXYZ-6730'); + expect(human.stdout).toContain('User code: WXYZ-6730'); + expect(human.stdout).not.toContain('"device_code"'); + expect(human.code).toBe(0); + }); + }); +}); + +describe('the exception stays declared, not just implemented (#6730 ruling)', () => { + const cloudLoginSrc = () => readFileSync(CLOUD_LOGIN_SRC, 'utf-8'); + + it('routes every --json write through the single compact emitter', () => { + // The contract is "one document per line" for the WHOLE command, so a new + // write that called `emitJson` directly could reintroduce a multi-line + // record on a path the e2e above does not drive. One emitter is what makes + // that structurally impossible; this is the guard on the emitter. + const src = cloudLoginSrc(); + const direct = src + .split('\n') + .map((line, i) => ({ line, n: i + 1 })) + .filter(({ line }) => /\bemitJson\s*\(/.test(line)) + .filter(({ line }) => !/^\s*await emitJson\(payload, exitCode, \{ compact: true \}\);$/.test(line)); + expect( + direct.map(({ n, line }) => `${n}: ${line.trim()}`), + 'every --json write in cloud/login.ts must go through emitRecord()', + ).toEqual([]); + expect(/async function emitRecord\(/.test(src)).toBe(true); + }); + + it('declares NDJSON in the --json flag help text', () => { + // `--help` is where a consumer looks first, and the ruling made the + // declaration a condition of the fix: an undocumented exception harms the + // same audience as the bug. + const src = cloudLoginSrc(); + const flag = /json:\s*Flags\.boolean\(\{[\s\S]*?\}\)/.exec(src)?.[0] ?? ''; + expect(flag).toMatch(/NDJSON/); + expect(flag).toMatch(/per line/i); + }); + + // Prose in .mdx is hard-wrapped, so these patterns treat any run of + // whitespace as a word gap. A pin that broke when a sentence rewrapped would + // train the next editor to delete it rather than to keep the declaration. + it('documents the exception on the CLI reference page', () => { + const doc = readFileSync(CLI_DOCS, 'utf-8'); + expect(doc).toMatch(/`os\s+cloud\s+login\s+--json`\s+is\s+NDJSON/); + expect(doc).toMatch(/one\s+compact\s+JSON\s+document\s+per\s+line/i); + expect(doc).toMatch(/parse\s+it\s+line\s+by\s+line/i); + }); + + it('documents the exception where the cloud login step is prescribed', () => { + // The deployment page is where a script author meets `os cloud login` in + // the first place — the publish flow — so the declaration has to be + // reachable from there and not only from the CLI reference. + const doc = readFileSync(DEPLOY_DOCS, 'utf-8'); + expect(doc).toMatch(/NDJSON/); + expect(doc).toMatch(/one\s+per\s+line/i); + expect(doc).toMatch(/\/docs\/deployment\/cli#os-cloud-login/); + }); +}); From 50c26f59d47e4553c4b14315c4944fac88bffb4a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:47:57 +0000 Subject: [PATCH 2/3] =?UTF-8?q?docs(changeset):=20=E7=BB=99=20#6730=20?= =?UTF-8?q?=E7=9A=84=E7=A0=B4=E5=9D=8F=E6=80=A7=20changeset=20=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=20ADR-0087=20=E5=A4=84=E7=BD=AE=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .changeset/cloud-login-json-ndjson.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/cloud-login-json-ndjson.md b/.changeset/cloud-login-json-ndjson.md index 20e7bd03db..d23db087cd 100644 --- a/.changeset/cloud-login-json-ndjson.md +++ b/.changeset/cloud-login-json-ndjson.md @@ -65,3 +65,6 @@ that previously worked stops working", which was true there — the output was unreadable before. It is false here: a single-document reader of `os cloud login --json` works today and stops working on the device-flow path. The bump follows the wire shape, not the size of the diff. + + + From 12fe52ce2b115493d0a1f62af03752aa41361692 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 23:48:39 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(changeset):=20=E5=86=99=E6=98=8E=20min?= =?UTF-8?q?or=20=E6=98=AF=E6=9C=AC=E4=BB=93=E7=A0=B4=E5=9D=8F=E6=80=A7?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E8=83=BD=E5=8F=96=E7=9A=84=E6=9C=80=E9=AB=98?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-changeset-no-major.mjs`:所有可发布包锁步版本,launch window 期间 破坏性变更一律走 minor,major 被门禁拒。补这一句是为了让评审者不必再问 「为什么不是 major」,并点明真正承担警示职责的是正文披露而非版本号。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017uFVNMmTxLpmfQYiuKM1Yx --- .changeset/cloud-login-json-ndjson.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.changeset/cloud-login-json-ndjson.md b/.changeset/cloud-login-json-ndjson.md index d23db087cd..55304bf9de 100644 --- a/.changeset/cloud-login-json-ndjson.md +++ b/.changeset/cloud-login-json-ndjson.md @@ -66,5 +66,11 @@ unreadable before. It is false here: a single-document reader of `os cloud login --json` works today and stops working on the device-flow path. The bump follows the wire shape, not the size of the diff. +`major` is not the alternative: every publishable package versions in lockstep, +so during the launch window a breaking change ships as `minor` by convention and +`scripts/check-changeset-no-major.mjs` enforces it. `minor` is therefore the +highest bump this change can carry, and the disclosure above — not the number — +is what has to do the work of warning a consumer. +