@pi-archimedes/core 2.10.1 → 2.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/settings-io.test.ts +115 -0
- package/src/settings-io.ts +55 -17
package/package.json
CHANGED
package/src/settings-io.test.ts
CHANGED
|
@@ -353,3 +353,118 @@ describe("updateConfig", () => {
|
|
|
353
353
|
expect(data["concurrent.ns"]).toBeDefined(); // siblings survived
|
|
354
354
|
});
|
|
355
355
|
});
|
|
356
|
+
|
|
357
|
+
describe("symlink preservation", () => {
|
|
358
|
+
const settingsLink = () => join(tempDir, "settings.json");
|
|
359
|
+
const target = () => join(tempDir, "dotfiles-settings.json");
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Per-case setup: wipe settings.json (and any stale link/target/tmp), then
|
|
363
|
+
* create a fresh target file pre-seeded with `{ preexisting: "keep-me" }`
|
|
364
|
+
* (plus optional extra keys) and a settings.json **symlink** to it.
|
|
365
|
+
* For dangling cases, `targetName` points the link at a non-existent path
|
|
366
|
+
* instead of the seeded target.
|
|
367
|
+
*/
|
|
368
|
+
function setupSymlink(targetName?: string, extraTargetKeys?: Record<string, unknown>) {
|
|
369
|
+
const link = settingsLink();
|
|
370
|
+
const t = target();
|
|
371
|
+
for (const p of [link, link + ".tmp", t, join(tempDir, "nope.json"), join(tempDir, "nope2.json")]) {
|
|
372
|
+
try { fs.unlinkSync(p); } catch { /* ignore */ }
|
|
373
|
+
}
|
|
374
|
+
if (targetName) {
|
|
375
|
+
fs.symlinkSync(join(tempDir, targetName), link);
|
|
376
|
+
return join(tempDir, targetName);
|
|
377
|
+
}
|
|
378
|
+
fs.writeFileSync(t, JSON.stringify({ preexisting: "keep-me", ...extraTargetKeys }), "utf-8");
|
|
379
|
+
fs.symlinkSync(t, link);
|
|
380
|
+
return t;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
afterEach(() => {
|
|
384
|
+
for (const p of [settingsLink(), settingsLink() + ".tmp", target(), join(tempDir, "nope.json"), join(tempDir, "nope2.json")]) {
|
|
385
|
+
try { fs.unlinkSync(p); } catch { /* ignore */ }
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("saveConfig through symlink preserves the link and updates the destination", () => {
|
|
390
|
+
const t = setupSymlink();
|
|
391
|
+
saveConfig("archimedes.test", { a: 1 });
|
|
392
|
+
expect(fs.lstatSync(settingsLink()).isSymbolicLink()).toBe(true);
|
|
393
|
+
const data = JSON.parse(fs.readFileSync(t, "utf-8"));
|
|
394
|
+
expect(data["archimedes.test"]).toEqual({ a: 1 });
|
|
395
|
+
expect(data["preexisting"]).toBe("keep-me");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("removeConfig through symlink preserves the link", () => {
|
|
399
|
+
const t = setupSymlink();
|
|
400
|
+
saveConfig("archimedes.test", { a: 1 });
|
|
401
|
+
removeConfig("archimedes.test");
|
|
402
|
+
expect(fs.lstatSync(settingsLink()).isSymbolicLink()).toBe(true);
|
|
403
|
+
const data = JSON.parse(fs.readFileSync(t, "utf-8"));
|
|
404
|
+
expect(data["archimedes.test"]).toBeUndefined();
|
|
405
|
+
expect(data["preexisting"]).toBe("keep-me");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("regular file behavior unchanged (no symlink)", () => {
|
|
409
|
+
const link = settingsLink();
|
|
410
|
+
setupSymlink();
|
|
411
|
+
fs.unlinkSync(link);
|
|
412
|
+
fs.writeFileSync(link, "{}", "utf-8");
|
|
413
|
+
saveConfig("archimedes.test", { b: 2 });
|
|
414
|
+
expect(fs.lstatSync(link).isSymbolicLink()).toBe(false);
|
|
415
|
+
const data = JSON.parse(fs.readFileSync(link, "utf-8"));
|
|
416
|
+
expect(data["archimedes.test"]).toEqual({ b: 2 });
|
|
417
|
+
expect(fs.existsSync(link + ".tmp")).toBe(false);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("removeConfig with missing file creates nothing", () => {
|
|
421
|
+
const link = settingsLink();
|
|
422
|
+
setupSymlink();
|
|
423
|
+
fs.unlinkSync(link);
|
|
424
|
+
removeConfig("archimedes.test");
|
|
425
|
+
expect(fs.existsSync(link)).toBe(false);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("dangling symlink: saveConfig recreates the destination, link live again", () => {
|
|
429
|
+
const t = setupSymlink("nope.json");
|
|
430
|
+
saveConfig("archimedes.test", { c: 3 });
|
|
431
|
+
expect(fs.existsSync(t)).toBe(true);
|
|
432
|
+
const data = JSON.parse(fs.readFileSync(t, "utf-8"));
|
|
433
|
+
expect(data["archimedes.test"]).toEqual({ c: 3 });
|
|
434
|
+
expect(fs.lstatSync(settingsLink()).isSymbolicLink()).toBe(true);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("dangling symlink: removeConfig is a no-op, link untouched", () => {
|
|
438
|
+
const t = setupSymlink("nope2.json");
|
|
439
|
+
removeConfig("archimedes.test");
|
|
440
|
+
expect(fs.lstatSync(settingsLink()).isSymbolicLink()).toBe(true);
|
|
441
|
+
expect(fs.existsSync(t)).toBe(false);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("temp-write failure leaves the original file untouched", () => {
|
|
445
|
+
const t = setupSymlink();
|
|
446
|
+
const beforeRaw = fs.readFileSync(t, "utf-8");
|
|
447
|
+
// The temp file is written beside the target (inside tempDir), so make the
|
|
448
|
+
// parent directory read-only: the temp write fails (EACCES), the rename is
|
|
449
|
+
// never attempted, and the live file must survive untouched.
|
|
450
|
+
const mode = fs.statSync(tempDir).mode;
|
|
451
|
+
fs.chmodSync(tempDir, 0o555);
|
|
452
|
+
try {
|
|
453
|
+
expect(() => saveConfig("archimedes.test", { e: 5 })).toThrow();
|
|
454
|
+
expect(fs.readFileSync(t, "utf-8")).toBe(beforeRaw);
|
|
455
|
+
expect(fs.existsSync(t + ".tmp")).toBe(false);
|
|
456
|
+
expect(fs.existsSync(settingsLink() + ".tmp")).toBe(false);
|
|
457
|
+
expect(fs.lstatSync(settingsLink()).isSymbolicLink()).toBe(true);
|
|
458
|
+
} finally {
|
|
459
|
+
fs.chmodSync(tempDir, mode);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("sibling namespaces survive a save", () => {
|
|
464
|
+
const t = setupSymlink(undefined, { "archimedes.other": { keep: true } });
|
|
465
|
+
saveConfig("archimedes.test", { d: 4 });
|
|
466
|
+
const data = JSON.parse(fs.readFileSync(t, "utf-8"));
|
|
467
|
+
expect(data["archimedes.other"]).toEqual({ keep: true });
|
|
468
|
+
expect(data["archimedes.test"]).toEqual({ d: 4 });
|
|
469
|
+
});
|
|
470
|
+
});
|
package/src/settings-io.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync, lstatSync, realpathSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
|
|
@@ -45,21 +45,66 @@ export function loadConfig<T>(
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
48
|
+
* Atomic write that preserves symlinks: if `path` is a symlink to an
|
|
49
|
+
* existing file, the temp file is created beside the resolved destination
|
|
50
|
+
* and the rename targets the destination — leaving the link intact.
|
|
51
|
+
* Regular files and missing files behave exactly as a plain tmp-then-rename
|
|
52
|
+
* would (temp beside `path`, rename onto `path`).
|
|
53
|
+
*
|
|
54
|
+
* Dangling symlink: `realpathSync` throws (ENOENT) — the fallback writes
|
|
55
|
+
* directly through the link, recreating the destination so the link is
|
|
56
|
+
* live again.
|
|
57
|
+
*
|
|
58
|
+
* Failure-mode note (deliberate, differs from the old clobber behavior):
|
|
59
|
+
* a failed temp write (e.g. ENOSPC) cleans up the temp file and re-throws —
|
|
60
|
+
* the live file is left untouched, so no data loss. A failed rename falls
|
|
61
|
+
* back to a direct write-through, since the temp already holds the full
|
|
62
|
+
* content; writeFileSync follows links, so even that non-atomic path never
|
|
63
|
+
* clobbers a link to an existing file. A symlink loop (ELOOP) or an
|
|
64
|
+
* unwritable destination (EACCES) still throw out of this function, where
|
|
65
|
+
* the old code would have "succeeded" by replacing the link with a regular
|
|
66
|
+
* file in the writable agent dir.
|
|
49
67
|
*/
|
|
50
|
-
export function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
export function writeAtomicPreservingSymlinks(path: string, data: string): void {
|
|
69
|
+
let isLink = false;
|
|
70
|
+
try { isLink = lstatSync(path).isSymbolicLink(); } catch { /* missing → plain path */ }
|
|
71
|
+
let target = path;
|
|
72
|
+
if (isLink) {
|
|
73
|
+
try {
|
|
74
|
+
target = realpathSync(path);
|
|
75
|
+
} catch {
|
|
76
|
+
// Dangling symlink: write through the link, recreating the destination.
|
|
77
|
+
writeFileSync(path, data, "utf-8");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const tmpPath = target + ".tmp";
|
|
55
82
|
try {
|
|
56
|
-
|
|
83
|
+
writeFileSync(tmpPath, data, "utf-8");
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// Temp write failed (e.g. ENOSPC) — the live file must survive untouched.
|
|
86
|
+
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
renameSync(tmpPath, target);
|
|
57
91
|
} catch {
|
|
92
|
+
// Rename failed — the temp already holds the full content, so a direct
|
|
93
|
+
// write-through (which follows symlinks) is the fallback.
|
|
58
94
|
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
59
|
-
writeFileSync(
|
|
95
|
+
writeFileSync(target, data, "utf-8");
|
|
60
96
|
}
|
|
61
97
|
}
|
|
62
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Save a config section to settings.json (atomic, symlink-preserving — delegates to writeAtomicPreservingSymlinks).
|
|
101
|
+
*/
|
|
102
|
+
export function saveConfig(namespace: string, config: object): void {
|
|
103
|
+
const full = readSettings();
|
|
104
|
+
full[namespace] = config;
|
|
105
|
+
writeAtomicPreservingSymlinks(SETTINGS_PATH, JSON.stringify(full, null, 2));
|
|
106
|
+
}
|
|
107
|
+
|
|
63
108
|
/**
|
|
64
109
|
* Delete a namespace section from settings.json entirely.
|
|
65
110
|
* No-op if the key is absent (including when the file doesn't exist — the file
|
|
@@ -72,14 +117,7 @@ export function removeConfig(namespace: string): void {
|
|
|
72
117
|
if (!existsSync(SETTINGS_PATH)) return;
|
|
73
118
|
if (!(namespace in full)) return;
|
|
74
119
|
delete full[namespace];
|
|
75
|
-
|
|
76
|
-
writeFileSync(tmpPath, JSON.stringify(full, null, 2), "utf-8");
|
|
77
|
-
try {
|
|
78
|
-
renameSync(tmpPath, SETTINGS_PATH);
|
|
79
|
-
} catch {
|
|
80
|
-
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
81
|
-
writeFileSync(SETTINGS_PATH, JSON.stringify(full, null, 2), "utf-8");
|
|
82
|
-
}
|
|
120
|
+
writeAtomicPreservingSymlinks(SETTINGS_PATH, JSON.stringify(full, null, 2));
|
|
83
121
|
}
|
|
84
122
|
|
|
85
123
|
/**
|