skillrepo 2.0.0 → 3.1.0
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/README.md +276 -145
- package/bin/skillrepo.mjs +224 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +589 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +162 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +184 -0
- package/src/lib/artifact-registry.mjs +265 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/fs-utils.mjs +83 -1
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +67 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +697 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +322 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- package/src/test/e2e/payload-factory.mjs +0 -22
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/removers/claude-mcp.mjs (#885).
|
|
3
|
+
*
|
|
4
|
+
* Covers the core surgical-JSON contract: delete only
|
|
5
|
+
* `mcpServers.skillrepo`, preserve every other entry, skip cleanly
|
|
6
|
+
* when the target key isn't present.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
|
|
15
|
+
import { removeClaudeMcp } from "../../lib/removers/claude-mcp.mjs";
|
|
16
|
+
|
|
17
|
+
let sandbox;
|
|
18
|
+
let originalCwd;
|
|
19
|
+
|
|
20
|
+
function setup() {
|
|
21
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-unrm-claude-"));
|
|
22
|
+
originalCwd = process.cwd();
|
|
23
|
+
process.chdir(sandbox);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function teardown() {
|
|
27
|
+
process.chdir(originalCwd);
|
|
28
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("removeClaudeMcp", () => {
|
|
32
|
+
beforeEach(setup);
|
|
33
|
+
afterEach(teardown);
|
|
34
|
+
|
|
35
|
+
it("is a no-op when .mcp.json does not exist", () => {
|
|
36
|
+
const result = removeClaudeMcp();
|
|
37
|
+
assert.equal(result.action, "skipped");
|
|
38
|
+
assert.ok(!existsSync(".mcp.json"));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("is a no-op when mcpServers.skillrepo is absent", () => {
|
|
42
|
+
const content = JSON.stringify(
|
|
43
|
+
{ mcpServers: { otherTool: { type: "http", url: "https://example.com" } } },
|
|
44
|
+
null,
|
|
45
|
+
2,
|
|
46
|
+
);
|
|
47
|
+
writeFileSync(".mcp.json", content);
|
|
48
|
+
|
|
49
|
+
const result = removeClaudeMcp();
|
|
50
|
+
assert.equal(result.action, "skipped");
|
|
51
|
+
assert.equal(readFileSync(".mcp.json", "utf-8"), content);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("removes only the skillrepo entry, preserves other servers", () => {
|
|
55
|
+
writeFileSync(
|
|
56
|
+
".mcp.json",
|
|
57
|
+
JSON.stringify(
|
|
58
|
+
{
|
|
59
|
+
mcpServers: {
|
|
60
|
+
skillrepo: {
|
|
61
|
+
type: "http",
|
|
62
|
+
url: "https://skillrepo.dev/api/mcp",
|
|
63
|
+
headers: { Authorization: "Bearer ${SKILLREPO_ACCESS_KEY}" },
|
|
64
|
+
},
|
|
65
|
+
otherTool: {
|
|
66
|
+
type: "http",
|
|
67
|
+
url: "https://example.com/mcp",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
null,
|
|
72
|
+
2,
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const result = removeClaudeMcp();
|
|
77
|
+
assert.equal(result.action, "removed");
|
|
78
|
+
|
|
79
|
+
const parsed = JSON.parse(readFileSync(".mcp.json", "utf-8"));
|
|
80
|
+
assert.equal(parsed.mcpServers.skillrepo, undefined);
|
|
81
|
+
assert.ok(parsed.mcpServers.otherTool, "sibling server entry must survive");
|
|
82
|
+
assert.equal(parsed.mcpServers.otherTool.url, "https://example.com/mcp");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("leaves an empty mcpServers object when skillrepo was the only entry", () => {
|
|
86
|
+
// The design explicitly says `mcpServers` parent may become {}
|
|
87
|
+
// — don't delete the file or collapse the parent. The user's
|
|
88
|
+
// IDE will accept an empty servers object.
|
|
89
|
+
writeFileSync(
|
|
90
|
+
".mcp.json",
|
|
91
|
+
JSON.stringify({ mcpServers: { skillrepo: { type: "http", url: "x" } } }, null, 2),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const result = removeClaudeMcp();
|
|
95
|
+
assert.equal(result.action, "removed");
|
|
96
|
+
|
|
97
|
+
const parsed = JSON.parse(readFileSync(".mcp.json", "utf-8"));
|
|
98
|
+
assert.deepEqual(parsed, { mcpServers: {} });
|
|
99
|
+
assert.ok(existsSync(".mcp.json"), "file must not be deleted");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("preserves unrelated top-level keys (e.g. a future Claude Code setting)", () => {
|
|
103
|
+
writeFileSync(
|
|
104
|
+
".mcp.json",
|
|
105
|
+
JSON.stringify(
|
|
106
|
+
{
|
|
107
|
+
mcpServers: { skillrepo: { type: "http", url: "x" } },
|
|
108
|
+
someFutureField: { x: 1 },
|
|
109
|
+
},
|
|
110
|
+
null,
|
|
111
|
+
2,
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
removeClaudeMcp();
|
|
116
|
+
|
|
117
|
+
const parsed = JSON.parse(readFileSync(".mcp.json", "utf-8"));
|
|
118
|
+
assert.deepEqual(parsed.someFutureField, { x: 1 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns a structured error (does not throw) on unparseable JSON", () => {
|
|
122
|
+
// The uninstall command relies on removers returning errors in
|
|
123
|
+
// their result objects rather than throwing — continue-with-
|
|
124
|
+
// errors semantics require a structured signal.
|
|
125
|
+
writeFileSync(".mcp.json", "{ not valid json");
|
|
126
|
+
|
|
127
|
+
const result = removeClaudeMcp();
|
|
128
|
+
assert.equal(result.action, "skipped");
|
|
129
|
+
assert.ok(result.error, "unparseable JSON must surface as a structured error");
|
|
130
|
+
assert.match(result.error, /parse/i);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("is idempotent — second call is a no-op", () => {
|
|
134
|
+
writeFileSync(
|
|
135
|
+
".mcp.json",
|
|
136
|
+
JSON.stringify({ mcpServers: { skillrepo: { url: "x" } } }, null, 2),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const first = removeClaudeMcp();
|
|
140
|
+
assert.equal(first.action, "removed");
|
|
141
|
+
|
|
142
|
+
const second = removeClaudeMcp();
|
|
143
|
+
assert.equal(second.action, "skipped");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/removers/cursor-mcp.mjs (#885).
|
|
3
|
+
*
|
|
4
|
+
* Shares the JSON-key-surgery shape with claude-mcp. Focus tests
|
|
5
|
+
* on the path-specific and schema-specific details (cursor's env
|
|
6
|
+
* interpolation is `${env:...}` not `${...}`, but that's a writer
|
|
7
|
+
* concern — the remover just deletes the key).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
|
|
16
|
+
import { removeCursorMcp } from "../../lib/removers/cursor-mcp.mjs";
|
|
17
|
+
|
|
18
|
+
let sandbox;
|
|
19
|
+
let originalCwd;
|
|
20
|
+
|
|
21
|
+
function setup() {
|
|
22
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-unrm-cursor-"));
|
|
23
|
+
originalCwd = process.cwd();
|
|
24
|
+
process.chdir(sandbox);
|
|
25
|
+
mkdirSync(join(sandbox, ".cursor"), { recursive: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function teardown() {
|
|
29
|
+
process.chdir(originalCwd);
|
|
30
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("removeCursorMcp", () => {
|
|
34
|
+
beforeEach(setup);
|
|
35
|
+
afterEach(teardown);
|
|
36
|
+
|
|
37
|
+
it("is a no-op when .cursor/mcp.json does not exist", () => {
|
|
38
|
+
const result = removeCursorMcp();
|
|
39
|
+
assert.equal(result.action, "skipped");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("removes only skillrepo, preserves siblings", () => {
|
|
43
|
+
writeFileSync(
|
|
44
|
+
".cursor/mcp.json",
|
|
45
|
+
JSON.stringify(
|
|
46
|
+
{
|
|
47
|
+
mcpServers: {
|
|
48
|
+
skillrepo: { url: "x", headers: {} },
|
|
49
|
+
otherTool: { url: "y" },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
null,
|
|
53
|
+
2,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const result = removeCursorMcp();
|
|
58
|
+
assert.equal(result.action, "removed");
|
|
59
|
+
|
|
60
|
+
const parsed = JSON.parse(readFileSync(".cursor/mcp.json", "utf-8"));
|
|
61
|
+
assert.equal(parsed.mcpServers.skillrepo, undefined);
|
|
62
|
+
assert.ok(parsed.mcpServers.otherTool);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("is a no-op when mcpServers.skillrepo is absent", () => {
|
|
66
|
+
const content = JSON.stringify({ mcpServers: {} }, null, 2);
|
|
67
|
+
writeFileSync(".cursor/mcp.json", content);
|
|
68
|
+
|
|
69
|
+
const result = removeCursorMcp();
|
|
70
|
+
assert.equal(result.action, "skipped");
|
|
71
|
+
assert.equal(readFileSync(".cursor/mcp.json", "utf-8"), content);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("returns a structured error on unparseable JSON", () => {
|
|
75
|
+
writeFileSync(".cursor/mcp.json", "not json");
|
|
76
|
+
|
|
77
|
+
const result = removeCursorMcp();
|
|
78
|
+
assert.equal(result.action, "skipped");
|
|
79
|
+
assert.ok(result.error);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("is idempotent — second call is a no-op", () => {
|
|
83
|
+
writeFileSync(
|
|
84
|
+
".cursor/mcp.json",
|
|
85
|
+
JSON.stringify({ mcpServers: { skillrepo: { url: "x" } } }, null, 2),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const first = removeCursorMcp();
|
|
89
|
+
assert.equal(first.action, "removed");
|
|
90
|
+
|
|
91
|
+
const second = removeCursorMcp();
|
|
92
|
+
assert.equal(second.action, "skipped");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("dryRun does not modify the file and returns would-remove", () => {
|
|
96
|
+
const content = JSON.stringify(
|
|
97
|
+
{ mcpServers: { skillrepo: { url: "x" } } },
|
|
98
|
+
null,
|
|
99
|
+
2,
|
|
100
|
+
);
|
|
101
|
+
writeFileSync(".cursor/mcp.json", content);
|
|
102
|
+
|
|
103
|
+
const result = removeCursorMcp({ dryRun: true });
|
|
104
|
+
assert.equal(result.action, "would-remove");
|
|
105
|
+
// Byte-for-byte unchanged.
|
|
106
|
+
assert.equal(readFileSync(".cursor/mcp.json", "utf-8"), content);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/removers/env-local.mjs (#885).
|
|
3
|
+
*
|
|
4
|
+
* The remover strips every line whose prefix matches `SKILLREPO_ACCESS_KEY=`.
|
|
5
|
+
* It does NOT strip lines that merely contain the string `SKILLREPO_ACCESS_KEY`
|
|
6
|
+
* — a user-authored comment or template line is preserved by the
|
|
7
|
+
* prefix-match contract.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
|
|
16
|
+
import { removeEnvLocal } from "../../lib/removers/env-local.mjs";
|
|
17
|
+
|
|
18
|
+
let sandbox;
|
|
19
|
+
let originalCwd;
|
|
20
|
+
|
|
21
|
+
function setup() {
|
|
22
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-unrm-env-"));
|
|
23
|
+
originalCwd = process.cwd();
|
|
24
|
+
process.chdir(sandbox);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function teardown() {
|
|
28
|
+
process.chdir(originalCwd);
|
|
29
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("removeEnvLocal", () => {
|
|
33
|
+
beforeEach(setup);
|
|
34
|
+
afterEach(teardown);
|
|
35
|
+
|
|
36
|
+
it("is a no-op when .env.local does not exist", () => {
|
|
37
|
+
const result = removeEnvLocal();
|
|
38
|
+
assert.equal(result.action, "skipped");
|
|
39
|
+
assert.equal(result.removed, 0);
|
|
40
|
+
assert.ok(!existsSync(".env.local"));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("is a no-op when .env.local exists but has no SKILLREPO_ACCESS_KEY line", () => {
|
|
44
|
+
const content = "NODE_ENV=production\nDATABASE_URL=postgres://...\n";
|
|
45
|
+
writeFileSync(".env.local", content, "utf-8");
|
|
46
|
+
|
|
47
|
+
const result = removeEnvLocal();
|
|
48
|
+
assert.equal(result.action, "skipped");
|
|
49
|
+
assert.equal(result.removed, 0);
|
|
50
|
+
assert.equal(readFileSync(".env.local", "utf-8"), content);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("removes the SKILLREPO_ACCESS_KEY line, preserves everything else", () => {
|
|
54
|
+
const content = [
|
|
55
|
+
"NODE_ENV=production",
|
|
56
|
+
"SKILLREPO_ACCESS_KEY=sk_live_abc123",
|
|
57
|
+
"DATABASE_URL=postgres://localhost",
|
|
58
|
+
"",
|
|
59
|
+
].join("\n");
|
|
60
|
+
writeFileSync(".env.local", content, "utf-8");
|
|
61
|
+
|
|
62
|
+
const result = removeEnvLocal();
|
|
63
|
+
assert.equal(result.action, "removed");
|
|
64
|
+
assert.equal(result.removed, 1);
|
|
65
|
+
|
|
66
|
+
const after = readFileSync(".env.local", "utf-8");
|
|
67
|
+
assert.match(after, /NODE_ENV=production/);
|
|
68
|
+
assert.match(after, /DATABASE_URL=postgres:\/\/localhost/);
|
|
69
|
+
assert.doesNotMatch(after, /SKILLREPO_ACCESS_KEY/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("preserves a commented-out SKILLREPO_ACCESS_KEY line", () => {
|
|
73
|
+
// The prefix match requires the line to START with
|
|
74
|
+
// `SKILLREPO_ACCESS_KEY=` — a comment line doesn't match. This
|
|
75
|
+
// avoids surprising a user whose `.env.local` has template
|
|
76
|
+
// documentation referencing the variable.
|
|
77
|
+
const content = [
|
|
78
|
+
"# SKILLREPO_ACCESS_KEY=sk_live_example",
|
|
79
|
+
"SKILLREPO_ACCESS_KEY=sk_live_real",
|
|
80
|
+
"OTHER_VAR=value",
|
|
81
|
+
"",
|
|
82
|
+
].join("\n");
|
|
83
|
+
writeFileSync(".env.local", content, "utf-8");
|
|
84
|
+
|
|
85
|
+
removeEnvLocal();
|
|
86
|
+
|
|
87
|
+
const after = readFileSync(".env.local", "utf-8");
|
|
88
|
+
// The comment survives.
|
|
89
|
+
assert.match(after, /# SKILLREPO_ACCESS_KEY=sk_live_example/);
|
|
90
|
+
// The real credential line is gone.
|
|
91
|
+
assert.doesNotMatch(after, /^SKILLREPO_ACCESS_KEY=sk_live_real$/m);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("removes multiple SKILLREPO_ACCESS_KEY lines if the file has them", () => {
|
|
95
|
+
// Edge case: a file with duplicate entries (e.g. from a bad
|
|
96
|
+
// merge conflict or manual edit). All of them are SkillRepo-
|
|
97
|
+
// owned and should go.
|
|
98
|
+
const content = [
|
|
99
|
+
"NODE_ENV=dev",
|
|
100
|
+
"SKILLREPO_ACCESS_KEY=sk_live_old",
|
|
101
|
+
"FOO=bar",
|
|
102
|
+
"SKILLREPO_ACCESS_KEY=sk_live_new",
|
|
103
|
+
"",
|
|
104
|
+
].join("\n");
|
|
105
|
+
writeFileSync(".env.local", content, "utf-8");
|
|
106
|
+
|
|
107
|
+
const result = removeEnvLocal();
|
|
108
|
+
assert.equal(result.action, "removed");
|
|
109
|
+
assert.equal(result.removed, 2);
|
|
110
|
+
|
|
111
|
+
const after = readFileSync(".env.local", "utf-8");
|
|
112
|
+
assert.doesNotMatch(after, /SKILLREPO_ACCESS_KEY=/);
|
|
113
|
+
assert.match(after, /NODE_ENV=dev/);
|
|
114
|
+
assert.match(after, /FOO=bar/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("idempotent — second call is a no-op", () => {
|
|
118
|
+
writeFileSync(".env.local", "SKILLREPO_ACCESS_KEY=sk_live_x\n", "utf-8");
|
|
119
|
+
|
|
120
|
+
const first = removeEnvLocal();
|
|
121
|
+
assert.equal(first.action, "removed");
|
|
122
|
+
|
|
123
|
+
const second = removeEnvLocal();
|
|
124
|
+
assert.equal(second.action, "skipped");
|
|
125
|
+
assert.equal(second.removed, 0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("preserves CRLF line endings when the source file used them", () => {
|
|
129
|
+
const crlf = [
|
|
130
|
+
"NODE_ENV=production",
|
|
131
|
+
"SKILLREPO_ACCESS_KEY=sk_live_abc",
|
|
132
|
+
"DATABASE_URL=postgres://localhost",
|
|
133
|
+
"",
|
|
134
|
+
].join("\r\n");
|
|
135
|
+
writeFileSync(".env.local", crlf, "utf-8");
|
|
136
|
+
|
|
137
|
+
removeEnvLocal();
|
|
138
|
+
|
|
139
|
+
const after = readFileSync(".env.local", "utf-8");
|
|
140
|
+
// Surviving lines keep CRLF.
|
|
141
|
+
assert.ok(after.includes("NODE_ENV=production\r\n"));
|
|
142
|
+
assert.ok(after.includes("DATABASE_URL=postgres://localhost\r\n"));
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/removers/gitignore.mjs (#885).
|
|
3
|
+
*
|
|
4
|
+
* The gitignore remover is the attribution-sensitive one: the `.env.local`
|
|
5
|
+
* line in particular is commonly added manually by developers for
|
|
6
|
+
* reasons unrelated to SkillRepo. The remover MUST only strip lines
|
|
7
|
+
* that appeared under the SkillRepo section header — never lines that
|
|
8
|
+
* happen to match an entry-text but live outside the section.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors the coverage shape of `src/test/mergers/gitignore.test.mjs`
|
|
11
|
+
* (the installer tests). Inline-seeds file content with `writeFileSync`
|
|
12
|
+
* so failure modes are readable — each test's fixture is right there
|
|
13
|
+
* in the test body.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
|
|
22
|
+
import { removeGitignore } from "../../lib/removers/gitignore.mjs";
|
|
23
|
+
|
|
24
|
+
let sandbox;
|
|
25
|
+
let originalCwd;
|
|
26
|
+
|
|
27
|
+
function setup() {
|
|
28
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-unrm-gitignore-"));
|
|
29
|
+
originalCwd = process.cwd();
|
|
30
|
+
process.chdir(sandbox);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function teardown() {
|
|
34
|
+
process.chdir(originalCwd);
|
|
35
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("removeGitignore", () => {
|
|
39
|
+
beforeEach(setup);
|
|
40
|
+
afterEach(teardown);
|
|
41
|
+
|
|
42
|
+
it("is a no-op when .gitignore does not exist", () => {
|
|
43
|
+
const result = removeGitignore();
|
|
44
|
+
assert.equal(result.action, "skipped");
|
|
45
|
+
assert.equal(result.removed, 0);
|
|
46
|
+
assert.ok(!existsSync(".gitignore"));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("is a no-op when .gitignore exists but has no SkillRepo section", () => {
|
|
50
|
+
const content = "node_modules/\n.DS_Store\n";
|
|
51
|
+
writeFileSync(".gitignore", content, "utf-8");
|
|
52
|
+
|
|
53
|
+
const result = removeGitignore();
|
|
54
|
+
assert.equal(result.action, "skipped");
|
|
55
|
+
assert.equal(result.removed, 0);
|
|
56
|
+
// File content is unchanged — byte-for-byte, including trailing
|
|
57
|
+
// newline presence.
|
|
58
|
+
assert.equal(readFileSync(".gitignore", "utf-8"), content);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("removes the SkillRepo section and its entries (happy path)", () => {
|
|
62
|
+
const content = [
|
|
63
|
+
"node_modules/",
|
|
64
|
+
".DS_Store",
|
|
65
|
+
"",
|
|
66
|
+
"# SkillRepo CLI (added by `skillrepo init`)",
|
|
67
|
+
".env.local",
|
|
68
|
+
".claude/skills/",
|
|
69
|
+
".claude/settings.local.json",
|
|
70
|
+
"",
|
|
71
|
+
"dist/",
|
|
72
|
+
"",
|
|
73
|
+
].join("\n");
|
|
74
|
+
writeFileSync(".gitignore", content, "utf-8");
|
|
75
|
+
|
|
76
|
+
const result = removeGitignore();
|
|
77
|
+
assert.equal(result.action, "removed");
|
|
78
|
+
assert.equal(result.removed, 4); // header + 3 entries
|
|
79
|
+
|
|
80
|
+
const after = readFileSync(".gitignore", "utf-8");
|
|
81
|
+
// Survives: the three non-SkillRepo lines.
|
|
82
|
+
assert.match(after, /node_modules\//);
|
|
83
|
+
assert.match(after, /\.DS_Store/);
|
|
84
|
+
assert.match(after, /dist\//);
|
|
85
|
+
// Does NOT survive: any SkillRepo line.
|
|
86
|
+
assert.doesNotMatch(after, /SkillRepo/);
|
|
87
|
+
assert.doesNotMatch(after, /^\.env\.local$/m);
|
|
88
|
+
assert.doesNotMatch(after, /\.claude\/skills\//);
|
|
89
|
+
assert.doesNotMatch(after, /settings\.local\.json/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("preserves a user-authored `.env.local` line outside the SkillRepo section", () => {
|
|
93
|
+
// CRITICAL attribution test: the user has their own `.env.local`
|
|
94
|
+
// line from before they installed SkillRepo. The remover must NOT
|
|
95
|
+
// touch it — only lines inside the SkillRepo section are owned.
|
|
96
|
+
const content = [
|
|
97
|
+
".env.local", // user-owned
|
|
98
|
+
"",
|
|
99
|
+
"# SkillRepo CLI (added by `skillrepo init`)",
|
|
100
|
+
".env.local",
|
|
101
|
+
".claude/skills/",
|
|
102
|
+
".claude/settings.local.json",
|
|
103
|
+
"",
|
|
104
|
+
].join("\n");
|
|
105
|
+
writeFileSync(".gitignore", content, "utf-8");
|
|
106
|
+
|
|
107
|
+
const result = removeGitignore();
|
|
108
|
+
assert.equal(result.action, "removed");
|
|
109
|
+
|
|
110
|
+
const after = readFileSync(".gitignore", "utf-8");
|
|
111
|
+
// The user's .env.local line at the TOP survives. The one inside
|
|
112
|
+
// the SkillRepo section is gone. Line-count-of-match assertion
|
|
113
|
+
// captures this precisely.
|
|
114
|
+
const envLocalLines = after
|
|
115
|
+
.split(/\r?\n/)
|
|
116
|
+
.filter((l) => l === ".env.local");
|
|
117
|
+
assert.equal(
|
|
118
|
+
envLocalLines.length,
|
|
119
|
+
1,
|
|
120
|
+
"exactly one .env.local line should remain — the user-authored one",
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("handles the section at the end of the file with no trailing blank line", () => {
|
|
125
|
+
const content = [
|
|
126
|
+
"node_modules/",
|
|
127
|
+
"",
|
|
128
|
+
"# SkillRepo CLI (added by `skillrepo init`)",
|
|
129
|
+
".env.local",
|
|
130
|
+
".claude/skills/",
|
|
131
|
+
".claude/settings.local.json",
|
|
132
|
+
].join("\n");
|
|
133
|
+
writeFileSync(".gitignore", content, "utf-8");
|
|
134
|
+
|
|
135
|
+
const result = removeGitignore();
|
|
136
|
+
assert.equal(result.action, "removed");
|
|
137
|
+
|
|
138
|
+
const after = readFileSync(".gitignore", "utf-8");
|
|
139
|
+
assert.match(after, /node_modules\//);
|
|
140
|
+
assert.doesNotMatch(after, /SkillRepo/);
|
|
141
|
+
assert.doesNotMatch(after, /\.env\.local/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("is idempotent — running twice after a removal is a no-op on the second call", () => {
|
|
145
|
+
const content = [
|
|
146
|
+
"# SkillRepo CLI (added by `skillrepo init`)",
|
|
147
|
+
".env.local",
|
|
148
|
+
".claude/skills/",
|
|
149
|
+
".claude/settings.local.json",
|
|
150
|
+
"",
|
|
151
|
+
].join("\n");
|
|
152
|
+
writeFileSync(".gitignore", content, "utf-8");
|
|
153
|
+
|
|
154
|
+
const first = removeGitignore();
|
|
155
|
+
assert.equal(first.action, "removed");
|
|
156
|
+
|
|
157
|
+
const second = removeGitignore();
|
|
158
|
+
assert.equal(second.action, "skipped");
|
|
159
|
+
assert.equal(second.removed, 0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("preserves CRLF line endings when the source file used them", () => {
|
|
163
|
+
const crlf = [
|
|
164
|
+
"node_modules/",
|
|
165
|
+
"",
|
|
166
|
+
"# SkillRepo CLI (added by `skillrepo init`)",
|
|
167
|
+
".env.local",
|
|
168
|
+
".claude/skills/",
|
|
169
|
+
".claude/settings.local.json",
|
|
170
|
+
"",
|
|
171
|
+
"dist/",
|
|
172
|
+
"",
|
|
173
|
+
].join("\r\n");
|
|
174
|
+
writeFileSync(".gitignore", crlf, "utf-8");
|
|
175
|
+
|
|
176
|
+
removeGitignore();
|
|
177
|
+
|
|
178
|
+
const after = readFileSync(".gitignore", "utf-8");
|
|
179
|
+
// Non-SkillRepo lines survive with CRLF preserved.
|
|
180
|
+
assert.ok(after.includes("node_modules/\r\n"));
|
|
181
|
+
assert.ok(after.includes("dist/\r\n"));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("does not collapse consecutive blank lines in surrounding user content", () => {
|
|
185
|
+
// Cosmetic-but-important: if the remover aggressively normalizes
|
|
186
|
+
// whitespace, a user who deliberately uses multiple blank lines
|
|
187
|
+
// as visual separators loses that structure. The remover should
|
|
188
|
+
// remove the section and nothing else.
|
|
189
|
+
const content = [
|
|
190
|
+
"node_modules/",
|
|
191
|
+
"",
|
|
192
|
+
"",
|
|
193
|
+
"# SkillRepo CLI (added by `skillrepo init`)",
|
|
194
|
+
".env.local",
|
|
195
|
+
".claude/skills/",
|
|
196
|
+
".claude/settings.local.json",
|
|
197
|
+
"",
|
|
198
|
+
"",
|
|
199
|
+
"dist/",
|
|
200
|
+
].join("\n");
|
|
201
|
+
writeFileSync(".gitignore", content, "utf-8");
|
|
202
|
+
|
|
203
|
+
removeGitignore();
|
|
204
|
+
|
|
205
|
+
const after = readFileSync(".gitignore", "utf-8");
|
|
206
|
+
// The double-blank before dist/ is preserved.
|
|
207
|
+
assert.match(after, /\n\n\ndist\/|\n\ndist\//);
|
|
208
|
+
});
|
|
209
|
+
});
|