Skip to content

Commit 5ff6399

Browse files
authored
fix(cli): route interactive prompts and prelude to stderr (keep stdout pure) (#31)
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 759e85c commit 5ff6399

4 files changed

Lines changed: 97 additions & 3 deletions

File tree

src/commands/auth.test.ts

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

281+
it('routes the interactive prelude to stderr by default, keeping stdout for the result', async () => {
282+
// Regression: the prelude used to default to process.stdout, polluting the
283+
// result stream (and the JSON document under --output json). With no
284+
// injected preludeWrite/stderr, the default must land on stderr, not stdout.
285+
const stdout: string[] = [];
286+
const errChunks: string[] = [];
287+
const origErr = process.stderr.write.bind(process.stderr);
288+
(process.stderr as unknown as { write: (c: string) => boolean }).write = c => {
289+
errChunks.push(String(c));
290+
return true;
291+
};
292+
try {
293+
await runConfigure(
294+
{ profile: 'default', output: 'text', debug: false, fromEnv: false },
295+
{
296+
stdout: line => stdout.push(line),
297+
prompt: { secret: vi.fn(async () => 'sk-typed') },
298+
fetchImpl: meOkFetch,
299+
credentialsPath,
300+
env: {},
301+
},
302+
);
303+
} finally {
304+
(process.stderr as unknown as { write: typeof origErr }).write = origErr;
305+
}
306+
expect(errChunks.join('')).toContain('Configuring profile "default"');
307+
expect(stdout.join('\n')).not.toContain('Configuring profile');
308+
});
309+
281310
it('interactive path resolves the endpoint from TESTSPRITE_API_URL without prompting', async () => {
282311
const { capture, deps } = makeCapture();
283312
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
@@ -75,7 +75,10 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}):
7575
const env = deps.env ?? process.env;
7676
const credentialsPath = deps.credentialsPath ?? defaultCredentialsPath();
7777
const out = makeOutput(opts.output, deps);
78-
const prelude = deps.preludeWrite ?? ((chunk: string) => process.stdout.write(chunk));
78+
// The "Configuring profile …" prelude is informational, not result data, so
79+
// it defaults to stderr — stdout stays a pure result stream (the configured
80+
// JSON/text), which matters under `--output json` (§8.1 stdout purity).
81+
const prelude = deps.preludeWrite ?? ((chunk: string) => process.stderr.write(chunk));
7982
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
8083

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

src/lib/prompt.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,33 @@ describe('promptText', () => {
6969
const output = new CaptureStream();
7070
expect(await promptText('? ', { input, output })).toBe('eof-no-newline');
7171
});
72+
73+
it('writes the question to stderr by default — keeps stdout pure for --output json', async () => {
74+
// Regression: prompts used to default to stdout, which polluted the JSON
75+
// result on the interactive setup/configure path. Interactive UI belongs
76+
// on stderr. Manually swap the global stream writers (prompt reads
77+
// process.stderr/stdout at call time, so this is reliably intercepted).
78+
const errChunks: string[] = [];
79+
const outChunks: string[] = [];
80+
const origErr = process.stderr.write.bind(process.stderr);
81+
const origOut = process.stdout.write.bind(process.stdout);
82+
(process.stderr as unknown as { write: (c: string) => boolean }).write = c => {
83+
errChunks.push(String(c));
84+
return true;
85+
};
86+
(process.stdout as unknown as { write: (c: string) => boolean }).write = c => {
87+
outChunks.push(String(c));
88+
return true;
89+
};
90+
try {
91+
await promptText('Q: ', { input: Readable.from(['x\n']) }); // no output → default
92+
} finally {
93+
(process.stderr as unknown as { write: typeof origErr }).write = origErr;
94+
(process.stdout as unknown as { write: typeof origOut }).write = origOut;
95+
}
96+
expect(errChunks.join('')).toContain('Q: ');
97+
expect(outChunks.join('')).not.toContain('Q: ');
98+
});
7299
});
73100

74101
describe('promptSecret (non-TTY behavior)', () => {
@@ -88,6 +115,35 @@ describe('promptSecret (non-TTY behavior)', () => {
88115
expect(written).not.toContain('sk-hidden-12345');
89116
});
90117

118+
it('writes the prompt to stderr by default — keeps stdout pure for --output json', async () => {
119+
// Same regression as promptText: the secret prompt is interactive UI and
120+
// must default to stderr so stdout carries only the command result. Manual
121+
// stream swap (vi.spyOn does not intercept process.stderr.write here).
122+
const errChunks: string[] = [];
123+
const outChunks: string[] = [];
124+
const origErr = process.stderr.write.bind(process.stderr);
125+
const origOut = process.stdout.write.bind(process.stdout);
126+
(process.stderr as unknown as { write: (c: string) => boolean }).write = c => {
127+
errChunks.push(String(c));
128+
return true;
129+
};
130+
(process.stdout as unknown as { write: (c: string) => boolean }).write = c => {
131+
outChunks.push(String(c));
132+
return true;
133+
};
134+
try {
135+
await promptSecret('Key: ', { input: Readable.from(['sk-x\n']) }); // no output → default
136+
} finally {
137+
(process.stderr as unknown as { write: typeof origErr }).write = origErr;
138+
(process.stdout as unknown as { write: typeof origOut }).write = origOut;
139+
}
140+
expect(errChunks.join('')).toContain('Key: ');
141+
expect(outChunks.join('')).not.toContain('Key: ');
142+
// The typed secret is never echoed to either real stream.
143+
expect(errChunks.join('')).not.toContain('sk-x');
144+
expect(outChunks.join('')).not.toContain('sk-x');
145+
});
146+
91147
it('honors DEL/backspace before submission', async () => {
92148
const DEL = String.fromCharCode(0x7f);
93149
const input = Readable.from([`abc${DEL}d\n`]);

src/lib/prompt.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,20 @@ const pendingPromptInput = new WeakMap<NodeJS.ReadableStream, string>();
1515

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

2328
export async function promptSecret(question: string, streams: PromptStreams = {}): Promise<string> {
2429
const input = streams.input ?? process.stdin;
25-
const output = streams.output ?? process.stdout;
30+
// See promptText: interactive prompt + masking go to stderr, not stdout.
31+
const output = streams.output ?? process.stderr;
2632
output.write(question);
2733

2834
const inputAsTTY = input as Readable & RawModeCapable;

0 commit comments

Comments
 (0)