skillrepo 3.1.1 → 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 +4 -2
- package/package.json +1 -1
- package/src/commands/init-session-sync.mjs +307 -0
- package/src/commands/init.mjs +74 -111
- package/src/commands/session-sync-actions.mjs +92 -0
- package/src/lib/binary-locator.mjs +99 -0
- package/src/lib/cli-config.mjs +7 -72
- package/src/lib/cli-version.mjs +56 -0
- package/src/lib/global-install.mjs +387 -0
- package/src/lib/mcp-merge.mjs +16 -5
- package/src/lib/mergers/session-hook.mjs +80 -68
- package/src/lib/transient-runners.mjs +204 -0
- package/src/test/commands/init.test.mjs +662 -1
- package/src/test/commands/session-sync-actions.test.mjs +74 -0
- package/src/test/helpers/mock-spawn.mjs +121 -0
- package/src/test/lib/cli-config.test.mjs +66 -9
- package/src/test/lib/cli-version.test.mjs +47 -0
- package/src/test/lib/global-install.test.mjs +424 -0
- package/src/test/lib/mcp-merge.test.mjs +3 -3
- package/src/test/lib/transient-runners.test.mjs +270 -0
- package/src/test/mergers/session-hook.test.mjs +284 -14
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/transient-runners.mjs (#894 / v3.1.2).
|
|
3
|
+
*
|
|
4
|
+
* Covers the three exports:
|
|
5
|
+
* - detectTransientRunner — returns the runner's display NAME
|
|
6
|
+
* (npx / pnpx / yarn dlx / bunx) or null. Used by init.mjs's
|
|
7
|
+
* Next-Steps prefix.
|
|
8
|
+
* - isTransientRunnerInvocation — boolean shortcut. Used as the
|
|
9
|
+
* gate for auto-install and the npx-guard in mergers/session-hook.
|
|
10
|
+
* - isTransientCachePath — boolean for filtering `where`/`which`
|
|
11
|
+
* output. Used by the binary locator.
|
|
12
|
+
*
|
|
13
|
+
* The boolean shortcut's behavior is also covered transitively by
|
|
14
|
+
* `cli-config.test.mjs` (which re-exports it). This file specifically
|
|
15
|
+
* locks in:
|
|
16
|
+
* - The runner-NAME return contract (v3.1.2 added this so init's
|
|
17
|
+
* Next-Steps shows `pnpx skillrepo list` for pnpm dlx users
|
|
18
|
+
* instead of always `npx skillrepo list`).
|
|
19
|
+
* - The `isTransientCachePath` filter behavior the binary locator
|
|
20
|
+
* depends on.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it } from "node:test";
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
detectTransientRunner,
|
|
28
|
+
isTransientRunnerInvocation,
|
|
29
|
+
isTransientCachePath,
|
|
30
|
+
globalInstallCommandFor,
|
|
31
|
+
} from "../../lib/transient-runners.mjs";
|
|
32
|
+
|
|
33
|
+
// ── detectTransientRunner — returns the runner display name ────────
|
|
34
|
+
|
|
35
|
+
describe("detectTransientRunner", () => {
|
|
36
|
+
it("returns null for a vanilla stable-install argv", () => {
|
|
37
|
+
const result = detectTransientRunner({
|
|
38
|
+
argv: ["/usr/local/bin/node", "/usr/local/bin/skillrepo"],
|
|
39
|
+
env: {},
|
|
40
|
+
});
|
|
41
|
+
assert.equal(result, null);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns 'npx' for an npm npx invocation", () => {
|
|
45
|
+
const result = detectTransientRunner({
|
|
46
|
+
argv: [
|
|
47
|
+
"/usr/local/bin/node",
|
|
48
|
+
"/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
|
|
49
|
+
],
|
|
50
|
+
env: {},
|
|
51
|
+
});
|
|
52
|
+
assert.equal(result, "npx");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns 'pnpx' for a pnpm dlx invocation (cache substring)", () => {
|
|
56
|
+
const result = detectTransientRunner({
|
|
57
|
+
argv: [
|
|
58
|
+
"/usr/local/bin/node",
|
|
59
|
+
"/Users/alice/.local/share/pnpm/store/dlx-abc123/node_modules/.bin/skillrepo",
|
|
60
|
+
],
|
|
61
|
+
env: {},
|
|
62
|
+
});
|
|
63
|
+
assert.equal(result, "pnpx");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns 'yarn dlx' for a yarn berry dlx invocation", () => {
|
|
67
|
+
const result = detectTransientRunner({
|
|
68
|
+
argv: [
|
|
69
|
+
"/usr/local/bin/node",
|
|
70
|
+
"/Users/alice/project/.yarn/berry/cache/skillrepo-npm-3.1.2-abc/node_modules/skillrepo/bin/skillrepo.mjs",
|
|
71
|
+
],
|
|
72
|
+
env: {},
|
|
73
|
+
});
|
|
74
|
+
assert.equal(result, "yarn dlx");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns 'bunx' for a bun bunx invocation (cache substring)", () => {
|
|
78
|
+
const result = detectTransientRunner({
|
|
79
|
+
argv: [
|
|
80
|
+
"/usr/local/bin/node",
|
|
81
|
+
"/Users/alice/.bun/install/cache/skillrepo@3.1.2/node_modules/.bin/skillrepo",
|
|
82
|
+
],
|
|
83
|
+
env: {},
|
|
84
|
+
});
|
|
85
|
+
assert.equal(result, "bunx");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns 'npx' from `_` env-var fallback when argv path doesn't match", () => {
|
|
89
|
+
// E.g., a wrapper script symlinks the npx binary somewhere
|
|
90
|
+
// outside the npx cache. The `_` signal still identifies the
|
|
91
|
+
// launcher.
|
|
92
|
+
const result = detectTransientRunner({
|
|
93
|
+
argv: ["/usr/local/bin/node", "/somewhere/bin/skillrepo"],
|
|
94
|
+
env: { _: "/usr/local/bin/npx" },
|
|
95
|
+
});
|
|
96
|
+
assert.equal(result, "npx");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns 'pnpx' from `_` env-var fallback", () => {
|
|
100
|
+
const result = detectTransientRunner({
|
|
101
|
+
argv: ["/usr/local/bin/node", "/somewhere/bin/skillrepo"],
|
|
102
|
+
env: { _: "/usr/local/bin/pnpx" },
|
|
103
|
+
});
|
|
104
|
+
assert.equal(result, "pnpx");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns 'bunx' from `_` env-var fallback", () => {
|
|
108
|
+
const result = detectTransientRunner({
|
|
109
|
+
argv: ["/usr/local/bin/node", "/somewhere/bin/skillrepo"],
|
|
110
|
+
env: { _: "/Users/alice/.bun/bin/bunx" },
|
|
111
|
+
});
|
|
112
|
+
assert.equal(result, "bunx");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("argv signal beats `_` env-var (first-match wins on argv)", () => {
|
|
116
|
+
// If both signal a runner, argv wins. Realistically these would
|
|
117
|
+
// signal the SAME runner (npx invocation sets argv to the npx
|
|
118
|
+
// cache AND `_` to the npx binary), but the test pins the
|
|
119
|
+
// priority for the rare disagreement case.
|
|
120
|
+
const result = detectTransientRunner({
|
|
121
|
+
argv: [
|
|
122
|
+
"/usr/local/bin/node",
|
|
123
|
+
"/Users/alice/.bun/install/cache/skillrepo@3.1.2/node_modules/.bin/skillrepo",
|
|
124
|
+
],
|
|
125
|
+
env: { _: "/usr/local/bin/npx" },
|
|
126
|
+
});
|
|
127
|
+
// bunx wins because argv is checked first.
|
|
128
|
+
assert.equal(result, "bunx");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("does not false-positive on a directory named 'dlx-utils' (substring not in pnpm cache)", () => {
|
|
132
|
+
// Defensive: `/dlx-` is the substring, but a user-named directory
|
|
133
|
+
// like `/Users/alice/utility-dlx/` doesn't trigger because the
|
|
134
|
+
// substring requires a leading `/` separator before `dlx-`.
|
|
135
|
+
const result = detectTransientRunner({
|
|
136
|
+
argv: ["/usr/local/bin/node", "/Users/alice/utility-dlx/bin/skillrepo"],
|
|
137
|
+
env: {},
|
|
138
|
+
});
|
|
139
|
+
assert.equal(result, null);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("does not false-positive on an `npx`-named user dir without the _npx cache pattern", () => {
|
|
143
|
+
const result = detectTransientRunner({
|
|
144
|
+
argv: ["/usr/local/bin/node", "/opt/npxtools/bin/skillrepo"],
|
|
145
|
+
env: {},
|
|
146
|
+
});
|
|
147
|
+
assert.equal(result, null);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── isTransientRunnerInvocation — boolean shortcut ──────────────────
|
|
152
|
+
|
|
153
|
+
describe("isTransientRunnerInvocation (boolean shortcut)", () => {
|
|
154
|
+
// The boolean version reads from process.argv / process.env directly
|
|
155
|
+
// (no override hook for this convenience wrapper). The full table
|
|
156
|
+
// of detection cases is covered above via detectTransientRunner;
|
|
157
|
+
// these tests just verify the boolean shape.
|
|
158
|
+
let originalArgv;
|
|
159
|
+
let originalUnderscore;
|
|
160
|
+
|
|
161
|
+
function setup() {
|
|
162
|
+
originalArgv = process.argv;
|
|
163
|
+
originalUnderscore = process.env._;
|
|
164
|
+
delete process.env._;
|
|
165
|
+
}
|
|
166
|
+
function teardown() {
|
|
167
|
+
process.argv = originalArgv;
|
|
168
|
+
if (originalUnderscore === undefined) delete process.env._;
|
|
169
|
+
else process.env._ = originalUnderscore;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it("returns true when argv signals a transient runner", () => {
|
|
173
|
+
setup();
|
|
174
|
+
try {
|
|
175
|
+
process.argv = [
|
|
176
|
+
"/usr/local/bin/node",
|
|
177
|
+
"/Users/alice/.npm/_npx/abc/skillrepo",
|
|
178
|
+
];
|
|
179
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
180
|
+
} finally {
|
|
181
|
+
teardown();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns false for a stable-install argv", () => {
|
|
186
|
+
setup();
|
|
187
|
+
try {
|
|
188
|
+
process.argv = ["/usr/local/bin/node", "/usr/local/bin/skillrepo"];
|
|
189
|
+
assert.equal(isTransientRunnerInvocation(), false);
|
|
190
|
+
} finally {
|
|
191
|
+
teardown();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── isTransientCachePath — used by binary locator ──────────────────
|
|
197
|
+
|
|
198
|
+
describe("isTransientCachePath", () => {
|
|
199
|
+
it("identifies an npx cache path", () => {
|
|
200
|
+
assert.equal(
|
|
201
|
+
isTransientCachePath("/Users/alice/.npm/_npx/abc/node_modules/.bin/skillrepo"),
|
|
202
|
+
true,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("identifies a pnpm dlx cache path", () => {
|
|
207
|
+
assert.equal(
|
|
208
|
+
isTransientCachePath("/Users/alice/.local/share/pnpm/store/dlx-abc/node_modules/.bin/skillrepo"),
|
|
209
|
+
true,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("identifies a yarn berry cache path", () => {
|
|
214
|
+
assert.equal(
|
|
215
|
+
isTransientCachePath(
|
|
216
|
+
"/Users/alice/proj/.yarn/berry/cache/skillrepo-npm-3.1.2-abc/...",
|
|
217
|
+
),
|
|
218
|
+
true,
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("identifies a bun cache path", () => {
|
|
223
|
+
assert.equal(
|
|
224
|
+
isTransientCachePath("/Users/alice/.bun/install/cache/skillrepo@3.1.2/bin/skillrepo"),
|
|
225
|
+
true,
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("identifies a Windows-style npx cache path", () => {
|
|
230
|
+
assert.equal(
|
|
231
|
+
isTransientCachePath(
|
|
232
|
+
"C:\\Users\\alice\\.npm\\_npx\\abc\\node_modules\\.bin\\skillrepo.cmd",
|
|
233
|
+
),
|
|
234
|
+
true,
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("returns false for a stable global path", () => {
|
|
239
|
+
assert.equal(isTransientCachePath("/usr/local/bin/skillrepo"), false);
|
|
240
|
+
assert.equal(isTransientCachePath("/opt/homebrew/bin/skillrepo"), false);
|
|
241
|
+
assert.equal(
|
|
242
|
+
isTransientCachePath("C:\\Program Files\\nodejs\\skillrepo.cmd"),
|
|
243
|
+
false,
|
|
244
|
+
);
|
|
245
|
+
assert.equal(
|
|
246
|
+
isTransientCachePath("/Users/alice/.npm-global/bin/skillrepo"),
|
|
247
|
+
false,
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ── globalInstallCommandFor — per-runner install hint ──────────────
|
|
253
|
+
|
|
254
|
+
describe("globalInstallCommandFor", () => {
|
|
255
|
+
it("returns the right install command for each known runner", () => {
|
|
256
|
+
assert.equal(globalInstallCommandFor("npx"), "npm install -g skillrepo");
|
|
257
|
+
assert.equal(globalInstallCommandFor("pnpx"), "pnpm add -g skillrepo");
|
|
258
|
+
// yarn berry has no `yarn global add`; falls back to npm
|
|
259
|
+
// (documented in the TRANSIENT_RUNNERS comment).
|
|
260
|
+
assert.equal(globalInstallCommandFor("yarn dlx"), "npm install -g skillrepo");
|
|
261
|
+
assert.equal(globalInstallCommandFor("bunx"), "bun add -g skillrepo");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("returns null for unknown / null runner names so callers can fall back", () => {
|
|
265
|
+
assert.equal(globalInstallCommandFor(null), null);
|
|
266
|
+
assert.equal(globalInstallCommandFor(undefined), null);
|
|
267
|
+
assert.equal(globalInstallCommandFor(""), null);
|
|
268
|
+
assert.equal(globalInstallCommandFor("not-a-real-runner"), null);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
@@ -76,10 +76,11 @@ function teardown() {
|
|
|
76
76
|
|
|
77
77
|
describe("buildHookCommand", () => {
|
|
78
78
|
it("produces the exact command shape Claude Code expects (POSIX)", () => {
|
|
79
|
-
// INTENT: the shape is load-bearing in
|
|
80
|
-
// installer's docstring): absolute path,
|
|
81
|
-
//
|
|
82
|
-
//
|
|
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.
|
|
83
84
|
//
|
|
84
85
|
// The `platform: "linux"` override is explicit because this test
|
|
85
86
|
// asserts the POSIX command shape. Without it, the test runs
|
|
@@ -93,7 +94,7 @@ describe("buildHookCommand", () => {
|
|
|
93
94
|
});
|
|
94
95
|
assert.equal(
|
|
95
96
|
cmd,
|
|
96
|
-
"/usr/local/bin/skillrepo update --session-hook 2>&1 || true",
|
|
97
|
+
"'/usr/local/bin/skillrepo' update --session-hook 2>&1 || true",
|
|
97
98
|
);
|
|
98
99
|
});
|
|
99
100
|
|
|
@@ -128,8 +129,8 @@ describe("buildHookCommand", () => {
|
|
|
128
129
|
);
|
|
129
130
|
assert.match(
|
|
130
131
|
cmd,
|
|
131
|
-
/skillrepo\.cmd update --session-hook 2>&1$/,
|
|
132
|
-
"Windows hook command ends at '2>&1' — no shell backstop",
|
|
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",
|
|
133
134
|
);
|
|
134
135
|
// Fingerprint still present — remover round-trip must work on
|
|
135
136
|
// Windows too.
|
|
@@ -147,6 +148,85 @@ describe("buildHookCommand", () => {
|
|
|
147
148
|
assert.match(cmdDarwin, /\|\| true$/);
|
|
148
149
|
});
|
|
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
|
+
|
|
150
230
|
it("rejects empty or non-string binary paths", () => {
|
|
151
231
|
// INTENT: no silent production of a malformed command. The
|
|
152
232
|
// installer upstream should never pass null/empty, but defensive
|
|
@@ -400,8 +480,10 @@ describe("v3.1.1 Windows hook: fingerprint + round-trip contract", () => {
|
|
|
400
480
|
`Windows hook must NOT contain "|| true" — cmd.exe has no such builtin. Got: "${cmd}"`,
|
|
401
481
|
);
|
|
402
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).
|
|
403
485
|
assert.ok(
|
|
404
|
-
cmd.endsWith(
|
|
486
|
+
cmd.endsWith(`skillrepo.cmd" update --session-hook 2>&1`),
|
|
405
487
|
`Windows hook command must end at "2>&1". Got: "${cmd}"`,
|
|
406
488
|
);
|
|
407
489
|
|
|
@@ -643,7 +725,13 @@ describe("mergeSessionHook — idempotency", () => {
|
|
|
643
725
|
1,
|
|
644
726
|
"still exactly one SkillRepo hook (not duplicated)",
|
|
645
727
|
);
|
|
646
|
-
|
|
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
|
+
);
|
|
647
735
|
});
|
|
648
736
|
});
|
|
649
737
|
|
|
@@ -818,15 +906,18 @@ describe("mergeSessionHook — failure modes", () => {
|
|
|
818
906
|
afterEach(teardown);
|
|
819
907
|
|
|
820
908
|
it("returns 'skipped' (not a throw) when the binary cannot be resolved", () => {
|
|
821
|
-
// INTENT:
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
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`.
|
|
825
914
|
ASSERT_HOME_ISOLATED();
|
|
826
915
|
const result = mergeSessionHook({ binaryPath: null });
|
|
827
916
|
assert.equal(result.action, "skipped");
|
|
828
917
|
assert.ok(result.reason);
|
|
829
|
-
|
|
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/);
|
|
830
921
|
});
|
|
831
922
|
|
|
832
923
|
it("v3.1.1 fix: returns 'skipped' under npx invocation even when `which skillrepo` would succeed", async () => {
|
|
@@ -892,6 +983,185 @@ describe("mergeSessionHook — failure modes", () => {
|
|
|
892
983
|
/Cannot parse/i,
|
|
893
984
|
);
|
|
894
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
|
+
});
|
|
895
1165
|
});
|
|
896
1166
|
|
|
897
1167
|
describe("removeSessionHook — inverse of install", () => {
|