Skip to content

os login --json in a non-TTY shell writes the bare prompt Email: to stdout, then exits 13 with no payload #6728

Description

@os-project-manager

Found while implementing #6531 (os login --json → NDJSON). Unclaimed, unassigned. Different path, different decision, same stdout-purity harm class — filed separately rather than widened into PR #6727.

Observation

#6531 covers the device flow (stdin.isTTY true). The path below it is broken in a different way. packages/cli/src/commands/login.ts, the non-TTY fallback:

// --- Non-TTY fallback: prompt for email/password ---
const rl = readline.createInterface({ input, output });   // output === process.stdout
if (!email) email = await rl.question('Email: ');
rl.close();
if (!password) password = await promptPassword('Password: ');

readline writes the prompt to the output stream, which is process.stdout — unconditionally, including under --json. promptPassword does the same (process.stdout.write(promptText) on its own non-TTY branch).

Measured (unmodified origin/main @ b230e5efd)

$ HOME=<tmp> os login --json --url http://127.0.0.1:1 < /dev/null > out.txt 2> err.txt
exit=13
$ cat -A out.txt
Email: $            # ← no trailing newline; this is the ENTIRE stdout

So the whole of stdout under a declared machine-readable flag is the string Email: . Not a JSON document, not NDJSON, no payload at all. stderr carries only Warning: Detected unsettled top-level await.

The exit code is the second half: readline gets EOF from the closed stdin, the question promise never settles, and Node tears the process down through the unsettled-top-level-await path — exit 13, which is neither the CLI's 0 nor its 1 (CliExitCode in utils/format.ts deliberately admits only those two). A CI step judging success by exit status sees a code the CLI does not define.

Why it matters

This is the path a CI runner actually takes: no TTY, and --email/--password not passed (forgotten, or the secret failed to interpolate). The audience --json exists for gets Email: on stdout and exit 13, with nothing naming the real problem ("email and password are required in a non-interactive shell").

Why it is not part of #6531 / PR #6727

PR #6727 makes os login --json a declared NDJSON stream and routes every --json write through one compact emitter, so the payload side of this command is now uniform. It deliberately does not touch this path, because the fix turns on a contract question that PR cannot answer on its own:

Should os login --json interactively prompt at all? Plausible shapes, undecided:

  1. Refuse. Under --json with no --email/--password and no TTY, emit {"success":false,"error":"…"} with exit 1 and never prompt. Treats --json as "non-interactive by definition".
  2. Prompt on stderr. Keep the prompt, move it off stdout. Preserves today's behaviour for a human piping stdout to a file, but puts a non-diagnostic on the diagnostic stream (the objection that sank route 3 in os login --json (device flow) writes TWO JSON documents to stdout, so the whole stream is unparseable #6531).
  3. Reserve stdout. Apply os migrate recorded-by --json 的 stdout 里混着内核 INFO 日志,payload 无法直接 JSON.parse #6217's reserveStdoutForJson() seam here, which forwards the prompt to stderr as a side effect. Same end state as 2, reached structurally.

Option 1 also fixes the exit-13 half; 2 and 3 leave it. Recommendation is not mine to make — this is a --json contract decision like #6531's was.

Separately and regardless of the shape chosen, the unsettled-await exit 13 is a defect on its own: an EOF on stdin should produce a defined CliExitCode, not a Node teardown artifact.

Refs #6531, #6217, PR #6727.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions