skillrepo 4.8.0 → 4.8.2

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.
Files changed (43) hide show
  1. package/bin/skillrepo.mjs +6 -3
  2. package/package.json +1 -1
  3. package/src/commands/add.mjs +2 -1
  4. package/src/commands/init.mjs +15 -5
  5. package/src/lib/http.mjs +25 -11
  6. package/src/test/commands/add.test.mjs +79 -2
  7. package/src/test/commands/get.test.mjs +131 -2
  8. package/src/test/commands/init-session-sync.test.mjs +724 -0
  9. package/src/test/commands/init.test.mjs +159 -2
  10. package/src/test/commands/list.test.mjs +573 -1
  11. package/src/test/commands/publish.test.mjs +136 -3
  12. package/src/test/commands/push.test.mjs +280 -1
  13. package/src/test/commands/remove.test.mjs +221 -2
  14. package/src/test/commands/search.test.mjs +203 -1
  15. package/src/test/commands/session-sync.test.mjs +227 -1
  16. package/src/test/commands/uninstall.test.mjs +216 -0
  17. package/src/test/commands/update.test.mjs +218 -0
  18. package/src/test/dispatcher.test.mjs +103 -2
  19. package/src/test/e2e/advertised-surface.test.mjs +207 -0
  20. package/src/test/e2e/cli-commands.test.mjs +1 -1
  21. package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
  22. package/src/test/e2e/update-check-suppression.test.mjs +135 -0
  23. package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
  24. package/src/test/lib/browser-open.test.mjs +43 -0
  25. package/src/test/lib/config.test.mjs +87 -0
  26. package/src/test/lib/crypto-shas.test.mjs +17 -0
  27. package/src/test/lib/file-write.test.mjs +244 -0
  28. package/src/test/lib/fs-utils.test.mjs +259 -0
  29. package/src/test/lib/global-install.test.mjs +134 -0
  30. package/src/test/lib/http-timeout.test.mjs +114 -0
  31. package/src/test/lib/http.test.mjs +616 -1
  32. package/src/test/lib/mcp-merge.test.mjs +157 -0
  33. package/src/test/lib/npm-update-check.test.mjs +180 -0
  34. package/src/test/lib/placement-walk.test.mjs +132 -0
  35. package/src/test/lib/skill-walk.test.mjs +39 -1
  36. package/src/test/lib/sync.test.mjs +139 -5
  37. package/src/test/lib/telemetry.test.mjs +34 -0
  38. package/src/test/mergers/claude-mcp.test.mjs +30 -0
  39. package/src/test/mergers/cursor-mcp.test.mjs +115 -0
  40. package/src/test/mergers/env-local.test.mjs +126 -0
  41. package/src/test/mergers/vscode-mcp.test.mjs +177 -0
  42. package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
  43. package/src/test/resolve-key.test.mjs +33 -0
@@ -37,6 +37,7 @@ import {
37
37
  isValidSkillName,
38
38
  readFrontmatterName,
39
39
  resolvePlacementDir,
40
+ describePlacementTarget,
40
41
  placementTargetsFor,
41
42
  writeSkillDir,
42
43
  removeSkillDir,
@@ -267,6 +268,42 @@ describe("readFrontmatterName", () => {
267
268
  const files = [{ path: "SKILL.md", content: "---\ndescription: foo\n---\nBody" }];
268
269
  assert.equal(readFrontmatterName(files), null);
269
270
  });
271
+
272
+ it("returns null when the frontmatter block closes before any name line (closing --- branch)", () => {
273
+ // Frontmatter opens and immediately closes with `---` on the very
274
+ // next line — the loop hits the closing-fence branch and returns
275
+ // null without ever seeing a `name:` line.
276
+ const files = [{ path: "SKILL.md", content: "---\n---\nBody only, no fields" }];
277
+ assert.equal(readFrontmatterName(files), null);
278
+ });
279
+
280
+ it("returns null when SKILL.md content is not a string", () => {
281
+ // A SKILL.md entry whose content isn't a string (defensive guard).
282
+ const files = [{ path: "SKILL.md", content: Buffer.from("---\nname: x\n---") }];
283
+ assert.equal(readFrontmatterName(files), null);
284
+ });
285
+ });
286
+
287
+ // ── describePlacementTarget ─────────────────────────────────────────────
288
+
289
+ describe("describePlacementTarget", () => {
290
+ it("maps every known target to its human-readable path label", () => {
291
+ assert.equal(describePlacementTarget("claudeProject"), ".claude/skills/");
292
+ assert.equal(describePlacementTarget("claudeGlobal"), "~/.claude/skills/");
293
+ assert.equal(describePlacementTarget("agentsProject"), ".agents/skills/");
294
+ assert.equal(describePlacementTarget("agentsGlobal"), "~/.agents/skills/");
295
+ assert.equal(
296
+ describePlacementTarget("windsurfGlobal"),
297
+ "~/.codeium/windsurf/skills/",
298
+ );
299
+ });
300
+
301
+ it("falls back to echoing an unknown target (default branch)", () => {
302
+ // Defensive default arm: an unrecognized target string is echoed
303
+ // verbatim rather than throwing, so a future target that forgets a
304
+ // label still renders something.
305
+ assert.equal(describePlacementTarget("someFutureTarget"), "someFutureTarget");
306
+ });
270
307
  });
271
308
 
272
309
  // ── resolvePlacementDir + placementTargetsFor ───────────────────────────
@@ -446,6 +483,39 @@ describe("writeSkillDir — validation", () => {
446
483
  (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
447
484
  );
448
485
  });
486
+
487
+ it("rejects a null/non-object skill payload", () => {
488
+ // The `!skill || typeof skill !== "object"` guard at the top of
489
+ // validateSkill — exercised via the public writeSkillDir entry.
490
+ assert.throws(
491
+ () => writeSkillDir(null, { vendors: ["claudeCode"] }),
492
+ (err) =>
493
+ err instanceof CliError &&
494
+ err.exitCode === EXIT_VALIDATION &&
495
+ /missing or not an object/.test(err.message),
496
+ );
497
+ assert.throws(
498
+ () => writeSkillDir("not-an-object", { vendors: ["claudeCode"] }),
499
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
500
+ );
501
+ });
502
+
503
+ it("rejects a skill whose file entry has no string path", () => {
504
+ // The per-file `!file || typeof file.path !== "string"` guard.
505
+ const skill = minimalSkill({
506
+ files: [
507
+ ...minimalSkill().files,
508
+ { content: "no path here" },
509
+ ],
510
+ });
511
+ assert.throws(
512
+ () => writeSkillDir(skill, { vendors: ["claudeCode"] }),
513
+ (err) =>
514
+ err instanceof CliError &&
515
+ err.exitCode === EXIT_VALIDATION &&
516
+ /has a file with no path/.test(err.message),
517
+ );
518
+ });
449
519
  });
450
520
 
451
521
  // ── writeSkillDir happy path + path preservation (Defect A fix) ─────────
@@ -556,6 +626,23 @@ describe("writeSkillDir — happy path", () => {
556
626
  assert.match(gi, /\.env/);
557
627
  assert.match(gi, /\.agents\/skills\//);
558
628
  });
629
+
630
+ it("inserts a newline when the existing .gitignore has no trailing newline", () => {
631
+ // The `existing.endsWith("\n") ? "" : "\n"` ternary: when the file
632
+ // does NOT end in a newline, the appended block must be preceded by
633
+ // one so the existing last line and the SkillRepo header don't
634
+ // collapse onto a single line.
635
+ writeFileSync(join(process.cwd(), ".gitignore"), "node_modules"); // no trailing \n
636
+ writeSkillDir(minimalSkill(), { vendors: ["cursor"] });
637
+ const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
638
+ // The original entry stays on its own line — not "node_modules# SkillRepo…".
639
+ assert.match(gi, /node_modules\n/);
640
+ assert.ok(
641
+ !/node_modules#/.test(gi),
642
+ "the no-trailing-newline branch must insert a separator newline",
643
+ );
644
+ assert.match(gi, /\.agents\/skills\//);
645
+ });
559
646
  });
560
647
 
561
648
  // ── writeSkillDir update path (atomic) ──────────────────────────────────
@@ -649,6 +736,53 @@ describe("writeSkillDir — update path", () => {
649
736
  assert.ok(!existsSync(oldDir));
650
737
  });
651
738
 
739
+ it(
740
+ "throws diskError when the .tmp/ directory cannot be created (read-only skills root)",
741
+ { skip: process.platform === "win32" || process.getuid?.() === 0 },
742
+ () => {
743
+ // Covers the `catch` around mkdirSync(tmpDir): the skills root
744
+ // exists but is read-only, so creating <skill>.tmp/ inside it
745
+ // fails with EACCES. writeSkillToDir must surface a typed
746
+ // diskError (EXIT_DISK = 3), wrapped by writeSkillDir's per-target
747
+ // catch into a "Failed to write" diskError (still exit code 3).
748
+ const root = join(process.cwd(), ".claude", "skills");
749
+ mkdirSync(root, { recursive: true });
750
+ chmodSync(root, 0o555); // read + execute, no write
751
+ try {
752
+ assert.throws(
753
+ () => writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] }),
754
+ (err) => err instanceof CliError && err.exitCode === 3,
755
+ );
756
+ } finally {
757
+ chmodSync(root, 0o755);
758
+ }
759
+ },
760
+ );
761
+
762
+ it(
763
+ "throws diskError when a stale .tmp/ cannot be removed (read-only skills root)",
764
+ { skip: process.platform === "win32" || process.getuid?.() === 0 },
765
+ () => {
766
+ // Covers the `catch` around rmSync of a pre-existing stale .tmp/:
767
+ // a leftover <skill>.tmp/ exists from a prior crash, but the
768
+ // skills root is read-only so the cleanup rmSync is denied. The
769
+ // function surfaces a "Cannot remove stale" diskError.
770
+ const root = join(process.cwd(), ".claude", "skills");
771
+ const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
772
+ mkdirSync(`${targetDir}.tmp`, { recursive: true });
773
+ writeFileSync(`${targetDir}.tmp/leftover.txt`, "garbage");
774
+ chmodSync(root, 0o555);
775
+ try {
776
+ assert.throws(
777
+ () => writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] }),
778
+ (err) => err instanceof CliError && err.exitCode === 3,
779
+ );
780
+ } finally {
781
+ chmodSync(root, 0o755);
782
+ }
783
+ },
784
+ );
785
+
652
786
  it("throws diskError when the .tmp path is occupied by a regular file", { skip: process.platform === "win32" }, () => {
653
787
  const skill = minimalSkill();
654
788
  const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
@@ -786,6 +920,22 @@ describe("ensureFallbackGitignore — error surfacing", () => {
786
920
  }
787
921
  });
788
922
 
923
+ it("throws diskError when .gitignore cannot be READ (it is a directory → EISDIR)", () => {
924
+ // The read-error branch in ensureAgentsGitignore: readFileSafe
925
+ // re-throws any non-ENOENT error. Making `.gitignore` a DIRECTORY
926
+ // causes readFileSync to throw EISDIR, which surfaces as a typed
927
+ // diskError with a "Cannot read" message before any write is
928
+ // attempted. Cross-platform (no chmod needed).
929
+ mkdirSync(join(process.cwd(), ".gitignore"), { recursive: true });
930
+ assert.throws(
931
+ () => writeSkillDir(minimalSkill(), { vendors: ["cursor"] }),
932
+ (err) =>
933
+ err instanceof CliError &&
934
+ err.exitCode === 3 &&
935
+ /Cannot read/.test(err.message),
936
+ );
937
+ });
938
+
789
939
  it("read-only .gitignore aborts ALL writes (no partial state) when both claudeCode + cursor requested", { skip: process.platform === "win32" || process.getuid?.() === 0 }, () => {
790
940
  // Architect re-review found that ensureFallbackGitignore() throwing
791
941
  // mid-loop would leave a successful claudeCode write + a thrown
@@ -877,6 +1027,27 @@ describe("removeSkillDir", () => {
877
1027
  (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
878
1028
  );
879
1029
  });
1030
+
1031
+ it(
1032
+ "throws diskError when the skill directory cannot be removed (rmSync fails)",
1033
+ { skip: process.platform === "win32" || process.getuid?.() === 0 },
1034
+ () => {
1035
+ // Write a skill, then make its PARENT (the skills root) read-only
1036
+ // so rmSync of the skill dir fails with EACCES. removeSkillDir must
1037
+ // surface a typed diskError (EXIT_DISK = 3), not a raw OS error.
1038
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
1039
+ const root = join(process.cwd(), ".claude", "skills");
1040
+ chmodSync(root, 0o555); // read + execute, no write → unlink denied
1041
+ try {
1042
+ assert.throws(
1043
+ () => removeSkillDir("pdf-helper", { vendors: ["claudeCode"] }),
1044
+ (err) => err instanceof CliError && err.exitCode === 3,
1045
+ );
1046
+ } finally {
1047
+ chmodSync(root, 0o755);
1048
+ }
1049
+ },
1050
+ );
880
1051
  });
881
1052
 
882
1053
  // ── cleanupOrphans ──────────────────────────────────────────────────────
@@ -947,6 +1118,79 @@ describe("cleanupOrphans", () => {
947
1118
  assert.deepEqual(result.cleaned, []);
948
1119
  });
949
1120
 
1121
+ it("sweeps every global root under --global with no vendors (global-only branch)", () => {
1122
+ // The `else if (options.global)` arm: bare --global with no vendors
1123
+ // sweeps the known GLOBAL roots. Seed a cleanable .old/ in the
1124
+ // claudeGlobal root and confirm it's swept.
1125
+ const globalRoot = join(process.env.HOME, ".claude", "skills");
1126
+ mkdirSync(globalRoot, { recursive: true });
1127
+ mkdirSync(join(globalRoot, "ghost.old"));
1128
+
1129
+ const result = cleanupOrphans({ global: true });
1130
+ assert.equal(result.cleaned.length, 1);
1131
+ assert.ok(!existsSync(join(globalRoot, "ghost.old")));
1132
+ });
1133
+
1134
+ it("ignores a .tmp/.old name that is a regular FILE, not a directory", () => {
1135
+ // The `if (!st.isDirectory()) continue` guard: a plain file whose
1136
+ // name happens to end in .old must NOT be removed — only orphan
1137
+ // DIRECTORIES are cleanable.
1138
+ const root = join(process.cwd(), ".claude", "skills");
1139
+ mkdirSync(root, { recursive: true });
1140
+ writeFileSync(join(root, "decoy.old"), "i am a file, not a dir");
1141
+
1142
+ const result = cleanupOrphans({ vendors: ["claudeCode"] });
1143
+ assert.deepEqual(result.cleaned, []);
1144
+ assert.ok(existsSync(join(root, "decoy.old")), "the file must be left alone");
1145
+ });
1146
+
1147
+ it(
1148
+ "skips an unreadable root without throwing (readdir EACCES is swallowed)",
1149
+ { skip: process.platform === "win32" || process.getuid?.() === 0 },
1150
+ () => {
1151
+ // The `catch { continue; }` around readdirSync(root): a root dir
1152
+ // that exists but can't be listed is skipped rather than crashing
1153
+ // the cleanup sweep.
1154
+ const root = join(process.cwd(), ".claude", "skills");
1155
+ mkdirSync(root, { recursive: true });
1156
+ mkdirSync(join(root, "ghost.old"));
1157
+ chmodSync(root, 0o000);
1158
+ try {
1159
+ const result = cleanupOrphans({ vendors: ["claudeCode"] });
1160
+ // Could not enumerate the root → nothing cleaned, no throw.
1161
+ assert.deepEqual(result.cleaned, []);
1162
+ } finally {
1163
+ chmodSync(root, 0o755);
1164
+ }
1165
+ },
1166
+ );
1167
+
1168
+ it(
1169
+ "best-effort swallows an rmSync failure on an orphan (no throw, not counted)",
1170
+ { skip: process.platform === "win32" || process.getuid?.() === 0 },
1171
+ () => {
1172
+ // The `catch { /* best-effort */ }` around rmSync(orphanPath):
1173
+ // the root is readable+searchable (so readdir + statSync succeed
1174
+ // and the orphan is identified as cleanable) but NOT writable, so
1175
+ // the actual rmSync(orphanPath) is denied. cleanupOrphans must
1176
+ // swallow that, report nothing cleaned, and not throw.
1177
+ const root = join(process.cwd(), ".claude", "skills");
1178
+ mkdirSync(root, { recursive: true });
1179
+ // .old/ has no preservation invariant → it IS selected for removal.
1180
+ mkdirSync(join(root, "ghost.old"));
1181
+ // 0o555 = read + execute (readdir + statSync work), no write
1182
+ // (removing the child entry is denied with EACCES).
1183
+ chmodSync(root, 0o555);
1184
+ try {
1185
+ const result = cleanupOrphans({ vendors: ["claudeCode"] });
1186
+ assert.deepEqual(result.cleaned, [], "rmSync failure must not be counted as cleaned");
1187
+ assert.ok(existsSync(join(root, "ghost.old")), "the orphan survives the failed rmSync");
1188
+ } finally {
1189
+ chmodSync(root, 0o755);
1190
+ }
1191
+ },
1192
+ );
1193
+
950
1194
  // QA cross-PR review (#1252): the permutation where BOTH .tmp/ and
951
1195
  // .old/ exist but the live target is MISSING was uncovered. This
952
1196
  // tests the divergence between the two recovery invariants:
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Unit tests for src/lib/fs-utils.mjs.
3
+ *
4
+ * Covers the three exported helpers:
5
+ * - readFileSafe — UTF-8 read, null on ENOENT, re-throw otherwise
6
+ * - writeFileSafe — write + recursive parent mkdir + dir-as-file guard
7
+ * - pathExists — thin existsSync wrapper
8
+ * - writeFileAtomic — temp-file + rename dance, optional chmod, dir guard,
9
+ * and the cleanup-on-rename-failure path
10
+ *
11
+ * Every test runs in a `mkdtempSync` sandbox so nothing touches the
12
+ * developer's real filesystem. chmod- and permission-dependent tests are
13
+ * guarded with `process.platform === "win32"` (Windows ACLs don't map to
14
+ * POSIX mode bits) and `process.getuid?.() === 0` (root bypasses 0o555
15
+ * permission denials), matching the convention in config.test.mjs.
16
+ */
17
+
18
+ import { describe, it, beforeEach, afterEach } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import {
21
+ mkdtempSync,
22
+ mkdirSync,
23
+ rmSync,
24
+ writeFileSync,
25
+ readFileSync,
26
+ statSync,
27
+ chmodSync,
28
+ readdirSync,
29
+ } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { tmpdir } from "node:os";
32
+
33
+ import {
34
+ readFileSafe,
35
+ writeFileSafe,
36
+ pathExists,
37
+ writeFileAtomic,
38
+ } from "../../lib/fs-utils.mjs";
39
+
40
+ let sandbox;
41
+
42
+ function setupSandbox() {
43
+ sandbox = mkdtempSync(join(tmpdir(), "cli-fs-utils-"));
44
+ }
45
+
46
+ function teardownSandbox() {
47
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
48
+ }
49
+
50
+ // ── readFileSafe ─────────────────────────────────────────────────────────
51
+
52
+ describe("readFileSafe", () => {
53
+ beforeEach(setupSandbox);
54
+ afterEach(teardownSandbox);
55
+
56
+ it("returns the file content as UTF-8", () => {
57
+ const p = join(sandbox, "file.txt");
58
+ writeFileSync(p, "hello world", "utf-8");
59
+ assert.equal(readFileSafe(p), "hello world");
60
+ });
61
+
62
+ it("returns null when the file does not exist (ENOENT)", () => {
63
+ assert.equal(readFileSafe(join(sandbox, "missing.txt")), null);
64
+ });
65
+
66
+ it("re-throws non-ENOENT errors (EISDIR when path is a directory)", () => {
67
+ // Reading a directory throws EISDIR, not ENOENT — must propagate.
68
+ assert.throws(
69
+ () => readFileSafe(sandbox),
70
+ (err) => err.code !== "ENOENT",
71
+ );
72
+ });
73
+
74
+ it(
75
+ "re-throws permission errors (EACCES)",
76
+ { skip: process.platform === "win32" || process.getuid?.() === 0 },
77
+ () => {
78
+ const p = join(sandbox, "locked.txt");
79
+ writeFileSync(p, "secret", "utf-8");
80
+ chmodSync(p, 0o000);
81
+ try {
82
+ assert.throws(
83
+ () => readFileSafe(p),
84
+ (err) => err.code === "EACCES",
85
+ );
86
+ } finally {
87
+ chmodSync(p, 0o644);
88
+ }
89
+ },
90
+ );
91
+ });
92
+
93
+ // ── writeFileSafe ──────────────────────────────────────────────────────────
94
+
95
+ describe("writeFileSafe", () => {
96
+ beforeEach(setupSandbox);
97
+ afterEach(teardownSandbox);
98
+
99
+ it("writes content to a file in an existing directory", () => {
100
+ const p = join(sandbox, "out.txt");
101
+ writeFileSafe(p, "content");
102
+ assert.equal(readFileSync(p, "utf-8"), "content");
103
+ });
104
+
105
+ it("creates missing parent directories recursively", () => {
106
+ const p = join(sandbox, "a", "b", "c", "deep.txt");
107
+ writeFileSafe(p, "nested");
108
+ assert.equal(readFileSync(p, "utf-8"), "nested");
109
+ });
110
+
111
+ it("overwrites an existing file", () => {
112
+ const p = join(sandbox, "over.txt");
113
+ writeFileSafe(p, "v1");
114
+ writeFileSafe(p, "v2");
115
+ assert.equal(readFileSync(p, "utf-8"), "v2");
116
+ });
117
+
118
+ it("throws when the target path is an existing directory", () => {
119
+ const dirPath = join(sandbox, "iAmADir");
120
+ mkdirSync(dirPath);
121
+ assert.throws(
122
+ () => writeFileSafe(dirPath, "content"),
123
+ /is a directory, expected a file/,
124
+ );
125
+ });
126
+ });
127
+
128
+ // ── pathExists ─────────────────────────────────────────────────────────────
129
+
130
+ describe("pathExists", () => {
131
+ beforeEach(setupSandbox);
132
+ afterEach(teardownSandbox);
133
+
134
+ it("returns true for an existing file", () => {
135
+ const p = join(sandbox, "exists.txt");
136
+ writeFileSync(p, "x");
137
+ assert.equal(pathExists(p), true);
138
+ });
139
+
140
+ it("returns true for an existing directory", () => {
141
+ assert.equal(pathExists(sandbox), true);
142
+ });
143
+
144
+ it("returns false for a missing path", () => {
145
+ assert.equal(pathExists(join(sandbox, "nope")), false);
146
+ });
147
+ });
148
+
149
+ // ── writeFileAtomic ────────────────────────────────────────────────────────
150
+
151
+ describe("writeFileAtomic", () => {
152
+ beforeEach(setupSandbox);
153
+ afterEach(teardownSandbox);
154
+
155
+ it("writes content atomically and leaves no .tmp residue", () => {
156
+ const p = join(sandbox, "atomic.txt");
157
+ writeFileAtomic(p, "atomic content");
158
+ assert.equal(readFileSync(p, "utf-8"), "atomic content");
159
+ // No leftover *.tmp siblings.
160
+ const siblings = readdirSync(sandbox);
161
+ assert.ok(
162
+ !siblings.some((f) => f.endsWith(".tmp")),
163
+ `expected no .tmp residue, found: ${siblings.join(", ")}`,
164
+ );
165
+ });
166
+
167
+ it("creates missing parent directories recursively", () => {
168
+ const p = join(sandbox, "x", "y", "z", "atomic.txt");
169
+ writeFileAtomic(p, "deep atomic");
170
+ assert.equal(readFileSync(p, "utf-8"), "deep atomic");
171
+ });
172
+
173
+ it("overwrites an existing destination file", () => {
174
+ const p = join(sandbox, "replace.txt");
175
+ writeFileSync(p, "old");
176
+ writeFileAtomic(p, "new");
177
+ assert.equal(readFileSync(p, "utf-8"), "new");
178
+ });
179
+
180
+ it("throws when the destination path is an existing directory", () => {
181
+ const dirPath = join(sandbox, "destDir");
182
+ mkdirSync(dirPath);
183
+ assert.throws(
184
+ () => writeFileAtomic(dirPath, "content"),
185
+ /is a directory, expected a file/,
186
+ );
187
+ });
188
+
189
+ it(
190
+ "applies the requested mode to the final file on POSIX",
191
+ { skip: process.platform === "win32" || process.getuid?.() === 0 },
192
+ () => {
193
+ const p = join(sandbox, "perms.txt");
194
+ writeFileAtomic(p, "secret", { mode: 0o600 });
195
+ const mode = statSync(p).mode & 0o777;
196
+ assert.equal(mode, 0o600, `expected 0o600, got ${mode.toString(8)}`);
197
+ },
198
+ );
199
+
200
+ it("ignores mode when undefined (no chmod attempted, write still succeeds)", () => {
201
+ const p = join(sandbox, "nomode.txt");
202
+ // mode omitted → the chmod branch is skipped entirely.
203
+ writeFileAtomic(p, "no mode");
204
+ assert.equal(readFileSync(p, "utf-8"), "no mode");
205
+ });
206
+
207
+ it(
208
+ "succeeds even when chmod on the temp file fails (chmod failure is non-fatal)",
209
+ { skip: process.platform === "win32" || process.getuid?.() === 0 },
210
+ () => {
211
+ // An immutable-mode value can't be forced portably, but we CAN
212
+ // exercise the non-fatal chmod path with a normal mode and assert
213
+ // the write still completes. The chmod-failure swallow is defensive
214
+ // (see fs-utils.mjs) — the contract we lock here is that a mode
215
+ // request never aborts the write.
216
+ const p = join(sandbox, "chmod-ok.txt");
217
+ writeFileAtomic(p, "data", { mode: 0o640 });
218
+ assert.equal(readFileSync(p, "utf-8"), "data");
219
+ },
220
+ );
221
+
222
+ it(
223
+ "wraps a temp-file write failure with a 'Cannot write' message and preserves the cause",
224
+ { skip: process.platform === "win32" || process.getuid?.() === 0 },
225
+ () => {
226
+ // Make the parent directory non-writable so writeFileSync on the
227
+ // temp path fails. The dir must already exist (so the mkdir branch
228
+ // is skipped) and be read-only.
229
+ const dir = join(sandbox, "ro-parent");
230
+ mkdirSync(dir);
231
+ chmodSync(dir, 0o555);
232
+ const p = join(dir, "target.txt");
233
+ try {
234
+ assert.throws(
235
+ () => writeFileAtomic(p, "content"),
236
+ (err) =>
237
+ err instanceof Error &&
238
+ /Cannot write .*\.tmp:/.test(err.message) &&
239
+ err.cause !== undefined,
240
+ );
241
+ } finally {
242
+ chmodSync(dir, 0o755);
243
+ }
244
+ },
245
+ );
246
+
247
+ // NOTE: the rename-failure cleanup branch in writeFileAtomic (the
248
+ // `catch` around `renameSync(tmpPath, filePath)` that unlinks the temp
249
+ // file and throws "Cannot install …") is NOT covered here. The temp
250
+ // file and the destination share the same parent directory
251
+ // (`dirname(filePath)`), so any state that makes the rename fail also
252
+ // makes the preceding temp-file write fail — there is no portable,
253
+ // deterministic way to fail ONLY the rename without mocking
254
+ // `node:fs.renameSync`. The CI harness runs the suite with a plain
255
+ // `node --test` (no `--experimental-test-module-mocks`), so module
256
+ // mocking is off the table. Reaching this branch would require a DI
257
+ // seam for the rename call, which is out of scope for a tests-only
258
+ // change.
259
+ });