cligentic
← back to catalog
cligentic block

atomic-write

Write files atomically: temp file, fsync, rename. Prevents corruption from crashes or concurrent CLI processes. Windows-aware (unlink before rename). Includes atomicWriteJson convenience.

Install
NPM dependencies

zero deps — pure TS

Block dependencies

standalone

Size

89 LOC

The problem

Your CLI writes config, session tokens, audit logs. If it crashes mid-write (or two terminals run concurrently), the file ends up corrupt or truncated. writeFileSync is not atomic.

The atomic-write block writes to a temp file first, fsyncs to disk, then renames. The file either has the old content or the new content. Never partial.

Quickstart

import { atomicWrite, atomicWriteJson } from "@/cli/foundation/atomic-write";
 
// Write a config file
atomicWrite("~/.myapp/config.toml", tomlContent);
 
// Write a session with restricted permissions
atomicWriteJson("~/.myapp/sessions/current.json", session, { mode: 0o600 });

How it works

  1. Create parent directories if missing
  2. Write to {dir}/.{timestamp}.{random}.tmp
  3. fdatasync to flush to disk (data hits platters, not just OS buffer)
  4. On Windows: unlink target first (rename doesn't overwrite on Windows)
  5. rename temp to target (atomic on POSIX)

If the process crashes at any step before rename, the original file is untouched. The temp file is orphaned but harmless.

API

export function atomicWrite(
  filePath: string,
  content: string | Buffer,
  options?: { mode?: number; encoding?: BufferEncoding },
): void;
 
export function atomicWriteJson(
  filePath: string,
  value: unknown,
  options?: { mode?: number },
): void;

atomicWriteJson serializes with 2-space indentation + trailing newline. The most common pattern for CLI config/session files.

When to use it

  • Config files (config.toml, config.json)
  • Session tokens (sessions/current.json, mode 0o600)
  • Killswitch file (KILLSWITCH)
  • Ledger entries (idempotency fingerprints)
  • Any file that two concurrent CLI invocations might race on
Source

The full file, 89 lines.

This is the exact TypeScript that lands in your project when you run the install command. Read it, copy it, edit it, own it. cligentic never touches it again.

src/cli/foundation/atomic-write.ts
// cligentic block: atomic-write
//
// Write files atomically: write to a temp file, fsync, rename. Prevents
// corruption from crashes, power loss, or concurrent CLI processes.
//
// Design rules:
//   1. Write to a .tmp sibling file first.
//   2. fsync before rename to ensure data hits disk.
//   3. rename() is atomic on POSIX. On Windows, unlink target first.
//   4. Enforce file mode (default 0o644, 0o600 for secrets).
//   5. Create parent directories if they don't exist.
//
// Usage:
//   import { atomicWrite, atomicWriteJson } from "./foundation/atomic-write";
//
//   atomicWrite("~/.myapp/config.toml", tomlString);
//   atomicWriteJson("~/.myapp/session.json", session, { mode: 0o600 });

import {
  closeSync,
  existsSync,
  fdatasyncSync,
  mkdirSync,
  openSync,
  renameSync,
  unlinkSync,
  writeSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { platform } from "node:os";

export type WriteOptions = {
  /** File permissions. Default 0o644. Use 0o600 for secrets. */
  mode?: number;
  /** Encoding for string content. Default "utf8". */
  encoding?: BufferEncoding;
};

/**
 * Writes content to a file atomically. The file either contains the old
 * content or the new content, never a partial write.
 *
 * Steps:
 *   1. Create parent directories if missing
 *   2. Write to {path}.tmp
 *   3. fsync to flush to disk
 *   4. On Windows: unlink target if it exists (rename doesn't overwrite)
 *   5. rename .tmp to target (atomic on POSIX)
 */
export function atomicWrite(
  filePath: string,
  content: string | Buffer,
  options: WriteOptions = {},
): void {
  const { mode = 0o644, encoding = "utf8" } = options;
  const dir = dirname(filePath);
  const tmpPath = join(dir, `.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`);

  mkdirSync(dir, { recursive: true });

  const data = typeof content === "string" ? Buffer.from(content, encoding) : content;
  const fd = openSync(tmpPath, "w", mode);
  try {
    writeSync(fd, data);
    fdatasyncSync(fd);
  } finally {
    closeSync(fd);
  }

  // Windows rename() fails if target exists. Unlink first.
  if (platform() === "win32" && existsSync(filePath)) {
    unlinkSync(filePath);
  }

  renameSync(tmpPath, filePath);
}

/**
 * Convenience: atomically write a JSON-serializable value with 2-space
 * indentation and a trailing newline. Common pattern for config/session files.
 */
export function atomicWriteJson(
  filePath: string,
  value: unknown,
  options: WriteOptions = {},
): void {
  atomicWrite(filePath, `${JSON.stringify(value, null, 2)}\n`, options);
}