Skip to content

Commit 7da6558

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 3ab8136 commit 7da6558

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
@@ -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: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)', () => {
@@ -63,6 +90,35 @@ describe('promptSecret (non-TTY behavior)', () => {
6390
expect(written).not.toContain('sk-hidden-12345');
6491
});
6592

93+
it('writes the prompt to stderr by default — keeps stdout pure for --output json', async () => {
94+
// Same regression as promptText: the secret prompt is interactive UI and
95+
// must default to stderr so stdout carries only the command result. Manual
96+
// stream swap (vi.spyOn does not intercept process.stderr.write here).
97+
const errChunks: string[] = [];
98+
const outChunks: string[] = [];
99+
const origErr = process.stderr.write.bind(process.stderr);
100+
const origOut = process.stdout.write.bind(process.stdout);
101+
(process.stderr as unknown as { write: (c: string) => boolean }).write = c => {
102+
errChunks.push(String(c));
103+
return true;
104+
};
105+
(process.stdout as unknown as { write: (c: string) => boolean }).write = c => {
106+
outChunks.push(String(c));
107+
return true;
108+
};
109+
try {
110+
await promptSecret('Key: ', { input: Readable.from(['sk-x\n']) }); // no output → default
111+
} finally {
112+
(process.stderr as unknown as { write: typeof origErr }).write = origErr;
113+
(process.stdout as unknown as { write: typeof origOut }).write = origOut;
114+
}
115+
expect(errChunks.join('')).toContain('Key: ');
116+
expect(outChunks.join('')).not.toContain('Key: ');
117+
// The typed secret is never echoed to either real stream.
118+
expect(errChunks.join('')).not.toContain('sk-x');
119+
expect(outChunks.join('')).not.toContain('sk-x');
120+
});
121+
66122
it('honors DEL/backspace before submission', async () => {
67123
const DEL = String.fromCharCode(0x7f);
68124
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
@@ -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)