Skip to content

Commit 5388878

Browse files
committed
fix(cli): route interactive prompts and prelude to stderr (keep stdout pure)
Interactive prompts (`prompt.ts` — the API-key prompt during `setup`/`auth configure`, the target prompt during `agent install`) wrote the question and masking to STDOUT, and the "Configuring profile …" prelude defaulted to stdout too. On the interactive path that mixes UI text into stdout — and under `--output json` it breaks the contract that stdout is a single JSON document, so a consumer doing `JSON.parse(stdout)` fails. Default both to stderr: prompts and informational preludes are interactive UI, not result data. stdout now carries only the command's result (the §8.1 stdout-purity principle the repo already enforces elsewhere). stderr is still the user's TTY, so prompts remain visible; the secret is still never echoed. Callers that inject explicit streams are unaffected. Adds regression tests: promptText writes the question to stderr by default, and the configure prelude lands on stderr (not the result stdout).
1 parent 18f6e6e commit 5388878

4 files changed

Lines changed: 69 additions & 4 deletions

File tree

src/commands/auth.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,35 @@ describe('runConfigure', () => {
140140
expect(capture.prelude.join('')).toContain('Configuring profile "default"');
141141
});
142142

143+
it('routes the interactive prelude to stderr by default, keeping stdout for the result', async () => {
144+
// Regression: the prelude used to default to process.stdout, polluting the
145+
// result stream (and the JSON document under --output json). With no
146+
// injected preludeWrite/stderr, the default must land on stderr, not stdout.
147+
const stdout: string[] = [];
148+
const errChunks: string[] = [];
149+
const origErr = process.stderr.write.bind(process.stderr);
150+
(process.stderr as unknown as { write: (c: string) => boolean }).write = c => {
151+
errChunks.push(String(c));
152+
return true;
153+
};
154+
try {
155+
await runConfigure(
156+
{ profile: 'default', output: 'text', debug: false, fromEnv: false },
157+
{
158+
stdout: line => stdout.push(line),
159+
prompt: { secret: vi.fn(async () => 'sk-typed') },
160+
fetchImpl: meOkFetch,
161+
credentialsPath,
162+
env: {},
163+
},
164+
);
165+
} finally {
166+
(process.stderr as unknown as { write: typeof origErr }).write = origErr;
167+
}
168+
expect(errChunks.join('')).toContain('Configuring profile "default"');
169+
expect(stdout.join('\n')).not.toContain('Configuring profile');
170+
});
171+
143172
it('interactive path resolves the endpoint from TESTSPRITE_API_URL without prompting', async () => {
144173
const { capture, deps } = makeCapture();
145174
const prompt = { secret: vi.fn(async () => 'sk-typed') };

src/commands/auth.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}):
7373
const env = deps.env ?? process.env;
7474
const credentialsPath = deps.credentialsPath ?? defaultCredentialsPath();
7575
const out = makeOutput(opts.output, deps);
76-
const prelude = deps.preludeWrite ?? ((chunk: string) => process.stdout.write(chunk));
76+
// The "Configuring profile …" prelude is informational, not result data, so
77+
// it defaults to stderr — stdout stays a pure result stream (the configured
78+
// JSON/text), which matters under `--output json` (§8.1 stdout purity).
79+
const prelude = deps.preludeWrite ?? ((chunk: string) => process.stderr.write(chunk));
7780
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
7881

7982
// Normalize the env endpoint: an empty / whitespace-only TESTSPRITE_API_URL is

src/lib/prompt.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Readable, Writable } from 'node:stream';
2-
import { describe, expect, it } from 'vitest';
2+
import { describe, expect, it, vi } from 'vitest';
33
import { promptSecret, promptText } from './prompt.js';
44

55
class CaptureStream extends Writable {
@@ -44,6 +44,33 @@ describe('promptText', () => {
4444
const output = new CaptureStream();
4545
expect(await promptText('? ', { input, output })).toBe('eof-no-newline');
4646
});
47+
48+
it('writes the question to stderr by default — keeps stdout pure for --output json', async () => {
49+
// Regression: prompts used to default to stdout, which polluted the JSON
50+
// result on the interactive setup/configure path. Interactive UI belongs
51+
// on stderr. Manually swap the global stream writers (prompt reads
52+
// process.stderr/stdout at call time, so this is reliably intercepted).
53+
const errChunks: string[] = [];
54+
const outChunks: string[] = [];
55+
const origErr = process.stderr.write.bind(process.stderr);
56+
const origOut = process.stdout.write.bind(process.stdout);
57+
(process.stderr as unknown as { write: (c: string) => boolean }).write = c => {
58+
errChunks.push(String(c));
59+
return true;
60+
};
61+
(process.stdout as unknown as { write: (c: string) => boolean }).write = c => {
62+
outChunks.push(String(c));
63+
return true;
64+
};
65+
try {
66+
await promptText('Q: ', { input: Readable.from(['x\n']) }); // no output → default
67+
} finally {
68+
(process.stderr as unknown as { write: typeof origErr }).write = origErr;
69+
(process.stdout as unknown as { write: typeof origOut }).write = origOut;
70+
}
71+
expect(errChunks.join('')).toContain('Q: ');
72+
expect(outChunks.join('')).not.toContain('Q: ');
73+
});
4774
});
4875

4976
describe('promptSecret (non-TTY behavior)', () => {

src/lib/prompt.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,20 @@ interface RawModeCapable {
1313

1414
export async function promptText(question: string, streams: PromptStreams = {}): Promise<string> {
1515
const input = streams.input ?? process.stdin;
16-
const output = streams.output ?? process.stdout;
16+
// Prompts are interactive UI, not data — write the question (and any echo)
17+
// to stderr so stdout carries only the command's result. This keeps
18+
// `--output json` stdout a single pure JSON document even on the interactive
19+
// setup / configure path (§8.1 stdout purity). stderr is still the user's
20+
// TTY, so the prompt remains visible.
21+
const output = streams.output ?? process.stderr;
1722
output.write(question);
1823
return readLine(input, output, false);
1924
}
2025

2126
export async function promptSecret(question: string, streams: PromptStreams = {}): Promise<string> {
2227
const input = streams.input ?? process.stdin;
23-
const output = streams.output ?? process.stdout;
28+
// See promptText: interactive prompt + masking go to stderr, not stdout.
29+
const output = streams.output ?? process.stderr;
2430
output.write(question);
2531

2632
const inputAsTTY = input as Readable & RawModeCapable;

0 commit comments

Comments
 (0)