@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/core",
3
- "version": "2.10.1",
3
+ "version": "2.10.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -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
+ });
@@ -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
- * Save a config section to settings.json (atomic: write to .tmp then rename).
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 saveConfig(namespace: string, config: object): void {
51
- const full = readSettings();
52
- full[namespace] = config;
53
- const tmpPath = SETTINGS_PATH + ".tmp";
54
- writeFileSync(tmpPath, JSON.stringify(full, null, 2), "utf-8");
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
- renameSync(tmpPath, SETTINGS_PATH);
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(SETTINGS_PATH, JSON.stringify(full, null, 2), "utf-8");
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
- const tmpPath = SETTINGS_PATH + ".tmp";
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
  /**