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
@@ -253,6 +253,42 @@ describe("readLastSync / writeLastSync", () => {
253
253
  assert.equal(readLastSync(), null);
254
254
  });
255
255
 
256
+ it("returns null when the JSON parses to a non-object primitive (`null`)", () => {
257
+ // `!parsed || typeof parsed !== "object"` — a file that parses to a
258
+ // bare `null` (or a number) is not a usable state object.
259
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
260
+ writeFileSync(globalLastSyncPath(), "null");
261
+ assert.equal(readLastSync(), null);
262
+ });
263
+
264
+ it("a v1 file with a non-string etag migrates to etag: null", () => {
265
+ // The v1 → v2 migration guards each carried-over field with a
266
+ // `typeof === "string"` check. A v1 file whose etag is the wrong
267
+ // type carries forward as null (forces a fresh unconditional sync)
268
+ // rather than propagating a bogus value.
269
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
270
+ writeFileSync(
271
+ globalLastSyncPath(),
272
+ JSON.stringify({ schemaVersion: 1, etag: 12345, syncedAt: "2024-12-01T00:00:00Z" }),
273
+ );
274
+ const migrated = readLastSync();
275
+ assert.ok(migrated);
276
+ assert.equal(migrated.etag, null, "non-string v1 etag becomes null");
277
+ assert.equal(migrated.syncedAt, "2024-12-01T00:00:00Z");
278
+ });
279
+
280
+ it("a v1 file with a non-string syncedAt migrates to syncedAt: null", () => {
281
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
282
+ writeFileSync(
283
+ globalLastSyncPath(),
284
+ JSON.stringify({ schemaVersion: 1, etag: '"old"', syncedAt: { not: "a string" } }),
285
+ );
286
+ const migrated = readLastSync();
287
+ assert.ok(migrated);
288
+ assert.equal(migrated.etag, '"old"');
289
+ assert.equal(migrated.syncedAt, null, "non-string v1 syncedAt becomes null");
290
+ });
291
+
256
292
  it("returns null on unknown schema version", () => {
257
293
  writeLastSync({ etag: "x", syncedAt: "x" });
258
294
  const path = globalLastSyncPath();
@@ -1162,14 +1198,23 @@ describe("runSync — one-time membership heal (#1911)", () => {
1162
1198
  assert.equal(cliVersionBelowFloor("5.0.0", "4.8.0"), false);
1163
1199
  });
1164
1200
 
1165
- it("FULL_RESYNC_FLOOR equals the shipped CLI version", () => {
1166
- // The floor MUST equal the version that ships this fix; otherwise the
1167
- // running CLI's own writes would stamp a version below the floor and
1168
- // it would re-heal on every run. Lock them together.
1201
+ it("shipped CLI version is at/above the heal floor (no self-re-heal)", () => {
1202
+ // FULL_RESYNC_FLOOR is pinned to the #1911 release (4.8.0) the version
1203
+ // that shipped the membership fix. The hard invariant is that the CURRENT
1204
+ // shipped version is NOT BELOW the floor: writeLastSync stamps the running
1205
+ // version, and if that were below the floor the CLI would re-heal on every
1206
+ // run. (Earlier this asserted strict equality, which held only at 4.8.0 and
1207
+ // broke on the first patch bump — the real invariant is >=, and the floor
1208
+ // intentionally stays at the #1911 release until a NEW one-time heal
1209
+ // migration supersedes it.) #1923.
1169
1210
  const pkg = JSON.parse(
1170
1211
  readFileSync(new URL("../../../package.json", import.meta.url), "utf-8"),
1171
1212
  );
1172
- assert.equal(FULL_RESYNC_FLOOR, pkg.version);
1213
+ assert.equal(
1214
+ cliVersionBelowFloor(pkg.version, FULL_RESYNC_FLOOR),
1215
+ false,
1216
+ `shipped version ${pkg.version} must not be below the heal floor ${FULL_RESYNC_FLOOR}`,
1217
+ );
1173
1218
  });
1174
1219
 
1175
1220
  it("a pre-fix .last-sync forces ONE full re-fetch, then resumes delta", async () => {
@@ -1424,6 +1469,73 @@ describe("runSync — one-time membership heal (#1911)", () => {
1424
1469
  "remove must not advance the heal-gate version",
1425
1470
  );
1426
1471
  });
1472
+
1473
+ // ── upsert/delete input guards (no state written) ─────────────────
1474
+
1475
+ it("upsertLastSyncEntry no-ops on a non-object skill", () => {
1476
+ // `if (!skill || typeof skill !== "object") return;` — neither a
1477
+ // null nor a primitive can produce a state file.
1478
+ upsertLastSyncEntry(null);
1479
+ upsertLastSyncEntry("not a skill");
1480
+ assert.equal(readLastSync(), null, "no state file written for a bad skill arg");
1481
+ });
1482
+
1483
+ it("upsertLastSyncEntry no-ops when owner/name are not strings", () => {
1484
+ upsertLastSyncEntry({ owner: 1, name: 2, files: [] });
1485
+ assert.equal(readLastSync(), null);
1486
+ });
1487
+
1488
+ it("upsertLastSyncEntry no-ops when files is not an array", () => {
1489
+ upsertLastSyncEntry({ owner: "a", name: "b", files: "nope" });
1490
+ assert.equal(readLastSync(), null);
1491
+ });
1492
+
1493
+ it("upsertLastSyncEntry no-ops when there is no SKILL.md (null SHAs)", () => {
1494
+ // computeSkillShas returns null SHAs when no SKILL.md is present;
1495
+ // persisting those would later misclassify as `edited`, so the
1496
+ // upsert is skipped entirely.
1497
+ upsertLastSyncEntry({
1498
+ owner: "a",
1499
+ name: "b",
1500
+ version: "1.0.0",
1501
+ files: [{ path: "references/only.md", content: "no skill md here" }],
1502
+ });
1503
+ assert.equal(readLastSync(), null, "missing SKILL.md must skip the upsert");
1504
+ });
1505
+
1506
+ it("upsertLastSyncEntry coerces a non-string version to an empty string", () => {
1507
+ // `typeof skill.version === "string" ? skill.version : ""` — a
1508
+ // numeric version is stored as "".
1509
+ upsertLastSyncEntry({
1510
+ owner: "alice",
1511
+ name: "weird",
1512
+ version: 1.0, // not a string
1513
+ files: [
1514
+ { path: "SKILL.md", content: "---\nname: weird\ndescription: d\n---\nbody" },
1515
+ ],
1516
+ });
1517
+ const stored = readLastSync().skills["alice/weird"];
1518
+ assert.ok(stored, "entry should be written");
1519
+ assert.equal(stored.version, "", "non-string version coerces to empty string");
1520
+ });
1521
+
1522
+ it("deleteLastSyncEntry no-ops when owner/name are not strings", () => {
1523
+ upsertLastSyncEntry({
1524
+ owner: "alice",
1525
+ name: "keep",
1526
+ version: "1.0.0",
1527
+ files: [
1528
+ { path: "SKILL.md", content: "---\nname: keep\ndescription: d\n---\nbody" },
1529
+ ],
1530
+ });
1531
+ const before = readLastSync();
1532
+ deleteLastSyncEntry(42, undefined);
1533
+ assert.deepEqual(
1534
+ readLastSync().skills,
1535
+ before.skills,
1536
+ "a non-string owner/name must leave the skills map untouched",
1537
+ );
1538
+ });
1427
1539
  });
1428
1540
 
1429
1541
  describe("runSync — input validation", () => {
@@ -1911,4 +2023,26 @@ describe("placementsAreComplete — direct unit coverage", () => {
1911
2023
  // malformed entry is skipped (continue) — no false negative.
1912
2024
  assert.equal(placementsAreComplete(skills, ["claudeCode"], false), true);
1913
2025
  });
2026
+
2027
+ it("skips a key with a trailing slash (empty skill-name half) without crashing", () => {
2028
+ // `"owner/"` has slashAt >= 0 (passes the no-slash guard) but the
2029
+ // name half is empty → the `if (!skillName) continue` branch skips
2030
+ // it. The valid skill is still on disk, so the result is true.
2031
+ seedSkillMd("claudeProject", "good");
2032
+ const skills = {
2033
+ "alice/good": {
2034
+ version: "1.0.0",
2035
+ skillMdSha256: "a".repeat(64),
2036
+ filesSha256: "b".repeat(64),
2037
+ syncedAt: "x",
2038
+ },
2039
+ "owner-with-no-name/": {
2040
+ version: "1.0.0",
2041
+ skillMdSha256: "a".repeat(64),
2042
+ filesSha256: "b".repeat(64),
2043
+ syncedAt: "x",
2044
+ },
2045
+ };
2046
+ assert.equal(placementsAreComplete(skills, ["claudeCode"], false), true);
2047
+ });
1914
2048
  });
@@ -265,6 +265,40 @@ describe("reportInitFailure", () => {
265
265
  );
266
266
  });
267
267
 
268
+ it("sends serverUrlHost 'unknown' when the server URL can't be parsed", async () => {
269
+ // `hostnameFrom` wraps `new URL(serverUrl).host` in try/catch and
270
+ // returns "unknown" on a parse failure. A non-URL string reaches the
271
+ // payload builder (telemetry is still enabled, stage is valid) and
272
+ // exercises the catch branch. The endpoint URL still gets built via
273
+ // `serverUrl.replace(...)` — only the host extraction falls back.
274
+ const fetchSpy = makeFetchSpy();
275
+ await reportInitFailure({
276
+ serverUrl: "::::not a url::::",
277
+ config: { telemetry: true },
278
+ stage: "library_sync",
279
+ errorCode: 1,
280
+ deps: { fetch: fetchSpy },
281
+ });
282
+ assert.equal(fetchSpy.calls.length, 1);
283
+ const body = JSON.parse(fetchSpy.calls[0].init.body);
284
+ assert.equal(body.serverUrlHost, "unknown");
285
+ });
286
+
287
+ it("does nothing when the injected fetch is not a function (defensive)", async () => {
288
+ // `(deps && deps.fetch) || globalThis.fetch` selects the injected
289
+ // value when it's truthy; a truthy-but-not-callable value then trips
290
+ // the `typeof fetchImpl !== "function"` guard and the function
291
+ // returns before constructing an AbortController or calling fetch.
292
+ await reportInitFailure({
293
+ serverUrl: "https://skillrepo.dev",
294
+ config: { telemetry: true },
295
+ stage: "post_paste_validate",
296
+ errorCode: 401,
297
+ deps: { fetch: 12345 },
298
+ });
299
+ // No throw, no network call — reaching this line is the assertion.
300
+ });
301
+
268
302
  it("does not fire on unsupported platforms (server would 400)", async () => {
269
303
  // Simulate by swapping global fetch with a sentinel — we can't
270
304
  // easily monkey `os.platform()`, so this verifies the symmetric
@@ -75,4 +75,34 @@ describe("Claude Code MCP config merger", () => {
75
75
 
76
76
  assert.throws(() => mergeClaudeMcpConfig("https://mcp.skillrepo.dev"), /invalid JSON/);
77
77
  });
78
+
79
+ it("rebuilds mcpServers when the existing field is not an object", async () => {
80
+ const { mergeClaudeMcpConfig } = await import("../../lib/mergers/claude-mcp.mjs");
81
+
82
+ // mcpServers present but not an object — the merger must replace it
83
+ // with {} before adding skillrepo (the typeof guard branch).
84
+ writeFileSync(join(tempDir, ".mcp.json"), JSON.stringify({ mcpServers: "nope" }));
85
+
86
+ const result = mergeClaudeMcpConfig("https://mcp.skillrepo.dev");
87
+
88
+ // No prior skillrepo entry → "merged" (added to existing file).
89
+ assert.equal(result.action, "merged");
90
+ const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
91
+ assert.equal(typeof content.mcpServers, "object");
92
+ assert.ok(content.mcpServers.skillrepo);
93
+ });
94
+
95
+ it("creates mcpServers when the key is entirely absent", async () => {
96
+ const { mergeClaudeMcpConfig } = await import("../../lib/mergers/claude-mcp.mjs");
97
+
98
+ // Valid JSON object, no mcpServers key (falsy branch of the guard).
99
+ writeFileSync(join(tempDir, ".mcp.json"), JSON.stringify({ unrelated: true }));
100
+
101
+ const result = mergeClaudeMcpConfig("https://mcp.skillrepo.dev");
102
+
103
+ assert.equal(result.action, "merged");
104
+ const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
105
+ assert.ok(content.mcpServers.skillrepo);
106
+ assert.equal(content.unrelated, true, "unrelated keys preserved");
107
+ });
78
108
  });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Unit tests for src/lib/mergers/cursor-mcp.mjs.
3
+ *
4
+ * Cursor's .cursor/mcp.json uses `mcpServers` with a `url` key and
5
+ * ${env:VAR} interpolation. These cover the branches the mcp-merge
6
+ * integration tests don't reach directly: invalid-JSON throw and the
7
+ * non-object `mcpServers` rebuild.
8
+ */
9
+
10
+ import { describe, it, beforeEach, afterEach } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+
16
+ let originalCwd;
17
+ let tempDir;
18
+
19
+ beforeEach(() => {
20
+ originalCwd = process.cwd;
21
+ tempDir = mkdtempSync(join(tmpdir(), "cli-test-cursor-"));
22
+ process.cwd = () => tempDir;
23
+ mkdirSync(join(tempDir, ".cursor"), { recursive: true });
24
+ });
25
+
26
+ afterEach(() => {
27
+ process.cwd = originalCwd;
28
+ rmSync(tempDir, { recursive: true, force: true });
29
+ });
30
+
31
+ const cursorPath = () => join(tempDir, ".cursor", "mcp.json");
32
+
33
+ describe("Cursor MCP config merger", () => {
34
+ it("creates .cursor/mcp.json when file does not exist", async () => {
35
+ const { mergeCursorMcpConfig } = await import("../../lib/mergers/cursor-mcp.mjs");
36
+ const result = mergeCursorMcpConfig("https://mcp.skillrepo.dev");
37
+
38
+ assert.equal(result.action, "created");
39
+ assert.equal(result.path, ".cursor/mcp.json");
40
+
41
+ const content = JSON.parse(readFileSync(cursorPath(), "utf-8"));
42
+ assert.equal(content.mcpServers.skillrepo.url, "https://mcp.skillrepo.dev");
43
+ assert.equal(
44
+ content.mcpServers.skillrepo.headers.Authorization,
45
+ "Bearer ${env:SKILLREPO_ACCESS_KEY}",
46
+ );
47
+ });
48
+
49
+ it("merges into existing config preserving other servers", async () => {
50
+ const { mergeCursorMcpConfig } = await import("../../lib/mergers/cursor-mcp.mjs");
51
+
52
+ writeFileSync(
53
+ cursorPath(),
54
+ JSON.stringify({ mcpServers: { other: { url: "https://other.example" } } }),
55
+ );
56
+
57
+ const result = mergeCursorMcpConfig("https://mcp.skillrepo.dev");
58
+
59
+ assert.equal(result.action, "created");
60
+ const content = JSON.parse(readFileSync(cursorPath(), "utf-8"));
61
+ assert.ok(content.mcpServers.other, "other server preserved");
62
+ assert.ok(content.mcpServers.skillrepo, "skillrepo added");
63
+ });
64
+
65
+ it("returns 'merged' when a skillrepo entry already exists", async () => {
66
+ const { mergeCursorMcpConfig } = await import("../../lib/mergers/cursor-mcp.mjs");
67
+
68
+ writeFileSync(
69
+ cursorPath(),
70
+ JSON.stringify({ mcpServers: { skillrepo: { url: "https://old.example" } } }),
71
+ );
72
+
73
+ const result = mergeCursorMcpConfig("https://new.example/mcp");
74
+
75
+ assert.equal(result.action, "merged");
76
+ const content = JSON.parse(readFileSync(cursorPath(), "utf-8"));
77
+ assert.equal(content.mcpServers.skillrepo.url, "https://new.example/mcp");
78
+ });
79
+
80
+ it("rebuilds mcpServers when the existing field is not an object", async () => {
81
+ const { mergeCursorMcpConfig } = await import("../../lib/mergers/cursor-mcp.mjs");
82
+
83
+ writeFileSync(cursorPath(), JSON.stringify({ mcpServers: 42 }));
84
+
85
+ const result = mergeCursorMcpConfig("https://mcp.skillrepo.dev");
86
+
87
+ assert.equal(result.action, "created");
88
+ const content = JSON.parse(readFileSync(cursorPath(), "utf-8"));
89
+ assert.equal(typeof content.mcpServers, "object");
90
+ assert.ok(content.mcpServers.skillrepo);
91
+ });
92
+
93
+ it("creates mcpServers when the key is entirely absent", async () => {
94
+ const { mergeCursorMcpConfig } = await import("../../lib/mergers/cursor-mcp.mjs");
95
+
96
+ writeFileSync(cursorPath(), JSON.stringify({ unrelated: true }));
97
+
98
+ const result = mergeCursorMcpConfig("https://mcp.skillrepo.dev");
99
+
100
+ assert.equal(result.action, "created");
101
+ const content = JSON.parse(readFileSync(cursorPath(), "utf-8"));
102
+ assert.ok(content.mcpServers.skillrepo);
103
+ assert.equal(content.unrelated, true, "unrelated keys preserved");
104
+ });
105
+
106
+ it("throws on invalid JSON", async () => {
107
+ const { mergeCursorMcpConfig } = await import("../../lib/mergers/cursor-mcp.mjs");
108
+ writeFileSync(cursorPath(), "not json {{{");
109
+
110
+ assert.throws(
111
+ () => mergeCursorMcpConfig("https://mcp.skillrepo.dev"),
112
+ /invalid JSON/,
113
+ );
114
+ });
115
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Unit tests for src/lib/mergers/env-local.mjs.
3
+ *
4
+ * mergeEnvLocal is append-only: it writes SKILLREPO_ACCESS_KEY into
5
+ * .env.local, creating the file if absent, updating the line in place
6
+ * if the value differs, skipping if identical, and appending if the
7
+ * key is missing — preserving the file's existing line endings.
8
+ */
9
+
10
+ import { describe, it, beforeEach, afterEach } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+
16
+ let originalCwd;
17
+ let tempDir;
18
+
19
+ beforeEach(() => {
20
+ originalCwd = process.cwd;
21
+ tempDir = mkdtempSync(join(tmpdir(), "cli-test-envlocal-"));
22
+ process.cwd = () => tempDir;
23
+ });
24
+
25
+ afterEach(() => {
26
+ process.cwd = originalCwd;
27
+ rmSync(tempDir, { recursive: true, force: true });
28
+ });
29
+
30
+ const envPath = () => join(tempDir, ".env.local");
31
+ const KEY = "SKILLREPO_ACCESS_KEY";
32
+
33
+ describe("env-local merger", () => {
34
+ it("creates .env.local when file does not exist", async () => {
35
+ const { mergeEnvLocal } = await import("../../lib/mergers/env-local.mjs");
36
+ const result = mergeEnvLocal("sk_live_abc");
37
+
38
+ assert.equal(result.action, "created");
39
+ assert.equal(result.path, ".env.local");
40
+ assert.equal(readFileSync(envPath(), "utf-8"), `${KEY}=sk_live_abc\n`);
41
+ });
42
+
43
+ it("skips when the key is already present with the same value", async () => {
44
+ const { mergeEnvLocal } = await import("../../lib/mergers/env-local.mjs");
45
+ writeFileSync(envPath(), `${KEY}=sk_live_abc\n`);
46
+
47
+ const result = mergeEnvLocal("sk_live_abc");
48
+
49
+ assert.equal(result.action, "skipped");
50
+ // File is untouched.
51
+ assert.equal(readFileSync(envPath(), "utf-8"), `${KEY}=sk_live_abc\n`);
52
+ });
53
+
54
+ it("updates in place when the existing value differs", async () => {
55
+ const { mergeEnvLocal } = await import("../../lib/mergers/env-local.mjs");
56
+ writeFileSync(envPath(), `FOO=bar\n${KEY}=sk_live_old\nBAZ=qux\n`);
57
+
58
+ const result = mergeEnvLocal("sk_live_new");
59
+
60
+ assert.equal(result.action, "updated");
61
+ const content = readFileSync(envPath(), "utf-8");
62
+ assert.match(content, new RegExp(`${KEY}=sk_live_new`));
63
+ assert.doesNotMatch(content, /sk_live_old/);
64
+ // Surrounding lines preserved.
65
+ assert.match(content, /FOO=bar/);
66
+ assert.match(content, /BAZ=qux/);
67
+ });
68
+
69
+ it("appends when the key is missing and the file ends with a newline", async () => {
70
+ const { mergeEnvLocal } = await import("../../lib/mergers/env-local.mjs");
71
+ writeFileSync(envPath(), `FOO=bar\n`);
72
+
73
+ const result = mergeEnvLocal("sk_live_abc");
74
+
75
+ assert.equal(result.action, "added");
76
+ assert.equal(readFileSync(envPath(), "utf-8"), `FOO=bar\n${KEY}=sk_live_abc\n`);
77
+ });
78
+
79
+ it("appends a newline first when the file has no trailing newline", async () => {
80
+ const { mergeEnvLocal } = await import("../../lib/mergers/env-local.mjs");
81
+ // No trailing newline — exercises the content += lineEnding branch.
82
+ writeFileSync(envPath(), `FOO=bar`);
83
+
84
+ const result = mergeEnvLocal("sk_live_abc");
85
+
86
+ assert.equal(result.action, "added");
87
+ assert.equal(readFileSync(envPath(), "utf-8"), `FOO=bar\n${KEY}=sk_live_abc\n`);
88
+ });
89
+
90
+ it("preserves CRLF line endings when updating in place", async () => {
91
+ const { mergeEnvLocal } = await import("../../lib/mergers/env-local.mjs");
92
+ writeFileSync(envPath(), `FOO=bar\r\n${KEY}=sk_live_old\r\n`);
93
+
94
+ const result = mergeEnvLocal("sk_live_new");
95
+
96
+ assert.equal(result.action, "updated");
97
+ const content = readFileSync(envPath(), "utf-8");
98
+ assert.ok(content.includes("\r\n"), "CRLF endings preserved");
99
+ assert.match(content, new RegExp(`${KEY}=sk_live_new`));
100
+ });
101
+
102
+ it("preserves CRLF line endings when appending", async () => {
103
+ const { mergeEnvLocal } = await import("../../lib/mergers/env-local.mjs");
104
+ writeFileSync(envPath(), `FOO=bar\r\n`);
105
+
106
+ const result = mergeEnvLocal("sk_live_abc");
107
+
108
+ assert.equal(result.action, "added");
109
+ assert.equal(
110
+ readFileSync(envPath(), "utf-8"),
111
+ `FOO=bar\r\n${KEY}=sk_live_abc\r\n`,
112
+ );
113
+ });
114
+
115
+ it("treats an empty existing file as create-on-append (no leading newline)", async () => {
116
+ const { mergeEnvLocal } = await import("../../lib/mergers/env-local.mjs");
117
+ // Empty string is non-null → goes through the append path, but the
118
+ // `content &&` guard skips the newline prefix for empty content.
119
+ writeFileSync(envPath(), "");
120
+
121
+ const result = mergeEnvLocal("sk_live_abc");
122
+
123
+ assert.equal(result.action, "added");
124
+ assert.equal(readFileSync(envPath(), "utf-8"), `${KEY}=sk_live_abc\n`);
125
+ });
126
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Unit tests for src/lib/mergers/vscode-mcp.mjs.
3
+ *
4
+ * VS Code's .vscode/mcp.json is the complex merger: it carries an
5
+ * `inputs` array (the access-key prompt) AND a `servers` object (note:
6
+ * "servers", not "mcpServers"). Both sections must be created/merged
7
+ * idempotently while preserving unrelated user entries.
8
+ */
9
+
10
+ import { describe, it, beforeEach, afterEach } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+
16
+ let originalCwd;
17
+ let tempDir;
18
+
19
+ beforeEach(() => {
20
+ originalCwd = process.cwd;
21
+ tempDir = mkdtempSync(join(tmpdir(), "cli-test-vscode-"));
22
+ process.cwd = () => tempDir;
23
+ // Pre-create .vscode so existing-file tests can seed mcp.json directly;
24
+ // the create-path test writes to a fresh subdir and doesn't depend on it.
25
+ mkdirSync(join(tempDir, ".vscode"), { recursive: true });
26
+ });
27
+
28
+ afterEach(() => {
29
+ process.cwd = originalCwd;
30
+ rmSync(tempDir, { recursive: true, force: true });
31
+ });
32
+
33
+ const vscodePath = () => join(tempDir, ".vscode", "mcp.json");
34
+
35
+ describe("VS Code + Copilot MCP config merger", () => {
36
+ it("creates .vscode/mcp.json when file does not exist", async () => {
37
+ const { mergeVscodeMcpConfig } = await import("../../lib/mergers/vscode-mcp.mjs");
38
+ const result = mergeVscodeMcpConfig("https://mcp.skillrepo.dev");
39
+
40
+ assert.equal(result.action, "created");
41
+ assert.equal(result.path, ".vscode/mcp.json");
42
+
43
+ const content = JSON.parse(readFileSync(vscodePath(), "utf-8"));
44
+ assert.equal(content.servers.skillrepo.type, "http");
45
+ assert.equal(content.servers.skillrepo.url, "https://mcp.skillrepo.dev");
46
+ assert.equal(
47
+ content.servers.skillrepo.headers.Authorization,
48
+ "Bearer ${input:skillrepo-api-key}",
49
+ );
50
+ // The input prompt entry must be seeded on create.
51
+ assert.equal(content.inputs.length, 1);
52
+ assert.equal(content.inputs[0].id, "skillrepo-api-key");
53
+ assert.equal(content.inputs[0].password, true);
54
+ });
55
+
56
+ it("merges into existing config preserving other servers and inputs", async () => {
57
+ const { mergeVscodeMcpConfig } = await import("../../lib/mergers/vscode-mcp.mjs");
58
+
59
+ const existing = {
60
+ inputs: [{ id: "other-tool-key", type: "promptString" }],
61
+ servers: {
62
+ other: { type: "http", url: "https://other.example" },
63
+ },
64
+ };
65
+ writeFileSync(vscodePath(), JSON.stringify(existing));
66
+
67
+ const result = mergeVscodeMcpConfig("https://mcp.skillrepo.dev");
68
+
69
+ // No skillrepo server existed yet → "created".
70
+ assert.equal(result.action, "created");
71
+ const content = JSON.parse(readFileSync(vscodePath(), "utf-8"));
72
+ assert.ok(content.servers.other, "other server preserved");
73
+ assert.ok(content.servers.skillrepo, "skillrepo added");
74
+ // Both the pre-existing input and our seeded input must be present.
75
+ assert.equal(content.inputs.length, 2);
76
+ assert.ok(content.inputs.some((i) => i.id === "other-tool-key"));
77
+ assert.ok(content.inputs.some((i) => i.id === "skillrepo-api-key"));
78
+ });
79
+
80
+ it("returns 'merged' when a skillrepo server entry already exists", async () => {
81
+ const { mergeVscodeMcpConfig } = await import("../../lib/mergers/vscode-mcp.mjs");
82
+
83
+ const existing = {
84
+ inputs: [{ id: "skillrepo-api-key", type: "promptString" }],
85
+ servers: {
86
+ skillrepo: { type: "http", url: "https://old.example", headers: {} },
87
+ },
88
+ };
89
+ writeFileSync(vscodePath(), JSON.stringify(existing));
90
+
91
+ const result = mergeVscodeMcpConfig("https://new.example/mcp");
92
+
93
+ assert.equal(result.action, "merged");
94
+ const content = JSON.parse(readFileSync(vscodePath(), "utf-8"));
95
+ assert.equal(content.servers.skillrepo.url, "https://new.example/mcp");
96
+ // The input must NOT be duplicated (hasInput true branch).
97
+ assert.equal(
98
+ content.inputs.filter((i) => i.id === "skillrepo-api-key").length,
99
+ 1,
100
+ );
101
+ });
102
+
103
+ it("rebuilds inputs when the existing inputs field is not an array", async () => {
104
+ const { mergeVscodeMcpConfig } = await import("../../lib/mergers/vscode-mcp.mjs");
105
+
106
+ // `inputs` is an object, not an array — the merger must replace it.
107
+ writeFileSync(
108
+ vscodePath(),
109
+ JSON.stringify({ inputs: { bogus: true }, servers: {} }),
110
+ );
111
+
112
+ const result = mergeVscodeMcpConfig("https://mcp.skillrepo.dev");
113
+
114
+ assert.equal(result.action, "created");
115
+ const content = JSON.parse(readFileSync(vscodePath(), "utf-8"));
116
+ assert.ok(Array.isArray(content.inputs));
117
+ assert.equal(content.inputs.length, 1);
118
+ assert.equal(content.inputs[0].id, "skillrepo-api-key");
119
+ });
120
+
121
+ it("rebuilds servers when the existing servers field is missing/non-object", async () => {
122
+ const { mergeVscodeMcpConfig } = await import("../../lib/mergers/vscode-mcp.mjs");
123
+
124
+ // `servers` is a string — the merger must replace it with {}.
125
+ writeFileSync(
126
+ vscodePath(),
127
+ JSON.stringify({ inputs: [], servers: "nope" }),
128
+ );
129
+
130
+ const result = mergeVscodeMcpConfig("https://mcp.skillrepo.dev");
131
+
132
+ assert.equal(result.action, "created");
133
+ const content = JSON.parse(readFileSync(vscodePath(), "utf-8"));
134
+ assert.equal(typeof content.servers, "object");
135
+ assert.ok(content.servers.skillrepo);
136
+ });
137
+
138
+ it("creates servers object when the key is entirely absent", async () => {
139
+ const { mergeVscodeMcpConfig } = await import("../../lib/mergers/vscode-mcp.mjs");
140
+
141
+ // Valid JSON object but no `servers` key at all (falsy branch).
142
+ writeFileSync(vscodePath(), JSON.stringify({ inputs: [] }));
143
+
144
+ const result = mergeVscodeMcpConfig("https://mcp.skillrepo.dev");
145
+
146
+ assert.equal(result.action, "created");
147
+ const content = JSON.parse(readFileSync(vscodePath(), "utf-8"));
148
+ assert.ok(content.servers.skillrepo);
149
+ });
150
+
151
+ it("throws on invalid JSON", async () => {
152
+ const { mergeVscodeMcpConfig } = await import("../../lib/mergers/vscode-mcp.mjs");
153
+ writeFileSync(vscodePath(), "not json {{{");
154
+
155
+ assert.throws(
156
+ () => mergeVscodeMcpConfig("https://mcp.skillrepo.dev"),
157
+ /invalid JSON/,
158
+ );
159
+ });
160
+
161
+ it("is idempotent — second run re-asserts the same entry as 'merged'", async () => {
162
+ const { mergeVscodeMcpConfig } = await import("../../lib/mergers/vscode-mcp.mjs");
163
+
164
+ const first = mergeVscodeMcpConfig("https://mcp.skillrepo.dev");
165
+ assert.equal(first.action, "created");
166
+
167
+ const second = mergeVscodeMcpConfig("https://mcp.skillrepo.dev");
168
+ assert.equal(second.action, "merged");
169
+
170
+ const content = JSON.parse(readFileSync(vscodePath(), "utf-8"));
171
+ // Still exactly one input entry — no duplication across runs.
172
+ assert.equal(
173
+ content.inputs.filter((i) => i.id === "skillrepo-api-key").length,
174
+ 1,
175
+ );
176
+ });
177
+ });