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.
- package/bin/skillrepo.mjs +6 -3
- package/package.json +1 -1
- package/src/commands/add.mjs +2 -1
- package/src/commands/init.mjs +15 -5
- package/src/lib/http.mjs +25 -11
- package/src/test/commands/add.test.mjs +79 -2
- package/src/test/commands/get.test.mjs +131 -2
- package/src/test/commands/init-session-sync.test.mjs +724 -0
- package/src/test/commands/init.test.mjs +159 -2
- package/src/test/commands/list.test.mjs +573 -1
- package/src/test/commands/publish.test.mjs +136 -3
- package/src/test/commands/push.test.mjs +280 -1
- package/src/test/commands/remove.test.mjs +221 -2
- package/src/test/commands/search.test.mjs +203 -1
- package/src/test/commands/session-sync.test.mjs +227 -1
- package/src/test/commands/uninstall.test.mjs +216 -0
- package/src/test/commands/update.test.mjs +218 -0
- package/src/test/dispatcher.test.mjs +103 -2
- package/src/test/e2e/advertised-surface.test.mjs +207 -0
- package/src/test/e2e/cli-commands.test.mjs +1 -1
- package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
- package/src/test/e2e/update-check-suppression.test.mjs +135 -0
- package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
- package/src/test/lib/browser-open.test.mjs +43 -0
- package/src/test/lib/config.test.mjs +87 -0
- package/src/test/lib/crypto-shas.test.mjs +17 -0
- package/src/test/lib/file-write.test.mjs +244 -0
- package/src/test/lib/fs-utils.test.mjs +259 -0
- package/src/test/lib/global-install.test.mjs +134 -0
- package/src/test/lib/http-timeout.test.mjs +114 -0
- package/src/test/lib/http.test.mjs +616 -1
- package/src/test/lib/mcp-merge.test.mjs +157 -0
- package/src/test/lib/npm-update-check.test.mjs +180 -0
- package/src/test/lib/placement-walk.test.mjs +132 -0
- package/src/test/lib/skill-walk.test.mjs +39 -1
- package/src/test/lib/sync.test.mjs +139 -5
- package/src/test/lib/telemetry.test.mjs +34 -0
- package/src/test/mergers/claude-mcp.test.mjs +30 -0
- package/src/test/mergers/cursor-mcp.test.mjs +115 -0
- package/src/test/mergers/env-local.test.mjs +126 -0
- package/src/test/mergers/vscode-mcp.test.mjs +177 -0
- package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
- 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
|
+
});
|