skillrepo 3.1.0 → 3.1.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/README.md +6 -2
- package/package.json +1 -1
- package/src/commands/init-session-sync.mjs +307 -0
- package/src/commands/init.mjs +111 -101
- package/src/commands/session-sync-actions.mjs +92 -0
- package/src/lib/artifact-registry.mjs +43 -3
- package/src/lib/binary-locator.mjs +99 -0
- package/src/lib/cli-config.mjs +16 -3
- package/src/lib/cli-version.mjs +56 -0
- package/src/lib/config.mjs +6 -3
- package/src/lib/file-write.mjs +8 -3
- package/src/lib/fs-utils.mjs +9 -10
- package/src/lib/global-install.mjs +387 -0
- package/src/lib/mcp-merge.mjs +16 -5
- package/src/lib/mergers/session-hook.mjs +125 -33
- package/src/lib/platform.mjs +124 -0
- package/src/lib/sync.mjs +26 -0
- package/src/lib/transient-runners.mjs +204 -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 +889 -15
- 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-actions.test.mjs +74 -0
- package/src/test/commands/session-sync.test.mjs +25 -23
- package/src/test/commands/uninstall.test.mjs +20 -14
- package/src/test/commands/update.test.mjs +10 -4
- package/src/test/helpers/mock-spawn.mjs +121 -0
- 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/cli-config.test.mjs +182 -4
- package/src/test/lib/cli-version.test.mjs +47 -0
- package/src/test/lib/config.test.mjs +10 -4
- package/src/test/lib/file-write.test.mjs +24 -10
- package/src/test/lib/global-install.test.mjs +424 -0
- package/src/test/lib/mcp-merge.test.mjs +13 -7
- 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/lib/transient-runners.test.mjs +270 -0
- package/src/test/mergers/session-hook.test.mjs +722 -22
- package/src/test/mergers/uninstall-settings.test.mjs +12 -1
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +10 -4
|
@@ -34,49 +34,67 @@ import {
|
|
|
34
34
|
} from "../../lib/mergers/session-hook.mjs";
|
|
35
35
|
import { removeSettingsSessionHook } from "../../lib/removers/settings.mjs";
|
|
36
36
|
import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
|
|
37
|
+
import {
|
|
38
|
+
captureHome,
|
|
39
|
+
setSandboxHome,
|
|
40
|
+
restoreHome,
|
|
41
|
+
assertHomeIsolated,
|
|
42
|
+
} from "../helpers/sandbox-home.mjs";
|
|
37
43
|
|
|
38
44
|
let sandbox;
|
|
39
45
|
let originalCwd;
|
|
40
|
-
|
|
46
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
47
|
+
let originalHomeEnv;
|
|
41
48
|
const FAKE_BINARY = "/usr/local/bin/skillrepo";
|
|
42
49
|
|
|
43
50
|
function ASSERT_HOME_ISOLATED() {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
);
|
|
51
|
+
// Thin wrapper around the shared helper. Checks BOTH HOME and
|
|
52
|
+
// USERPROFILE so Windows is actually guarded — `os.homedir()`
|
|
53
|
+
// reads USERPROFILE on Windows, and an assertion that checked
|
|
54
|
+
// only HOME would pass while real user state was at risk.
|
|
55
|
+
assertHomeIsolated(tmpdir(), "session-hook tests");
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
function setup() {
|
|
52
59
|
sandbox = mkdtempSync(join(tmpdir(), "cli-session-hook-"));
|
|
53
60
|
originalCwd = process.cwd();
|
|
54
|
-
|
|
61
|
+
originalHomeEnv = captureHome();
|
|
55
62
|
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
56
63
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
57
64
|
process.chdir(join(sandbox, "project"));
|
|
58
|
-
|
|
65
|
+
setSandboxHome(join(sandbox, "home"));
|
|
59
66
|
ASSERT_HOME_ISOLATED();
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
function teardown() {
|
|
63
70
|
process.chdir(originalCwd);
|
|
64
|
-
|
|
71
|
+
restoreHome(originalHomeEnv);
|
|
65
72
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
// ──────────────────────────────────────────────────────────────────
|
|
69
76
|
|
|
70
77
|
describe("buildHookCommand", () => {
|
|
71
|
-
it("produces the exact command shape Claude Code expects", () => {
|
|
72
|
-
// INTENT: the shape is load-bearing in
|
|
73
|
-
// installer's docstring): absolute path,
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
78
|
+
it("produces the exact command shape Claude Code expects (POSIX)", () => {
|
|
79
|
+
// INTENT: the shape is load-bearing in four ways (per the
|
|
80
|
+
// installer's docstring): absolute path, single-quoted to
|
|
81
|
+
// tolerate spaces/parens, --session-hook flag, `|| true`
|
|
82
|
+
// backstop. A refactor that drops any of the four must fail
|
|
83
|
+
// this test loudly.
|
|
84
|
+
//
|
|
85
|
+
// The `platform: "linux"` override is explicit because this test
|
|
86
|
+
// asserts the POSIX command shape. Without it, the test runs
|
|
87
|
+
// under `os.platform()` — which is `win32` on the Windows CI
|
|
88
|
+
// runner and correctly drops `|| true`, causing a spurious
|
|
89
|
+
// failure against an assertion that was written for POSIX.
|
|
90
|
+
// Platform-specific shapes are covered by the dedicated POSIX/
|
|
91
|
+
// Windows tests below; this one is the POSIX-shape contract.
|
|
92
|
+
const cmd = buildHookCommand("/usr/local/bin/skillrepo", {
|
|
93
|
+
platform: "linux",
|
|
94
|
+
});
|
|
77
95
|
assert.equal(
|
|
78
96
|
cmd,
|
|
79
|
-
"/usr/local/bin/skillrepo update --session-hook 2>&1 || true",
|
|
97
|
+
"'/usr/local/bin/skillrepo' update --session-hook 2>&1 || true",
|
|
80
98
|
);
|
|
81
99
|
});
|
|
82
100
|
|
|
@@ -94,6 +112,121 @@ describe("buildHookCommand", () => {
|
|
|
94
112
|
);
|
|
95
113
|
});
|
|
96
114
|
|
|
115
|
+
it("v3.1.1 Windows support: buildHookCommand OMITS '|| true' on Windows", () => {
|
|
116
|
+
// INTENT: cmd.exe doesn't understand the `true` builtin. If we
|
|
117
|
+
// leave `|| true` on Windows, a binary-missing scenario emits a
|
|
118
|
+
// confusing "'true' is not recognized" error. Windows hook
|
|
119
|
+
// commands drop the shell backstop; the `--session-hook` flag's
|
|
120
|
+
// exit-0 contract inside the Node process is the primary
|
|
121
|
+
// defense regardless of platform.
|
|
122
|
+
const cmd = buildHookCommand("C:\\Program Files\\node\\skillrepo.cmd", {
|
|
123
|
+
platform: "win32",
|
|
124
|
+
});
|
|
125
|
+
assert.doesNotMatch(
|
|
126
|
+
cmd,
|
|
127
|
+
/\|\| true/,
|
|
128
|
+
"Windows hook command must NOT contain '|| true'",
|
|
129
|
+
);
|
|
130
|
+
assert.match(
|
|
131
|
+
cmd,
|
|
132
|
+
/skillrepo\.cmd" update --session-hook 2>&1$/,
|
|
133
|
+
"Windows hook command ends at '2>&1' — no shell backstop, with closing quote on the path",
|
|
134
|
+
);
|
|
135
|
+
// Fingerprint still present — remover round-trip must work on
|
|
136
|
+
// Windows too.
|
|
137
|
+
assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("v3.1.1 Windows support: buildHookCommand keeps '|| true' on POSIX", () => {
|
|
141
|
+
const cmd = buildHookCommand("/usr/local/bin/skillrepo", { platform: "linux" });
|
|
142
|
+
assert.match(
|
|
143
|
+
cmd,
|
|
144
|
+
/\|\| true$/,
|
|
145
|
+
"POSIX hook command must retain the '|| true' shell backstop",
|
|
146
|
+
);
|
|
147
|
+
const cmdDarwin = buildHookCommand("/usr/local/bin/skillrepo", { platform: "darwin" });
|
|
148
|
+
assert.match(cmdDarwin, /\|\| true$/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("v3.1.2: POSIX path with spaces is single-quoted", () => {
|
|
152
|
+
// Real-world case: macOS users with a space in their home dir
|
|
153
|
+
// (`/Users/First Last/.npm-global/bin/skillrepo`). The unquoted
|
|
154
|
+
// command would be parsed by the shell as multiple arguments
|
|
155
|
+
// and silently fail on session start.
|
|
156
|
+
const cmd = buildHookCommand(
|
|
157
|
+
"/Users/First Last/.npm-global/bin/skillrepo",
|
|
158
|
+
{ platform: "linux" },
|
|
159
|
+
);
|
|
160
|
+
assert.equal(
|
|
161
|
+
cmd,
|
|
162
|
+
"'/Users/First Last/.npm-global/bin/skillrepo' update --session-hook 2>&1 || true",
|
|
163
|
+
);
|
|
164
|
+
// Fingerprint still present after quoting.
|
|
165
|
+
assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("v3.1.2: POSIX path with single quote escapes correctly ('\\\\'')", () => {
|
|
169
|
+
// Hardcore edge case: a path with a literal single quote. POSIX
|
|
170
|
+
// shells require closing the quote, escaping the literal `'`,
|
|
171
|
+
// then reopening — the standard `'\\''` trick.
|
|
172
|
+
const cmd = buildHookCommand("/Users/J's bin/skillrepo", {
|
|
173
|
+
platform: "linux",
|
|
174
|
+
});
|
|
175
|
+
assert.equal(
|
|
176
|
+
cmd,
|
|
177
|
+
"'/Users/J'\\''s bin/skillrepo' update --session-hook 2>&1 || true",
|
|
178
|
+
);
|
|
179
|
+
assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("v3.1.2: Windows path with spaces is double-quoted", () => {
|
|
183
|
+
// Real-world: `C:\Program Files\nodejs\skillrepo.cmd` and
|
|
184
|
+
// `C:\Program Files (x86)\...`. cmd.exe needs double quotes;
|
|
185
|
+
// backslashes inside double quotes are NOT escape characters
|
|
186
|
+
// (they pass through to the resolved path verbatim).
|
|
187
|
+
const cmd = buildHookCommand(
|
|
188
|
+
"C:\\Program Files\\nodejs\\skillrepo.cmd",
|
|
189
|
+
{ platform: "win32" },
|
|
190
|
+
);
|
|
191
|
+
assert.equal(
|
|
192
|
+
cmd,
|
|
193
|
+
'"C:\\Program Files\\nodejs\\skillrepo.cmd" update --session-hook 2>&1',
|
|
194
|
+
);
|
|
195
|
+
assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("v3.1.2: Windows path with parentheses (Program Files (x86)) is preserved verbatim inside double quotes", () => {
|
|
199
|
+
const cmd = buildHookCommand(
|
|
200
|
+
"C:\\Program Files (x86)\\node\\skillrepo.cmd",
|
|
201
|
+
{ platform: "win32" },
|
|
202
|
+
);
|
|
203
|
+
assert.ok(cmd.startsWith('"C:\\Program Files (x86)\\node\\skillrepo.cmd"'));
|
|
204
|
+
assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("v3.1.2: backward-compat — fingerprint matches both unquoted (v3.1.0/3.1.1) and quoted (v3.1.2+) shapes", () => {
|
|
208
|
+
// The fingerprint is `" update --session-hook"` with a leading
|
|
209
|
+
// space. That space sits between the path's closing context
|
|
210
|
+
// (a `'`/`"` quote in v3.1.2, the bare path char in v3.1.0/3.1.1)
|
|
211
|
+
// and `update`. Both shapes contain the leading-space substring.
|
|
212
|
+
// Without this contract, an upgrade from v3.1.1 to v3.1.2 would
|
|
213
|
+
// duplicate the hook entry instead of updating it in place.
|
|
214
|
+
const v311Posix =
|
|
215
|
+
"/usr/local/bin/skillrepo update --session-hook 2>&1 || true";
|
|
216
|
+
const v312Posix =
|
|
217
|
+
"'/usr/local/bin/skillrepo' update --session-hook 2>&1 || true";
|
|
218
|
+
const v311Win =
|
|
219
|
+
"C:\\path\\skillrepo.cmd update --session-hook 2>&1";
|
|
220
|
+
const v312Win =
|
|
221
|
+
'"C:\\path\\skillrepo.cmd" update --session-hook 2>&1';
|
|
222
|
+
for (const cmd of [v311Posix, v312Posix, v311Win, v312Win]) {
|
|
223
|
+
assert.ok(
|
|
224
|
+
cmd.includes(SESSION_HOOK_FINGERPRINT),
|
|
225
|
+
`fingerprint must match shape: ${cmd}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
97
230
|
it("rejects empty or non-string binary paths", () => {
|
|
98
231
|
// INTENT: no silent production of a malformed command. The
|
|
99
232
|
// installer upstream should never pass null/empty, but defensive
|
|
@@ -105,6 +238,338 @@ describe("buildHookCommand", () => {
|
|
|
105
238
|
});
|
|
106
239
|
});
|
|
107
240
|
|
|
241
|
+
describe("resolveSkillrepoBinary — v3.1.1 Windows support", () => {
|
|
242
|
+
// These tests exercise the platform-specific locator branch
|
|
243
|
+
// (`where` on Windows, `which` elsewhere) and the Windows
|
|
244
|
+
// absolute-path check (`C:\...` not `/`). They use the
|
|
245
|
+
// `{ platform }` option to avoid relying on the host OS.
|
|
246
|
+
//
|
|
247
|
+
// IMPORTANT: these tests DO NOT run a real `where.exe` subprocess
|
|
248
|
+
// on Linux/macOS CI (there is no `where` to run). Instead they
|
|
249
|
+
// verify the LOCATOR-SELECTION logic by checking that calling
|
|
250
|
+
// with `platform: "win32"` on a Unix host produces a null return
|
|
251
|
+
// (because `where` doesn't exist there) rather than a thrown
|
|
252
|
+
// error. That's sufficient to prove the platform branch works;
|
|
253
|
+
// actual Windows binary resolution is tested by the Windows CI
|
|
254
|
+
// smoke job added in .github/workflows/.
|
|
255
|
+
//
|
|
256
|
+
// Behavioral coverage requires beforeEach/afterEach to clear npx
|
|
257
|
+
// signals (the function returns null early on npx regardless of
|
|
258
|
+
// platform). Reuses the isolate-process-state pattern from the
|
|
259
|
+
// isNpxInvocation tests.
|
|
260
|
+
let originalArgv;
|
|
261
|
+
let originalUnderscore;
|
|
262
|
+
|
|
263
|
+
beforeEach(() => {
|
|
264
|
+
originalArgv = process.argv;
|
|
265
|
+
originalUnderscore = process.env._;
|
|
266
|
+
process.argv = ["/usr/local/bin/node", "/usr/local/bin/skillrepo"];
|
|
267
|
+
delete process.env._;
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
afterEach(() => {
|
|
271
|
+
process.argv = originalArgv;
|
|
272
|
+
if (originalUnderscore === undefined) delete process.env._;
|
|
273
|
+
else process.env._ = originalUnderscore;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("does not throw when called with platform: 'win32' on a non-Windows host", async () => {
|
|
277
|
+
// On Linux/macOS there is no `where.exe` on PATH, so the
|
|
278
|
+
// execFileSync throws ENOENT. Our try/catch returns null —
|
|
279
|
+
// the same safe-skip path that fires when the binary isn't
|
|
280
|
+
// on PATH. Verifies the platform branch compiles cleanly
|
|
281
|
+
// without requiring a real Windows environment.
|
|
282
|
+
const { resolveSkillrepoBinary } = await import(
|
|
283
|
+
"../../lib/mergers/session-hook.mjs?v311-win-test=" + Date.now()
|
|
284
|
+
);
|
|
285
|
+
const result = resolveSkillrepoBinary({ platform: "win32" });
|
|
286
|
+
assert.equal(
|
|
287
|
+
result,
|
|
288
|
+
null,
|
|
289
|
+
"on a Unix host with platform:'win32', where.exe isn't available → null return (not throw)",
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("mergeSessionHook under platform:'win32' routes to the architect's skipped path when binary can't be resolved", async () => {
|
|
294
|
+
// End-to-end test of the Windows binary-resolution path. When
|
|
295
|
+
// `where` isn't available (simulating a Windows environment
|
|
296
|
+
// where the user hasn't installed skillrepo globally), the
|
|
297
|
+
// installer must skip gracefully with the architect-specified
|
|
298
|
+
// reason — same as the Unix "no global install" fallback.
|
|
299
|
+
const { mergeSessionHook: mergeFresh } = await import(
|
|
300
|
+
"../../lib/mergers/session-hook.mjs?v311-win-integration=" + Date.now()
|
|
301
|
+
);
|
|
302
|
+
const tmpSandbox = mkdtempSync(join(tmpdir(), "cli-win-test-"));
|
|
303
|
+
const originalCwd = process.cwd();
|
|
304
|
+
const originalHomeEnvLocal = captureHome();
|
|
305
|
+
mkdirSync(join(tmpSandbox, "project"), { recursive: true });
|
|
306
|
+
mkdirSync(join(tmpSandbox, "home"), { recursive: true });
|
|
307
|
+
process.chdir(join(tmpSandbox, "project"));
|
|
308
|
+
setSandboxHome(join(tmpSandbox, "home"));
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
// Explicitly pass binaryPath: null so the test doesn't rely
|
|
312
|
+
// on resolveSkillrepoBinary. This is a different check —
|
|
313
|
+
// verifying the downstream skipped-with-reason path works
|
|
314
|
+
// identically regardless of what caused the null.
|
|
315
|
+
const result = mergeFresh({ binaryPath: null });
|
|
316
|
+
assert.equal(result.action, "skipped");
|
|
317
|
+
assert.ok(result.reason);
|
|
318
|
+
assert.match(
|
|
319
|
+
result.reason,
|
|
320
|
+
/stable.*skillrepo/i,
|
|
321
|
+
"Windows skipped path must surface the same user-actionable message as POSIX",
|
|
322
|
+
);
|
|
323
|
+
} finally {
|
|
324
|
+
process.chdir(originalCwd);
|
|
325
|
+
restoreHome(originalHomeEnvLocal);
|
|
326
|
+
rmSync(tmpSandbox, { recursive: true, force: true });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("v3.1.1 Windows hook: fingerprint + round-trip contract", () => {
|
|
332
|
+
// The fingerprint SESSION_HOOK_FINGERPRINT is the substring that
|
|
333
|
+
// lets the uninstaller identify SkillRepo-owned hook entries.
|
|
334
|
+
// These tests lock the contract from BOTH platform ends: the
|
|
335
|
+
// Windows command shape must contain the fingerprint, and the
|
|
336
|
+
// remover must strip what the Windows builder produces. If the
|
|
337
|
+
// refactor that produced platform.mjs ever drifts the two sides,
|
|
338
|
+
// one of these tests fails before any user ships.
|
|
339
|
+
beforeEach(setup);
|
|
340
|
+
afterEach(teardown);
|
|
341
|
+
|
|
342
|
+
// A realistic Windows npm-install shim path. `npm install -g` on
|
|
343
|
+
// Windows drops three launchers into `%AppData%\npm\`:
|
|
344
|
+
// skillrepo (bash shim, used by Git Bash via shell lookup)
|
|
345
|
+
// skillrepo.cmd (cmd.exe shim)
|
|
346
|
+
// skillrepo.ps1 (PowerShell shim)
|
|
347
|
+
//
|
|
348
|
+
// `where skillrepo` matches files whose extension appears in
|
|
349
|
+
// `%PATHEXT%`. The default PATHEXT is
|
|
350
|
+
// `.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC` — `.cmd`
|
|
351
|
+
// is in that list, `.ps1` usually isn't, and the extension-less
|
|
352
|
+
// bash shim never appears in `where`'s output because `where`
|
|
353
|
+
// only matches extensions from PATHEXT. So in practice `where
|
|
354
|
+
// skillrepo` returns a single line ending in `.cmd` — that's the
|
|
355
|
+
// path we must exercise. The CLI's resolver takes the first line
|
|
356
|
+
// regardless, so even on a system with a custom PATHEXT that
|
|
357
|
+
// surfaces `.ps1` first, the first-line rule still produces a
|
|
358
|
+
// usable absolute path.
|
|
359
|
+
const WIN_SHIM_PATH = "C:\\Users\\alice\\AppData\\Roaming\\npm\\skillrepo.cmd";
|
|
360
|
+
|
|
361
|
+
it("fingerprint is platform-neutral: POSIX and Windows command shapes both contain it", () => {
|
|
362
|
+
// This is the test that would have caught the v3.1.1-dev bug
|
|
363
|
+
// where the fingerprint was `"skillrepo update --session-hook"`
|
|
364
|
+
// (the longer form) and the Windows `.cmd` path broke the
|
|
365
|
+
// substring match because the extension intrudes between
|
|
366
|
+
// `skillrepo` and `update`. If this assertion fires, the
|
|
367
|
+
// fingerprint has regressed and Windows hook identification is
|
|
368
|
+
// broken.
|
|
369
|
+
const posixCmd = buildHookCommand("/usr/local/bin/skillrepo", {
|
|
370
|
+
platform: "darwin",
|
|
371
|
+
});
|
|
372
|
+
const windowsCmd = buildHookCommand(WIN_SHIM_PATH, { platform: "win32" });
|
|
373
|
+
|
|
374
|
+
assert.ok(
|
|
375
|
+
posixCmd.includes(SESSION_HOOK_FINGERPRINT),
|
|
376
|
+
`POSIX command "${posixCmd}" must include fingerprint "${SESSION_HOOK_FINGERPRINT}"`,
|
|
377
|
+
);
|
|
378
|
+
assert.ok(
|
|
379
|
+
windowsCmd.includes(SESSION_HOOK_FINGERPRINT),
|
|
380
|
+
`Windows command "${windowsCmd}" must include fingerprint "${SESSION_HOOK_FINGERPRINT}". ` +
|
|
381
|
+
`The Windows shim path ends in .cmd, which the earlier fingerprint ` +
|
|
382
|
+
`"skillrepo update --session-hook" failed to match.`,
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("backward compat: pre-v3.1.1 hooks (with longer fingerprint) still match the new shorter fingerprint", () => {
|
|
387
|
+
// Users who ran `skillrepo init` under v3.1.0 have a hook whose
|
|
388
|
+
// command contains the LONGER fingerprint "skillrepo update
|
|
389
|
+
// --session-hook". The shorter v3.1.1 fingerprint "update
|
|
390
|
+
// --session-hook" must still be a substring of every legacy
|
|
391
|
+
// command — otherwise upgrading breaks uninstall for every
|
|
392
|
+
// existing user. This test is the backward-compat gate.
|
|
393
|
+
const legacyPosixCommand =
|
|
394
|
+
"/usr/local/bin/skillrepo update --session-hook 2>&1 || true";
|
|
395
|
+
assert.ok(
|
|
396
|
+
legacyPosixCommand.includes(SESSION_HOOK_FINGERPRINT),
|
|
397
|
+
`Legacy v3.1.0 POSIX hook "${legacyPosixCommand}" must still be ` +
|
|
398
|
+
`identifiable by the v3.1.1 fingerprint "${SESSION_HOOK_FINGERPRINT}" — ` +
|
|
399
|
+
`if this fires, upgrading breaks uninstall for existing users.`,
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("Windows hook round-trip: installer + remover identify the same .cmd-shim command", () => {
|
|
404
|
+
// End-to-end proof that on Windows, the hook written by the
|
|
405
|
+
// installer is the hook found by the remover. We can't spawn a
|
|
406
|
+
// real where.exe on macOS/Linux CI, so we simulate the Windows
|
|
407
|
+
// result by (1) asking buildHookCommand for the Windows shape
|
|
408
|
+
// and (2) writing that exact command into a settings file,
|
|
409
|
+
// then (3) invoking the remover and verifying the hook is
|
|
410
|
+
// gone. This exercises the fingerprint match against a real
|
|
411
|
+
// Windows-shaped command string.
|
|
412
|
+
ASSERT_HOME_ISOLATED();
|
|
413
|
+
const windowsCmd = buildHookCommand(WIN_SHIM_PATH, { platform: "win32" });
|
|
414
|
+
|
|
415
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
416
|
+
writeFileSync(
|
|
417
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
418
|
+
JSON.stringify(
|
|
419
|
+
{
|
|
420
|
+
hooks: {
|
|
421
|
+
SessionStart: [
|
|
422
|
+
{ hooks: [{ type: "command", command: windowsCmd }] },
|
|
423
|
+
],
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
null,
|
|
427
|
+
2,
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const removeResult = removeSettingsSessionHook();
|
|
432
|
+
assert.equal(
|
|
433
|
+
removeResult.action,
|
|
434
|
+
"removed",
|
|
435
|
+
"Remover must identify a Windows-shaped hook command. If this fires, " +
|
|
436
|
+
"SESSION_HOOK_FINGERPRINT has drifted from what buildHookCommand " +
|
|
437
|
+
"produces on Windows — the .cmd extension likely intrudes between " +
|
|
438
|
+
"`skillrepo` and `update`.",
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("mergeSessionHook under platform:'win32' writes a Windows-shaped command end-to-end", () => {
|
|
443
|
+
// INTENT: the architect's round-3 HIGH finding. mergeSessionHook
|
|
444
|
+
// must propagate the platform override through to buildHookCommand
|
|
445
|
+
// so a non-Windows CI host can still prove that a real Windows
|
|
446
|
+
// user would receive a command WITHOUT `|| true` — cmd.exe
|
|
447
|
+
// doesn't understand the `true` builtin.
|
|
448
|
+
//
|
|
449
|
+
// Before round 3, this test documented its own gap: `mergeSessionHook`
|
|
450
|
+
// didn't accept a platform option, so passing a Windows-shaped
|
|
451
|
+
// binaryPath still produced a POSIX command (because buildHookCommand
|
|
452
|
+
// read os.platform() directly). Passing only the fingerprint check
|
|
453
|
+
// gave false confidence. Fixing this closes that gap — the assertion
|
|
454
|
+
// now verifies the Windows command shape end-to-end against real
|
|
455
|
+
// file I/O.
|
|
456
|
+
ASSERT_HOME_ISOLATED();
|
|
457
|
+
|
|
458
|
+
const installResult = mergeSessionHook({
|
|
459
|
+
binaryPath: WIN_SHIM_PATH,
|
|
460
|
+
platform: "win32",
|
|
461
|
+
});
|
|
462
|
+
assert.equal(installResult.action, "installed");
|
|
463
|
+
|
|
464
|
+
const parsed = JSON.parse(
|
|
465
|
+
readFileSync(
|
|
466
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
467
|
+
"utf-8",
|
|
468
|
+
),
|
|
469
|
+
);
|
|
470
|
+
const cmd = parsed.hooks.SessionStart[0].hooks[0].command;
|
|
471
|
+
|
|
472
|
+
// 1. Fingerprint present — installer/remover round-trip works.
|
|
473
|
+
assert.ok(
|
|
474
|
+
cmd.includes(SESSION_HOOK_FINGERPRINT),
|
|
475
|
+
`Hook with Windows-shaped binaryPath must contain the fingerprint. Got: "${cmd}"`,
|
|
476
|
+
);
|
|
477
|
+
// 2. No `|| true` backstop — cmd.exe can't execute it.
|
|
478
|
+
assert.ok(
|
|
479
|
+
!cmd.includes("|| true"),
|
|
480
|
+
`Windows hook must NOT contain "|| true" — cmd.exe has no such builtin. Got: "${cmd}"`,
|
|
481
|
+
);
|
|
482
|
+
// 3. Command ends exactly at "2>&1" — sanity on the full shape.
|
|
483
|
+
// The closing `"` is the path's wrapping quote (v3.1.2:
|
|
484
|
+
// paths are quoted to tolerate spaces).
|
|
485
|
+
assert.ok(
|
|
486
|
+
cmd.endsWith(`skillrepo.cmd" update --session-hook 2>&1`),
|
|
487
|
+
`Windows hook command must end at "2>&1". Got: "${cmd}"`,
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
// Remover identifies and strips the Windows-shaped hook.
|
|
491
|
+
const removeResult = removeSettingsSessionHook();
|
|
492
|
+
assert.equal(removeResult.action, "removed");
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("mergeSessionHook under platform:'linux' still writes the POSIX `|| true` backstop", () => {
|
|
496
|
+
// Inverse guard: a test that asserts the POSIX path still emits
|
|
497
|
+
// `|| true` so a refactor that accidentally inverts the platform
|
|
498
|
+
// check (or a bug in platform.mjs that swaps the conventions)
|
|
499
|
+
// fails loudly here instead of silently stripping the backstop
|
|
500
|
+
// from every POSIX user's hook.
|
|
501
|
+
ASSERT_HOME_ISOLATED();
|
|
502
|
+
|
|
503
|
+
const installResult = mergeSessionHook({
|
|
504
|
+
binaryPath: "/usr/local/bin/skillrepo",
|
|
505
|
+
platform: "linux",
|
|
506
|
+
});
|
|
507
|
+
assert.equal(installResult.action, "installed");
|
|
508
|
+
|
|
509
|
+
const parsed = JSON.parse(
|
|
510
|
+
readFileSync(
|
|
511
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
512
|
+
"utf-8",
|
|
513
|
+
),
|
|
514
|
+
);
|
|
515
|
+
const cmd = parsed.hooks.SessionStart[0].hooks[0].command;
|
|
516
|
+
assert.ok(
|
|
517
|
+
cmd.endsWith("|| true"),
|
|
518
|
+
`POSIX hook command must end with "|| true". Got: "${cmd}"`,
|
|
519
|
+
);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("buildHookCommand contract: Windows command shape has NO `|| true` suffix", () => {
|
|
523
|
+
// Explicit contract: on Windows, cmd.exe doesn't know `true`,
|
|
524
|
+
// so the shell backstop MUST be omitted. A regression that
|
|
525
|
+
// adds `|| true` back to the Windows command would cause every
|
|
526
|
+
// session with a missing binary to emit "'true' is not
|
|
527
|
+
// recognized as an internal or external command". The
|
|
528
|
+
// --session-hook exit-0 contract in the Node process is the
|
|
529
|
+
// only defense on Windows, by design.
|
|
530
|
+
const windowsCmd = buildHookCommand(WIN_SHIM_PATH, { platform: "win32" });
|
|
531
|
+
assert.ok(
|
|
532
|
+
!windowsCmd.includes("|| true"),
|
|
533
|
+
`Windows hook command must not contain "|| true" — cmd.exe has no such builtin. ` +
|
|
534
|
+
`Got: "${windowsCmd}"`,
|
|
535
|
+
);
|
|
536
|
+
assert.ok(
|
|
537
|
+
windowsCmd.endsWith("2>&1"),
|
|
538
|
+
`Windows command must end at "2>&1" — no backstop appended. Got: "${windowsCmd}"`,
|
|
539
|
+
);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("the fingerprint is specific enough that innocuous user hooks do NOT match it", () => {
|
|
543
|
+
// Guard against false-positives on the shorter fingerprint.
|
|
544
|
+
// User-authored SessionStart hooks shouldn't happen to match
|
|
545
|
+
// `update --session-hook` by accident. Enumerate a few
|
|
546
|
+
// plausible user commands and verify they don't trip the
|
|
547
|
+
// predicate. If a new one starts matching, the fingerprint is
|
|
548
|
+
// too loose and needs tightening.
|
|
549
|
+
const plausibleUserCommands = [
|
|
550
|
+
"echo 'session started'",
|
|
551
|
+
"/usr/local/bin/claude-sync update",
|
|
552
|
+
"git status",
|
|
553
|
+
"npm update --save",
|
|
554
|
+
"brew update --quiet",
|
|
555
|
+
"some-other-tool --session-start",
|
|
556
|
+
// The closest plausible miss: a different CLI whose flags
|
|
557
|
+
// happen to include `--session-hook`. This is so specific
|
|
558
|
+
// (two-flag combination with a space between) that inventing
|
|
559
|
+
// a real conflicting command is hard, but document the limit.
|
|
560
|
+
"other-tool --session-begin",
|
|
561
|
+
];
|
|
562
|
+
for (const userCmd of plausibleUserCommands) {
|
|
563
|
+
assert.ok(
|
|
564
|
+
!userCmd.includes(SESSION_HOOK_FINGERPRINT),
|
|
565
|
+
`User command "${userCmd}" must NOT match fingerprint ` +
|
|
566
|
+
`"${SESSION_HOOK_FINGERPRINT}" — false-positive means uninstall ` +
|
|
567
|
+
`would delete user-authored hooks.`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
108
573
|
describe("mergeSessionHook — install fresh", () => {
|
|
109
574
|
beforeEach(setup);
|
|
110
575
|
afterEach(teardown);
|
|
@@ -260,7 +725,13 @@ describe("mergeSessionHook — idempotency", () => {
|
|
|
260
725
|
1,
|
|
261
726
|
"still exactly one SkillRepo hook (not duplicated)",
|
|
262
727
|
);
|
|
263
|
-
|
|
728
|
+
// Path is shell-quoted (v3.1.2: see buildHookCommand docstring)
|
|
729
|
+
// so the absolute path appears INSIDE the command rather than
|
|
730
|
+
// at position 0.
|
|
731
|
+
assert.ok(
|
|
732
|
+
skillrepoHooks[0].command.includes("/new/path/skillrepo"),
|
|
733
|
+
`updated hook must use new path, got: ${skillrepoHooks[0].command}`,
|
|
734
|
+
);
|
|
264
735
|
});
|
|
265
736
|
});
|
|
266
737
|
|
|
@@ -435,15 +906,65 @@ describe("mergeSessionHook — failure modes", () => {
|
|
|
435
906
|
afterEach(teardown);
|
|
436
907
|
|
|
437
908
|
it("returns 'skipped' (not a throw) when the binary cannot be resolved", () => {
|
|
438
|
-
// INTENT:
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
909
|
+
// INTENT: a caller passing `binaryPath: null` (e.g. session-sync
|
|
910
|
+
// enable under npx) must not have the action throw. Skip
|
|
911
|
+
// gracefully with an actionable reason. Init bypasses this path
|
|
912
|
+
// in v3.1.2 by passing the post-auto-install absolute path
|
|
913
|
+
// explicitly via `binaryPath`.
|
|
442
914
|
ASSERT_HOME_ISOLATED();
|
|
443
915
|
const result = mergeSessionHook({ binaryPath: null });
|
|
444
916
|
assert.equal(result.action, "skipped");
|
|
445
917
|
assert.ok(result.reason);
|
|
446
|
-
|
|
918
|
+
// The remediation hint must mention `npm install -g` so the user
|
|
919
|
+
// has a copy-pasteable next step.
|
|
920
|
+
assert.match(result.reason, /npm install -g/);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it("v3.1.1 fix: returns 'skipped' under npx invocation even when `which skillrepo` would succeed", async () => {
|
|
924
|
+
// Regression guard for the v3.1.0 shipped bug: `which skillrepo`
|
|
925
|
+
// finds the transient npx cache path and returns it as if it
|
|
926
|
+
// were a stable install location. The hook command baked in at
|
|
927
|
+
// install time then points at `~/.npm/_npx/<hash>/.../skillrepo`
|
|
928
|
+
// which disappears on npm cache eviction or version bump.
|
|
929
|
+
//
|
|
930
|
+
// This test exercises the FIX path: `resolveSkillrepoBinary` now
|
|
931
|
+
// calls `isNpxInvocation()` BEFORE `which`, and returns null if
|
|
932
|
+
// npx is detected. That null propagates to mergeSessionHook,
|
|
933
|
+
// which returns `skipped` with the architect's intended reason.
|
|
934
|
+
ASSERT_HOME_ISOLATED();
|
|
935
|
+
|
|
936
|
+
// Simulate the npx invocation via process.argv[1]. The real
|
|
937
|
+
// invocation would have the npx-cache path as argv[1] and npx
|
|
938
|
+
// would put its bin dir on PATH — but simulating argv alone is
|
|
939
|
+
// sufficient to exercise the isNpxInvocation check.
|
|
940
|
+
const originalArgv = process.argv;
|
|
941
|
+
process.argv = [
|
|
942
|
+
"/usr/local/bin/node",
|
|
943
|
+
"/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
|
|
944
|
+
];
|
|
945
|
+
|
|
946
|
+
try {
|
|
947
|
+
// Call WITHOUT passing binaryPath explicitly — forces the
|
|
948
|
+
// resolveSkillrepoBinary() path to run, which must detect npx
|
|
949
|
+
// and return null.
|
|
950
|
+
const { mergeSessionHook: mergeFresh } = await import(
|
|
951
|
+
"../../lib/mergers/session-hook.mjs?v311-npx-test=" + Date.now()
|
|
952
|
+
);
|
|
953
|
+
const result = mergeFresh();
|
|
954
|
+
assert.equal(
|
|
955
|
+
result.action,
|
|
956
|
+
"skipped",
|
|
957
|
+
"npx invocation must skip session-sync install",
|
|
958
|
+
);
|
|
959
|
+
assert.ok(result.reason, "skipped action must carry a reason");
|
|
960
|
+
assert.match(
|
|
961
|
+
result.reason,
|
|
962
|
+
/stable.*skillrepo/i,
|
|
963
|
+
"reason must mention the stable-binary requirement",
|
|
964
|
+
);
|
|
965
|
+
} finally {
|
|
966
|
+
process.argv = originalArgv;
|
|
967
|
+
}
|
|
447
968
|
});
|
|
448
969
|
|
|
449
970
|
it("throws diskError on unparseable settings file", () => {
|
|
@@ -462,6 +983,185 @@ describe("mergeSessionHook — failure modes", () => {
|
|
|
462
983
|
/Cannot parse/i,
|
|
463
984
|
);
|
|
464
985
|
});
|
|
986
|
+
|
|
987
|
+
it("v3.1.2 bypass: explicit binaryPath under npx → 'updated' when prior _npx-cache hook exists", async () => {
|
|
988
|
+
// QA gap fix: the bypass-via-binaryPath contract must work
|
|
989
|
+
// for ALL three success states (installed/updated/unchanged),
|
|
990
|
+
// not just "installed" (the empty-disk case). This test
|
|
991
|
+
// exercises "updated" — pre-seed a hook with an _npx cache
|
|
992
|
+
// path command, then call merge with an explicit non-npx
|
|
993
|
+
// binaryPath, assert action is "updated" and the new path
|
|
994
|
+
// replaced the cache path.
|
|
995
|
+
ASSERT_HOME_ISOLATED();
|
|
996
|
+
|
|
997
|
+
const originalArgv = process.argv;
|
|
998
|
+
process.argv = [
|
|
999
|
+
"/usr/local/bin/node",
|
|
1000
|
+
"/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
|
|
1001
|
+
];
|
|
1002
|
+
|
|
1003
|
+
try {
|
|
1004
|
+
// Pre-seed a v3.1.0-style hook with an _npx cache path baked
|
|
1005
|
+
// in (the bug v3.1.1 was trying to prevent).
|
|
1006
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
1007
|
+
const STALE_NPX_PATH =
|
|
1008
|
+
"/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo";
|
|
1009
|
+
const stalePath = join(
|
|
1010
|
+
process.cwd(),
|
|
1011
|
+
".claude",
|
|
1012
|
+
"settings.local.json",
|
|
1013
|
+
);
|
|
1014
|
+
writeFileSync(
|
|
1015
|
+
stalePath,
|
|
1016
|
+
JSON.stringify(
|
|
1017
|
+
{
|
|
1018
|
+
hooks: {
|
|
1019
|
+
SessionStart: [
|
|
1020
|
+
{
|
|
1021
|
+
hooks: [
|
|
1022
|
+
{
|
|
1023
|
+
type: "command",
|
|
1024
|
+
command: `${STALE_NPX_PATH} update --session-hook 2>&1 || true`,
|
|
1025
|
+
},
|
|
1026
|
+
],
|
|
1027
|
+
},
|
|
1028
|
+
],
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
null,
|
|
1032
|
+
2,
|
|
1033
|
+
),
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
const { mergeSessionHook: mergeFresh } = await import(
|
|
1037
|
+
"../../lib/mergers/session-hook.mjs?v312-updated-test=" + Date.now()
|
|
1038
|
+
);
|
|
1039
|
+
const POST_INSTALL_PATH = "/usr/local/bin/skillrepo";
|
|
1040
|
+
const result = mergeFresh({ binaryPath: POST_INSTALL_PATH });
|
|
1041
|
+
|
|
1042
|
+
assert.equal(
|
|
1043
|
+
result.action,
|
|
1044
|
+
"updated",
|
|
1045
|
+
"stale _npx hook with new binaryPath must be 'updated', not 'installed' or 'skipped'",
|
|
1046
|
+
);
|
|
1047
|
+
// The on-disk file must reflect the new path, not the cache.
|
|
1048
|
+
const written = JSON.parse(readFileSync(stalePath, "utf-8"));
|
|
1049
|
+
const cmd = written.hooks.SessionStart[0].hooks[0].command;
|
|
1050
|
+
assert.ok(
|
|
1051
|
+
cmd.includes(POST_INSTALL_PATH),
|
|
1052
|
+
`updated hook must use new path, got: ${cmd}`,
|
|
1053
|
+
);
|
|
1054
|
+
assert.ok(
|
|
1055
|
+
!cmd.includes("_npx"),
|
|
1056
|
+
"updated hook must NOT leak the prior _npx cache path",
|
|
1057
|
+
);
|
|
1058
|
+
} finally {
|
|
1059
|
+
process.argv = originalArgv;
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it("v3.1.2 bypass: explicit binaryPath under npx → 'unchanged' when same hook already present", async () => {
|
|
1064
|
+
// The third success state — repeated init with the same
|
|
1065
|
+
// global. Idempotency: no file write, action is "unchanged".
|
|
1066
|
+
ASSERT_HOME_ISOLATED();
|
|
1067
|
+
|
|
1068
|
+
const originalArgv = process.argv;
|
|
1069
|
+
process.argv = [
|
|
1070
|
+
"/usr/local/bin/node",
|
|
1071
|
+
"/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
|
|
1072
|
+
];
|
|
1073
|
+
|
|
1074
|
+
try {
|
|
1075
|
+
const POST_INSTALL_PATH = "/usr/local/bin/skillrepo";
|
|
1076
|
+
const { buildHookCommand: buildFresh, mergeSessionHook: mergeFresh } =
|
|
1077
|
+
await import(
|
|
1078
|
+
"../../lib/mergers/session-hook.mjs?v312-unchanged-test=" + Date.now()
|
|
1079
|
+
);
|
|
1080
|
+
const expectedCmd = buildFresh(POST_INSTALL_PATH);
|
|
1081
|
+
|
|
1082
|
+
// Pre-seed the EXACT command we'd write — idempotent re-run.
|
|
1083
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
1084
|
+
writeFileSync(
|
|
1085
|
+
join(process.cwd(), ".claude", "settings.local.json"),
|
|
1086
|
+
JSON.stringify(
|
|
1087
|
+
{
|
|
1088
|
+
hooks: {
|
|
1089
|
+
SessionStart: [
|
|
1090
|
+
{
|
|
1091
|
+
hooks: [{ type: "command", command: expectedCmd }],
|
|
1092
|
+
},
|
|
1093
|
+
],
|
|
1094
|
+
},
|
|
1095
|
+
},
|
|
1096
|
+
null,
|
|
1097
|
+
2,
|
|
1098
|
+
),
|
|
1099
|
+
);
|
|
1100
|
+
|
|
1101
|
+
const result = mergeFresh({ binaryPath: POST_INSTALL_PATH });
|
|
1102
|
+
assert.equal(
|
|
1103
|
+
result.action,
|
|
1104
|
+
"unchanged",
|
|
1105
|
+
"identical hook with same binaryPath must be 'unchanged'",
|
|
1106
|
+
);
|
|
1107
|
+
assert.equal(result.command, expectedCmd);
|
|
1108
|
+
} finally {
|
|
1109
|
+
process.argv = originalArgv;
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
it("v3.1.2: explicit binaryPath bypasses the isNpxInvocation guard", async () => {
|
|
1114
|
+
// INTENT: init's v3.1.2 auto-install flow runs `npm install -g
|
|
1115
|
+
// skillrepo` itself under npx, then calls mergeSessionHook with
|
|
1116
|
+
// the resulting absolute path passed explicitly via `binaryPath`.
|
|
1117
|
+
// The internal `resolveSkillrepoBinary` early-returns under npx —
|
|
1118
|
+
// but when the caller already has the binary path, that guard
|
|
1119
|
+
// must NOT block the install. The `binaryPath` parameter is the
|
|
1120
|
+
// bypass mechanism.
|
|
1121
|
+
//
|
|
1122
|
+
// This test is the lock-in for the v3.1.2 contract: explicit
|
|
1123
|
+
// `binaryPath` short-circuits the npx detection. If a future
|
|
1124
|
+
// refactor moves the npx guard into mergeSessionHook itself
|
|
1125
|
+
// (rather than resolveSkillrepoBinary), this test fails — and
|
|
1126
|
+
// it should.
|
|
1127
|
+
ASSERT_HOME_ISOLATED();
|
|
1128
|
+
|
|
1129
|
+
const originalArgv = process.argv;
|
|
1130
|
+
process.argv = [
|
|
1131
|
+
"/usr/local/bin/node",
|
|
1132
|
+
"/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
|
|
1133
|
+
];
|
|
1134
|
+
|
|
1135
|
+
try {
|
|
1136
|
+
const { mergeSessionHook: mergeFresh, buildHookCommand: buildFresh } =
|
|
1137
|
+
await import(
|
|
1138
|
+
"../../lib/mergers/session-hook.mjs?v312-bypass-test=" + Date.now()
|
|
1139
|
+
);
|
|
1140
|
+
// Pass an absolute, stable path explicitly — the kind init's
|
|
1141
|
+
// auto-install flow obtains from `resolveGlobalBinary()` after
|
|
1142
|
+
// a successful `npm install -g`.
|
|
1143
|
+
const POST_INSTALL_PATH = "/usr/local/bin/skillrepo";
|
|
1144
|
+
const result = mergeFresh({ binaryPath: POST_INSTALL_PATH });
|
|
1145
|
+
assert.equal(
|
|
1146
|
+
result.action,
|
|
1147
|
+
"installed",
|
|
1148
|
+
"explicit binaryPath under npx must succeed, not skip",
|
|
1149
|
+
);
|
|
1150
|
+
// The hook command must contain the explicit path (not the
|
|
1151
|
+
// _npx cache path).
|
|
1152
|
+
assert.equal(result.command, buildFresh(POST_INSTALL_PATH));
|
|
1153
|
+
assert.ok(
|
|
1154
|
+
result.command.includes(POST_INSTALL_PATH),
|
|
1155
|
+
"hook command must use the explicit path verbatim",
|
|
1156
|
+
);
|
|
1157
|
+
assert.ok(
|
|
1158
|
+
!result.command.includes("_npx"),
|
|
1159
|
+
"hook command must NOT leak the _npx cache path",
|
|
1160
|
+
);
|
|
1161
|
+
} finally {
|
|
1162
|
+
process.argv = originalArgv;
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
465
1165
|
});
|
|
466
1166
|
|
|
467
1167
|
describe("removeSessionHook — inverse of install", () => {
|