Skip to content
Draft
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
60 changes: 60 additions & 0 deletions .changeset/login-json-refuses-non-interactive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@objectstack/cli": patch
---

fix(cli): `os login --json` refuses in a non-interactive shell instead of writing `Email: ` to stdout and exiting 13 (#6728)

`os login --json` had a path below the device flow that wrote a **prompt** to
the payload stream. With no TTY and one or both of `--email`/`--password`
missing — what a CI runner produces when a secret fails to interpolate — the
command fell through to `readline`, whose prompt goes to the `output` stream
the interface was built on: `process.stdout`, unconditionally, `--json` or not.

Measured on the released behaviour:

```console
$ os login --json --url https://api.example.com < /dev/null
exit=13
$ cat -A out.txt
Email:
```

The entire stdout of a run under a declared machine-readable flag was the
string `Email: `, with no trailing newline: not a JSON document, not NDJSON, no
payload at all. Supplying `--password` alone produced the same; supplying
`--email` alone produced `Password: `. stderr carried only Node's
`Warning: Detected unsettled top-level await`.

Two defects, fixed together.

**`--json` is non-interactive by definition, so it refuses.** A `--json` run
that would otherwise have to ask now emits one record through the same NDJSON
emitter as every other `--json` write in the command, and exits `1`:

```console
$ os login --json --url https://api.example.com < /dev/null
{"success":false,"error":"email and password are required in a non-interactive shell"}
```

The refusal is keyed on the flag, not on `isTTY`: reaching it under `--json`
means the only way forward was a prompt, and what stdin happens to be attached
to does not un-declare the run. The `--email`-only and `--password`-only
combinations are covered, since a half-interpolated secret is the usual shape
of the mistake.

**End of input now produces an exit code the CLI defines.** Exit 13 was not a
decision this CLI made — it is Node's unsettled-top-level-await teardown:
`readline`'s `question()` promise is *abandoned* rather than rejected when
stdin is at EOF, nothing throws, and the `await execute(...)` in `bin/run.js`
never settles. `CliExitCode` admits `0` and `1` only, and a CI step judging
success by exit status depends on that. Every prompt is now bound to an abort
that fires on the interface's `close`, so an abandoned question rejects and the
failure reaches the exit code.

Without `--json`, `os login` still prompts on a pipe exactly as before; what
changed for that path is the ending — an EOF at either prompt now reports
`stdin reached end of input before the credentials were entered. Pass --email
and --password to log in non-interactively.` and exits `1`.

If you relied on `os login --json` prompting, pass `--email` and `--password`,
or drop `--json` to keep the interactive prompts.
22 changes: 22 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,28 @@ 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.

##### `--json` is non-interactive: it refuses rather than prompting

`--json` has one audience, a program, so it never asks a question. If a `--json`
run has no `--email` and no `--password` to work from and cannot use the device
flow, it does not fall back to a prompt — it emits one record and exits `1`:

```console
$ os login --json --url https://api.example.com < /dev/null
{"success":false,"error":"email and password are required in a non-interactive shell"}
$ echo $?
1
```

The same applies when only one of the two is supplied, which is the usual shape
of the mistake: a CI step whose `--password` secret interpolated and whose
`--email` did not gets that record, not a `Password: ` prompt.

Without `--json`, `os login` still prompts on a pipe as before. What changed for
that path is the ending: if stdin reaches end of input before a prompt is
answered, the command reports it and exits `1`, rather than being torn down by
Node with an exit code the CLI does not define.

#### `os logout`

Logout calls `POST /api/v1/auth/sign-out` before deleting local credentials, so
Expand Down
149 changes: 135 additions & 14 deletions packages/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,43 @@
* 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.
*
* ## `--json` never prompts, and a prompt never outlives its input (#6728)
*
* Below the device flow sat a second path with the same harm class and a
* different cause: with no TTY and no `--email`/`--password` — precisely what a
* CI runner produces when a secret fails to interpolate — the command fell
* through to `readline`, which writes its prompt to the `output` stream it was
* built on. That stream is `process.stdout`, unconditionally, `--json` or not.
* Measured on `origin/main` @ `73bff86`:
*
* ```
* $ os login --json --url http://127.0.0.1:1 < /dev/null > out.txt 2> err.txt
* exit=13
* $ cat -A out.txt
* Email:
* ```
*
* The whole of stdout under a declared machine-readable flag was the string
* `Email: `, with no trailing newline: not a JSON document, not NDJSON, no
* payload at all — while stderr carried only `Warning: Detected unsettled
* top-level await`. Two defects in one run, ruled on together (2026-08-09) and
* fixed together here:
*
* 1. **`--json` refuses instead of prompting.** `--json` means non-interactive
* by definition, so a run that would have to ask emits
* `{"success":false,"error":"email and password are required in a
* non-interactive shell"}` — one record, through the same
* {@link emitRecord} as every other `--json` write in this file — and
* exits 1.
* 2. **EOF produces a defined `CliExitCode`.** Exit 13 is Node's
* unsettled-top-level-await teardown, not a decision this CLI made;
* {@link askOrFailAtEof} makes the abandoned question reject, so the failure
* reaches the exit code. That half is deliberately not a side effect of the
* first: without `--json` the prompts remain, and an EOF at either of them
* now names its cause and exits 1 instead of 13.
*
* Pinned by `packages/cli/test/login-json-noninteractive.e2e.test.ts`.
*/

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

/**
* Prompt for a password with masked input (shows * per character).
* Falls back to plain readline.question() in non-TTY environments.
* The refusal `--json` gives when it would otherwise have to prompt (#6728).
*
* Verbatim from the maintainer ruling of 2026-08-09, and deliberately a
* constant rather than an inline literal: it is the string a CI step reads out
* of the record to tell "you forgot the credentials" apart from "the server
* rejected them", so rewording it is a contract change, not a copy edit.
*/
async function promptPassword(promptText: string): Promise<string> {
if (!process.stdin.isTTY) {
const rl = readline.createInterface({ input, output });
const answer = await rl.question(promptText);
rl.close();
return answer;
const NON_INTERACTIVE_CREDENTIALS_REQUIRED =
'email and password are required in a non-interactive shell';

/**
* The failure a prompt reports when stdin ends before it is answered (#6728).
*
* Names the remedy rather than the mechanism, because every audience that
* reaches it — a CI step without `--json`, a `< /dev/null` redirect, a piped
* heredoc that ran out of lines — fixes it the same way: pass the flags.
*/
const STDIN_EOF_BEFORE_CREDENTIALS =
'stdin reached end of input before the credentials were entered. Pass --email and --password to log in non-interactively.';

/**
* Ask one question, and FAIL on end-of-input instead of hanging forever (#6728).
*
* `readline`'s `question()` promise settles only when a line arrives. When
* stdin is already at EOF — `os login < /dev/null`, the shape a CI runner that
* forgot `--email`/`--password` actually produces — no line ever arrives, the
* interface emits `'close'`, and the promise is simply abandoned. Nothing
* throws, so the outer `catch` never runs and the command never reaches an
* exit code of its own: Node finds the top-level `await` in `bin/run.js`
* permanently unsettled and tears the process down with **exit 13**. Measured
* on `origin/main` @ `73bff86`, `os login --json --url … < /dev/null` exited 13
* with `Warning: Detected unsettled top-level await` on stderr — a code
* {@link CliExitCode} does not define, from a command that never decided
* anything.
*
* So the fix is not a `try`/`catch` around the question — there is no error to
* catch — it is making the question settleable: `'close'` aborts the signal
* `question()` is watching, which rejects it, which puts the failure back on
* the path that ends in `this.exit(1)`.
*/
async function askOrFailAtEof(
rl: readline.Interface,
signal: AbortSignal,
promptText: string,
): Promise<string> {
// Already closed before we got here (stdin was at EOF when the interface was
// created): `question()` would reject on the next tick anyway, but only after
// writing the prompt no one can answer.
if (signal.aborted) throw new Error(STDIN_EOF_BEFORE_CREDENTIALS);
try {
return await rl.question(promptText, { signal });
} catch (error) {
if (signal.aborted) throw new Error(STDIN_EOF_BEFORE_CREDENTIALS);
throw error;
}
}

/**
* Prompt for a password with masked input (shows * per character).
*
* **TTY only** — the caller checks `process.stdin.isTTY` first. This used to
* open a second `readline` interface for the non-TTY case, which was where the
* unsettleable question of #6728 lived; the non-TTY prompts now share the one
* interface in `run()`, which is what makes {@link askOrFailAtEof} cover both
* of them. Reintroducing a private interface here would reintroduce the hang.
*/
async function promptPassword(promptText: string): Promise<string> {
return new Promise((resolve) => {
const chars: string[] = [];
process.stdout.write(promptText);
Expand Down Expand Up @@ -179,7 +272,7 @@ export default class AuthLogin extends Command {
}),
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.',
'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.',
}),
};

Expand Down Expand Up @@ -228,14 +321,42 @@ export default class AuthLogin extends Command {
return;
}

// --- Non-TTY fallback: prompt for email/password ---
const rl = readline.createInterface({ input, output });
// --- Prompt fallback: one or both credentials are still missing ---

// `--json` declares this run machine-readable, and a machine cannot answer
// a prompt — so under it the command refuses here instead of writing
// `Email: ` onto the payload stream (#6728, maintainer ruling 2026-08-09:
// "`--json` means non-interactive by definition"). The check is on the
// flag alone, not on `isTTY`: reaching this line under `--json` means the
// only way forward is a prompt, and what a TTY happens to be attached to
// does not un-declare the run.
if (flags.json) {
await emitRecord({ success: false, error: NON_INTERACTIVE_CREDENTIALS_REQUIRED }, 1);
return;
}

let email = flags.email;
let password = flags.password;

if (!email) email = await rl.question('Email: ');
rl.close();
if (!password) password = await promptPassword('Password: ');
// ONE interface for both prompts, with `'close'` wired to an abort so a
// question can never outlive its input — see {@link askOrFailAtEof} for
// the exit-13 teardown that shape replaces.
const rl = readline.createInterface({ input, output });
const atEof = new AbortController();
const abortOnClose = () => atEof.abort();
rl.once('close', abortOnClose);
try {
if (!email) email = await askOrFailAtEof(rl, atEof.signal, 'Email: ');
// The masked prompt needs raw mode on `process.stdin`, which cannot
// coexist with an open interface, so a TTY takes it below instead.
if (!password && !process.stdin.isTTY) {
password = await askOrFailAtEof(rl, atEof.signal, 'Password: ');
}
} finally {
rl.off('close', abortOnClose);
rl.close();
}
if (!password && process.stdin.isTTY) password = await promptPassword('Password: ');

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

Expand Down
Loading
Loading