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+
359import { Command , Flags } from '@oclif/core' ;
60+ import type { CliExitCode } from '../utils/format.js' ;
461import { printHeader , printSuccess , printError , printKV , emitJson } from '../utils/format.js' ;
562import { writeAuthConfig , readAuthConfig } from '../utils/auth-config.js' ;
663import { ObjectStackClient } from '@objectstack/client' ;
764import * as readline from 'node:readline/promises' ;
865import { 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 ) ;
0 commit comments