diff --git a/packages/core/src/artifacts/write-artifacts.ts b/packages/core/src/artifacts/write-artifacts.ts index c7149903..61f4c563 100644 --- a/packages/core/src/artifacts/write-artifacts.ts +++ b/packages/core/src/artifacts/write-artifacts.ts @@ -1,6 +1,5 @@ -import { randomBytes } from "node:crypto"; -import { mkdir, rename, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; +import { atomicWriteFile } from "../fs/atomic-write-file.js"; import type { CaatingaArtifacts } from "./artifact.schema.js"; export async function writeArtifacts( @@ -8,19 +7,7 @@ export async function writeArtifacts( cwd = process.cwd() ): Promise { const artifactsPath = path.resolve(cwd, "caatinga.artifacts.json"); - await mkdir(path.dirname(artifactsPath), { recursive: true }); - - const tmpPath = `${artifactsPath}.${randomBytes(4).toString("hex")}.tmp`; - const payload = `${JSON.stringify(artifacts, null, 2)}\n`; - - try { - await writeFile(tmpPath, payload, "utf8"); - await rename(tmpPath, artifactsPath); - } catch (error) { - await unlink(tmpPath).catch(() => undefined); - throw error; - } - + await atomicWriteFile(artifactsPath, `${JSON.stringify(artifacts, null, 2)}\n`); return artifactsPath; } diff --git a/packages/core/src/bindings/binding-marker.ts b/packages/core/src/bindings/binding-marker.ts index 3ad67587..f4ee6edc 100644 --- a/packages/core/src/bindings/binding-marker.ts +++ b/packages/core/src/bindings/binding-marker.ts @@ -1,6 +1,7 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; +import { atomicWriteFile } from "../fs/atomic-write-file.js"; export const BINDING_MARKER_FILENAME = ".caatinga-bindings.json"; @@ -16,7 +17,7 @@ export type BindingMarker = z.infer; export async function writeBindingMarker(outputDir: string, marker: BindingMarker): Promise { const markerPath = path.join(outputDir, BINDING_MARKER_FILENAME); - await writeFile(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf8"); + await atomicWriteFile(markerPath, `${JSON.stringify(marker, null, 2)}\n`); } /** Returns null when the marker is absent or unreadable — freshness degrades, never throws. */ diff --git a/packages/core/src/frontend/ensure-buffer-dependency.ts b/packages/core/src/frontend/ensure-buffer-dependency.ts index e8ef2a82..4240afdb 100644 --- a/packages/core/src/frontend/ensure-buffer-dependency.ts +++ b/packages/core/src/frontend/ensure-buffer-dependency.ts @@ -1,5 +1,6 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import path from "node:path"; +import { atomicWriteFile } from "../fs/atomic-write-file.js"; // Backs the Buffer polyfill that every generated binding imports. Pinned to the // same major the templates ship so behaviour matches across init and adoption. @@ -80,7 +81,7 @@ export async function ensureBufferDependency( } pkg.dependencies = { ...(pkg.dependencies ?? {}), buffer: BUFFER_DEPENDENCY_RANGE }; - await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8"); + await atomicWriteFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`); return { packageJsonPath, added: true }; } diff --git a/packages/core/src/frontend/sync-frontend-env.ts b/packages/core/src/frontend/sync-frontend-env.ts index d4a78610..54919337 100644 --- a/packages/core/src/frontend/sync-frontend-env.ts +++ b/packages/core/src/frontend/sync-frontend-env.ts @@ -1,5 +1,6 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import path from "node:path"; +import { atomicWriteFile } from "../fs/atomic-write-file.js"; import { readArtifacts } from "../artifacts/read-artifacts.js"; import type { CaatingaConfig } from "../config/config.schema.js"; import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; @@ -141,10 +142,8 @@ export async function syncFrontendEnv( } const envFile = path.resolve(cwd, frontend.envFile); - await mkdir(path.dirname(envFile), { recursive: true }); - const body = mergeEnvContents(await readExistingEnv(envFile), entries); - await writeFile(envFile, body, "utf8"); + await atomicWriteFile(envFile, body); return { envFile, entries }; } diff --git a/packages/core/src/fs/atomic-write-file.test.ts b/packages/core/src/fs/atomic-write-file.test.ts new file mode 100644 index 00000000..e07e7540 --- /dev/null +++ b/packages/core/src/fs/atomic-write-file.test.ts @@ -0,0 +1,48 @@ +import { mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { atomicWriteFile } from "./atomic-write-file.js"; + +describe("atomicWriteFile", () => { + let dir: string; + + afterEach(async () => { + if (dir) { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("should_write_the_contents_and_leave_no_temp_file", async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "caatinga-atomic-")); + const target = path.join(dir, "out.json"); + + await atomicWriteFile(target, "hello\n"); + + expect(await readFile(target, "utf8")).toBe("hello\n"); + const leftovers = (await readdir(dir)).filter((name) => name.includes(".tmp")); + expect(leftovers).toEqual([]); + }); + + it("should_create_missing_parent_directories", async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "caatinga-atomic-")); + const target = path.join(dir, "nested", "deep", "out.txt"); + + await atomicWriteFile(target, "ok"); + + expect(await readFile(target, "utf8")).toBe("ok"); + }); + + it("should_clean_up_the_temp_file_when_the_write_cannot_complete", async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "caatinga-atomic-")); + const target = path.join(dir, "blocked"); + // Make the target a directory: the final rename cannot replace it, simulating + // an interrupted/failed commit. The temp file must not be left behind. + await mkdir(target); + + await expect(atomicWriteFile(target, "data")).rejects.toBeDefined(); + + const leftovers = (await readdir(dir)).filter((name) => name.includes(".tmp")); + expect(leftovers).toEqual([]); + }); +}); diff --git a/packages/core/src/fs/atomic-write-file.ts b/packages/core/src/fs/atomic-write-file.ts new file mode 100644 index 00000000..9c8b091e --- /dev/null +++ b/packages/core/src/fs/atomic-write-file.ts @@ -0,0 +1,25 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, rename, unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** + * Write `contents` to `filePath` atomically: write to a unique temp file in the + * same directory, then `rename` it into place. A rename on the same filesystem + * is atomic, so a reader never observes a half-written file and a crash mid-write + * can't corrupt the target — it leaves the old file intact (#84). + * + * Creates the parent directory if needed and removes the temp file on failure. + */ +export async function atomicWriteFile(filePath: string, contents: string): Promise { + const resolved = path.resolve(filePath); + await mkdir(path.dirname(resolved), { recursive: true }); + + const tmpPath = `${resolved}.${randomBytes(4).toString("hex")}.tmp`; + try { + await writeFile(tmpPath, contents, "utf8"); + await rename(tmpPath, resolved); + } catch (error) { + await unlink(tmpPath).catch(() => undefined); + throw error; + } +} diff --git a/packages/core/src/templates/create-project-from-template.ts b/packages/core/src/templates/create-project-from-template.ts index 9143254c..7f76ef6f 100644 --- a/packages/core/src/templates/create-project-from-template.ts +++ b/packages/core/src/templates/create-project-from-template.ts @@ -1,4 +1,5 @@ -import { cp, lstat, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { cp, lstat, mkdir, readFile, readdir, stat } from "node:fs/promises"; +import { atomicWriteFile } from "../fs/atomic-write-file.js"; import path from "node:path"; import { z } from "zod"; import { CaatingaError, CaatingaErrorCode } from "../errors/CaatingaError.js"; @@ -145,7 +146,7 @@ async function replaceTemplateVariables(dir: string, projectName: string): Promi } const content = await readFile(entryPath, "utf8"); - await writeFile(entryPath, content.replaceAll("__PROJECT_NAME__", projectName), "utf8"); + await atomicWriteFile(entryPath, content.replaceAll("__PROJECT_NAME__", projectName)); }) ); }