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); + }); +});