skillrepo 3.0.0 → 3.1.1
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 +74 -6
- package/bin/skillrepo.mjs +14 -0
- package/package.json +1 -1
- package/src/commands/init.mjs +184 -19
- package/src/commands/remove.mjs +8 -13
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +125 -8
- package/src/lib/artifact-registry.mjs +305 -0
- package/src/lib/cli-config.mjs +78 -0
- package/src/lib/config.mjs +6 -3
- package/src/lib/file-write.mjs +8 -3
- package/src/lib/fs-utils.mjs +90 -9
- package/src/lib/mergers/session-hook.mjs +378 -0
- package/src/lib/paths.mjs +21 -0
- package/src/lib/platform.mjs +124 -0
- 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 +26 -0
- package/src/test/commands/add.test.mjs +10 -4
- package/src/test/commands/get.test.mjs +10 -4
- package/src/test/commands/init.test.mjs +428 -4
- package/src/test/commands/list.test.mjs +10 -4
- package/src/test/commands/remove.test.mjs +10 -4
- package/src/test/commands/search.test.mjs +10 -4
- package/src/test/commands/session-sync.test.mjs +352 -0
- package/src/test/commands/uninstall.test.mjs +774 -0
- package/src/test/commands/update.test.mjs +168 -4
- package/src/test/helpers/sandbox-home.mjs +161 -0
- package/src/test/helpers/skillrepo-shim.mjs +133 -0
- package/src/test/integration/file-write.integration.test.mjs +10 -4
- package/src/test/lib/artifact-registry.test.mjs +268 -0
- package/src/test/lib/cli-config.test.mjs +126 -5
- package/src/test/lib/config.test.mjs +10 -4
- package/src/test/lib/file-write.test.mjs +24 -10
- package/src/test/lib/mcp-merge.test.mjs +10 -4
- package/src/test/lib/paths.test.mjs +10 -4
- package/src/test/lib/platform.test.mjs +135 -0
- package/src/test/lib/sync.test.mjs +20 -4
- package/src/test/mergers/session-hook.test.mjs +1175 -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 +296 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +128 -0
|
@@ -16,12 +16,18 @@ import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
|
16
16
|
import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
17
17
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
18
18
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
19
|
+
import {
|
|
20
|
+
captureHome,
|
|
21
|
+
setSandboxHome,
|
|
22
|
+
restoreHome,
|
|
23
|
+
} from "../helpers/sandbox-home.mjs";
|
|
19
24
|
|
|
20
25
|
let sandbox;
|
|
21
26
|
let server;
|
|
22
27
|
let serverUrl;
|
|
23
28
|
let originalCwd;
|
|
24
|
-
|
|
29
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
30
|
+
let originalHomeEnv;
|
|
25
31
|
let stdout;
|
|
26
32
|
const VALID_KEY = "sk_live_test";
|
|
27
33
|
|
|
@@ -49,9 +55,9 @@ async function setup() {
|
|
|
49
55
|
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
50
56
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
51
57
|
originalCwd = process.cwd();
|
|
52
|
-
|
|
58
|
+
originalHomeEnv = captureHome();
|
|
53
59
|
process.chdir(join(sandbox, "project"));
|
|
54
|
-
|
|
60
|
+
setSandboxHome(join(sandbox, "home"));
|
|
55
61
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
56
62
|
delete process.env.SKILLREPO_URL;
|
|
57
63
|
|
|
@@ -65,7 +71,7 @@ async function setup() {
|
|
|
65
71
|
async function teardown() {
|
|
66
72
|
if (server) await server.stop();
|
|
67
73
|
process.chdir(originalCwd);
|
|
68
|
-
|
|
74
|
+
restoreHome(originalHomeEnv);
|
|
69
75
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
70
76
|
server = null;
|
|
71
77
|
}
|
|
@@ -162,3 +168,161 @@ describe("runUpdate — flag handling", () => {
|
|
|
162
168
|
assert.ok(existsSync(dir));
|
|
163
169
|
});
|
|
164
170
|
});
|
|
171
|
+
|
|
172
|
+
// ── --session-hook mode (#884) ────────────────────────────────────────
|
|
173
|
+
//
|
|
174
|
+
// Session-hook mode is what the Claude Code SessionStart hook #884's
|
|
175
|
+
// installer writes invokes: `skillrepo update --session-hook`. The
|
|
176
|
+
// contract: exit 0 on ALL errors, silent on 304, one-line summary on
|
|
177
|
+
// changes, one-line failure message on error. A sync failure must
|
|
178
|
+
// NEVER block a session start.
|
|
179
|
+
//
|
|
180
|
+
// Architect pre-flight review flagged the flag-acceptance bug as
|
|
181
|
+
// "most likely silent-failure bug" — if `resolveFlags` rejects the
|
|
182
|
+
// flag before the exit-0 contract activates, every session-start
|
|
183
|
+
// hook silently fails with a symptom indistinguishable from 304.
|
|
184
|
+
// Tests in this suite specifically guard against that.
|
|
185
|
+
|
|
186
|
+
describe("runUpdate — --session-hook contract", () => {
|
|
187
|
+
beforeEach(setup);
|
|
188
|
+
afterEach(teardown);
|
|
189
|
+
|
|
190
|
+
it("accepts the --session-hook flag without throwing a validation error", async () => {
|
|
191
|
+
// THE architect-flagged regression guard. If a future refactor
|
|
192
|
+
// removes the acceptPositional callback from update.mjs,
|
|
193
|
+
// resolveFlags throws `Unknown argument: --session-hook` before
|
|
194
|
+
// the exit-0 contract has a chance to fire. The `|| true` shell
|
|
195
|
+
// backstop would catch it, but the user would lose the failure
|
|
196
|
+
// message AND every sync would silently no-op. This test makes
|
|
197
|
+
// that class of regression fail loudly at unit-test time.
|
|
198
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
199
|
+
await assert.doesNotReject(
|
|
200
|
+
() =>
|
|
201
|
+
runUpdate(
|
|
202
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
203
|
+
{ stdout },
|
|
204
|
+
),
|
|
205
|
+
"update --session-hook must NOT throw Unknown argument",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("is silent on 304 / up-to-date (no 'Syncing' output every session)", async () => {
|
|
210
|
+
// INTENT: 304 means nothing changed. Printing "Syncing..." on
|
|
211
|
+
// every session start with no value to show would clutter the
|
|
212
|
+
// system-message surface. The contract is SILENCE on 304.
|
|
213
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
214
|
+
await runUpdate(
|
|
215
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
216
|
+
{ stdout },
|
|
217
|
+
);
|
|
218
|
+
assert.equal(
|
|
219
|
+
stdout.text(),
|
|
220
|
+
"",
|
|
221
|
+
"session-hook mode must emit zero output when there's nothing to sync",
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("prints one line on successful sync with changes", async () => {
|
|
226
|
+
// INTENT: when content CHANGES, surface exactly one actionable
|
|
227
|
+
// line so the user knows their library updated. No banners, no
|
|
228
|
+
// multi-line output — a single line the hook runner can render
|
|
229
|
+
// as a system message.
|
|
230
|
+
server.setLibraryResponse({
|
|
231
|
+
skills: [makeSkill("sync-hook-test")],
|
|
232
|
+
removals: [],
|
|
233
|
+
syncedAt: "x",
|
|
234
|
+
});
|
|
235
|
+
await runUpdate(
|
|
236
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
237
|
+
{ stdout },
|
|
238
|
+
);
|
|
239
|
+
const out = stdout.text();
|
|
240
|
+
assert.match(out, /^\[SkillRepo\] Library synced: \d+ added, \d+ updated, \d+ removed\.\n$/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("exits 0 with a failure message on a server error instead of throwing", async () => {
|
|
244
|
+
// THE load-bearing contract. A sync failure must NEVER block a
|
|
245
|
+
// session start. runUpdate called with --session-hook against an
|
|
246
|
+
// unreachable server must return normally (no throw) and emit a
|
|
247
|
+
// one-line failure message.
|
|
248
|
+
//
|
|
249
|
+
// We simulate an unreachable server by pointing at an invalid
|
|
250
|
+
// port. Without the exit-0 contract, this would throw
|
|
251
|
+
// networkError from runSync; WITH the contract, the catch block
|
|
252
|
+
// swallows it and prints a failure message.
|
|
253
|
+
await assert.doesNotReject(
|
|
254
|
+
() =>
|
|
255
|
+
runUpdate(
|
|
256
|
+
["--key", VALID_KEY, "--url", "http://127.0.0.1:1", "--session-hook"],
|
|
257
|
+
{ stdout },
|
|
258
|
+
),
|
|
259
|
+
"session-hook mode must NEVER throw — session start must proceed",
|
|
260
|
+
);
|
|
261
|
+
const out = stdout.text();
|
|
262
|
+
assert.match(out, /^\[SkillRepo\] Sync failed: .+\n$/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("exits 0 with a failure message on auth error (invalid key)", async () => {
|
|
266
|
+
// Auth error is non-retryable AND non-blocking for session start.
|
|
267
|
+
// If the user's key was rotated, we want them to see "access key
|
|
268
|
+
// invalid" in their session system message, but the session must
|
|
269
|
+
// still open normally.
|
|
270
|
+
server.setForcedStatus(401, { error: "Invalid access key" });
|
|
271
|
+
await assert.doesNotReject(
|
|
272
|
+
() =>
|
|
273
|
+
runUpdate(
|
|
274
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
275
|
+
{ stdout },
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
const out = stdout.text();
|
|
279
|
+
assert.match(out, /Sync failed/);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("exits 0 with a failure message when no access key is configured", async () => {
|
|
283
|
+
// INTENT: the VERY first session after installing skillrepo, if
|
|
284
|
+
// init hasn't run yet, has no key. The hook must not block the
|
|
285
|
+
// session. This is a genuine first-run scenario, not a synthetic
|
|
286
|
+
// edge case — users who set up Claude Code before running
|
|
287
|
+
// `skillrepo init` will hit exactly this.
|
|
288
|
+
//
|
|
289
|
+
// No --key flag, no cached config, no env var. resolveFlags
|
|
290
|
+
// normally throws authError in this case. Session-hook mode
|
|
291
|
+
// must catch that and exit 0 anyway.
|
|
292
|
+
await assert.doesNotReject(
|
|
293
|
+
() =>
|
|
294
|
+
runUpdate(["--url", serverUrl, "--session-hook"], { stdout }),
|
|
295
|
+
"session-hook mode with no key must NEVER throw",
|
|
296
|
+
);
|
|
297
|
+
const out = stdout.text();
|
|
298
|
+
assert.match(out, /Sync failed/, "failure message must still surface");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("exits 0 when the --session-hook flag appears after other flags", async () => {
|
|
302
|
+
// INTENT: flag ordering is not part of the contract. The installer
|
|
303
|
+
// writes a specific order today, but a user hand-editing their
|
|
304
|
+
// settings (or a future refactor moving flags around) must not
|
|
305
|
+
// break the exit-0 behavior.
|
|
306
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
307
|
+
await assert.doesNotReject(
|
|
308
|
+
() =>
|
|
309
|
+
runUpdate(
|
|
310
|
+
["--session-hook", "--key", VALID_KEY, "--url", serverUrl],
|
|
311
|
+
{ stdout },
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
assert.equal(stdout.text(), "", "still silent on 304 regardless of flag order");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("exits 0 silently when the server returns zero deltas (empty success, not 304)", async () => {
|
|
318
|
+
// INTENT: 304 isn't the only silent-success case. A 200 response
|
|
319
|
+
// with zero added/updated/removed (a fresh sync that happened to
|
|
320
|
+
// produce no work) is also silent — same UX reasoning as 304.
|
|
321
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
322
|
+
await runUpdate(
|
|
323
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
324
|
+
{ stdout },
|
|
325
|
+
);
|
|
326
|
+
assert.equal(stdout.text(), "", "zero-delta 200 is silent");
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform sandbox HOME isolation for CLI tests.
|
|
3
|
+
*
|
|
4
|
+
* Problem this solves
|
|
5
|
+
* -------------------
|
|
6
|
+
* Every CLI test that touches the filesystem runs in a temp-dir sandbox and
|
|
7
|
+
* redirects "home" so writes under `~/.claude/` or `~/.claude/skills/` land
|
|
8
|
+
* inside the sandbox instead of polluting the developer's real home.
|
|
9
|
+
*
|
|
10
|
+
* The pre-v3.1.1 pattern set `process.env.HOME` and relied on `os.homedir()`
|
|
11
|
+
* reading from HOME. That works on POSIX — `homedir()` reads HOME first —
|
|
12
|
+
* but it silently fails on Windows. On Windows, `homedir()` reads
|
|
13
|
+
* `USERPROFILE` (NOT HOME), so the tests' HOME redirect is a no-op: the
|
|
14
|
+
* CLI writes to `C:\Users\<runner>\.claude\` (the real user home) instead
|
|
15
|
+
* of the sandbox, and the post-assert `dir.startsWith(process.env.HOME)`
|
|
16
|
+
* fails because the string paths don't match.
|
|
17
|
+
*
|
|
18
|
+
* This surfaced the first time `.github/workflows/ci.yml` gained a
|
|
19
|
+
* `windows-latest` runner in PR #892 — 99 tests failed, none of them real
|
|
20
|
+
* CLI regressions, all because the sandbox isolation wasn't cross-platform.
|
|
21
|
+
*
|
|
22
|
+
* The contract
|
|
23
|
+
* ------------
|
|
24
|
+
* `captureHome()` snapshots BOTH env vars before you overwrite them. The
|
|
25
|
+
* snapshot correctly distinguishes "was undefined" from "was empty
|
|
26
|
+
* string" so `restoreHome()` can `delete` the var rather than set it to
|
|
27
|
+
* the literal string `"undefined"` (which `process.env.X = undefined`
|
|
28
|
+
* would do in Node — verified on Node 20).
|
|
29
|
+
*
|
|
30
|
+
* `setSandboxHome(path)` sets BOTH HOME and USERPROFILE to the same
|
|
31
|
+
* sandbox path. Setting both guarantees `homedir()` lands in the sandbox
|
|
32
|
+
* on every platform without the caller having to know which env var the
|
|
33
|
+
* current platform prefers.
|
|
34
|
+
*
|
|
35
|
+
* `restoreHome(snapshot)` restores the original state, with the delete-
|
|
36
|
+
* on-undefined pattern.
|
|
37
|
+
*
|
|
38
|
+
* Usage
|
|
39
|
+
* -----
|
|
40
|
+
* import { captureHome, setSandboxHome, restoreHome } from "../helpers/sandbox-home.mjs";
|
|
41
|
+
*
|
|
42
|
+
* let originalHomeEnv;
|
|
43
|
+
* function setup() {
|
|
44
|
+
* sandbox = mkdtempSync(join(tmpdir(), "cli-foo-"));
|
|
45
|
+
* originalHomeEnv = captureHome();
|
|
46
|
+
* setSandboxHome(join(sandbox, "home"));
|
|
47
|
+
* }
|
|
48
|
+
* function teardown() {
|
|
49
|
+
* restoreHome(originalHomeEnv);
|
|
50
|
+
* rmSync(sandbox, { recursive: true, force: true });
|
|
51
|
+
* }
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} HomeEnvSnapshot
|
|
56
|
+
* @property {string | undefined} HOME
|
|
57
|
+
* @property {string | undefined} USERPROFILE
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Snapshot HOME and USERPROFILE as they exist right now. Call before
|
|
62
|
+
* any `setSandboxHome` so `restoreHome` has something to put back.
|
|
63
|
+
*
|
|
64
|
+
* @returns {HomeEnvSnapshot}
|
|
65
|
+
*/
|
|
66
|
+
export function captureHome() {
|
|
67
|
+
return {
|
|
68
|
+
HOME: process.env.HOME,
|
|
69
|
+
USERPROFILE: process.env.USERPROFILE,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Set both HOME and USERPROFILE to the sandbox path. After this call,
|
|
75
|
+
* `os.homedir()` will resolve to `path` on every supported platform.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} path - Absolute path to the sandbox "home" directory.
|
|
78
|
+
* Caller is responsible for creating the directory if needed.
|
|
79
|
+
*/
|
|
80
|
+
export function setSandboxHome(path) {
|
|
81
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
82
|
+
throw new Error("setSandboxHome: path must be a non-empty string");
|
|
83
|
+
}
|
|
84
|
+
process.env.HOME = path;
|
|
85
|
+
process.env.USERPROFILE = path;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Restore HOME and USERPROFILE to whatever they were at the time of
|
|
90
|
+
* `captureHome()`. Deletes the env var when the snapshot value was
|
|
91
|
+
* `undefined` — setting `process.env.X = undefined` in Node produces
|
|
92
|
+
* the literal string `"undefined"`, which would corrupt subsequent
|
|
93
|
+
* tests that read the env var.
|
|
94
|
+
*
|
|
95
|
+
* @param {HomeEnvSnapshot} snapshot - Return value of `captureHome()`.
|
|
96
|
+
*/
|
|
97
|
+
export function restoreHome(snapshot) {
|
|
98
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
99
|
+
throw new Error("restoreHome: snapshot must be the object from captureHome()");
|
|
100
|
+
}
|
|
101
|
+
if (snapshot.HOME === undefined) delete process.env.HOME;
|
|
102
|
+
else process.env.HOME = snapshot.HOME;
|
|
103
|
+
if (snapshot.USERPROFILE === undefined) delete process.env.USERPROFILE;
|
|
104
|
+
else process.env.USERPROFILE = snapshot.USERPROFILE;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Assert both HOME and USERPROFILE point inside a sandbox rooted at
|
|
109
|
+
* `tmpdirRoot` (typically `os.tmpdir()`). This is the integrity check
|
|
110
|
+
* that prevents a test from accidentally writing into the developer's
|
|
111
|
+
* real home when `setSandboxHome()` was forgotten or bypassed.
|
|
112
|
+
*
|
|
113
|
+
* BOTH env vars are checked because `os.homedir()` reads `HOME` on
|
|
114
|
+
* POSIX and `USERPROFILE` on Windows. An assertion that checks only
|
|
115
|
+
* `HOME` would pass on Windows even when USERPROFILE still points at
|
|
116
|
+
* the real user home — defeating the purpose of the guard. The
|
|
117
|
+
* guards migrated from `session-sync.test.mjs`, `uninstall.test.mjs`,
|
|
118
|
+
* and `session-hook.test.mjs` into this helper after a review-round-4
|
|
119
|
+
* finding spotted the USERPROFILE gap.
|
|
120
|
+
*
|
|
121
|
+
* `USERPROFILE` is checked only when it's defined — on POSIX it's
|
|
122
|
+
* typically absent and `setSandboxHome()` does set it alongside
|
|
123
|
+
* HOME so it should be defined under the isolation contract, but we
|
|
124
|
+
* tolerate the "not set at all" state as well since that's
|
|
125
|
+
* equivalent to "can't leak outside the sandbox via it."
|
|
126
|
+
*
|
|
127
|
+
* @param {string} tmpdirRoot - Root path the HOME/USERPROFILE must
|
|
128
|
+
* be inside. Typically `os.tmpdir()`.
|
|
129
|
+
* @param {string} [contextMessage] - Optional context to include in
|
|
130
|
+
* the assertion failure message so the developer can tell
|
|
131
|
+
* which test suite's setup forgot the override.
|
|
132
|
+
* @throws {AssertionError} If either env var is set but falls
|
|
133
|
+
* outside `tmpdirRoot`.
|
|
134
|
+
*/
|
|
135
|
+
export function assertHomeIsolated(tmpdirRoot, contextMessage = "") {
|
|
136
|
+
if (typeof tmpdirRoot !== "string" || tmpdirRoot.length === 0) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"assertHomeIsolated: tmpdirRoot must be a non-empty string " +
|
|
139
|
+
"(usually os.tmpdir())",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const context = contextMessage ? ` (${contextMessage})` : "";
|
|
143
|
+
if (!process.env.HOME || !process.env.HOME.startsWith(tmpdirRoot)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`HOME must point inside "${tmpdirRoot}"${context}. ` +
|
|
146
|
+
`Current HOME="${process.env.HOME}" — setup() forgot setSandboxHome() ` +
|
|
147
|
+
`or something else overwrote it.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
process.env.USERPROFILE !== undefined &&
|
|
152
|
+
!process.env.USERPROFILE.startsWith(tmpdirRoot)
|
|
153
|
+
) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`USERPROFILE must point inside "${tmpdirRoot}"${context} ` +
|
|
156
|
+
`when defined. Current USERPROFILE="${process.env.USERPROFILE}" — ` +
|
|
157
|
+
`on Windows, os.homedir() reads USERPROFILE, so a stray real-host ` +
|
|
158
|
+
`value leaks tests into the real user home.`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform `skillrepo` binary shim for CLI tests.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists
|
|
5
|
+
* ---------------
|
|
6
|
+
* Several tests (init session-sync, session-sync command) invoke
|
|
7
|
+
* `runInit` or `runSessionSync`, which internally call
|
|
8
|
+
* `resolveSkillrepoBinary()` → `execFileSync("which" or "where",
|
|
9
|
+
* ["skillrepo"])`. For those calls to succeed deterministically —
|
|
10
|
+
* regardless of whether the developer has a global `skillrepo`
|
|
11
|
+
* install on PATH — the tests drop a fake `skillrepo` binary into
|
|
12
|
+
* a sandbox bin dir and prepend that dir to PATH.
|
|
13
|
+
*
|
|
14
|
+
* The original pattern was POSIX-only. On Windows it failed two ways:
|
|
15
|
+
* 1. The shim was created as a bash script (`#!/bin/sh\nexit 0\n`)
|
|
16
|
+
* which `where.exe` won't match — `where` only resolves files
|
|
17
|
+
* whose extension is in PATHEXT (.CMD by default), not
|
|
18
|
+
* extension-less shell scripts.
|
|
19
|
+
* 2. PATH was joined with `:` which is the POSIX separator;
|
|
20
|
+
* Windows uses `;`.
|
|
21
|
+
*
|
|
22
|
+
* This helper produces shims correctly for both families.
|
|
23
|
+
*
|
|
24
|
+
* Usage
|
|
25
|
+
* -----
|
|
26
|
+
* import { installShim, uninstallShim } from "../helpers/skillrepo-shim.mjs";
|
|
27
|
+
*
|
|
28
|
+
* let shim;
|
|
29
|
+
* function setup() {
|
|
30
|
+
* // ...sandbox + HOME/USERPROFILE setup...
|
|
31
|
+
* shim = installShim(join(sandbox, "home"));
|
|
32
|
+
* }
|
|
33
|
+
* function teardown() {
|
|
34
|
+
* uninstallShim(shim);
|
|
35
|
+
* // ...HOME/USERPROFILE restore + sandbox cleanup...
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* `installShim(homePath)` returns a `ShimHandle` carrying the path
|
|
39
|
+
* prepended to PATH and the original PATH value — pass it back to
|
|
40
|
+
* `uninstallShim` for restoration.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
|
44
|
+
import { join, delimiter } from "node:path";
|
|
45
|
+
import { platformConventions } from "../../lib/platform.mjs";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {Object} ShimHandle
|
|
49
|
+
* @property {string} binDir - The directory we created and prepended.
|
|
50
|
+
* @property {string | undefined} originalPath - PATH as it was before
|
|
51
|
+
* we prepended. Distinguishes "was undefined" from "was empty
|
|
52
|
+
* string" so `uninstallShim` can `delete` rather than set the
|
|
53
|
+
* env var to the literal string `"undefined"` (Node 20
|
|
54
|
+
* coerces `env.X = undefined` to that string, verified).
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Drop a `skillrepo` shim into `<homePath>/bin` and prepend that dir
|
|
59
|
+
* to PATH so the CLI's binary resolver finds it on both POSIX and
|
|
60
|
+
* Windows.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} homePath - Absolute path to the sandbox home dir.
|
|
63
|
+
* Must already exist (caller typically just ran `mkdirSync`).
|
|
64
|
+
* @returns {ShimHandle}
|
|
65
|
+
*/
|
|
66
|
+
export function installShim(homePath) {
|
|
67
|
+
if (typeof homePath !== "string" || homePath.length === 0) {
|
|
68
|
+
throw new Error("installShim: homePath must be a non-empty string");
|
|
69
|
+
}
|
|
70
|
+
const conv = platformConventions();
|
|
71
|
+
const binDir = join(homePath, "bin");
|
|
72
|
+
mkdirSync(binDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
if (conv.family === "windows") {
|
|
75
|
+
// On Windows we create BOTH a `.cmd` and an extension-less shim.
|
|
76
|
+
// `where.exe skillrepo` matches anything whose extension is in
|
|
77
|
+
// PATHEXT (.CMD is default, empty extension is NOT in default
|
|
78
|
+
// PATHEXT) — so the `.cmd` is what production `resolveSkillrepoBinary`
|
|
79
|
+
// finds. The extension-less shim is a courtesy so any test that
|
|
80
|
+
// happens to invoke `bash -c './bin/skillrepo'` directly gets a
|
|
81
|
+
// working file — hence the POSIX shebang content, NOT cmd.exe
|
|
82
|
+
// syntax. (An earlier revision wrote `@exit 0` to both files; the
|
|
83
|
+
// round-3c review caught that the extension-less file with
|
|
84
|
+
// cmd.exe syntax would fail under bash. The `.cmd` gets cmd.exe
|
|
85
|
+
// syntax; the bare file gets POSIX shebang syntax.)
|
|
86
|
+
const cmdShim = join(binDir, "skillrepo.cmd");
|
|
87
|
+
writeFileSync(cmdShim, "@exit 0\r\n");
|
|
88
|
+
const bareShim = join(binDir, "skillrepo");
|
|
89
|
+
writeFileSync(bareShim, "#!/bin/sh\nexit 0\n");
|
|
90
|
+
// On Windows file-system permissions don't gate executability,
|
|
91
|
+
// but chmodSync is a no-op on Windows anyway so we skip it to
|
|
92
|
+
// avoid any platform-conditional chmod paths. Production uses
|
|
93
|
+
// `platformConventions().supportsPosixPermissions` for the same
|
|
94
|
+
// reason in `fs-utils.mjs`.
|
|
95
|
+
} else {
|
|
96
|
+
// POSIX shim — bash script + executable bit.
|
|
97
|
+
const shim = join(binDir, "skillrepo");
|
|
98
|
+
writeFileSync(shim, "#!/bin/sh\nexit 0\n");
|
|
99
|
+
chmodSync(shim, 0o755);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Capture PATH as-is, including the `undefined` case. `??
|
|
103
|
+
// ""` would coalesce to empty string, but then `uninstallShim`
|
|
104
|
+
// can't tell "PATH was undefined" from "PATH was empty" — the
|
|
105
|
+
// restore would then write `""` into an env that had no PATH set,
|
|
106
|
+
// potentially breaking subsequent subprocess spawns that inherit
|
|
107
|
+
// env.
|
|
108
|
+
const originalPath = process.env.PATH;
|
|
109
|
+
// `path.delimiter` is `:` on POSIX, `;` on Windows — the correct
|
|
110
|
+
// separator for whichever platform we're running on.
|
|
111
|
+
process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`;
|
|
112
|
+
return { binDir, originalPath };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Restore PATH to what it was before `installShim` was called.
|
|
117
|
+
* Deletes the env var if PATH was undefined at capture time —
|
|
118
|
+
* setting `process.env.PATH = undefined` produces the literal
|
|
119
|
+
* string `"undefined"` in Node, which would silently break
|
|
120
|
+
* binary lookups in subsequent tests.
|
|
121
|
+
*
|
|
122
|
+
* @param {ShimHandle | null | undefined} handle - Return value of
|
|
123
|
+
* `installShim`, or null/undefined for a safe no-op on tests
|
|
124
|
+
* that bailed before install ran.
|
|
125
|
+
*/
|
|
126
|
+
export function uninstallShim(handle) {
|
|
127
|
+
if (!handle) return;
|
|
128
|
+
if (handle.originalPath === undefined) {
|
|
129
|
+
delete process.env.PATH;
|
|
130
|
+
} else {
|
|
131
|
+
process.env.PATH = handle.originalPath;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -33,24 +33,30 @@ import {
|
|
|
33
33
|
cleanupOrphans,
|
|
34
34
|
resolvePlacementDir,
|
|
35
35
|
} from "../../lib/file-write.mjs";
|
|
36
|
+
import {
|
|
37
|
+
captureHome,
|
|
38
|
+
setSandboxHome,
|
|
39
|
+
restoreHome,
|
|
40
|
+
} from "../helpers/sandbox-home.mjs";
|
|
36
41
|
|
|
37
42
|
let sandbox;
|
|
38
43
|
let originalCwd;
|
|
39
|
-
|
|
44
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
45
|
+
let originalHomeEnv;
|
|
40
46
|
|
|
41
47
|
function setupSandbox() {
|
|
42
48
|
sandbox = mkdtempSync(join(tmpdir(), "cli-fw-int-"));
|
|
43
49
|
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
44
50
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
45
51
|
originalCwd = process.cwd();
|
|
46
|
-
|
|
52
|
+
originalHomeEnv = captureHome();
|
|
47
53
|
process.chdir(join(sandbox, "project"));
|
|
48
|
-
|
|
54
|
+
setSandboxHome(join(sandbox, "home"));
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
function teardownSandbox() {
|
|
52
58
|
process.chdir(originalCwd);
|
|
53
|
-
|
|
59
|
+
restoreHome(originalHomeEnv);
|
|
54
60
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
55
61
|
}
|
|
56
62
|
|