Skip to content

Commit 2c20191

Browse files
authored
Merge pull request #124 from NianJiuZst/fix/safe-download-stream-json
fix: protect downloads and stream JSON output
2 parents 5d7813d + 9cc1289 commit 2c20191

4 files changed

Lines changed: 218 additions & 17 deletions

File tree

src/commands/text/chat.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,12 @@ export default defineCommand({
127127
const model = (flags.model as string)
128128
|| config.defaultTextModel
129129
|| 'MiniMax-M2.7';
130-
const shouldStream = flags.stream === true || (flags.stream === undefined && process.stdout.isTTY);
131130
const format = detectOutputFormat(config.output);
131+
const shouldStream = flags.stream === true || (
132+
flags.stream === undefined
133+
&& format !== 'json'
134+
&& process.stdout.isTTY
135+
);
132136

133137
const body: ChatRequest = {
134138
model,
@@ -190,11 +194,12 @@ export default defineCommand({
190194
let inThinking = false;
191195
const dim = config.noColor ? '' : '\x1b[2m';
192196
const reset = config.noColor ? '' : '\x1b[0m';
197+
const isJsonOutput = format === 'json';
193198
const isTTY = process.stdout.isTTY;
194199
// In TTY mode, write thinking/response headers to stdout for display.
195200
// In non-TTY (pipe/agent) mode, write everything but final text to stderr.
196-
const statusOut = isTTY ? process.stdout : process.stderr;
197-
const resultOut = process.stdout;
201+
const statusOut = isTTY && !isJsonOutput ? process.stdout : process.stderr;
202+
const resultOut = isJsonOutput ? undefined : process.stdout;
198203

199204
for await (const event of parseSSE(res)) {
200205
if (event.data === '[DONE]') break;
@@ -212,7 +217,7 @@ export default defineCommand({
212217
} else if (parsed.type === 'content_block_delta') {
213218
if (parsed.delta.type === 'text_delta') {
214219
textContent += parsed.delta.text;
215-
resultOut.write(parsed.delta.text);
220+
resultOut?.write(parsed.delta.text);
216221
} else if (parsed.delta.type === 'thinking_delta') {
217222
statusOut.write(parsed.delta.thinking);
218223
}
@@ -223,10 +228,11 @@ export default defineCommand({
223228
}
224229
}
225230
if (inThinking) statusOut.write(reset);
226-
resultOut.write('\n');
227231

228232
if (format === 'json') {
229233
console.log(formatOutput({ content: textContent }, format));
234+
} else {
235+
resultOut?.write('\n');
230236
}
231237
} else {
232238
const response = await requestJson<ChatResponse>(config, {

src/files/download.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createWriteStream, unlinkSync } from 'fs';
1+
import { createWriteStream, renameSync, unlinkSync } from 'fs';
22
import { createProgressBar } from '../output/progress';
33
import { CLIError } from '../errors/base';
44
import { ExitCode } from '../errors/codes';
@@ -41,12 +41,14 @@ export async function downloadFile(
4141
const reader = res.body?.getReader();
4242
if (!reader) throw new CLIError('No response body', ExitCode.GENERAL);
4343

44-
const writer = createWriteStream(destPath);
44+
const tmpPath = `${destPath}.tmp-${process.pid}-${Date.now()}-${attempt}-${Math.random().toString(36).slice(2)}`;
45+
const writer = createWriteStream(tmpPath);
4546
const progress = contentLength > 0 && !opts?.quiet
4647
? createProgressBar(contentLength, 'Downloading')
4748
: null;
4849

4950
let received = 0;
51+
let readComplete = false;
5052
let completed = false;
5153

5254
try {
@@ -67,22 +69,28 @@ export async function downloadFile(
6769
received += value.byteLength;
6870
progress?.update(received);
6971
}
70-
completed = true;
72+
readComplete = true;
7173
} finally {
7274
reader.releaseLock();
7375
progress?.finish();
7476

75-
await new Promise<void>((resolve, reject) => {
76-
writer.on('finish', resolve);
77-
writer.on('error', reject);
78-
writer.end();
79-
});
80-
81-
if (!completed) {
82-
try { unlinkSync(destPath); } catch { /* best effort */ }
77+
try {
78+
if (!writer.destroyed) {
79+
await new Promise<void>((resolve, reject) => {
80+
writer.on('finish', resolve);
81+
writer.on('error', reject);
82+
writer.end();
83+
});
84+
completed = readComplete;
85+
}
86+
} finally {
87+
if (!completed) {
88+
try { unlinkSync(tmpPath); } catch { /* best effort */ }
89+
}
8390
}
8491
}
8592

93+
renameSync(tmpPath, destPath);
8694
return { size: received };
8795
} catch (err) {
8896
lastError = err instanceof Error ? err : new Error(String(err));

test/commands/text/chat.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, afterEach } from 'bun:test';
2-
import { createMockServer, jsonResponse, type MockServer } from '../../helpers/mock-server';
2+
import { createMockServer, jsonResponse, sseResponse, type MockServer } from '../../helpers/mock-server';
33
import textChatResponse from '../../fixtures/text-chat-response.json';
44
import type { Config } from '../../../src/config/schema';
55

@@ -146,6 +146,119 @@ describe('text chat command', () => {
146146
}
147147
});
148148

149+
it('does not enable default streaming for json output', async () => {
150+
let requestBody: { stream?: boolean } | undefined;
151+
server = createMockServer({
152+
routes: {
153+
'/anthropic/v1/messages': async (req) => {
154+
requestBody = await req.json() as { stream?: boolean };
155+
return jsonResponse(textChatResponse);
156+
},
157+
},
158+
});
159+
160+
const { default: chatCommand } = await import('../../../src/commands/text/chat');
161+
162+
const config: Config = {
163+
apiKey: 'test-key',
164+
region: 'global' as const,
165+
baseUrl: server.url,
166+
output: 'json',
167+
timeout: 10,
168+
verbose: false,
169+
quiet: false,
170+
noColor: true,
171+
yes: false,
172+
dryRun: false,
173+
nonInteractive: true,
174+
async: false,
175+
};
176+
177+
const originalIsTTY = process.stdout.isTTY;
178+
const originalLog = console.log;
179+
let output = '';
180+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
181+
console.log = (msg: string) => { output += msg; };
182+
183+
try {
184+
await chatCommand.execute(config, {
185+
message: ['Hello'],
186+
quiet: false,
187+
verbose: false,
188+
noColor: true,
189+
yes: false,
190+
dryRun: false,
191+
help: false,
192+
nonInteractive: true,
193+
async: false,
194+
});
195+
196+
expect(requestBody?.stream).toBe(false);
197+
expect(JSON.parse(output).content[0].text).toBe('Hello! How can I help you today?');
198+
} finally {
199+
console.log = originalLog;
200+
Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
201+
}
202+
});
203+
204+
it('emits only final json to stdout for explicit stream json output', async () => {
205+
server = createMockServer({
206+
routes: {
207+
'/anthropic/v1/messages': () => sseResponse([
208+
{ data: JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: 'Hello' } }) },
209+
{ data: JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: ' world' } }) },
210+
]),
211+
},
212+
});
213+
214+
const { default: chatCommand } = await import('../../../src/commands/text/chat');
215+
216+
const config: Config = {
217+
apiKey: 'test-key',
218+
region: 'global' as const,
219+
baseUrl: server.url,
220+
output: 'json',
221+
timeout: 10,
222+
verbose: false,
223+
quiet: false,
224+
noColor: true,
225+
yes: false,
226+
dryRun: false,
227+
nonInteractive: true,
228+
async: false,
229+
};
230+
231+
const originalLog = console.log;
232+
const originalWrite = process.stdout.write;
233+
let output = '';
234+
console.log = (msg: string) => { output += `${msg}\n`; };
235+
process.stdout.write = ((chunk: string | Uint8Array) => {
236+
output += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8');
237+
return true;
238+
}) as typeof process.stdout.write;
239+
240+
try {
241+
await chatCommand.execute(config, {
242+
message: ['Hello'],
243+
stream: true,
244+
quiet: false,
245+
verbose: false,
246+
noColor: true,
247+
yes: false,
248+
dryRun: false,
249+
help: false,
250+
nonInteractive: true,
251+
async: false,
252+
});
253+
254+
expect(output).toBe('{\n "content": "Hello world"\n}\n');
255+
expect(JSON.parse(output).content).toBe('Hello world');
256+
} finally {
257+
console.log = originalLog;
258+
process.stdout.write = originalWrite;
259+
}
260+
});
261+
149262
it('--model flag overrides defaultTextModel', async () => {
150263
const { default: chatCommand } = await import('../../../src/commands/text/chat');
151264

test/files/download.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { afterEach, describe, expect, it } from 'bun:test';
2+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
3+
import { tmpdir } from 'os';
4+
import { join } from 'path';
5+
import { downloadFile } from '../../src/files/download';
6+
7+
const originalFetch = globalThis.fetch;
8+
const tempDirs: string[] = [];
9+
10+
function makeTempDir(): string {
11+
const dir = mkdtempSync(join(tmpdir(), 'mmx-download-test-'));
12+
tempDirs.push(dir);
13+
return dir;
14+
}
15+
16+
afterEach(() => {
17+
globalThis.fetch = originalFetch;
18+
for (const dir of tempDirs.splice(0)) {
19+
rmSync(dir, { recursive: true, force: true });
20+
}
21+
});
22+
23+
describe('downloadFile', () => {
24+
it('keeps an existing destination intact when the download stream fails', async () => {
25+
const dir = makeTempDir();
26+
const destPath = join(dir, 'video.mp4');
27+
writeFileSync(destPath, 'original');
28+
29+
globalThis.fetch = (async () => {
30+
let sentChunk = false;
31+
const stream = new ReadableStream<Uint8Array>({
32+
pull(controller) {
33+
if (!sentChunk) {
34+
sentChunk = true;
35+
controller.enqueue(new TextEncoder().encode('partial'));
36+
return;
37+
}
38+
controller.error(new Error('stream failed'));
39+
},
40+
});
41+
42+
return new Response(stream, {
43+
status: 200,
44+
headers: { 'content-length': '100' },
45+
});
46+
}) as unknown as typeof fetch;
47+
48+
await expect(
49+
downloadFile('https://example.com/video.mp4', destPath, { quiet: true, retries: 0 }),
50+
).rejects.toThrow('Download failed');
51+
52+
expect(readFileSync(destPath, 'utf-8')).toBe('original');
53+
expect(readdirSync(dir)).toEqual(['video.mp4']);
54+
});
55+
56+
it('replaces the destination only after a successful download', async () => {
57+
const dir = makeTempDir();
58+
const destPath = join(dir, 'video.mp4');
59+
writeFileSync(destPath, 'original');
60+
61+
globalThis.fetch = (async () => new Response(new TextEncoder().encode('new'), {
62+
status: 200,
63+
headers: { 'content-length': '3' },
64+
})) as unknown as typeof fetch;
65+
66+
await expect(
67+
downloadFile('https://example.com/video.mp4', destPath, { quiet: true, retries: 0 }),
68+
).resolves.toEqual({ size: 3 });
69+
70+
expect(readFileSync(destPath, 'utf-8')).toBe('new');
71+
expect(existsSync(destPath)).toBe(true);
72+
expect(readdirSync(dir)).toEqual(['video.mp4']);
73+
});
74+
});

0 commit comments

Comments
 (0)