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
17 changes: 2 additions & 15 deletions packages/core/src/artifacts/write-artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,13 @@
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(
artifacts: CaatingaArtifacts,
cwd = process.cwd()
): Promise<string> {
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;
}

Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/bindings/binding-marker.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -16,7 +17,7 @@ export type BindingMarker = z.infer<typeof BindingMarkerSchema>;

export async function writeBindingMarker(outputDir: string, marker: BindingMarker): Promise<void> {
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. */
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/frontend/ensure-buffer-dependency.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 };
}
7 changes: 3 additions & 4 deletions packages/core/src/frontend/sync-frontend-env.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 };
}
Expand Down
48 changes: 48 additions & 0 deletions packages/core/src/fs/atomic-write-file.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
25 changes: 25 additions & 0 deletions packages/core/src/fs/atomic-write-file.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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;
}
}
5 changes: 3 additions & 2 deletions packages/core/src/templates/create-project-from-template.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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));
})
);
}
Expand Down