Skip to content

Commit b51a090

Browse files
fix(cli): os login --json refuses in a non-interactive shell instead of prompting on stdout (#6728) (#6985)
The non-TTY fallback below the device flow wrote `readline`'s prompt to the `output` stream it was built on — `process.stdout`, unconditionally, `--json` or not. Measured on origin/main @ 73bff86, `os login --json --url … < /dev/null` produced exit 13 and a stdout consisting entirely of `Email: `, with no trailing newline. `--password` alone gave the same; `--email` alone gave `Password: `. Per the maintainer ruling of 2026-08-09 (shape 1), a `--json` run that would otherwise have to prompt now emits one record through the existing `emitRecord()` NDJSON emitter and exits 1: {"success":false,"error":"email and password are required in a non-interactive shell"} Separately and in the same PR: EOF on stdin now produces a defined `CliExitCode`. Exit 13 was Node's unsettled-top-level-await teardown — `readline`'s question promise is abandoned rather than rejected at EOF, so nothing threw and the command never decided anything. Every prompt is now bound to an abort that fires on the interface's `close`, which puts the failure back on the path that ends in `this.exit(1)`. The non-`--json` path keeps its prompts and gains a named error instead of a teardown. The two non-TTY prompts share one readline interface now; `promptPassword` loses its non-TTY branch, which was the second interface the unsettleable question lived in. Claude-Session: https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei Co-authored-by: Claude <noreply@anthropic.com>
1 parent dcafe58 commit b51a090

4 files changed

Lines changed: 571 additions & 14 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os login --json` refuses in a non-interactive shell instead of writing `Email: ` to stdout and exiting 13 (#6728)
6+
7+
`os login --json` had a path below the device flow that wrote a **prompt** to
8+
the payload stream. With no TTY and one or both of `--email`/`--password`
9+
missing — what a CI runner produces when a secret fails to interpolate — the
10+
command fell through to `readline`, whose prompt goes to the `output` stream
11+
the interface was built on: `process.stdout`, unconditionally, `--json` or not.
12+
13+
Measured on the released behaviour:
14+
15+
```console
16+
$ os login --json --url https://api.example.com < /dev/null
17+
exit=13
18+
$ cat -A out.txt
19+
Email:
20+
```
21+
22+
The entire stdout of a run under a declared machine-readable flag was the
23+
string `Email: `, with no trailing newline: not a JSON document, not NDJSON, no
24+
payload at all. Supplying `--password` alone produced the same; supplying
25+
`--email` alone produced `Password: `. stderr carried only Node's
26+
`Warning: Detected unsettled top-level await`.
27+
28+
Two defects, fixed together.
29+
30+
**`--json` is non-interactive by definition, so it refuses.** A `--json` run
31+
that would otherwise have to ask now emits one record through the same NDJSON
32+
emitter as every other `--json` write in the command, and exits `1`:
33+
34+
```console
35+
$ os login --json --url https://api.example.com < /dev/null
36+
{"success":false,"error":"email and password are required in a non-interactive shell"}
37+
```
38+
39+
The refusal is keyed on the flag, not on `isTTY`: reaching it under `--json`
40+
means the only way forward was a prompt, and what stdin happens to be attached
41+
to does not un-declare the run. The `--email`-only and `--password`-only
42+
combinations are covered, since a half-interpolated secret is the usual shape
43+
of the mistake.
44+
45+
**End of input now produces an exit code the CLI defines.** Exit 13 was not a
46+
decision this CLI made — it is Node's unsettled-top-level-await teardown:
47+
`readline`'s `question()` promise is *abandoned* rather than rejected when
48+
stdin is at EOF, nothing throws, and the `await execute(...)` in `bin/run.js`
49+
never settles. `CliExitCode` admits `0` and `1` only, and a CI step judging
50+
success by exit status depends on that. Every prompt is now bound to an abort
51+
that fires on the interface's `close`, so an abandoned question rejects and the
52+
failure reaches the exit code.
53+
54+
Without `--json`, `os login` still prompts on a pipe exactly as before; what
55+
changed for that path is the ending — an EOF at either prompt now reports
56+
`stdin reached end of input before the credentials were entered. Pass --email
57+
and --password to log in non-interactively.` and exits `1`.
58+
59+
If you relied on `os login --json` prompting, pass `--email` and `--password`,
60+
or drop `--json` to keep the interactive prompts.

content/docs/deployment/cli.mdx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,6 +1113,28 @@ that report failure also set exit code `1`.
11131113
Before this was declared, `os login --json` wrote a compact record followed by a
11141114
pretty-printed one, which parsed as neither a single document nor as NDJSON.
11151115

1116+
##### `--json` is non-interactive: it refuses rather than prompting
1117+
1118+
`--json` has one audience, a program, so it never asks a question. If a `--json`
1119+
run has no `--email` and no `--password` to work from and cannot use the device
1120+
flow, it does not fall back to a prompt — it emits one record and exits `1`:
1121+
1122+
```console
1123+
$ os login --json --url https://api.example.com < /dev/null
1124+
{"success":false,"error":"email and password are required in a non-interactive shell"}
1125+
$ echo $?
1126+
1
1127+
```
1128+
1129+
The same applies when only one of the two is supplied, which is the usual shape
1130+
of the mistake: a CI step whose `--password` secret interpolated and whose
1131+
`--email` did not gets that record, not a `Password: ` prompt.
1132+
1133+
Without `--json`, `os login` still prompts on a pipe as before. What changed for
1134+
that path is the ending: if stdin reaches end of input before a prompt is
1135+
answered, the command reports it and exits `1`, rather than being torn down by
1136+
Node with an exit code the CLI does not define.
1137+
11161138
#### `os logout`
11171139

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

packages/cli/src/commands/login.ts

Lines changed: 135 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,43 @@
5454
* the stream contract, driven through a real child process against a real
5555
* device endpoint, and the source pin that keeps a future write from bypassing
5656
* the helper.
57+
*
58+
* ## `--json` never prompts, and a prompt never outlives its input (#6728)
59+
*
60+
* Below the device flow sat a second path with the same harm class and a
61+
* different cause: with no TTY and no `--email`/`--password` — precisely what a
62+
* CI runner produces when a secret fails to interpolate — the command fell
63+
* through to `readline`, which writes its prompt to the `output` stream it was
64+
* built on. That stream is `process.stdout`, unconditionally, `--json` or not.
65+
* Measured on `origin/main` @ `73bff86`:
66+
*
67+
* ```
68+
* $ os login --json --url http://127.0.0.1:1 < /dev/null > out.txt 2> err.txt
69+
* exit=13
70+
* $ cat -A out.txt
71+
* Email:
72+
* ```
73+
*
74+
* The whole of stdout under a declared machine-readable flag was the string
75+
* `Email: `, with no trailing newline: not a JSON document, not NDJSON, no
76+
* payload at all — while stderr carried only `Warning: Detected unsettled
77+
* top-level await`. Two defects in one run, ruled on together (2026-08-09) and
78+
* fixed together here:
79+
*
80+
* 1. **`--json` refuses instead of prompting.** `--json` means non-interactive
81+
* by definition, so a run that would have to ask emits
82+
* `{"success":false,"error":"email and password are required in a
83+
* non-interactive shell"}` — one record, through the same
84+
* {@link emitRecord} as every other `--json` write in this file — and
85+
* exits 1.
86+
* 2. **EOF produces a defined `CliExitCode`.** Exit 13 is Node's
87+
* unsettled-top-level-await teardown, not a decision this CLI made;
88+
* {@link askOrFailAtEof} makes the abandoned question reject, so the failure
89+
* reaches the exit code. That half is deliberately not a side effect of the
90+
* first: without `--json` the prompts remain, and an EOF at either of them
91+
* now names its cause and exits 1 instead of 13.
92+
*
93+
* Pinned by `packages/cli/test/login-json-noninteractive.e2e.test.ts`.
5794
*/
5895

5996
import { Command, Flags } from '@oclif/core';
@@ -78,17 +115,73 @@ async function emitRecord(payload: unknown, exitCode: CliExitCode = 0): Promise<
78115
}
79116

80117
/**
81-
* Prompt for a password with masked input (shows * per character).
82-
* Falls back to plain readline.question() in non-TTY environments.
118+
* The refusal `--json` gives when it would otherwise have to prompt (#6728).
119+
*
120+
* Verbatim from the maintainer ruling of 2026-08-09, and deliberately a
121+
* constant rather than an inline literal: it is the string a CI step reads out
122+
* of the record to tell "you forgot the credentials" apart from "the server
123+
* rejected them", so rewording it is a contract change, not a copy edit.
83124
*/
84-
async function promptPassword(promptText: string): Promise<string> {
85-
if (!process.stdin.isTTY) {
86-
const rl = readline.createInterface({ input, output });
87-
const answer = await rl.question(promptText);
88-
rl.close();
89-
return answer;
125+
const NON_INTERACTIVE_CREDENTIALS_REQUIRED =
126+
'email and password are required in a non-interactive shell';
127+
128+
/**
129+
* The failure a prompt reports when stdin ends before it is answered (#6728).
130+
*
131+
* Names the remedy rather than the mechanism, because every audience that
132+
* reaches it — a CI step without `--json`, a `< /dev/null` redirect, a piped
133+
* heredoc that ran out of lines — fixes it the same way: pass the flags.
134+
*/
135+
const STDIN_EOF_BEFORE_CREDENTIALS =
136+
'stdin reached end of input before the credentials were entered. Pass --email and --password to log in non-interactively.';
137+
138+
/**
139+
* Ask one question, and FAIL on end-of-input instead of hanging forever (#6728).
140+
*
141+
* `readline`'s `question()` promise settles only when a line arrives. When
142+
* stdin is already at EOF — `os login < /dev/null`, the shape a CI runner that
143+
* forgot `--email`/`--password` actually produces — no line ever arrives, the
144+
* interface emits `'close'`, and the promise is simply abandoned. Nothing
145+
* throws, so the outer `catch` never runs and the command never reaches an
146+
* exit code of its own: Node finds the top-level `await` in `bin/run.js`
147+
* permanently unsettled and tears the process down with **exit 13**. Measured
148+
* on `origin/main` @ `73bff86`, `os login --json --url … < /dev/null` exited 13
149+
* with `Warning: Detected unsettled top-level await` on stderr — a code
150+
* {@link CliExitCode} does not define, from a command that never decided
151+
* anything.
152+
*
153+
* So the fix is not a `try`/`catch` around the question — there is no error to
154+
* catch — it is making the question settleable: `'close'` aborts the signal
155+
* `question()` is watching, which rejects it, which puts the failure back on
156+
* the path that ends in `this.exit(1)`.
157+
*/
158+
async function askOrFailAtEof(
159+
rl: readline.Interface,
160+
signal: AbortSignal,
161+
promptText: string,
162+
): Promise<string> {
163+
// Already closed before we got here (stdin was at EOF when the interface was
164+
// created): `question()` would reject on the next tick anyway, but only after
165+
// writing the prompt no one can answer.
166+
if (signal.aborted) throw new Error(STDIN_EOF_BEFORE_CREDENTIALS);
167+
try {
168+
return await rl.question(promptText, { signal });
169+
} catch (error) {
170+
if (signal.aborted) throw new Error(STDIN_EOF_BEFORE_CREDENTIALS);
171+
throw error;
90172
}
173+
}
91174

175+
/**
176+
* Prompt for a password with masked input (shows * per character).
177+
*
178+
* **TTY only** — the caller checks `process.stdin.isTTY` first. This used to
179+
* open a second `readline` interface for the non-TTY case, which was where the
180+
* unsettleable question of #6728 lived; the non-TTY prompts now share the one
181+
* interface in `run()`, which is what makes {@link askOrFailAtEof} cover both
182+
* of them. Reintroducing a private interface here would reintroduce the hang.
183+
*/
184+
async function promptPassword(promptText: string): Promise<string> {
92185
return new Promise((resolve) => {
93186
const chars: string[] = [];
94187
process.stdout.write(promptText);
@@ -179,7 +272,7 @@ export default class AuthLogin extends Command {
179272
}),
180273
json: Flags.boolean({
181274
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.',
275+
'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. Implies non-interactive: without --email and --password to work from, the command refuses with a record instead of prompting.',
183276
}),
184277
};
185278

@@ -228,14 +321,42 @@ export default class AuthLogin extends Command {
228321
return;
229322
}
230323

231-
// --- Non-TTY fallback: prompt for email/password ---
232-
const rl = readline.createInterface({ input, output });
324+
// --- Prompt fallback: one or both credentials are still missing ---
325+
326+
// `--json` declares this run machine-readable, and a machine cannot answer
327+
// a prompt — so under it the command refuses here instead of writing
328+
// `Email: ` onto the payload stream (#6728, maintainer ruling 2026-08-09:
329+
// "`--json` means non-interactive by definition"). The check is on the
330+
// flag alone, not on `isTTY`: reaching this line under `--json` means the
331+
// only way forward is a prompt, and what a TTY happens to be attached to
332+
// does not un-declare the run.
333+
if (flags.json) {
334+
await emitRecord({ success: false, error: NON_INTERACTIVE_CREDENTIALS_REQUIRED }, 1);
335+
return;
336+
}
337+
233338
let email = flags.email;
234339
let password = flags.password;
235340

236-
if (!email) email = await rl.question('Email: ');
237-
rl.close();
238-
if (!password) password = await promptPassword('Password: ');
341+
// ONE interface for both prompts, with `'close'` wired to an abort so a
342+
// question can never outlive its input — see {@link askOrFailAtEof} for
343+
// the exit-13 teardown that shape replaces.
344+
const rl = readline.createInterface({ input, output });
345+
const atEof = new AbortController();
346+
const abortOnClose = () => atEof.abort();
347+
rl.once('close', abortOnClose);
348+
try {
349+
if (!email) email = await askOrFailAtEof(rl, atEof.signal, 'Email: ');
350+
// The masked prompt needs raw mode on `process.stdin`, which cannot
351+
// coexist with an open interface, so a TTY takes it below instead.
352+
if (!password && !process.stdin.isTTY) {
353+
password = await askOrFailAtEof(rl, atEof.signal, 'Password: ');
354+
}
355+
} finally {
356+
rl.off('close', abortOnClose);
357+
rl.close();
358+
}
359+
if (!password && process.stdin.isTTY) password = await promptPassword('Password: ');
239360

240361
if (!email || !password) throw new Error('Email and password are required');
241362

0 commit comments

Comments
 (0)