Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/commands/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,35 @@ describe('runConfigure', () => {
expect(capture.prelude.join('')).toContain('Configuring profile "default"');
});

it('routes the interactive prelude to stderr by default, keeping stdout for the result', async () => {
// Regression: the prelude used to default to process.stdout, polluting the
// result stream (and the JSON document under --output json). With no
// injected preludeWrite/stderr, the default must land on stderr, not stdout.
const stdout: string[] = [];
const errChunks: string[] = [];
const origErr = process.stderr.write.bind(process.stderr);
(process.stderr as unknown as { write: (c: string) => boolean }).write = c => {
errChunks.push(String(c));
return true;
};
try {
await runConfigure(
{ profile: 'default', output: 'text', debug: false, fromEnv: false },
{
stdout: line => stdout.push(line),
prompt: { secret: vi.fn(async () => 'sk-typed') },
fetchImpl: meOkFetch,
credentialsPath,
env: {},
},
);
} finally {
(process.stderr as unknown as { write: typeof origErr }).write = origErr;
}
expect(errChunks.join('')).toContain('Configuring profile "default"');
expect(stdout.join('\n')).not.toContain('Configuring profile');
});

it('interactive path resolves the endpoint from TESTSPRITE_API_URL without prompting', async () => {
const { capture, deps } = makeCapture();
const prompt = { secret: vi.fn(async () => 'sk-typed') };
Expand Down
5 changes: 4 additions & 1 deletion src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}):
const env = deps.env ?? process.env;
const credentialsPath = deps.credentialsPath ?? defaultCredentialsPath();
const out = makeOutput(opts.output, deps);
const prelude = deps.preludeWrite ?? ((chunk: string) => process.stdout.write(chunk));
// The "Configuring profile …" prelude is informational, not result data, so
// it defaults to stderr — stdout stays a pure result stream (the configured
// JSON/text), which matters under `--output json` (§8.1 stdout purity).
const prelude = deps.preludeWrite ?? ((chunk: string) => process.stderr.write(chunk));
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));

// Normalize the env endpoint: an empty / whitespace-only TESTSPRITE_API_URL is
Expand Down
29 changes: 28 additions & 1 deletion src/lib/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Readable, Writable } from 'node:stream';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { promptSecret, promptText } from './prompt.js';

class CaptureStream extends Writable {
Expand Down Expand Up @@ -44,6 +44,33 @@ describe('promptText', () => {
const output = new CaptureStream();
expect(await promptText('? ', { input, output })).toBe('eof-no-newline');
});

it('writes the question to stderr by default — keeps stdout pure for --output json', async () => {
// Regression: prompts used to default to stdout, which polluted the JSON
// result on the interactive setup/configure path. Interactive UI belongs
// on stderr. Manually swap the global stream writers (prompt reads
// process.stderr/stdout at call time, so this is reliably intercepted).
const errChunks: string[] = [];
const outChunks: string[] = [];
const origErr = process.stderr.write.bind(process.stderr);
const origOut = process.stdout.write.bind(process.stdout);
(process.stderr as unknown as { write: (c: string) => boolean }).write = c => {
errChunks.push(String(c));
return true;
};
(process.stdout as unknown as { write: (c: string) => boolean }).write = c => {
outChunks.push(String(c));
return true;
};
try {
await promptText('Q: ', { input: Readable.from(['x\n']) }); // no output → default
} finally {
(process.stderr as unknown as { write: typeof origErr }).write = origErr;
(process.stdout as unknown as { write: typeof origOut }).write = origOut;
}
expect(errChunks.join('')).toContain('Q: ');
expect(outChunks.join('')).not.toContain('Q: ');
});
});

describe('promptSecret (non-TTY behavior)', () => {
Expand Down
10 changes: 8 additions & 2 deletions src/lib/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,20 @@ interface RawModeCapable {

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

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

const inputAsTTY = input as Readable & RawModeCapable;
Expand Down