11import { createWriteStream , readFileSync , readdirSync , statSync , type WriteStream } from 'node:fs' ;
2- import { stat } from 'node:fs/promises' ;
3- import { dirname , extname , isAbsolute , join , resolve } from 'node:path' ;
2+ import { rename , stat , unlink } from 'node:fs/promises' ;
3+ import { basename , dirname , extname , isAbsolute , join , resolve } from 'node:path' ;
44import { randomUUID } from 'node:crypto' ;
55import { Command } from 'commander' ;
66import {
@@ -3169,11 +3169,14 @@ interface CodeGetOptions extends CommonOptions {
31693169 * directly; presigned URLs are dereferenced via the same fetch impl
31703170 * (without API-key headers — the URL is the bearer of authority).
31713171 *
3172- * `--out <path>` redirects the same bytes into a file. We open the file
3173- * before issuing the network request so a permission/dir error fails
3174- * fast (exit 5 / VALIDATION_ERROR) without spending an API call. On
3175- * any error after open we tear down the sink so we don't leak a
3176- * half-written artifact.
3172+ * `--out <path>` redirects the same bytes into a file. We validate the
3173+ * path and open a sibling temp file before issuing the network request
3174+ * so a permission/dir error fails fast (exit 5 / VALIDATION_ERROR)
3175+ * without spending an API call. The temp file is renamed onto the real
3176+ * `--out` path only after a successful, complete write; on any error
3177+ * (or the "no code generated yet" branch, which writes nothing) the
3178+ * temp file is discarded and the user's pre-existing `--out` file, if
3179+ * any, is left untouched.
31773180 */
31783181export async function runCodeGet ( opts : CodeGetOptions , deps : TestDeps = { } ) : Promise < CliTestCode > {
31793182 // Dry-run: no fetch, no fs. Print the canned shape to stdout and, if
@@ -3204,9 +3207,11 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro
32043207
32053208 try {
32063209 const code = await client . get < CliTestCode > ( `/tests/${ encodeURIComponent ( opts . testId ) } /code` ) ;
3210+ let wroteContent = false ;
32073211
32083212 if ( opts . output === 'json' ) {
32093213 out . print ( code ) ;
3214+ wroteContent = true ;
32103215 } else if ( isPresignedCodeUrl ( code . code ) ) {
32113216 // Text mode: dump the source body. JSON consumers want the wire
32123217 // shape; humans (and agents shelling out via `> file.ts`) want
@@ -3217,20 +3222,24 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro
32173222 // or a piped `gzip`) pauses the upstream reader rather than
32183223 // letting chunks accumulate in V8's heap.
32193224 await streamPresignedBody ( code . code , out , deps ) ;
3225+ wroteContent = true ;
32203226 } else if ( code . code === '' || code . code === null ) {
32213227 // P2-10: draft test with no code yet — empty body would produce
32223228 // silent empty stdout. Print a friendly hint to stderr instead so
3223- // the operator knows what happened, and keep exit 0.
3229+ // the operator knows what happened, and keep exit 0. Nothing was
3230+ // written, so the temp file is discarded below without touching
3231+ // a pre-existing `--out` file.
32243232 const stderrFn = deps . stderr ?? ( ( line : string ) => process . stderr . write ( `${ line } \n` ) ) ;
32253233 stderrFn ( '(no code generated yet — run the test first)' ) ;
32263234 } else {
32273235 await out . writeChunk ( code . code ) ;
3236+ wroteContent = true ;
32283237 }
32293238
3230- if ( fileSink ) await closeOutputFile ( fileSink ) ;
3239+ if ( fileSink ) await closeOutputFile ( fileSink , wroteContent ) ;
32313240 return code ;
32323241 } catch ( err ) {
3233- if ( fileSink ) await closeOutputFile ( fileSink ) . catch ( ( ) => undefined ) ;
3242+ if ( fileSink ) await closeOutputFile ( fileSink , false ) . catch ( ( ) => undefined ) ;
32343243 throw err ;
32353244 }
32363245}
@@ -7733,22 +7742,29 @@ function makeOutput(mode: OutputMode, deps: TestDeps): Output {
77337742 * Internal handle for `--out <path>` writes. Wraps a Node WriteStream
77347743 * with a tracked `error` field so `closeOutputFile` can re-raise an
77357744 * async stream error (EACCES on a write, ENOSPC mid-stream, etc.) that
7736- * was emitted between writes.
7745+ * was emitted between writes. The stream writes to `tmpPath`, a sibling
7746+ * of the real `path`; `closeOutputFile` renames it into place only on
7747+ * a successful, complete write, so a forged or failed response never
7748+ * modifies (or empties) the operator's pre-existing `--out` file.
77377749 */
77387750interface FileSink {
77397751 readonly stream : WriteStream ;
77407752 readonly path : string ;
7753+ readonly tmpPath : string ;
77417754 error : Error | null ;
77427755}
77437756
77447757/**
7745- * Open the `--out` target before any network I/O so a permission/dir
7746- * error fails fast. Synchronous open via `createWriteStream` doesn't
7747- * actually open the descriptor until first write, so we don't surface
7748- * EACCES/ENOENT here — instead the stream emits `'error'`, which we
7749- * remember on the sink and re-throw at close time. The benefit of
7750- * opening early is still real: invalid path strings (empty, `/dev/null`
7751- * on a sandboxed fs, etc.) are caught before the API request goes out.
7758+ * Open a temp file next to the `--out` target before any network I/O so
7759+ * a permission/dir error fails fast. Synchronous open via
7760+ * `createWriteStream` doesn't actually open the descriptor until first
7761+ * write, so we don't surface EACCES/ENOENT here, instead the stream
7762+ * emits `'error'`, which we remember on the sink and re-throw at close
7763+ * time. The benefit of opening early is still real: invalid path
7764+ * strings (empty, `/dev/null` on a sandboxed fs, etc.) are caught
7765+ * before the API request goes out. Writing to a temp path rather than
7766+ * `resolved` directly means the real `--out` file is never truncated
7767+ * up front, see `closeOutputFile` for the commit step.
77527768 */
77537769function openOutputFile ( rawPath : string ) : FileSink {
77547770 if ( typeof rawPath !== 'string' || rawPath . length === 0 ) {
@@ -7777,8 +7793,9 @@ function openOutputFile(rawPath: string): FileSink {
77777793 if ( ! parentStat . isDirectory ( ) ) {
77787794 throw localValidationError ( 'out' , `parent path is not a directory: ${ parent } ` ) ;
77797795 }
7780- const stream = createWriteStream ( resolved , { encoding : 'utf8' } ) ;
7781- const sink : FileSink = { stream, path : resolved , error : null } ;
7796+ const tmpPath = join ( parent , `.${ basename ( resolved ) } .tmp-${ randomUUID ( ) } ` ) ;
7797+ const stream = createWriteStream ( tmpPath , { encoding : 'utf8' } ) ;
7798+ const sink : FileSink = { stream, path : resolved , tmpPath, error : null } ;
77827799 stream . on ( 'error' , err => {
77837800 sink . error = err instanceof Error ? err : new Error ( String ( err ) ) ;
77847801 } ) ;
@@ -7808,25 +7825,37 @@ function makeFileOutput(mode: OutputMode, sink: FileSink): Output {
78087825}
78097826
78107827/**
7811- * Flush + close the file sink. Called on the success path after the
7812- * last write and on the error path inside a `.catch(() => undefined)`
7813- * so the original error isn't masked by a teardown failure.
7828+ * Flush + close the file sink, then either commit or discard the temp
7829+ * file. Called on the success path after the last write (`commit:
7830+ * true` when content was actually written) and on the error / "no code
7831+ * yet" paths (`commit: false`) inside a `.catch(() => undefined)` so a
7832+ * teardown failure doesn't mask the original error.
7833+ *
7834+ * `commit: true` renames `tmpPath` onto the real `--out` path, the
7835+ * only point at which the operator's file is touched. `commit: false`
7836+ * discards the temp file and leaves any pre-existing `--out` file
7837+ * exactly as it was, this is what prevents a failed/empty response
7838+ * from silently truncating the operator's filesystem (mirrors the
7839+ * atomic-rename contract `bundle.ts` uses for multi-file bundles).
78147840 *
78157841 * Re-raises any async stream error captured by the `'error'` listener.
78167842 * Without this re-raise, an EACCES on first write would leave a
7817- * zero-byte file behind and exit 0 — a false-success surface that is
7818- * exactly the failure mode `--out` exists to avoid.
7843+ * zero-byte temp file behind and exit 0, a false-success surface that
7844+ * is exactly the failure mode `--out` exists to avoid.
78197845 */
7820- function closeOutputFile ( sink : FileSink ) : Promise < void > {
7821- return new Promise ( ( resolve , reject ) => {
7822- sink . stream . end ( ( ) => {
7823- if ( sink . error ) {
7824- reject ( new TransportError ( `Failed to write --out ${ sink . path } : ${ sink . error . message } ` ) ) ;
7825- return ;
7826- }
7827- resolve ( ) ;
7828- } ) ;
7846+ async function closeOutputFile ( sink : FileSink , commit : boolean ) : Promise < void > {
7847+ await new Promise < void > ( resolveStream => {
7848+ sink . stream . end ( ( ) => resolveStream ( ) ) ;
78297849 } ) ;
7850+ if ( sink . error ) {
7851+ await unlink ( sink . tmpPath ) . catch ( ( ) => undefined ) ;
7852+ throw new TransportError ( `Failed to write --out ${ sink . path } : ${ sink . error . message } ` ) ;
7853+ }
7854+ if ( ! commit ) {
7855+ await unlink ( sink . tmpPath ) . catch ( ( ) => undefined ) ;
7856+ return ;
7857+ }
7858+ await rename ( sink . tmpPath , sink . path ) ;
78307859}
78317860
78327861/** A presigned `code` body is any `https://` URL — never anything else. */
0 commit comments