Skip to content

Commit 2b01b00

Browse files
fix(cli): publish downloads atomically
1 parent b3f150c commit 2b01b00

2 files changed

Lines changed: 86 additions & 15 deletions

File tree

packages/sim-cli/src/commands/protocol/files-get.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
import { createWriteStream, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
1+
import {
2+
createWriteStream,
3+
existsSync,
4+
mkdtempSync,
5+
readFileSync,
6+
rmSync,
7+
writeFileSync,
8+
} from 'node:fs'
29
import { tmpdir } from 'node:os'
310
import { join } from 'node:path'
411
import { Writable } from 'node:stream'
512
import { Command } from 'commander'
613
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
714
import { buildGeneratedCommands } from '../../runtime/build'
8-
import { isTerminalSafeContentType, streamToFile } from './files-get'
15+
import { isTerminalSafeContentType, saveToFile, streamToFile } from './files-get'
916
import { attachProtocolCommands } from './index'
1017

1118
const { output, requestRaw } = vi.hoisted(() => ({
@@ -49,6 +56,15 @@ function bodyOf(chunks: string[]): ReadableStream<Uint8Array> {
4956
})
5057
}
5158

59+
function failingBody(): ReadableStream<Uint8Array> {
60+
return new ReadableStream({
61+
start(controller) {
62+
controller.enqueue(new TextEncoder().encode('partial'))
63+
controller.error(new Error('connection lost'))
64+
},
65+
})
66+
}
67+
5268
function program(): Command {
5369
const root = new Command('sim').exitOverride()
5470
for (const group of buildGeneratedCommands()) root.addCommand(group)
@@ -113,6 +129,34 @@ describe('streamToFile', () => {
113129
})
114130
})
115131

132+
describe('saveToFile', () => {
133+
it('preserves the original destination when a forced download fails', async () => {
134+
const target = join(dir, 'out.txt')
135+
writeFileSync(target, 'precious')
136+
137+
await expect(saveToFile(failingBody(), target, true)).rejects.toThrow(/connection lost/)
138+
139+
expect(readFileSync(target, 'utf8')).toBe('precious')
140+
})
141+
142+
it('leaves no partial destination when a new download fails', async () => {
143+
const target = join(dir, 'out.txt')
144+
145+
await expect(saveToFile(failingBody(), target, false)).rejects.toThrow(/connection lost/)
146+
147+
expect(existsSync(target)).toBe(false)
148+
})
149+
150+
it('publishes a completed forced download over the original', async () => {
151+
const target = join(dir, 'out.txt')
152+
writeFileSync(target, 'old')
153+
154+
await saveToFile(bodyOf(['new']), target, true)
155+
156+
expect(readFileSync(target, 'utf8')).toBe('new')
157+
})
158+
})
159+
116160
describe('isTerminalSafeContentType', () => {
117161
it('accepts text formats and rejects binary or unknown formats', () => {
118162
expect(isTerminalSafeContentType('text/markdown; charset=utf-8')).toBe(true)

packages/sim-cli/src/commands/protocol/files-get.ts

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { once } from 'node:events'
22
import { createWriteStream, type WriteStream } from 'node:fs'
3+
import { link, mkdtemp, rename, rm } from 'node:fs/promises'
4+
import { dirname, join } from 'node:path'
35
import { Readable, type Writable } from 'node:stream'
46
import { pipeline } from 'node:stream/promises'
57
import type { Command } from 'commander'
@@ -8,22 +10,50 @@ import { V2_OPERATIONS } from '../../generated/v2-api'
810
import { resolvePath, SimApiError } from '../../http/client'
911
import { printProtocolResult } from './result'
1012

13+
function writeFailure(path: WriteStream['path'], error: unknown): SimApiError {
14+
const code = (error as NodeJS.ErrnoException).code
15+
if (code === 'EEXIST') {
16+
return new SimApiError(
17+
`${path} already exists. Pass --force to overwrite it, or choose another output path.`,
18+
0
19+
)
20+
}
21+
return new SimApiError(`Could not write ${path}: ${(error as Error).message}`, 0)
22+
}
23+
1124
/** Streams a fetch body to disk while honoring write-stream backpressure. */
1225
export async function streamToFile(
1326
body: ReadableStream<Uint8Array>,
14-
file: Writable & Pick<WriteStream, 'path'>
27+
file: Writable & Pick<WriteStream, 'path'>,
28+
reportedPath: WriteStream['path'] = file.path
1529
): Promise<void> {
1630
try {
1731
await pipeline(Readable.fromWeb(body as Parameters<typeof Readable.fromWeb>[0]), file)
1832
} catch (error) {
19-
const code = (error as NodeJS.ErrnoException).code
20-
if (code === 'EEXIST') {
21-
throw new SimApiError(
22-
`${file.path} already exists. Pass --force to overwrite it, or choose another output path.`,
23-
0
24-
)
25-
}
26-
throw new SimApiError(`Could not write ${file.path}: ${(error as Error).message}`, 0)
33+
throw writeFailure(reportedPath, error)
34+
}
35+
}
36+
37+
/** Stages a complete download beside its destination before publishing it. */
38+
export async function saveToFile(
39+
body: ReadableStream<Uint8Array>,
40+
target: string,
41+
force: boolean
42+
): Promise<void> {
43+
let temporaryDirectory: string | null = null
44+
45+
try {
46+
temporaryDirectory = await mkdtemp(join(dirname(target), '.sim-download-'))
47+
const temporaryPath = join(temporaryDirectory, 'payload')
48+
await streamToFile(body, createWriteStream(temporaryPath, { flags: 'wx' }), target)
49+
50+
if (force) await rename(temporaryPath, target)
51+
else await link(temporaryPath, target)
52+
} catch (error) {
53+
if (error instanceof SimApiError) throw error
54+
throw writeFailure(target, error)
55+
} finally {
56+
if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true })
2757
}
2858
}
2959

@@ -111,10 +141,7 @@ export function attachFileGet(files: Command): void {
111141

112142
const target = options.outputFile
113143

114-
await streamToFile(
115-
response.body,
116-
createWriteStream(target, { flags: options.force ? 'w' : 'wx' })
117-
)
144+
await saveToFile(response.body, target, Boolean(options.force))
118145
printProtocolResult(profile.output, {
119146
id: fileId,
120147
path: target,

0 commit comments

Comments
 (0)