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
39 changes: 31 additions & 8 deletions LifeOS/install/LIFEOS/PULSE/lib/atomic-write.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
import { writeFileSync, renameSync, mkdirSync } from "node:fs";
import { writeFileSync, renameSync, mkdirSync, rmSync } from "node:fs";
import { dirname } from "node:path";

export function atomicWriteJSON(filePath: string, data: unknown): void {
/**
* Write to a uniquely-named sibling temp file, then rename it over the target.
*
* `renameSync` within a filesystem is atomic, so a reader either sees the whole
* previous file or the whole new one — never a truncated prefix. This matters
* for any file whose loss breaks the next launch (settings.json et al): a
* truncate-then-write leaves invalid JSON behind if the process is killed
* mid-write (SessionStart hook timeouts kill the process group) or the disk
* fills. Here both of those fail on the temp file and the target is untouched.
*
* On failure the temp file is removed, so a failed write leaves nothing behind.
* A hard kill can still strand a temp file, but the name (`<target>.tmp.<pid>.<ms>`)
* never matches the target's extension, so it can't be read back as real config.
*/
function atomicWrite(filePath: string, content: string): void {
mkdirSync(dirname(filePath), { recursive: true });
const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}`;
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { encoding: "utf8" });
renameSync(tmp, filePath);
try {
writeFileSync(tmp, content, { encoding: "utf8" });
renameSync(tmp, filePath);
} catch (error) {
// Best-effort cleanup — the original write failure is what the caller needs.
try {
rmSync(tmp, { force: true });
} catch {}
throw error;
}
}

export function atomicWriteJSON(filePath: string, data: unknown): void {
atomicWrite(filePath, JSON.stringify(data, null, 2) + "\n");
}

export function atomicWriteText(filePath: string, content: string): void {
mkdirSync(dirname(filePath), { recursive: true });
const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}`;
writeFileSync(tmp, content, { encoding: "utf8" });
renameSync(tmp, filePath);
atomicWrite(filePath, content);
}
10 changes: 7 additions & 3 deletions LifeOS/install/LIFEOS/TOOLS/MergeSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
* Merge annotations are consumed and never emitted in the output.
*/

import { readFile, writeFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { atomicWriteText } from "../PULSE/lib/atomic-write";

/**
* Snapshot of the last merge output, written alongside every successful
Expand Down Expand Up @@ -545,8 +546,11 @@ async function runCli(argv: string[]): Promise<number> {
const outputJson = validateRoundTripOrThrow(merged);

if (options.mode.kind === "output") {
// Atomic: this runs async at SessionStart under a 15s hook timeout, so a
// kill mid-write must not leave settings.json truncated — that drops every
// hook, permission rule and env var on the next launch.
try {
await writeFile(options.mode.path, outputJson, "utf8");
atomicWriteText(options.mode.path, outputJson);
} catch (error) {
throw new Error(
`Failed to write merged settings to ${options.mode.path}: ${formatErrorMessage(error)}`,
Expand All @@ -557,7 +561,7 @@ async function runCli(argv: string[]): Promise<number> {
// Best-effort: a failed snapshot write must never fail the merge itself
// (backport skips safely when the snapshot is missing/stale).
try {
await writeFile(MERGE_SNAPSHOT_PATH, outputJson, "utf8");
atomicWriteText(MERGE_SNAPSHOT_PATH, outputJson);
} catch {
process.stderr.write(`⚠️ could not write merge snapshot to ${MERGE_SNAPSHOT_PATH}\n`);
}
Expand Down
6 changes: 4 additions & 2 deletions LifeOS/install/LIFEOS/TOOLS/SettingsBackport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@
* bun SettingsBackport.ts --dry-run # report drift, write nothing
*/

import { writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { mergeSettings, deepEqual, parseJsonFileOrThrow, MERGE_SNAPSHOT_PATH } from "./MergeSettings";
import { atomicWriteText } from "../PULSE/lib/atomic-write";

const CLAUDE_DIR = path.join(os.homedir(), ".claude");
const SYSTEM_PATH = path.join(CLAUDE_DIR, "settings.system.json");
Expand Down Expand Up @@ -158,7 +158,9 @@ async function run(dryRun: boolean): Promise<number> {
for (const d of changes) {
deepSet(user, d.segments, d.value);
}
await writeFile(USER_PATH, `${JSON.stringify(user, null, 2)}\n`, "utf8");
// Atomic: settings.user.json is a merge SOURCE — truncating it takes out the
// next MergeSettings run too, not just this one.
atomicWriteText(USER_PATH, `${JSON.stringify(user, null, 2)}\n`);
console.log(`wrote ${changes.length} change(s) to ${USER_PATH}`);

// Verify per backported path: each value must survive the merge (overlay
Expand Down
5 changes: 3 additions & 2 deletions LifeOS/install/LIFEOS/TOOLS/SyncIdentityToSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@
* bun LIFEOS/TOOLS/SyncIdentityToSettings.ts # silent unless changed
* bun LIFEOS/TOOLS/SyncIdentityToSettings.ts --verbose
*/
import { readFileSync, writeFileSync } from 'fs';
import { readFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { paiUserDir } from './LifeosConfig';
import { atomicWriteText } from '../PULSE/lib/atomic-write';

const HOME = homedir();
const PRINCIPAL_PATH = join(paiUserDir(), 'PRINCIPAL/PRINCIPAL_IDENTITY.md');
Expand Down Expand Up @@ -87,7 +88,7 @@ if (!changed) {

settings.preferences = merged;
const out = JSON.stringify(settings, null, 2) + '\n';
writeFileSync(SETTINGS_PATH, out);
atomicWriteText(SETTINGS_PATH, out);

const summary = Object.entries(camelPrefs)
.map(([k, v]) => `${k}=${v}`)
Expand Down
5 changes: 3 additions & 2 deletions LifeOS/install/hooks/lib/work-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ for (const __k of ["LIFEOS_DIR", "LIFEOS_CONFIG_DIR", "PROJECTS_DIR"]) {
*
* Zero deps, zero throws — config breakage degrades to disabled state.
*/
import { existsSync, readFileSync, writeFileSync, chmodSync } from "fs";
import { existsSync, readFileSync, chmodSync } from "fs";
import { join } from "path";
import { atomicWriteText } from "../../LIFEOS/PULSE/lib/atomic-write";

// Normalize env path vars that Claude Code injects without shell expansion (LifeOS#1404)
for (const k of ["LIFEOS_DIR", "LIFEOS_CONFIG_DIR", "PROJECTS_DIR"]) {
Expand Down Expand Up @@ -168,7 +169,7 @@ export function loadWorkConfig(): WorkConfig {
`gh repo view ${parsed.repo} --json visibility,isPrivate`,
},
};
writeFileSync(REPO_JSON_PATH, JSON.stringify(updated, null, 2) + "\n");
atomicWriteText(REPO_JSON_PATH, JSON.stringify(updated, null, 2) + "\n");
chmodSync(REPO_JSON_PATH, 0o600);
parsed = updated;
revalidatedThisLoad = true;
Expand Down
7 changes: 4 additions & 3 deletions LifeOS/install/skills/LifeOS/Tools/DeployComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@
*/

import { execFileSync } from "node:child_process";
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { copyMissing, detectDevTree } from "./InstallEngine";
import { atomicWriteText } from "../../../LIFEOS/PULSE/lib/atomic-write";

// Enhancement components are the à-la-carte half of setup. The "LifeOS Core"
// (skills + system prompt + base settings + CLAUDE.md) is installed by Setup's
Expand Down Expand Up @@ -190,7 +191,7 @@ function deployStatusline(ctx: Ctx): ComponentResult {
if (!alreadyWired) {
backup(settingsPath);
settings.statusLine = { type: "command", command, refreshInterval: 1 };
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
atomicWriteText(settingsPath, JSON.stringify(settings, null, 2) + "\n");
}
r.applied = !alreadyWired;
const reread = JSON.parse(readFileSync(settingsPath, "utf-8"));
Expand Down Expand Up @@ -239,7 +240,7 @@ function deploySettingsKey(component: Component, key: string, ctx: Ctx): Compone
if (!already) {
backup(settingsPath);
settings[key] = enh[key];
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
atomicWriteText(settingsPath, JSON.stringify(settings, null, 2) + "\n");
}
r.applied = !already;
const reread = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, "utf-8")) : {};
Expand Down
5 changes: 3 additions & 2 deletions LifeOS/install/skills/LifeOS/Tools/InstallHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
* (dry-run by default — reports added/skipped without writing)
*/

import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { detectDevTree, mergeHooks } from "./InstallEngine";
import { atomicWriteText } from "../../../LIFEOS/PULSE/lib/atomic-write";

interface Args { configRoot: string; skillRoot: string; apply: boolean; allowDev: boolean; }

Expand Down Expand Up @@ -93,7 +94,7 @@ function main(): void {
copyFileSync(settingsPath, backup);
}
settings.hooks = merged;
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
atomicWriteText(settingsPath, JSON.stringify(settings, null, 2) + "\n");

// Deploy the hook scripts next to the merged settings (RC2): recursive copy of
// the whole payload hooks/ tree (*.hook.ts|sh + lib/**) into <configRoot>/hooks/.
Expand Down
7 changes: 4 additions & 3 deletions LifeOS/install/skills/LifeOS/Tools/InstallSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
* bun InstallSettings.ts [--config-root <dir>] [--skill-root <dir>] [--apply] [--allow-dev]
*/

import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { copyFileSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { detectDevTree } from "./InstallEngine";
import { atomicWriteText } from "../../../LIFEOS/PULSE/lib/atomic-write";

interface Args { configRoot: string; skillRoot: string; apply: boolean; allowDev: boolean; }

Expand Down Expand Up @@ -86,7 +87,7 @@ const report: Record<string, unknown> = { ok: true, apply: args.apply, target: t
if (!existsSync(targetPath)) {
report.mode = "create";
report.topLevelKeys = Object.keys(template).length;
if (args.apply) writeFileSync(targetPath, JSON.stringify(template, null, 2) + "\n");
if (args.apply) atomicWriteText(targetPath, JSON.stringify(template, null, 2) + "\n");
} else {
const current = JSON.parse(readFileSync(targetPath, "utf8")) as Record<string, unknown>;
const addedKeys: string[] = [];
Expand All @@ -105,7 +106,7 @@ if (!existsSync(targetPath)) {
report.addedEnv = addedEnv;
if (args.apply && (addedKeys.length || addedEnv.length)) {
copyFileSync(targetPath, targetPath + ".backup-" + new Date().toISOString().replace(/[:.]/g, "-"));
writeFileSync(targetPath, JSON.stringify(current, null, 2) + "\n");
atomicWriteText(targetPath, JSON.stringify(current, null, 2) + "\n");
} else if (args.apply) {
report.note = "nothing to add — no write, no backup";
}
Expand Down
162 changes: 162 additions & 0 deletions LifeOS/install/test/pulse/lib/atomic-write.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* Atomicity contract for LIFEOS/PULSE/lib/atomic-write.ts.
*
* The property under test: a write either lands whole or not at all. Every
* settings writer (MergeSettings, SettingsBackport, SyncIdentityToSettings,
* the installers) routes through this module, so an interrupted or failed
* write must leave the previous file byte-identical and still parseable —
* never the truncated prefix a plain writeFileSync leaves behind.
*/
import { describe, expect, test } from "bun:test";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";

import { atomicWriteJSON, atomicWriteText } from "../../../LIFEOS/PULSE/lib/atomic-write";

/** Disposable scratch dir — doctrine forbids touching a real config root. */
function scratch(): { path: string } & Disposable {
const path = mkdtempSync(join(tmpdir(), "lifeos-atomic-write-"));
return {
path,
[Symbol.dispose]() {
rmSync(path, { recursive: true, force: true });
},
};
}

/** Settings-shaped payload, big enough that a partial write would be visible. */
const PREVIOUS = JSON.stringify(
{ hooks: { SessionStart: ["a".repeat(4096)] }, permissions: { allow: [] } },
null,
2,
) + "\n";

describe("atomicWriteText", () => {
test("writes the exact content it was given", () => {
using dir = scratch();
const target = join(dir.path, "settings.json");

atomicWriteText(target, PREVIOUS);

expect(readFileSync(target, "utf8")).toBe(PREVIOUS);
});

test("replaces an existing file with the exact new content", () => {
using dir = scratch();
const target = join(dir.path, "settings.json");
writeFileSync(target, "{}\n");

atomicWriteText(target, PREVIOUS);

expect(readFileSync(target, "utf8")).toBe(PREVIOUS);
});

test("a failed write leaves the previous file byte-identical and valid JSON", () => {
using dir = scratch();
const target = join(dir.path, "settings.json");
writeFileSync(target, PREVIOUS);

// Read-only directory => creating the temp file fails, standing in for the
// ENOSPC / mid-write-kill class. A truncate-then-write does NOT fail here
// (write permission on the file is enough) and destroys the target — which
// is exactly the property this asserts against.
let threw = false;
chmodSync(dir.path, 0o500);
try {
atomicWriteText(target, '{"replacement":true}\n');
} catch {
threw = true;
} finally {
chmodSync(dir.path, 0o700);
}

expect(readFileSync(target, "utf8")).toBe(PREVIOUS);
expect(() => JSON.parse(readFileSync(target, "utf8"))).not.toThrow();
expect(threw).toBe(true); // the failure surfaces rather than being swallowed
});

test("a failed write leaves no temp file behind", () => {
using dir = scratch();
const target = join(dir.path, "settings.json");
writeFileSync(target, PREVIOUS);

// Target path occupied by a directory => the rename fails after the temp
// file was already created, exercising the cleanup path.
const blocked = join(dir.path, "blocked.json");
mkdirSync(join(blocked, "child"), { recursive: true });

expect(() => atomicWriteText(blocked, PREVIOUS)).toThrow();
expect(readdirSync(dir.path).filter((n) => n.includes(".tmp."))).toEqual([]);
});

test("a successful write leaves no temp file behind", () => {
using dir = scratch();

atomicWriteText(join(dir.path, "settings.json"), PREVIOUS);

expect(readdirSync(dir.path)).toEqual(["settings.json"]);
});

test("Anti: no temp file is ever named so it could be read back as config", () => {
using dir = scratch();
const blocked = join(dir.path, "settings.json");
mkdirSync(blocked, { recursive: true });

// Whatever the rename failure strands, it must not look like the target.
expect(() => atomicWriteText(blocked, PREVIOUS)).toThrow();
for (const name of readdirSync(dir.path)) {
if (name === "settings.json") continue;
expect(name.endsWith(".json")).toBe(false);
}
});

test("creates missing parent directories", () => {
using dir = scratch();
const target = join(dir.path, "nested", "deeper", "settings.json");

atomicWriteText(target, PREVIOUS);

expect(existsSync(target)).toBe(true);
});
});

describe("atomicWriteJSON", () => {
test("serializes with two-space indent and a trailing newline", () => {
using dir = scratch();
const target = join(dir.path, "state.json");

atomicWriteJSON(target, { a: 1, b: [2, 3] });

expect(readFileSync(target, "utf8")).toBe('{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}\n');
});

test("a failed write leaves the previous file byte-identical", () => {
using dir = scratch();
const target = join(dir.path, "state.json");
const previous = '{\n "keep": true\n}\n';
writeFileSync(target, previous);

let threw = false;
chmodSync(dir.path, 0o500);
try {
atomicWriteJSON(target, { keep: false });
} catch {
threw = true;
} finally {
chmodSync(dir.path, 0o700);
}

expect(readFileSync(target, "utf8")).toBe(previous);
expect(threw).toBe(true);
});
});
Loading