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
34 changes: 34 additions & 0 deletions .changeset/eighty-donuts-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/cli': patch
---

fix(cli): `os login --json` is a parseable NDJSON stream (#6531)

`os login --json` produced output that no consumer could read in any shape. The
device flow wrote its RFC 8628 device-authorization payload compact and, once
the token poll resolved, the result payload 2-space indented — two JSON
documents on one stdout. Driven against a live device endpoint, that stream
failed `JSON.parse(<entire stdout>)` with `Unexpected non-whitespace character
after JSON at position 200`, and read as NDJSON it failed on 5 of its 6 lines,
because the second document spanned five of them. The same two-document shape
appeared on the failure path, where an error payload could follow a
device-authorization record that had already been written.

`os 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. Every line parses on its
own, and the verification-URL record still arrives *before* the user
authorizes, which is what makes the device flow usable from a script at all.

This is the CLI's **one declared exception** to "`--json` means exactly one JSON
document on stdout" (#6217), and it is declared rather than silent: the
`--json` flag's `--help` text says so, and so do the CLI reference page and the
device-flow section of the authentication docs. Parse this command's stdout
line by line.

Bumped as a patch: no interface is added or removed and nothing that previously
worked stops working. The device-flow output was unparseable before, so it had
no consumers to break; the only other observable change is that the
email/password result is compact rather than indented, which `JSON.parse` reads
identically. Human-mode output is untouched.
34 changes: 34 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1078,6 +1078,40 @@ For CI and other non-interactive contexts, pass email/password directly:
os login --email user@example.com --password secret
```

##### `os login --json` is NDJSON — the one exception

Every other ObjectStack command writes **exactly one JSON document** to stdout
under `--json`, so `JSON.parse(<entire stdout>)` is the way to read it.
`os login` is the single declared exception: its `--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 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…"}
```

Read the first record, show the user the URL, then block on the next line:

```bash
os 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 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 failure also set exit code `1`.

Before this was declared, `os login --json` wrote a compact record followed by a
pretty-printed one, which parsed as neither a single document nor as NDJSON.

#### `os logout`

Logout calls `POST /api/v1/auth/sign-out` before deleting local credentials, so
Expand Down
6 changes: 6 additions & 0 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ expire after the server-configured TTL (the CLI assumes a 10-minute / 600s
default). The device flow requires `plugins: { deviceAuthorization: true }` in
your `AuthPlugin` configuration.

Under `--json` this command is the CLI's **one declared NDJSON exception**: it
emits the verification-URL record before you authorize and the result record
afterwards, one compact JSON document per line, so stdout must be parsed line by
line rather than with a single `JSON.parse`. See
[the CLI reference](/docs/deployment/cli#os-login--json-is-ndjson--the-one-exception).

The email/password path is still supported for CI and non-interactive shells:

```bash
Expand Down
91 changes: 85 additions & 6 deletions packages/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,82 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `os login --json` is NDJSON — the CLI's ONE declared exception (#6531).
*
* ## What was broken
*
* Everywhere else in this CLI `--json` means "stdout is exactly one JSON
* document" (#6217). The device-flow path could not honour that and did not
* try: it wrote the RFC 8628 device-authorization payload compact, and then,
* after the token poll succeeded, the result payload 2-space indented. Measured
* against a live device endpoint, stdout came out as
*
* ```
* {"device_code":"…","user_code":"…","verification_uri":"…","expires_in":600}
* {
* "success": true,
* …
* }
* ```
*
* — which `JSON.parse` rejects (`Unexpected non-whitespace character after JSON
* at position 200`) *and* which is not NDJSON either, because the second
* document spans five lines: 5 of its 6 lines fail an independent parse. A
* consumer had no shape to read it in at all. The same two-document stream
* appeared on the failure path too — device record, then an indented error
* payload when the poll timed out or was denied.
*
* ## Why a stream rather than one document
*
* Maintainer ruling, 2026-08-08 (#6531): this flow genuinely IS two events at
* two points in time, and emitting the verification URL **before** the user
* authorizes is the entire value of device flow in automation. Buffering both
* halves into one trailing document would make stdout parseable by destroying
* the thing the output exists for; putting the early record on stderr would
* abuse the diagnostic stream for non-diagnostic content. So `os login --json`
* is declared a newline-delimited stream, and — the ruling's binding condition
* — declared *explicitly*: in this command's `--help` text and in the command
* documentation (`content/docs/deployment/cli.mdx`, and the device-flow section
* of `content/docs/permissions/authentication.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 two
*
* The contract belongs to the command, not to one of its paths. If the
* `--email`/`--password` result or the error payload stayed indented, a
* consumer that read this command line-by-line — exactly what the docs now
* tell it to do — would break on the first run that took another path, and the
* failure path is reachable *after* the device record has already been written.
* So every `--json` write goes through {@link emitRecord}, which is the only
* emitter in this file; that makes "one compact document per line" a property
* of the command instead of four call sites that each have to remember an
* option. `packages/cli/test/login-json-ndjson.e2e.test.ts` holds both halves:
* the stream contract, driven through a real child process against a real
* device endpoint, and the source pin that keeps a future write from bypassing
* the helper.
*/

import { Command, Flags } from '@oclif/core';
import type { CliExitCode } from '../utils/format.js';
import { printHeader, printSuccess, printError, printKV, emitJson } from '../utils/format.js';
import { writeAuthConfig, readAuthConfig } from '../utils/auth-config.js';
import { ObjectStackClient } from '@objectstack/client';
import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

/**
* 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's two writes, has to hold it.
*/
async function emitRecord(payload: unknown, exitCode: CliExitCode = 0): Promise<void> {
await emitJson(payload, exitCode, { compact: true });
}

/**
* Prompt for a password with masked input (shows * per character).
* Falls back to plain readline.question() in non-TTY environments.
Expand Down Expand Up @@ -108,7 +178,8 @@ export default class AuthLogin extends Command {
default: false,
}),
json: Flags.boolean({
description: 'Output as JSON',
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.',
}),
};

Expand All @@ -122,7 +193,7 @@ export default class AuthLogin extends Command {
const existing = await readAuthConfig();
if (existing?.token) {
if (flags.json) {
await emitJson({ success: false, error: 'Already logged in', email: existing.email }, 0, { compact: true });
await emitRecord({ success: false, error: 'Already logged in', email: existing.email });
} else {
printSuccess(`Already logged in as ${existing.email || existing.userId}`);
console.log('');
Expand Down Expand Up @@ -171,7 +242,11 @@ export default class AuthLogin extends Command {
await this.loginWithPassword(client, flags.url, email, password, flags.json);
} 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 recreated the exact two-document stream #6531
// is about — 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));
Expand Down Expand Up @@ -206,7 +281,7 @@ export default class AuthLogin extends Command {
});

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

if (jsonOutput) {
await emitJson({ device_code, user_code, verification_uri, verification_uri_complete, expires_in }, 0, { compact: true });
// 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.
await emitRecord({ device_code, user_code, verification_uri, verification_uri_complete, expires_in });
} else {
console.log(' To authorize this CLI, visit:');
console.log('');
Expand Down Expand Up @@ -318,7 +396,8 @@ export default class AuthLogin extends Command {
});

if (jsonOutput) {
await emitJson({ success: true, email: user?.email, userId: user?.id });
// Record 2 of 2 — same line-per-document shape as record 1.
await emitRecord({ success: true, email: user?.email, userId: user?.id });
} else {
printSuccess('Authentication successful');
if (user?.email) printKV('Email', user.email);
Expand Down
19 changes: 15 additions & 4 deletions packages/cli/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,22 @@ export interface EmitJsonOptions {
* Exists so the sweep onto `emitJson` could be a pure truncation fix with no
* observable output change: roughly half the CLI's `--json` sites were
* already compact and half indented, and this preserves whichever each one
* emitted. The split is accidental rather than designed — `os login --json`
* prints a compact payload and then an indented one in the same run — so
* unifying it is worth doing, but as its own decision, not as a side effect
* of fixing truncated pipes.
* emitted.
*
* This comment used to cite `os login --json` — a compact payload followed by
* an indented one in the same run — as proof the split was accidental. That
* was true, and worse than a formatting inconsistency: two documents on one
* stdout parse as neither a single document nor as NDJSON. #6531 fixed it,
* and in doing so gave `compact` its one *designed* use. `os login` is the
* CLI's sole declared NDJSON command, because its device flow is genuinely
* two events over time and the first one has to reach an automation consumer
* before the user authorizes; there, one line per document IS the contract,
* enforced through a single emitter in `commands/login.ts` and pinned by
* `test/login-json-ndjson.e2e.test.ts`.
*
* Everywhere else `--json` still means exactly one JSON document on stdout
* (#6217), so the remaining compact call sites are still only preserving
* historical formatting and unifying them stays worth doing on its own.
* New code should use the default.
*/
compact?: boolean;
Expand Down
Loading
Loading