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
|
@@ -185,6 +185,163 @@ describe("mergeMcpForVendors — preview messages", () => {
|
|
|
185
185
|
// The merge should fail, not merge
|
|
186
186
|
assert.equal(results[0].outcome, "failed");
|
|
187
187
|
});
|
|
188
|
+
|
|
189
|
+
it("previews warning when existing JSON is valid but not an object (array)", async () => {
|
|
190
|
+
// Parses fine but `typeof config !== "object"`-style guard: an
|
|
191
|
+
// array is typeof "object" yet semantically wrong, and a bare
|
|
192
|
+
// number/string is the real non-object case. Use a JSON number.
|
|
193
|
+
writeFileSync(join(process.cwd(), ".mcp.json"), "42");
|
|
194
|
+
const results = await mergeMcpForVendors({
|
|
195
|
+
vendors: ["claudeCode"],
|
|
196
|
+
mcpUrl: "https://x.com/mcp",
|
|
197
|
+
yes: true,
|
|
198
|
+
io: { stdout, stderr },
|
|
199
|
+
});
|
|
200
|
+
assert.match(stdout.text(), /not a JSON object/);
|
|
201
|
+
// The underlying merger still runs on "yes" and throws (number is
|
|
202
|
+
// not a valid config), so the outcome is "failed".
|
|
203
|
+
assert.equal(results[0].outcome, "failed");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("previews warning when existing JSON is null", async () => {
|
|
207
|
+
// JSON `null` parses to null → the `!config` half of the guard.
|
|
208
|
+
writeFileSync(join(process.cwd(), ".mcp.json"), "null");
|
|
209
|
+
await mergeMcpForVendors({
|
|
210
|
+
vendors: ["claudeCode"],
|
|
211
|
+
mcpUrl: "https://x.com/mcp",
|
|
212
|
+
yes: true,
|
|
213
|
+
io: { stdout, stderr },
|
|
214
|
+
});
|
|
215
|
+
assert.match(stdout.text(), /not a JSON object/);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("previews 'add skillrepo entry to empty mcpServers' when no other servers", async () => {
|
|
219
|
+
// Existing file with an empty mcpServers object and no skillrepo
|
|
220
|
+
// entry → the "no other servers" add branch.
|
|
221
|
+
writeFileSync(
|
|
222
|
+
join(process.cwd(), ".mcp.json"),
|
|
223
|
+
JSON.stringify({ mcpServers: {} }),
|
|
224
|
+
);
|
|
225
|
+
await mergeMcpForVendors({
|
|
226
|
+
vendors: ["claudeCode"],
|
|
227
|
+
mcpUrl: "https://x.com/mcp",
|
|
228
|
+
yes: true,
|
|
229
|
+
io: { stdout, stderr },
|
|
230
|
+
});
|
|
231
|
+
assert.match(stdout.text(), /add skillrepo entry to empty mcpServers/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("previews 'update existing (no other servers)' when only skillrepo present", async () => {
|
|
235
|
+
// skillrepo entry exists and is the only server → the existingEntry
|
|
236
|
+
// + otherServers.length === 0 branch.
|
|
237
|
+
writeFileSync(
|
|
238
|
+
join(process.cwd(), ".mcp.json"),
|
|
239
|
+
JSON.stringify({ mcpServers: { skillrepo: { url: "https://old.example" } } }),
|
|
240
|
+
);
|
|
241
|
+
await mergeMcpForVendors({
|
|
242
|
+
vendors: ["claudeCode"],
|
|
243
|
+
mcpUrl: "https://new.example/mcp",
|
|
244
|
+
yes: true,
|
|
245
|
+
io: { stdout, stderr },
|
|
246
|
+
});
|
|
247
|
+
assert.match(stdout.text(), /update existing skillrepo entry \(no other servers\)/);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("previews singular server count for one other server (add branch)", async () => {
|
|
251
|
+
// One other server, no skillrepo → singular "1 existing server".
|
|
252
|
+
writeFileSync(
|
|
253
|
+
join(process.cwd(), ".mcp.json"),
|
|
254
|
+
JSON.stringify({ mcpServers: { only: { url: "https://only.example" } } }),
|
|
255
|
+
);
|
|
256
|
+
await mergeMcpForVendors({
|
|
257
|
+
vendors: ["claudeCode"],
|
|
258
|
+
mcpUrl: "https://x.com/mcp",
|
|
259
|
+
yes: true,
|
|
260
|
+
io: { stdout, stderr },
|
|
261
|
+
});
|
|
262
|
+
assert.match(stdout.text(), /1 existing server preserved/);
|
|
263
|
+
assert.doesNotMatch(stdout.text(), /1 existing servers preserved/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("previews plural server count for multiple other servers (add branch)", async () => {
|
|
267
|
+
// Two other servers, no skillrepo → plural "2 existing servers"
|
|
268
|
+
// (covers the "s" arm of the add-branch ternary).
|
|
269
|
+
writeFileSync(
|
|
270
|
+
join(process.cwd(), ".mcp.json"),
|
|
271
|
+
JSON.stringify({
|
|
272
|
+
mcpServers: {
|
|
273
|
+
alpha: { url: "https://alpha.example" },
|
|
274
|
+
beta: { url: "https://beta.example" },
|
|
275
|
+
},
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
await mergeMcpForVendors({
|
|
279
|
+
vendors: ["claudeCode"],
|
|
280
|
+
mcpUrl: "https://x.com/mcp",
|
|
281
|
+
yes: true,
|
|
282
|
+
io: { stdout, stderr },
|
|
283
|
+
});
|
|
284
|
+
assert.match(stdout.text(), /2 existing servers preserved: alpha, beta/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("previews update with one other server (singular update branch)", async () => {
|
|
288
|
+
// skillrepo entry present + exactly one other server → the update
|
|
289
|
+
// branch's singular "1 other server preserved".
|
|
290
|
+
writeFileSync(
|
|
291
|
+
join(process.cwd(), ".mcp.json"),
|
|
292
|
+
JSON.stringify({
|
|
293
|
+
mcpServers: {
|
|
294
|
+
skillrepo: { url: "https://old.example" },
|
|
295
|
+
alpha: { url: "https://alpha.example" },
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
await mergeMcpForVendors({
|
|
300
|
+
vendors: ["claudeCode"],
|
|
301
|
+
mcpUrl: "https://new.example/mcp",
|
|
302
|
+
yes: true,
|
|
303
|
+
io: { stdout, stderr },
|
|
304
|
+
});
|
|
305
|
+
assert.match(stdout.text(), /update existing skillrepo entry \(1 other server preserved: alpha\)/);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("previews update with multiple other servers (plural update branch)", async () => {
|
|
309
|
+
// skillrepo entry present + two other servers → the update branch's
|
|
310
|
+
// plural "2 other servers preserved".
|
|
311
|
+
writeFileSync(
|
|
312
|
+
join(process.cwd(), ".mcp.json"),
|
|
313
|
+
JSON.stringify({
|
|
314
|
+
mcpServers: {
|
|
315
|
+
skillrepo: { url: "https://old.example" },
|
|
316
|
+
alpha: { url: "https://alpha.example" },
|
|
317
|
+
beta: { url: "https://beta.example" },
|
|
318
|
+
},
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
await mergeMcpForVendors({
|
|
322
|
+
vendors: ["claudeCode"],
|
|
323
|
+
mcpUrl: "https://new.example/mcp",
|
|
324
|
+
yes: true,
|
|
325
|
+
io: { stdout, stderr },
|
|
326
|
+
});
|
|
327
|
+
assert.match(stdout.text(), /update existing skillrepo entry \(2 other servers preserved: alpha, beta\)/);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("previews 'add to empty mcpServers' when the mcpServers key is absent entirely", async () => {
|
|
331
|
+
// Valid object with NO mcpServers key → the `?? {}` fallback on
|
|
332
|
+
// config.mcpServers fires, yielding an empty server set.
|
|
333
|
+
writeFileSync(
|
|
334
|
+
join(process.cwd(), ".mcp.json"),
|
|
335
|
+
JSON.stringify({ unrelated: true }),
|
|
336
|
+
);
|
|
337
|
+
await mergeMcpForVendors({
|
|
338
|
+
vendors: ["claudeCode"],
|
|
339
|
+
mcpUrl: "https://x.com/mcp",
|
|
340
|
+
yes: true,
|
|
341
|
+
io: { stdout, stderr },
|
|
342
|
+
});
|
|
343
|
+
assert.match(stdout.text(), /add skillrepo entry to empty mcpServers/);
|
|
344
|
+
});
|
|
188
345
|
});
|
|
189
346
|
|
|
190
347
|
// ── mergeMcpForVendors — user declined ─────────────────────────────────
|
|
@@ -655,6 +655,186 @@ describe("constants", () => {
|
|
|
655
655
|
});
|
|
656
656
|
});
|
|
657
657
|
|
|
658
|
+
// ── readCache field-validation branches (each malformed shape → miss) ──
|
|
659
|
+
//
|
|
660
|
+
// readCache parses the on-disk file then rejects it field-by-field. Each
|
|
661
|
+
// of these seeds a JSON-parseable cache file that passes the prior gates
|
|
662
|
+
// but trips exactly one validation branch, proving the entry is treated
|
|
663
|
+
// as a miss (a re-fetch fires). The "wrong schemaVersion" case is already
|
|
664
|
+
// covered above; these cover the remaining per-field guards.
|
|
665
|
+
|
|
666
|
+
describe("checkForCliUpdate (readCache field validation → cache miss)", () => {
|
|
667
|
+
const validFetch = () =>
|
|
668
|
+
makeIo({ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) } });
|
|
669
|
+
|
|
670
|
+
it("treats a non-object JSON cache (e.g. `null`) as a miss", async () => {
|
|
671
|
+
mkdirSync(join(testHome, ".claude", "skillrepo"), { recursive: true });
|
|
672
|
+
writeFileSync(cachePath(), "null", "utf-8");
|
|
673
|
+
const fixture = validFetch();
|
|
674
|
+
const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
|
|
675
|
+
assert.equal(fixture.getFetchCalls().length, 1, "non-object cache must force a re-fetch");
|
|
676
|
+
assert.equal(result.nudged, true);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("treats a missing checkedAt (non-string) as a miss", async () => {
|
|
680
|
+
writeCacheFile({
|
|
681
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
682
|
+
// checkedAt intentionally a number, not a string
|
|
683
|
+
checkedAt: 12345,
|
|
684
|
+
currentCliVersion: "4.3.0",
|
|
685
|
+
latestPublishedVersion: "4.5.0",
|
|
686
|
+
fetchOk: true,
|
|
687
|
+
});
|
|
688
|
+
const fixture = validFetch();
|
|
689
|
+
const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
|
|
690
|
+
assert.equal(fixture.getFetchCalls().length, 1);
|
|
691
|
+
assert.equal(result.nudged, true);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("treats a missing currentCliVersion (non-string) as a miss", async () => {
|
|
695
|
+
writeCacheFile({
|
|
696
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
697
|
+
checkedAt: new Date().toISOString(),
|
|
698
|
+
// currentCliVersion intentionally missing
|
|
699
|
+
latestPublishedVersion: "4.5.0",
|
|
700
|
+
fetchOk: true,
|
|
701
|
+
});
|
|
702
|
+
const fixture = validFetch();
|
|
703
|
+
const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
|
|
704
|
+
assert.equal(fixture.getFetchCalls().length, 1);
|
|
705
|
+
assert.equal(result.nudged, true);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("treats a non-boolean fetchOk as a miss", async () => {
|
|
709
|
+
writeCacheFile({
|
|
710
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
711
|
+
checkedAt: new Date().toISOString(),
|
|
712
|
+
currentCliVersion: "4.3.0",
|
|
713
|
+
latestPublishedVersion: "4.5.0",
|
|
714
|
+
fetchOk: "yes", // not a boolean
|
|
715
|
+
});
|
|
716
|
+
const fixture = validFetch();
|
|
717
|
+
const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
|
|
718
|
+
assert.equal(fixture.getFetchCalls().length, 1);
|
|
719
|
+
assert.equal(result.nudged, true);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("treats a latestPublishedVersion of the wrong type (number) as a miss", async () => {
|
|
723
|
+
// latestPublishedVersion may be a string OR null; a number is
|
|
724
|
+
// neither and must invalidate the entry.
|
|
725
|
+
writeCacheFile({
|
|
726
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
727
|
+
checkedAt: new Date().toISOString(),
|
|
728
|
+
currentCliVersion: "4.3.0",
|
|
729
|
+
latestPublishedVersion: 450, // wrong type
|
|
730
|
+
fetchOk: true,
|
|
731
|
+
});
|
|
732
|
+
const fixture = validFetch();
|
|
733
|
+
const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
|
|
734
|
+
assert.equal(fixture.getFetchCalls().length, 1);
|
|
735
|
+
assert.equal(result.nudged, true);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("treats an unparseable checkedAt timestamp as not-fresh (isCacheFresh re-fetches)", async () => {
|
|
739
|
+
// The field IS a string (passes readCache) but Date.parse can't
|
|
740
|
+
// parse it → isCacheFresh's `Number.isFinite(checkedAtMs)` is false
|
|
741
|
+
// → re-fetch.
|
|
742
|
+
writeCacheFile({
|
|
743
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
744
|
+
checkedAt: "not-a-real-date",
|
|
745
|
+
currentCliVersion: "4.3.0",
|
|
746
|
+
latestPublishedVersion: "4.5.0",
|
|
747
|
+
fetchOk: true,
|
|
748
|
+
});
|
|
749
|
+
const fixture = validFetch();
|
|
750
|
+
const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
|
|
751
|
+
assert.equal(fixture.getFetchCalls().length, 1, "unparseable checkedAt must re-fetch");
|
|
752
|
+
assert.equal(result.nudged, true);
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// ── fetchLatestVersion + emitNudge + no-fetch edge branches ────────────
|
|
757
|
+
|
|
758
|
+
describe("checkForCliUpdate (fetch body + emit + no-fetch edges)", () => {
|
|
759
|
+
it("a JSON body that is not an object (null) yields no result", async () => {
|
|
760
|
+
// `!body || typeof body !== "object"` — the registry returns a bare
|
|
761
|
+
// `null` JSON body. fetchLatestVersion bails before reading
|
|
762
|
+
// `.version`, the cache records fetchOk:false, and no nudge fires.
|
|
763
|
+
const fixture = makeIo({
|
|
764
|
+
fetchResult: { ok: true, json: async () => null },
|
|
765
|
+
});
|
|
766
|
+
const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
|
|
767
|
+
assert.equal(result.nudged, false);
|
|
768
|
+
assert.equal(JSON.parse(fixture.getWriteCalls()[0].contents).fetchOk, false);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it("a no-result is treated identically when no fetch impl is available", async () => {
|
|
772
|
+
// `typeof resolvedIo.fetch !== "function"` — a truthy-but-not-callable
|
|
773
|
+
// fetch (defensive against a misconfigured DI) short-circuits with
|
|
774
|
+
// reason "no-fetch" and never attempts a network call or cache write.
|
|
775
|
+
const fixture = makeIo({
|
|
776
|
+
fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
|
|
777
|
+
});
|
|
778
|
+
fixture.io.fetch = 42; // truthy, not a function
|
|
779
|
+
const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
|
|
780
|
+
assert.equal(result.nudged, false);
|
|
781
|
+
assert.equal(result.reason, "no-fetch");
|
|
782
|
+
assert.equal(fixture.getWriteCalls().length, 0, "no cache write without a fetch impl");
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it("a throwing stderrWrite during the nudge is swallowed (never crashes)", async () => {
|
|
786
|
+
// emitNudge wraps the stderr write in try/catch so a closed pipe
|
|
787
|
+
// can't break the user's flow. Inject a stderrWrite that throws and
|
|
788
|
+
// assert the call still resolves nudged:true.
|
|
789
|
+
const fixture = makeIo({
|
|
790
|
+
fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
|
|
791
|
+
});
|
|
792
|
+
fixture.io.stderrWrite = () => {
|
|
793
|
+
throw new Error("EPIPE: write to closed stream");
|
|
794
|
+
};
|
|
795
|
+
const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
|
|
796
|
+
// The nudge "fired" (we attempted to write) even though the write
|
|
797
|
+
// threw — the return value reflects the decision, not the I/O outcome.
|
|
798
|
+
assert.equal(result.nudged, true);
|
|
799
|
+
});
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// ── dim/bold ANSI styling under a TTY without NO_COLOR ─────────────────
|
|
803
|
+
|
|
804
|
+
describe("checkForCliUpdate (nudge styling — TTY color branch)", () => {
|
|
805
|
+
it("emits ANSI dim/bold escapes when stderr is a TTY and NO_COLOR is unset", async () => {
|
|
806
|
+
// The `dim`/`bold` helpers gate on `process.stderr.isTTY &&
|
|
807
|
+
// !process.env.NO_COLOR`. Every other test runs under a piped
|
|
808
|
+
// (non-TTY) stderr, so only the no-color branch is exercised. Force
|
|
809
|
+
// the TTY branch deterministically and assert the emitted nudge
|
|
810
|
+
// carries the ANSI escape codes.
|
|
811
|
+
const originalIsTTY = process.stderr.isTTY;
|
|
812
|
+
const prevNoColor = process.env.NO_COLOR;
|
|
813
|
+
delete process.env.NO_COLOR;
|
|
814
|
+
Object.defineProperty(process.stderr, "isTTY", {
|
|
815
|
+
value: true,
|
|
816
|
+
configurable: true,
|
|
817
|
+
});
|
|
818
|
+
const fixture = makeIo({
|
|
819
|
+
fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
|
|
820
|
+
});
|
|
821
|
+
try {
|
|
822
|
+
const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
|
|
823
|
+
assert.equal(result.nudged, true);
|
|
824
|
+
const out = fixture.getStderrWrites().join("");
|
|
825
|
+
assert.ok(out.includes("\x1b[2m"), "dim() must emit the ANSI dim escape on a TTY");
|
|
826
|
+
assert.ok(out.includes("\x1b[1m"), "bold() must emit the ANSI bold escape on a TTY");
|
|
827
|
+
} finally {
|
|
828
|
+
Object.defineProperty(process.stderr, "isTTY", {
|
|
829
|
+
value: originalIsTTY,
|
|
830
|
+
configurable: true,
|
|
831
|
+
});
|
|
832
|
+
if (prevNoColor === undefined) delete process.env.NO_COLOR;
|
|
833
|
+
else process.env.NO_COLOR = prevNoColor;
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
658
838
|
// ── Notes on coverage gaps ─────────────────────────────────────────────
|
|
659
839
|
//
|
|
660
840
|
// 1. The `--json` suppression lives in `bin/skillrepo.mjs`. Asserting
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "node:fs";
|
|
30
30
|
import { join } from "node:path";
|
|
31
31
|
import { tmpdir } from "node:os";
|
|
32
|
+
import { execFileSync } from "node:child_process";
|
|
32
33
|
|
|
33
34
|
import { walkDetectedPlacements, walkPlacementTarget } from "../../lib/placement-walk.mjs";
|
|
34
35
|
import { computeSkillShas } from "../../lib/crypto-shas.mjs";
|
|
@@ -348,6 +349,137 @@ describe("walkPlacementTarget", () => {
|
|
|
348
349
|
assert.equal(result.length, 1);
|
|
349
350
|
assert.equal(result[0].skillName, "cohort-skill");
|
|
350
351
|
});
|
|
352
|
+
|
|
353
|
+
it("returns empty array when the parent root exists but is unreadable (readdir EACCES)", () => {
|
|
354
|
+
// Covers the `catch` around readdirSync(parentDir): the root dir
|
|
355
|
+
// exists (so the existsSync gate passes) but cannot be listed.
|
|
356
|
+
// chmod 000 makes readdir throw EACCES → the walker swallows it and
|
|
357
|
+
// returns []. Skipped on Windows (chmod is a no-op) and root (root
|
|
358
|
+
// bypasses permission checks).
|
|
359
|
+
if (process.platform === "win32" || process.getuid?.() === 0) return;
|
|
360
|
+
|
|
361
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
362
|
+
mkdirSync(parent, { recursive: true });
|
|
363
|
+
seedSkill(parent, "hidden", [
|
|
364
|
+
{ path: "SKILL.md", content: skillMd("hidden") },
|
|
365
|
+
]);
|
|
366
|
+
chmodSync(parent, 0o000);
|
|
367
|
+
try {
|
|
368
|
+
assert.deepEqual(walkPlacementTarget("claudeProject"), []);
|
|
369
|
+
} finally {
|
|
370
|
+
chmodSync(parent, 0o755);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("skips a root entry whose lstat fails (no-exec parent dir)", () => {
|
|
375
|
+
// Covers the `catch` around lstatSync(skillDir) for an entry at the
|
|
376
|
+
// parent root. A directory with read but NOT execute (search)
|
|
377
|
+
// permission lets readdirSync enumerate the names but makes
|
|
378
|
+
// lstatSync on each child fail with EACCES. The walker `continue`s
|
|
379
|
+
// past the unstatable entry, yielding an empty result rather than
|
|
380
|
+
// crashing.
|
|
381
|
+
if (process.platform === "win32" || process.getuid?.() === 0) return;
|
|
382
|
+
|
|
383
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
384
|
+
mkdirSync(parent, { recursive: true });
|
|
385
|
+
seedSkill(parent, "unstatable", [
|
|
386
|
+
{ path: "SKILL.md", content: skillMd("unstatable") },
|
|
387
|
+
]);
|
|
388
|
+
// 0o444 = read but no execute/search → readdir lists, lstat denied.
|
|
389
|
+
chmodSync(parent, 0o444);
|
|
390
|
+
try {
|
|
391
|
+
const result = walkPlacementTarget("claudeProject");
|
|
392
|
+
// The entry could not be stat'd, so it is skipped entirely.
|
|
393
|
+
assert.deepEqual(result, []);
|
|
394
|
+
} finally {
|
|
395
|
+
chmodSync(parent, 0o755);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("produces null SHAs when a skill contains an unreadable SUBDIRECTORY (recursive readdir EACCES)", () => {
|
|
400
|
+
// Covers the `catch` around the recursive readdirSync(currentDir)
|
|
401
|
+
// for a nested directory inside a skill. The subdir exists but
|
|
402
|
+
// chmod 000 makes its listing fail → onError fires → readError set
|
|
403
|
+
// → computeShasFromDir gate returns null SHAs.
|
|
404
|
+
if (process.platform === "win32" || process.getuid?.() === 0) return;
|
|
405
|
+
|
|
406
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
407
|
+
const skillDir = join(parent, "bad-subdir");
|
|
408
|
+
mkdirSync(skillDir, { recursive: true });
|
|
409
|
+
writeFileSync(join(skillDir, "SKILL.md"), skillMd("bad-subdir"), "utf8");
|
|
410
|
+
const subDir = join(skillDir, "references");
|
|
411
|
+
mkdirSync(subDir, { recursive: true });
|
|
412
|
+
writeFileSync(join(subDir, "note.md"), "# note\n", "utf8");
|
|
413
|
+
chmodSync(subDir, 0o000);
|
|
414
|
+
try {
|
|
415
|
+
const result = walkPlacementTarget("claudeProject");
|
|
416
|
+
assert.equal(result.length, 1);
|
|
417
|
+
assert.equal(result[0].skillName, "bad-subdir");
|
|
418
|
+
assert.equal(result[0].skillMdSha256, null);
|
|
419
|
+
assert.equal(result[0].filesSha256, null);
|
|
420
|
+
} finally {
|
|
421
|
+
chmodSync(subDir, 0o755);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("skips a non-regular-file entry inside a skill (FIFO / named pipe)", () => {
|
|
426
|
+
// Covers the `if (!stat.isFile()) continue` guard in the recursive
|
|
427
|
+
// walk: an entry that is neither a symlink, a directory, nor a
|
|
428
|
+
// regular file (a FIFO here) is skipped — only the real SKILL.md is
|
|
429
|
+
// hashed. POSIX-only: FIFOs require `mkfifo`, which Windows lacks,
|
|
430
|
+
// and Node has no portable FIFO-creation API.
|
|
431
|
+
if (process.platform === "win32") return;
|
|
432
|
+
let mkfifoOk = true;
|
|
433
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
434
|
+
const skillDir = join(parent, "with-fifo");
|
|
435
|
+
mkdirSync(skillDir, { recursive: true });
|
|
436
|
+
writeFileSync(join(skillDir, "SKILL.md"), skillMd("with-fifo"), "utf8");
|
|
437
|
+
try {
|
|
438
|
+
execFileSync("mkfifo", [join(skillDir, "pipe")]);
|
|
439
|
+
} catch {
|
|
440
|
+
// mkfifo not available on this runner — nothing to assert.
|
|
441
|
+
mkfifoOk = false;
|
|
442
|
+
}
|
|
443
|
+
if (!mkfifoOk) return;
|
|
444
|
+
|
|
445
|
+
const result = walkPlacementTarget("claudeProject");
|
|
446
|
+
assert.equal(result.length, 1);
|
|
447
|
+
assert.equal(result[0].skillName, "with-fifo");
|
|
448
|
+
// The FIFO contributed nothing; the SHAs reflect SKILL.md alone.
|
|
449
|
+
const expected = computeSkillShas([
|
|
450
|
+
{ path: "SKILL.md", content: skillMd("with-fifo") },
|
|
451
|
+
]);
|
|
452
|
+
assert.equal(result[0].skillMdSha256, expected.skillMdSha256);
|
|
453
|
+
assert.equal(result[0].filesSha256, expected.filesSha256);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("produces null SHAs when a nested entry's lstat fails (recursive lstat EACCES)", () => {
|
|
457
|
+
// Covers the `catch` around the recursive lstatSync(fullPath) for an
|
|
458
|
+
// entry inside a subdirectory. The subdir is readable (readdir lists
|
|
459
|
+
// its children) but has no execute bit, so lstat on each child fails
|
|
460
|
+
// → onError → readError → null SHAs.
|
|
461
|
+
if (process.platform === "win32" || process.getuid?.() === 0) return;
|
|
462
|
+
|
|
463
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
464
|
+
const skillDir = join(parent, "bad-nested-stat");
|
|
465
|
+
mkdirSync(skillDir, { recursive: true });
|
|
466
|
+
writeFileSync(join(skillDir, "SKILL.md"), skillMd("bad-nested-stat"), "utf8");
|
|
467
|
+
const subDir = join(skillDir, "references");
|
|
468
|
+
mkdirSync(subDir, { recursive: true });
|
|
469
|
+
writeFileSync(join(subDir, "note.md"), "# note\n", "utf8");
|
|
470
|
+
// 0o444 → readable (readdir lists "note.md") but not searchable
|
|
471
|
+
// (lstat on note.md denied).
|
|
472
|
+
chmodSync(subDir, 0o444);
|
|
473
|
+
try {
|
|
474
|
+
const result = walkPlacementTarget("claudeProject");
|
|
475
|
+
assert.equal(result.length, 1);
|
|
476
|
+
assert.equal(result[0].skillName, "bad-nested-stat");
|
|
477
|
+
assert.equal(result[0].skillMdSha256, null);
|
|
478
|
+
assert.equal(result[0].filesSha256, null);
|
|
479
|
+
} finally {
|
|
480
|
+
chmodSync(subDir, 0o755);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
351
483
|
});
|
|
352
484
|
|
|
353
485
|
// ── walkDetectedPlacements ──────────────────────────────────────────────
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
|
-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, symlinkSync } from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
16
|
import { tmpdir } from "node:os";
|
|
17
17
|
|
|
@@ -110,6 +110,44 @@ describe("walkSkillFiles", () => {
|
|
|
110
110
|
assert.deepEqual(paths, ["SKILL.md"]);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
it("returns an empty array when the skill directory does not exist (ENOENT)", async () => {
|
|
114
|
+
// readdir on a missing root throws ENOENT — the walker swallows it
|
|
115
|
+
// and returns [] rather than propagating.
|
|
116
|
+
const walked = await walkSkillFiles(join(sandbox, "does-not-exist"));
|
|
117
|
+
assert.deepEqual(walked, []);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("re-throws non-ENOENT readdir errors (ENOTDIR when the path is a file)", async () => {
|
|
121
|
+
// readdir on a regular file throws ENOTDIR, which is NOT swallowed —
|
|
122
|
+
// it must propagate so the caller learns the path is not a directory.
|
|
123
|
+
const filePath = join(sandbox, "not-a-dir.txt");
|
|
124
|
+
writeFileSync(filePath, "x");
|
|
125
|
+
await assert.rejects(
|
|
126
|
+
() => walkSkillFiles(filePath),
|
|
127
|
+
(err) => err && err.code === "ENOTDIR",
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it(
|
|
132
|
+
"skips symlink entries (not regular files)",
|
|
133
|
+
{ skip: process.platform === "win32" },
|
|
134
|
+
async () => {
|
|
135
|
+
// A symlink-to-file is not `entry.isFile()`, so the walker skips
|
|
136
|
+
// it via the `if (!entry.isFile()) continue` guard. Only the real
|
|
137
|
+
// SKILL.md is collected.
|
|
138
|
+
file("SKILL.md");
|
|
139
|
+
const realTarget = join(sandbox, "real.md");
|
|
140
|
+
writeFileSync(realTarget, "real");
|
|
141
|
+
symlinkSync(realTarget, join(sandbox, "link.md"));
|
|
142
|
+
|
|
143
|
+
const walked = await walkSkillFiles(sandbox);
|
|
144
|
+
const paths = walked.map((f) => f.relativePath);
|
|
145
|
+
assert.ok(!paths.includes("link.md"), "symlink should be excluded");
|
|
146
|
+
assert.ok(paths.includes("SKILL.md"));
|
|
147
|
+
assert.ok(paths.includes("real.md"));
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
113
151
|
it("returns absolute paths + sizes for each file", async () => {
|
|
114
152
|
file("SKILL.md", "ab");
|
|
115
153
|
file("references/a.md", "abc");
|