From d21b9169a787017e4f662c0e48bb95caecdfab9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 07:42:46 +0000 Subject: [PATCH] fix(cli): `os login --json` refuses in a non-interactive shell instead of prompting on stdout (#6728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei --- .../login-json-refuses-non-interactive.md | 60 +++ content/docs/deployment/cli.mdx | 22 ++ packages/cli/src/commands/login.ts | 149 +++++++- .../login-json-noninteractive.e2e.test.ts | 354 ++++++++++++++++++ 4 files changed, 571 insertions(+), 14 deletions(-) create mode 100644 .changeset/login-json-refuses-non-interactive.md create mode 100644 packages/cli/test/login-json-noninteractive.e2e.test.ts diff --git a/.changeset/login-json-refuses-non-interactive.md b/.changeset/login-json-refuses-non-interactive.md new file mode 100644 index 0000000000..184cdfecce --- /dev/null +++ b/.changeset/login-json-refuses-non-interactive.md @@ -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. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 66fe8d5847..35269cd3d7 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -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 diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts index 3145e4532c..1cae2c2ee9 100644 --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -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'; @@ -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 { - 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 { + // 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 { return new Promise((resolve) => { const chars: string[] = []; process.stdout.write(promptText); @@ -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.', }), }; @@ -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'); diff --git a/packages/cli/test/login-json-noninteractive.e2e.test.ts b/packages/cli/test/login-json-noninteractive.e2e.test.ts new file mode 100644 index 0000000000..c0fb31f7f2 --- /dev/null +++ b/packages/cli/test/login-json-noninteractive.e2e.test.ts @@ -0,0 +1,354 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os login --json` refuses in a non-interactive shell — it does not prompt, + * and it does not exit 13 (#6728). + * + * ## The defect these pin shut + * + * Under the device flow that #6531 fixed sat a second path with the same + * stdout-purity harm and a different cause. With no TTY and one or both of + * `--email`/`--password` missing — what a CI runner produces when a secret + * fails to interpolate — `login.ts` fell through to `readline`, whose prompt + * goes to the `output` stream the interface was built on: `process.stdout`, + * unconditionally, `--json` or not. Measured on unmodified `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 ENTIRE stdout of a run under a declared machine-readable flag, with no + * trailing newline. The same run with `--password` supplied wrote `Email: `; + * with `--email` supplied it wrote `Password: `. All three exited 13. + * + * ## Why the exit code is asserted, and asserted as a MEMBER of a set + * + * 13 is Node's `Unsettled Top-Level Await` teardown: `readline` gets EOF, the + * question promise is abandoned rather than rejected, nothing throws, and the + * `await execute(...)` in `bin/run.js` never settles. It is not a code this CLI + * chose — `CliExitCode` (`src/utils/format.ts`) admits **0 and 1 only**, and + * that narrowness is the contract a CI step judging success by exit status + * depends on. + * + * So "non-zero" is not the assertion. A bare `expect(code).not.toBe(0)` stays + * **green against the defect itself**, since 13 is as non-zero as 1 is. Each + * case therefore asserts membership in {@link DEFINED_EXIT_CODES} *and* the + * specific value, and the membership assertion is the one that names 13. + * + * ## Why no PTY here, unlike the device-flow sibling + * + * `login-json-ndjson.e2e.test.ts` needs `script(1)` because the device flow is + * gated on `process.stdin.isTTY`. This path is the opposite: it is reachable + * precisely *because* stdin is a pipe, which is what a plain `spawn` already + * gives the child. Measured both ways before this file was written — the + * repro above is a shell redirect, no pty involved. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn } from 'node:child_process'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { mkdtempSync, rmSync, readFileSync } 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 LOGIN_SRC = resolve(HERE, '../src/commands/login.ts'); +const CLI_DOCS = resolve(HERE, '../../..', 'content/docs/deployment/cli.mdx'); + +/** + * Every exit code `CliExitCode` admits (`src/utils/format.ts`). Kept as a list + * rather than a `toBe(1)` alone so a regression to Node's exit 13 fails on + * "not a code this CLI defines", which is the actual defect, instead of on a + * value mismatch that reads like a flake. + */ +const DEFINED_EXIT_CODES = [0, 1]; + +/** The refusal the maintainer ruled on (2026-08-09), verbatim. */ +const REFUSAL = 'email and password are required in a non-interactive shell'; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +/** + * One `os login` run in a real child process with a **piped** stdin — the + * non-TTY shape the defect lives in. + * + * `answer` drives the prompts the way an interactive consumer would: each entry + * waits for its pattern to appear on stdout before writing its line, so a run + * that never prompts simply never gets fed (and ends on its own). With no + * entries stdin is closed immediately, which is the `< /dev/null` repro. + */ +function runLogin(args: string[], answer: Array<{ when: RegExp; send: string }> = []): Promise { + const dir = mkdtempSync(join(tmpdir(), 'os-login-6728-')); + // HOME is the credentials root (`auth-config.ts` builds every path from + // `os.homedir()`), so a temp one keeps the run from reading — or + // overwriting — the developer's real ~/.objectstack/credentials.json. + const home = join(dir, 'home'); + + return new Promise((done) => { + const child = spawn(TSX, [CLI, 'login', ...args], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, HOME: home, NO_COLOR: '1' }, + }); + + let stdout = ''; + let stderr = ''; + const pending = [...answer]; + + const feed = () => { + while (pending.length > 0 && pending[0]!.when.test(stdout)) { + child.stdin.write(pending.shift()!.send); + } + if (pending.length === 0 && child.stdin.writable) child.stdin.end(); + }; + + child.stdout.on('data', (b: Buffer) => { + stdout += b.toString(); + feed(); + }); + child.stderr.on('data', (b: Buffer) => { stderr += b.toString(); }); + + // No answers to give: EOF straight away, the `< /dev/null` shape. + if (pending.length === 0) child.stdin.end(); + + child.on('close', (code) => { + rmSync(dir, { recursive: true, force: true }); + done({ code: code ?? -1, stdout, stderr }); + }); + }); +} + +/** Every non-empty line of a captured stdout. */ +function lines(stdout: string): string[] { + return stdout.split('\n').filter((l) => l.length > 0); +} + +/** + * Carriage return (13) and ESC (27) — the invisible bytes that corrupt a + * line-oriented reader even where they carry no visible text. + * + * Built from code points on purpose: a literal class would put the very bytes + * this asserts against into the source file, which is the accident AGENTS.md's + * byte discipline and `scripts/check-nul-bytes.mjs` exist to prevent. + */ +const CONTROL_NOISE = new RegExp(`[${String.fromCharCode(13)}${String.fromCharCode(27)}]`); + +/** + * A minimal better-auth `sign-in/email` endpoint — enough for the CLI's + * `client.auth.login()` to succeed, so the paths that must KEEP working can be + * asserted as successes rather than as "a different failure". + */ +function startAuthEndpoint() { + const seen: Array<{ email?: string; password?: string }> = []; + const server: Server = createServer((req, res) => { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + const { pathname } = new URL(req.url ?? '/', 'http://placeholder'); + if (pathname === '/api/v1/auth/sign-in/email') { + seen.push(JSON.parse(body || '{}') as { email?: string; password?: string }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ + token: 'TOKEN-6728', + user: { id: 'usr_6728', email: 'ci@example.com' }, + })); + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not_found' })); + }); + }); + return { + seen, + listen: () => new Promise((r) => { + server.listen(0, '127.0.0.1', () => r((server.address() as AddressInfo).port)); + }), + close: () => new Promise((r) => server.close(() => r())), + }; +} + +/** The URL flag every case passes — a port nothing listens on unless said so. */ +const DEAD_URL = 'http://127.0.0.1:1'; + +describe('os login --json refuses rather than prompting (#6728 ruling: shape 1)', () => { + // The three ways a run can arrive here with something still to ask for. The + // partial-flag pair matters on its own: a CI step that interpolated one + // secret and lost the other is the likeliest real shape, and it prompts for + // the OTHER field — `Password: ` on stdout where `--email` was supplied. + const cases: Array<{ name: string; args: string[] }> = [ + { name: 'neither credential supplied', args: ['--json', '--url', DEAD_URL] }, + { name: 'only --password supplied', args: ['--json', '--url', DEAD_URL, '--password', 'secret'] }, + { name: 'only --email supplied', args: ['--json', '--url', DEAD_URL, '--email', 'ci@example.com'] }, + ]; + + const runs = new Map(); + + beforeAll(async () => { + // Sequential: each child owns a HOME and a startup budget, and overlapping + // them buys nothing but interleaved timing. + for (const c of cases) runs.set(c.name, await runLogin(c.args)); + }, 300_000); + + for (const c of cases) { + describe(c.name, () => { + it('emits the ruled refusal record and nothing else', () => { + const run = runs.get(c.name)!; + const all = lines(run.stdout); + // One record, so this subsumes "no prompt, no banner" rather than + // enumerating strings a future edit could add to. + expect(all, `stdout was: ${JSON.stringify(run.stdout)}`).toHaveLength(1); + expect(JSON.parse(all[0]!)).toEqual({ success: false, error: REFUSAL }); + }); + + it('exits with a code this CLI defines — the half that is not about the payload', () => { + const run = runs.get(c.name)!; + expect( + DEFINED_EXIT_CODES, + `exit ${run.code} is not a CliExitCode. 13 is Node's unsettled-top-level-await ` + + 'teardown, which is exactly what this path used to produce.', + ).toContain(run.code); + expect(run.code).toBe(1); + }); + + it('lets no prompt onto stdout, and leaves no partial line behind', () => { + const run = runs.get(c.name)!; + expect(run.stdout).not.toContain('Email:'); + expect(run.stdout).not.toContain('Password:'); + expect(run.stdout).not.toContain('ObjectStack Login'); + expect(run.stdout).not.toMatch(CONTROL_NOISE); + // The record is a whole line: `Email: ` had no trailing newline at all. + expect(run.stdout.endsWith('\n')).toBe(true); + }); + + it('is parseable NDJSON on every line', () => { + const run = runs.get(c.name)!; + for (const [i, line] of lines(run.stdout).entries()) { + expect(() => JSON.parse(line), `stdout line ${i + 1}: ${JSON.stringify(line)}`).not.toThrow(); + } + }); + }); + } +}); + +describe('EOF on stdin ends in a defined CliExitCode, not Node exit 13 (#6728)', () => { + // Without `--json` the prompts stay — the ruling changed the machine-readable + // contract, not the human one — so these runs still print `Email: `. What is + // asserted is the ENDING: a question that outlives its input now rejects, + // which puts the failure back on the path that ends in `this.exit(1)`. + let both: Run; + let passwordOnly: Run; + + beforeAll(async () => { + both = await runLogin(['--url', DEAD_URL]); + passwordOnly = await runLogin(['--url', DEAD_URL, '--email', 'ci@example.com']); + }, 300_000); + + it('fails at the email prompt with a defined exit code', () => { + expect( + DEFINED_EXIT_CODES, + `exit ${both.code} is not a CliExitCode — 13 means the process was torn down ` + + 'by an unsettled top-level await instead of deciding anything.', + ).toContain(both.code); + expect(both.code).toBe(1); + }); + + it('fails at the password prompt with a defined exit code', () => { + expect(DEFINED_EXIT_CODES).toContain(passwordOnly.code); + expect(passwordOnly.code).toBe(1); + }); + + it('names the cause and the remedy instead of dying silently', () => { + // On the defect the only diagnostic anywhere was Node's own + // `Warning: Detected unsettled top-level await`, which names the runtime + // artefact rather than the operator's mistake. + const said = both.stdout + both.stderr; + expect(said).toContain('stdin reached end of input'); + expect(said).toContain('--email'); + expect(said).toContain('--password'); + expect(both.stderr).not.toContain('unsettled top-level await'); + }); +}); + +describe('the paths that must keep working (#6728 did not narrow them)', () => { + const endpoint = startAuthEndpoint(); + let port: number; + + beforeAll(async () => { port = await endpoint.listen(); }, 30_000); + afterAll(async () => { await endpoint.close(); }); + + it('logs in with both flags under --json — one record, exit 0', async () => { + const run = await runLogin(['--json', '--url', `http://127.0.0.1:${port}`, '--email', 'ci@example.com', '--password', 'secret']); + const all = lines(run.stdout); + expect(all, `stdout was: ${JSON.stringify(run.stdout)}`).toHaveLength(1); + expect(JSON.parse(all[0]!)).toEqual({ success: true, email: 'ci@example.com', userId: 'usr_6728' }); + expect(DEFINED_EXIT_CODES).toContain(run.code); + expect(run.code).toBe(0); + }, 300_000); + + it('still prompts, and still succeeds, for a consumer that answers', async () => { + // The refusal is keyed on `--json`, not on "stdin is a pipe": a driver that + // reads the prompt and writes the line is served exactly as before. This is + // also the run that proves the single shared readline interface still hands + // BOTH answers over — the email prompt and the password prompt. + const run = await runLogin( + ['--url', `http://127.0.0.1:${port}`], + [ + { when: /Email: $/m, send: 'ci@example.com\n' }, + { when: /Password: $/m, send: 'secret\n' }, + ], + ); + expect(run.stdout).toContain('Authentication successful'); + expect(DEFINED_EXIT_CODES).toContain(run.code); + expect(run.code).toBe(0); + expect(endpoint.seen.at(-1)).toMatchObject({ email: 'ci@example.com', password: 'secret' }); + }, 300_000); +}); + +describe('the refusal stays structural, not one call site (#6728)', () => { + const loginSrc = () => readFileSync(LOGIN_SRC, 'utf-8'); + + it('asks no question that can outlive its input', () => { + // `rl.question(...)` without the abort signal is the unsettleable form — + // the whole of exit 13. One helper owns it so a future prompt cannot + // reintroduce the hang by simply forgetting; this is the guard on that + // helper, in the same shape as the `emitRecord` guard in the NDJSON suite. + const src = loginSrc(); + const raw = src + .split('\n') + .map((line, n) => ({ line, n: n + 1 })) + .filter(({ line }) => /\brl\.question\s*\(/.test(line)) + .filter(({ line }) => !/\{\s*signal\s*\}/.test(line)); + expect( + raw.map(({ n, line }) => `${n}: ${line.trim()}`), + 'every readline question in login.ts must carry the EOF abort signal (askOrFailAtEof)', + ).toEqual([]); + expect(/async function askOrFailAtEof\(/.test(src)).toBe(true); + }); + + it('declares the non-interactive implication in the --json flag help', () => { + // `--help` is where a CI author looks when the record says the credentials + // were required; an undocumented refusal reads as a bug in the CLI. + const flag = /json:\s*Flags\.boolean\(\{[\s\S]*?\}\)/.exec(loginSrc())?.[0] ?? ''; + expect(flag).toMatch(/non-interactive/i); + expect(flag).toMatch(/refuses/i); + }); + + it('documents the refusal on the CLI reference page', () => { + // Prose in .mdx is hard-wrapped, so any run of whitespace is treated 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. + const doc = readFileSync(CLI_DOCS, 'utf-8'); + expect(doc).toMatch(/refuses\s+rather\s+than\s+prompting/i); + expect(doc).toContain(REFUSAL); + }); +});