skillrepo 4.7.0 → 4.8.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/bin/skillrepo.mjs +6 -3
- package/package.json +1 -1
- package/src/commands/init.mjs +15 -5
- package/src/lib/http.mjs +12 -2
- package/src/lib/sync.mjs +124 -8
- package/src/test/commands/add.test.mjs +78 -1
- package/src/test/commands/get.test.mjs +131 -2
- package/src/test/commands/init-session-sync.test.mjs +724 -0
- package/src/test/commands/init.test.mjs +159 -2
- package/src/test/commands/list.test.mjs +573 -1
- package/src/test/commands/publish.test.mjs +133 -0
- package/src/test/commands/push.test.mjs +280 -1
- package/src/test/commands/remove.test.mjs +221 -2
- package/src/test/commands/search.test.mjs +203 -1
- package/src/test/commands/session-sync.test.mjs +227 -1
- package/src/test/commands/uninstall.test.mjs +216 -0
- package/src/test/commands/update.test.mjs +218 -0
- package/src/test/dispatcher.test.mjs +103 -2
- package/src/test/e2e/advertised-surface.test.mjs +207 -0
- package/src/test/e2e/mock-server.mjs +19 -11
- package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
- package/src/test/e2e/update-check-suppression.test.mjs +135 -0
- package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
- package/src/test/lib/browser-open.test.mjs +43 -0
- package/src/test/lib/config.test.mjs +87 -0
- package/src/test/lib/crypto-shas.test.mjs +17 -0
- package/src/test/lib/file-write.test.mjs +244 -0
- package/src/test/lib/fs-utils.test.mjs +259 -0
- package/src/test/lib/global-install.test.mjs +134 -0
- package/src/test/lib/http-timeout.test.mjs +114 -0
- package/src/test/lib/http.test.mjs +615 -0
- package/src/test/lib/mcp-merge.test.mjs +157 -0
- package/src/test/lib/npm-update-check.test.mjs +180 -0
- package/src/test/lib/placement-walk.test.mjs +132 -0
- package/src/test/lib/skill-walk.test.mjs +39 -1
- package/src/test/lib/sync.test.mjs +434 -0
- package/src/test/lib/telemetry.test.mjs +34 -0
- package/src/test/mergers/claude-mcp.test.mjs +30 -0
- package/src/test/mergers/cursor-mcp.test.mjs +115 -0
- package/src/test/mergers/env-local.test.mjs +126 -0
- package/src/test/mergers/vscode-mcp.test.mjs +177 -0
- package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
- package/src/test/resolve-key.test.mjs +33 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/commands/init-session-sync.mjs (#894 / v3.1.2).
|
|
3
|
+
*
|
|
4
|
+
* Step 6 of `skillrepo init` owns the SessionStart-hook decision tree,
|
|
5
|
+
* the lowest-branch-coverage module in the CLI before these tests. The
|
|
6
|
+
* exported `installSessionSyncHook(...)` is directly unit-testable: it
|
|
7
|
+
* takes injected `deps` (`confirmFn`, `spawn`, `getCliVersion`) and a
|
|
8
|
+
* printer `p`, so we drive every branch WITHOUT running the full
|
|
9
|
+
* `runInit` orchestration.
|
|
10
|
+
*
|
|
11
|
+
* ## Decision tree under test
|
|
12
|
+
*
|
|
13
|
+
* 1. noSessionSync → OptedOut
|
|
14
|
+
* 2. !claudeTargeted → NotApplicable
|
|
15
|
+
* 3. non-npx, declined → Declined
|
|
16
|
+
* 4. non-npx, proceed → Installed/Updated/Unchanged
|
|
17
|
+
* 5. npx + existing global on PATH → use-existing-global
|
|
18
|
+
* 6. npx + no global, declined → Declined + manual steps
|
|
19
|
+
* 7. npx + no global, getCliVersion throw→ Skipped + manual steps
|
|
20
|
+
* 8. npx + no global, install fails → Skipped
|
|
21
|
+
* 9. npx + no global, install succeeds → Installed (auto-install)
|
|
22
|
+
*
|
|
23
|
+
* ## Environment control
|
|
24
|
+
*
|
|
25
|
+
* npx-mode is decided by `isTransientRunnerInvocation()`, which reads
|
|
26
|
+
* `process.env._` (suffix `/npx`) and `process.argv[1]` (cache
|
|
27
|
+
* substring). We force npx mode by setting `process.env._` to a value
|
|
28
|
+
* ending in `/npx`; non-npx mode by deleting `_` (the node:test
|
|
29
|
+
* runner's argv[1] contains no runner cache substring). We
|
|
30
|
+
* save/restore `_` and `PATH` in beforeEach/afterEach.
|
|
31
|
+
*
|
|
32
|
+
* Existing-global presence is decided by `resolveGlobalBinary()`,
|
|
33
|
+
* which scans `process.env.PATH`. A `skillrepo` shim on PATH makes it
|
|
34
|
+
* resolve; an empty/shim-free PATH makes it return null. HOME is
|
|
35
|
+
* isolated to a sandbox so any hook write lands in the sandbox.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
39
|
+
import assert from "node:assert/strict";
|
|
40
|
+
import {
|
|
41
|
+
mkdtempSync,
|
|
42
|
+
mkdirSync,
|
|
43
|
+
rmSync,
|
|
44
|
+
readFileSync,
|
|
45
|
+
writeFileSync,
|
|
46
|
+
existsSync,
|
|
47
|
+
} from "node:fs";
|
|
48
|
+
import { join } from "node:path";
|
|
49
|
+
import { tmpdir } from "node:os";
|
|
50
|
+
|
|
51
|
+
import { installSessionSyncHook } from "../../commands/init-session-sync.mjs";
|
|
52
|
+
import { SessionSyncAction } from "../../commands/session-sync-actions.mjs";
|
|
53
|
+
import { buildHookCommand } from "../../lib/mergers/session-hook.mjs";
|
|
54
|
+
import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
|
|
55
|
+
import {
|
|
56
|
+
captureHome,
|
|
57
|
+
setSandboxHome,
|
|
58
|
+
restoreHome,
|
|
59
|
+
assertHomeIsolated,
|
|
60
|
+
} from "../helpers/sandbox-home.mjs";
|
|
61
|
+
import { installShim, uninstallShim } from "../helpers/skillrepo-shim.mjs";
|
|
62
|
+
|
|
63
|
+
let sandbox;
|
|
64
|
+
let originalCwd;
|
|
65
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
66
|
+
let originalHomeEnv;
|
|
67
|
+
/** @type {string | undefined} */
|
|
68
|
+
let originalUnderscore;
|
|
69
|
+
/** @type {string | undefined} */
|
|
70
|
+
let originalPath;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Stub printer matching the `makePrinter` surface used by this module:
|
|
74
|
+
* only `success` and `warning` are called. Records every line so tests
|
|
75
|
+
* can assert on the user-facing copy (especially the manual-steps text
|
|
76
|
+
* emitted by `printManualSteps`).
|
|
77
|
+
*/
|
|
78
|
+
function makeStubPrinter() {
|
|
79
|
+
const success = [];
|
|
80
|
+
const warning = [];
|
|
81
|
+
return {
|
|
82
|
+
success(msg) {
|
|
83
|
+
success.push(msg);
|
|
84
|
+
},
|
|
85
|
+
warning(msg) {
|
|
86
|
+
warning.push(msg);
|
|
87
|
+
},
|
|
88
|
+
/** All lines, regardless of channel, joined for substring asserts. */
|
|
89
|
+
all() {
|
|
90
|
+
return [...success, ...warning].join("\n");
|
|
91
|
+
},
|
|
92
|
+
_success: success,
|
|
93
|
+
_warning: warning,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Force npx (transient-runner) mode via process.env._. */
|
|
98
|
+
function forceNpxMode() {
|
|
99
|
+
process.env._ = "/usr/local/bin/npx";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Force non-npx mode by removing the `_` signal. */
|
|
103
|
+
function forceNonNpxMode() {
|
|
104
|
+
delete process.env._;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Clear `skillrepo` off PATH entirely so `resolveGlobalBinary()` and
|
|
109
|
+
* `mergeSessionHook`'s resolver both return null. We point PATH at an
|
|
110
|
+
* empty sandbox bin dir (kept absolute so the locator doesn't drop it
|
|
111
|
+
* as a relative entry).
|
|
112
|
+
*/
|
|
113
|
+
function clearSkillrepoFromPath() {
|
|
114
|
+
const emptyBin = join(sandbox, "empty-bin");
|
|
115
|
+
mkdirSync(emptyBin, { recursive: true });
|
|
116
|
+
process.env.PATH = emptyBin;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ASSERT_HOME_ISOLATED() {
|
|
120
|
+
assertHomeIsolated(tmpdir(), "init-session-sync tests");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function setup() {
|
|
124
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-init-session-sync-"));
|
|
125
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
126
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
127
|
+
originalCwd = process.cwd();
|
|
128
|
+
originalHomeEnv = captureHome();
|
|
129
|
+
originalUnderscore = process.env._;
|
|
130
|
+
originalPath = process.env.PATH;
|
|
131
|
+
process.chdir(join(sandbox, "project"));
|
|
132
|
+
setSandboxHome(join(sandbox, "home"));
|
|
133
|
+
ASSERT_HOME_ISOLATED();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function teardown() {
|
|
137
|
+
process.chdir(originalCwd);
|
|
138
|
+
// Restore PATH first (shim install/uninstall mutates it).
|
|
139
|
+
if (originalPath === undefined) delete process.env.PATH;
|
|
140
|
+
else process.env.PATH = originalPath;
|
|
141
|
+
if (originalUnderscore === undefined) delete process.env._;
|
|
142
|
+
else process.env._ = originalUnderscore;
|
|
143
|
+
restoreHome(originalHomeEnv);
|
|
144
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Absolute path to the project-local settings file in the sandbox. */
|
|
148
|
+
function projectSettingsPath() {
|
|
149
|
+
return join(process.cwd(), ".claude", "settings.local.json");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Does the on-disk project settings file contain the SkillRepo hook? */
|
|
153
|
+
function projectHasHook() {
|
|
154
|
+
const p = projectSettingsPath();
|
|
155
|
+
if (!existsSync(p)) return false;
|
|
156
|
+
const parsed = JSON.parse(readFileSync(p, "utf-8"));
|
|
157
|
+
return (parsed.hooks?.SessionStart ?? [])
|
|
158
|
+
.flatMap((g) => g?.hooks ?? [])
|
|
159
|
+
.some((h) => h?.command?.includes(SESSION_HOOK_FINGERPRINT));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ──────────────────────────────────────────────────────────────────
|
|
163
|
+
// 1. noSessionSync → OptedOut
|
|
164
|
+
// ──────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
describe("init-session-sync — opted out / not applicable", () => {
|
|
167
|
+
beforeEach(setup);
|
|
168
|
+
afterEach(teardown);
|
|
169
|
+
|
|
170
|
+
it("returns OptedOut and writes nothing when --no-session-sync", async () => {
|
|
171
|
+
const p = makeStubPrinter();
|
|
172
|
+
const r = await installSessionSyncHook({
|
|
173
|
+
noSessionSync: true,
|
|
174
|
+
claudeTargeted: true,
|
|
175
|
+
yes: true,
|
|
176
|
+
json: false,
|
|
177
|
+
global: false,
|
|
178
|
+
p,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
assert.equal(r.action, SessionSyncAction.OptedOut);
|
|
182
|
+
assert.equal(r.path, null);
|
|
183
|
+
assert.equal(r.globalInstallActive, false);
|
|
184
|
+
assert.match(p.all(), /--no-session-sync/);
|
|
185
|
+
assert.equal(projectHasHook(), false, "must not write a hook");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returns NotApplicable when Claude Code is not a target", async () => {
|
|
189
|
+
const p = makeStubPrinter();
|
|
190
|
+
const r = await installSessionSyncHook({
|
|
191
|
+
noSessionSync: false,
|
|
192
|
+
claudeTargeted: false,
|
|
193
|
+
yes: true,
|
|
194
|
+
json: false,
|
|
195
|
+
global: false,
|
|
196
|
+
p,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
assert.equal(r.action, SessionSyncAction.NotApplicable);
|
|
200
|
+
assert.equal(r.path, null);
|
|
201
|
+
assert.equal(r.globalInstallActive, false);
|
|
202
|
+
assert.match(p.all(), /Claude Code-specific/);
|
|
203
|
+
assert.equal(projectHasHook(), false);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ──────────────────────────────────────────────────────────────────
|
|
208
|
+
// 3 + 4. Non-npx branch
|
|
209
|
+
// ──────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
describe("init-session-sync — non-npx branch", () => {
|
|
212
|
+
let shimHandle;
|
|
213
|
+
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
setup();
|
|
216
|
+
forceNonNpxMode();
|
|
217
|
+
});
|
|
218
|
+
afterEach(() => {
|
|
219
|
+
uninstallShim(shimHandle);
|
|
220
|
+
shimHandle = undefined;
|
|
221
|
+
teardown();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("declined (prompt says no) → Declined, no write", async () => {
|
|
225
|
+
// No shim needed: we never reach the merge step.
|
|
226
|
+
clearSkillrepoFromPath();
|
|
227
|
+
const p = makeStubPrinter();
|
|
228
|
+
const r = await installSessionSyncHook({
|
|
229
|
+
noSessionSync: false,
|
|
230
|
+
claudeTargeted: true,
|
|
231
|
+
yes: false,
|
|
232
|
+
json: false,
|
|
233
|
+
global: false,
|
|
234
|
+
p,
|
|
235
|
+
deps: { confirmFn: () => false },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
assert.equal(r.action, SessionSyncAction.Declined);
|
|
239
|
+
assert.equal(r.path, null);
|
|
240
|
+
assert.equal(r.globalInstallActive, false);
|
|
241
|
+
assert.match(p.all(), /session-sync enable/);
|
|
242
|
+
assert.equal(projectHasHook(), false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("proceed via --yes → Installed (fresh hook on disk)", async () => {
|
|
246
|
+
// Shim on PATH so mergeSessionHook's resolver finds a binary.
|
|
247
|
+
shimHandle = installShim(join(sandbox, "home"));
|
|
248
|
+
const p = makeStubPrinter();
|
|
249
|
+
const r = await installSessionSyncHook({
|
|
250
|
+
noSessionSync: false,
|
|
251
|
+
claudeTargeted: true,
|
|
252
|
+
yes: true,
|
|
253
|
+
json: false,
|
|
254
|
+
global: false,
|
|
255
|
+
p,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
assert.equal(r.action, SessionSyncAction.Installed);
|
|
259
|
+
assert.equal(r.path, ".claude/settings.local.json");
|
|
260
|
+
assert.equal(r.globalInstallActive, true);
|
|
261
|
+
assert.match(p.all(), /installed/i);
|
|
262
|
+
assert.equal(projectHasHook(), true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("proceed via confirm prompt (returns true) → Installed", async () => {
|
|
266
|
+
// Exercises the confirmFn-returns-true path (yes:false but prompt
|
|
267
|
+
// accepts), distinct from the --yes short-circuit above.
|
|
268
|
+
shimHandle = installShim(join(sandbox, "home"));
|
|
269
|
+
const p = makeStubPrinter();
|
|
270
|
+
const r = await installSessionSyncHook({
|
|
271
|
+
noSessionSync: false,
|
|
272
|
+
claudeTargeted: true,
|
|
273
|
+
yes: false,
|
|
274
|
+
json: false,
|
|
275
|
+
global: false,
|
|
276
|
+
p,
|
|
277
|
+
deps: { confirmFn: () => true },
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
assert.equal(r.action, SessionSyncAction.Installed);
|
|
281
|
+
assert.equal(r.globalInstallActive, true);
|
|
282
|
+
assert.equal(projectHasHook(), true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("re-run with same binary → Unchanged (idempotent)", async () => {
|
|
286
|
+
shimHandle = installShim(join(sandbox, "home"));
|
|
287
|
+
const p1 = makeStubPrinter();
|
|
288
|
+
await installSessionSyncHook({
|
|
289
|
+
noSessionSync: false,
|
|
290
|
+
claudeTargeted: true,
|
|
291
|
+
yes: true,
|
|
292
|
+
json: false,
|
|
293
|
+
global: false,
|
|
294
|
+
p: p1,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const p2 = makeStubPrinter();
|
|
298
|
+
const r = await installSessionSyncHook({
|
|
299
|
+
noSessionSync: false,
|
|
300
|
+
claudeTargeted: true,
|
|
301
|
+
yes: true,
|
|
302
|
+
json: false,
|
|
303
|
+
global: false,
|
|
304
|
+
p: p2,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
assert.equal(r.action, SessionSyncAction.Unchanged);
|
|
308
|
+
assert.equal(r.globalInstallActive, true);
|
|
309
|
+
assert.match(p2.all(), /already installed/i);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("existing stale SkillRepo hook → Updated", async () => {
|
|
313
|
+
shimHandle = installShim(join(sandbox, "home"));
|
|
314
|
+
// Seed a SkillRepo hook pointing at a different binary path so the
|
|
315
|
+
// command differs and triggers the in-place 'updated' replacement.
|
|
316
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
317
|
+
const stale = buildHookCommand("/old/location/skillrepo");
|
|
318
|
+
writeFileSync(
|
|
319
|
+
projectSettingsPath(),
|
|
320
|
+
JSON.stringify(
|
|
321
|
+
{
|
|
322
|
+
hooks: {
|
|
323
|
+
SessionStart: [{ hooks: [{ type: "command", command: stale }] }],
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
null,
|
|
327
|
+
2,
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const p = makeStubPrinter();
|
|
332
|
+
const r = await installSessionSyncHook({
|
|
333
|
+
noSessionSync: false,
|
|
334
|
+
claudeTargeted: true,
|
|
335
|
+
yes: true,
|
|
336
|
+
json: false,
|
|
337
|
+
global: false,
|
|
338
|
+
p,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
assert.equal(r.action, SessionSyncAction.Updated);
|
|
342
|
+
assert.equal(r.globalInstallActive, true);
|
|
343
|
+
assert.match(p.all(), /updated/i);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("Skipped when no binary resolvable (bare-install PATH miss)", async () => {
|
|
347
|
+
// Non-npx, proceed, but skillrepo is NOT on PATH → mergeSessionHook
|
|
348
|
+
// returns action 'skipped' with an actionable reason. Exercises the
|
|
349
|
+
// Skipped branch of tryMergeAndPrint (252-281) and isHookActive's
|
|
350
|
+
// false arm for a non-active action.
|
|
351
|
+
clearSkillrepoFromPath();
|
|
352
|
+
const p = makeStubPrinter();
|
|
353
|
+
const r = await installSessionSyncHook({
|
|
354
|
+
noSessionSync: false,
|
|
355
|
+
claudeTargeted: true,
|
|
356
|
+
yes: true,
|
|
357
|
+
json: false,
|
|
358
|
+
global: false,
|
|
359
|
+
p,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
assert.equal(r.action, SessionSyncAction.Skipped);
|
|
363
|
+
assert.equal(r.globalInstallActive, false);
|
|
364
|
+
assert.match(p.all(), /skillrepo/i);
|
|
365
|
+
assert.equal(projectHasHook(), false);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("Failed when settings file is corrupt (disk error caught)", async () => {
|
|
369
|
+
// A present-but-corrupt settings file makes mergeSessionHook throw;
|
|
370
|
+
// tryMergeAndPrint's catch (272-280) converts it to Failed and the
|
|
371
|
+
// module does NOT propagate the throw.
|
|
372
|
+
shimHandle = installShim(join(sandbox, "home"));
|
|
373
|
+
mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
|
|
374
|
+
writeFileSync(projectSettingsPath(), "{ not valid json");
|
|
375
|
+
|
|
376
|
+
const p = makeStubPrinter();
|
|
377
|
+
const r = await installSessionSyncHook({
|
|
378
|
+
noSessionSync: false,
|
|
379
|
+
claudeTargeted: true,
|
|
380
|
+
yes: true,
|
|
381
|
+
json: false,
|
|
382
|
+
global: false,
|
|
383
|
+
p,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
assert.equal(r.action, SessionSyncAction.Failed);
|
|
387
|
+
assert.equal(r.path, null);
|
|
388
|
+
assert.equal(r.globalInstallActive, false);
|
|
389
|
+
assert.match(p.all(), /Session sync failed/i);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ──────────────────────────────────────────────────────────────────
|
|
394
|
+
// 5. npx + existing global on PATH
|
|
395
|
+
// ──────────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
describe("init-session-sync — npx with existing global", () => {
|
|
398
|
+
let shimHandle;
|
|
399
|
+
|
|
400
|
+
beforeEach(() => {
|
|
401
|
+
setup();
|
|
402
|
+
forceNpxMode();
|
|
403
|
+
// A resolvable global skillrepo on PATH → resolveGlobalBinary()
|
|
404
|
+
// returns a path → use-existing-global branch.
|
|
405
|
+
shimHandle = installShim(join(sandbox, "home"));
|
|
406
|
+
});
|
|
407
|
+
afterEach(() => {
|
|
408
|
+
uninstallShim(shimHandle);
|
|
409
|
+
shimHandle = undefined;
|
|
410
|
+
teardown();
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("uses the existing global and installs the hook → Installed", async () => {
|
|
414
|
+
const p = makeStubPrinter();
|
|
415
|
+
const r = await installSessionSyncHook({
|
|
416
|
+
noSessionSync: false,
|
|
417
|
+
claudeTargeted: true,
|
|
418
|
+
yes: true,
|
|
419
|
+
json: false,
|
|
420
|
+
global: false,
|
|
421
|
+
p,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
assert.equal(r.action, SessionSyncAction.Installed);
|
|
425
|
+
// globalInstallActive is forced true regardless of hook outcome.
|
|
426
|
+
assert.equal(r.globalInstallActive, true);
|
|
427
|
+
assert.match(p.all(), /Found global skillrepo/);
|
|
428
|
+
assert.match(p.all(), /using it for session sync/);
|
|
429
|
+
assert.equal(projectHasHook(), true);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("forces globalInstallActive=true even when the hook is Unchanged", async () => {
|
|
433
|
+
// Second run: hook is already present → mergeSessionHook returns
|
|
434
|
+
// Unchanged, but the existing-global branch still overrides
|
|
435
|
+
// globalInstallActive to true (line 177-178). isHookActive would
|
|
436
|
+
// already be true here, so to truly prove the override we also
|
|
437
|
+
// cover the Skipped-but-still-active case below.
|
|
438
|
+
const p1 = makeStubPrinter();
|
|
439
|
+
await installSessionSyncHook({
|
|
440
|
+
noSessionSync: false,
|
|
441
|
+
claudeTargeted: true,
|
|
442
|
+
yes: true,
|
|
443
|
+
json: false,
|
|
444
|
+
global: false,
|
|
445
|
+
p: p1,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const p2 = makeStubPrinter();
|
|
449
|
+
const r = await installSessionSyncHook({
|
|
450
|
+
noSessionSync: false,
|
|
451
|
+
claudeTargeted: true,
|
|
452
|
+
yes: true,
|
|
453
|
+
json: false,
|
|
454
|
+
global: false,
|
|
455
|
+
p: p2,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
assert.equal(r.action, SessionSyncAction.Unchanged);
|
|
459
|
+
assert.equal(r.globalInstallActive, true);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ──────────────────────────────────────────────────────────────────
|
|
464
|
+
// 6-9. npx + no global (auto-install branch)
|
|
465
|
+
// ──────────────────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
describe("init-session-sync — npx without global (auto-install)", () => {
|
|
468
|
+
let shimHandle;
|
|
469
|
+
|
|
470
|
+
beforeEach(() => {
|
|
471
|
+
setup();
|
|
472
|
+
forceNpxMode();
|
|
473
|
+
// No skillrepo on PATH → resolveGlobalBinary() (line 132) returns
|
|
474
|
+
// null → auto-install branch.
|
|
475
|
+
clearSkillrepoFromPath();
|
|
476
|
+
});
|
|
477
|
+
afterEach(() => {
|
|
478
|
+
uninstallShim(shimHandle);
|
|
479
|
+
shimHandle = undefined;
|
|
480
|
+
teardown();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("declined → Declined + manual-steps hint (npx command)", async () => {
|
|
484
|
+
const p = makeStubPrinter();
|
|
485
|
+
const r = await installSessionSyncHook({
|
|
486
|
+
noSessionSync: false,
|
|
487
|
+
claudeTargeted: true,
|
|
488
|
+
yes: false,
|
|
489
|
+
json: false,
|
|
490
|
+
global: false,
|
|
491
|
+
p,
|
|
492
|
+
deps: { confirmFn: () => false },
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
assert.equal(r.action, SessionSyncAction.Declined);
|
|
496
|
+
assert.equal(r.globalInstallActive, false);
|
|
497
|
+
assert.match(p.all(), /declined/i);
|
|
498
|
+
// printManualSteps uses the detected runner's install command.
|
|
499
|
+
// Under forced npx mode the runner resolves to "npx" → the
|
|
500
|
+
// canonical "npm install -g skillrepo" command.
|
|
501
|
+
assert.match(p.all(), /npm install -g skillrepo/);
|
|
502
|
+
assert.match(p.all(), /session-sync enable/);
|
|
503
|
+
assert.equal(projectHasHook(), false);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("getCliVersion throws → Skipped + manual steps", async () => {
|
|
507
|
+
const p = makeStubPrinter();
|
|
508
|
+
const r = await installSessionSyncHook({
|
|
509
|
+
noSessionSync: false,
|
|
510
|
+
claudeTargeted: true,
|
|
511
|
+
yes: true, // proceed without prompting
|
|
512
|
+
json: false,
|
|
513
|
+
global: false,
|
|
514
|
+
p,
|
|
515
|
+
deps: {
|
|
516
|
+
getCliVersion: () => {
|
|
517
|
+
throw new Error("boom");
|
|
518
|
+
},
|
|
519
|
+
// spawn must never be reached; fail loudly if it is.
|
|
520
|
+
spawn: () => {
|
|
521
|
+
throw new Error("spawn must not run after version-read failure");
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
assert.equal(r.action, SessionSyncAction.Skipped);
|
|
527
|
+
assert.equal(r.globalInstallActive, false);
|
|
528
|
+
assert.match(p.all(), /Could not determine CLI version/);
|
|
529
|
+
assert.match(p.all(), /boom/);
|
|
530
|
+
assert.match(p.all(), /npm install -g skillrepo/);
|
|
531
|
+
assert.equal(projectHasHook(), false);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("install fails (npm non-zero) → Skipped", async () => {
|
|
535
|
+
// Stub spawn returns a child that closes with a non-zero exit code,
|
|
536
|
+
// so installSkillrepoGlobally reports success:false (npm-nonzero).
|
|
537
|
+
const p = makeStubPrinter();
|
|
538
|
+
const r = await installSessionSyncHook({
|
|
539
|
+
noSessionSync: false,
|
|
540
|
+
claudeTargeted: true,
|
|
541
|
+
yes: true,
|
|
542
|
+
json: false,
|
|
543
|
+
global: false,
|
|
544
|
+
p,
|
|
545
|
+
deps: {
|
|
546
|
+
getCliVersion: () => "9.9.9",
|
|
547
|
+
spawn: makeSpawnStub({ exitCode: 1 }),
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
assert.equal(r.action, SessionSyncAction.Skipped);
|
|
552
|
+
assert.equal(r.globalInstallActive, false);
|
|
553
|
+
assert.match(p.all(), /Could not install skillrepo globally/);
|
|
554
|
+
assert.match(p.all(), /npm install -g skillrepo/);
|
|
555
|
+
assert.equal(projectHasHook(), false);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("install succeeds → Installed (auto-install path)", async () => {
|
|
559
|
+
// Stub spawn closes with code 0 AND, just before emitting close,
|
|
560
|
+
// installs a real shim on PATH so installSkillrepoGlobally's
|
|
561
|
+
// post-install resolveGlobalBinary() finds the binary. The
|
|
562
|
+
// top-level resolveGlobalBinary() at line 132 already ran (and
|
|
563
|
+
// returned null) before the spawn, so we still took the
|
|
564
|
+
// auto-install branch.
|
|
565
|
+
let installedShim;
|
|
566
|
+
const p = makeStubPrinter();
|
|
567
|
+
const r = await installSessionSyncHook({
|
|
568
|
+
noSessionSync: false,
|
|
569
|
+
claudeTargeted: true,
|
|
570
|
+
yes: true,
|
|
571
|
+
json: false,
|
|
572
|
+
global: false,
|
|
573
|
+
p,
|
|
574
|
+
deps: {
|
|
575
|
+
getCliVersion: () => "9.9.9",
|
|
576
|
+
spawn: makeSpawnStub({
|
|
577
|
+
exitCode: 0,
|
|
578
|
+
onBeforeClose: () => {
|
|
579
|
+
installedShim = installShim(join(sandbox, "home"));
|
|
580
|
+
},
|
|
581
|
+
}),
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// Clean up the shim PATH mutation done inside the spawn stub.
|
|
586
|
+
shimHandle = installedShim;
|
|
587
|
+
|
|
588
|
+
assert.equal(r.action, SessionSyncAction.Installed);
|
|
589
|
+
assert.equal(r.globalInstallActive, true);
|
|
590
|
+
assert.match(p.all(), /Running: npm install -g skillrepo@9\.9\.9/);
|
|
591
|
+
assert.match(p.all(), /Installed skillrepo@9\.9\.9 globally/);
|
|
592
|
+
assert.equal(projectHasHook(), true);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("falls back to the real getCliVersion when none is injected", async () => {
|
|
596
|
+
// deps.getCliVersion omitted → the `?? getCliVersion` default
|
|
597
|
+
// (line 203) is exercised. The real read returns the package's
|
|
598
|
+
// own version; spawn is still stubbed so no npm runs. Asserting
|
|
599
|
+
// the printed version is a non-empty semver-ish string proves the
|
|
600
|
+
// real reader ran.
|
|
601
|
+
let installedShim;
|
|
602
|
+
const p = makeStubPrinter();
|
|
603
|
+
const r = await installSessionSyncHook({
|
|
604
|
+
noSessionSync: false,
|
|
605
|
+
claudeTargeted: true,
|
|
606
|
+
yes: true,
|
|
607
|
+
json: false,
|
|
608
|
+
global: false,
|
|
609
|
+
p,
|
|
610
|
+
deps: {
|
|
611
|
+
spawn: makeSpawnStub({
|
|
612
|
+
exitCode: 0,
|
|
613
|
+
onBeforeClose: () => {
|
|
614
|
+
installedShim = installShim(join(sandbox, "home"));
|
|
615
|
+
},
|
|
616
|
+
}),
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
shimHandle = installedShim;
|
|
621
|
+
|
|
622
|
+
assert.equal(r.action, SessionSyncAction.Installed);
|
|
623
|
+
assert.equal(r.globalInstallActive, true);
|
|
624
|
+
assert.match(p.all(), /Running: npm install -g skillrepo@\d+\.\d+\.\d+/);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it("non-Error thrown by getCliVersion → String(err) in the message", async () => {
|
|
628
|
+
// Throwing a non-Error (a bare string) makes `err?.message`
|
|
629
|
+
// undefined, exercising the `?? String(err)` arm at line 209.
|
|
630
|
+
const p = makeStubPrinter();
|
|
631
|
+
const r = await installSessionSyncHook({
|
|
632
|
+
noSessionSync: false,
|
|
633
|
+
claudeTargeted: true,
|
|
634
|
+
yes: true,
|
|
635
|
+
json: false,
|
|
636
|
+
global: false,
|
|
637
|
+
p,
|
|
638
|
+
deps: {
|
|
639
|
+
getCliVersion: () => {
|
|
640
|
+
throw "string-failure";
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
assert.equal(r.action, SessionSyncAction.Skipped);
|
|
646
|
+
assert.match(
|
|
647
|
+
p.all(),
|
|
648
|
+
/Could not determine CLI version for global install: string-failure/,
|
|
649
|
+
);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("--json mode runs install silently and still installs the hook", async () => {
|
|
653
|
+
// json:true → installSkillrepoGlobally gets outputMode 'silent',
|
|
654
|
+
// which pipes stdout/stderr. The spawn stub provides those streams.
|
|
655
|
+
let installedShim;
|
|
656
|
+
const p = makeStubPrinter();
|
|
657
|
+
const r = await installSessionSyncHook({
|
|
658
|
+
noSessionSync: false,
|
|
659
|
+
claudeTargeted: true,
|
|
660
|
+
yes: true,
|
|
661
|
+
json: true,
|
|
662
|
+
global: false,
|
|
663
|
+
p,
|
|
664
|
+
deps: {
|
|
665
|
+
getCliVersion: () => "9.9.9",
|
|
666
|
+
spawn: makeSpawnStub({
|
|
667
|
+
exitCode: 0,
|
|
668
|
+
silent: true,
|
|
669
|
+
onBeforeClose: () => {
|
|
670
|
+
installedShim = installShim(join(sandbox, "home"));
|
|
671
|
+
},
|
|
672
|
+
}),
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
shimHandle = installedShim;
|
|
677
|
+
|
|
678
|
+
assert.equal(r.action, SessionSyncAction.Installed);
|
|
679
|
+
assert.equal(r.globalInstallActive, true);
|
|
680
|
+
assert.equal(projectHasHook(), true);
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// ──────────────────────────────────────────────────────────────────
|
|
685
|
+
// Spawn stub for installSkillrepoGlobally
|
|
686
|
+
// ──────────────────────────────────────────────────────────────────
|
|
687
|
+
|
|
688
|
+
import { EventEmitter } from "node:events";
|
|
689
|
+
import { Readable } from "node:stream";
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Build an injectable `spawn` stub matching the contract
|
|
693
|
+
* `installSkillrepoGlobally` expects:
|
|
694
|
+
* - returns a child EventEmitter
|
|
695
|
+
* - emits `close` with an exit code on a later tick
|
|
696
|
+
* - in `silent` mode the child carries readable `stdout`/`stderr`
|
|
697
|
+
*
|
|
698
|
+
* @param {object} opts
|
|
699
|
+
* @param {number} opts.exitCode - exit code to emit via `close`.
|
|
700
|
+
* @param {boolean} [opts.silent] - attach stdout/stderr streams (for
|
|
701
|
+
* the `outputMode: "silent"` capture path).
|
|
702
|
+
* @param {Function} [opts.onBeforeClose] - run synchronously right
|
|
703
|
+
* before the `close` event fires (used to install a PATH shim
|
|
704
|
+
* so the post-install binary resolution succeeds).
|
|
705
|
+
*/
|
|
706
|
+
function makeSpawnStub({ exitCode, silent = false, onBeforeClose }) {
|
|
707
|
+
return function spawnStub() {
|
|
708
|
+
const child = new EventEmitter();
|
|
709
|
+
if (silent) {
|
|
710
|
+
// Provide empty readable streams; installSkillrepoGlobally
|
|
711
|
+
// attaches `data` listeners to drain/capture them.
|
|
712
|
+
child.stdout = Readable.from([]);
|
|
713
|
+
child.stderr = Readable.from([]);
|
|
714
|
+
}
|
|
715
|
+
child.kill = () => {};
|
|
716
|
+
// Fire close on the next tick so the awaiting Promise's listeners
|
|
717
|
+
// are registered first.
|
|
718
|
+
setImmediate(() => {
|
|
719
|
+
if (onBeforeClose) onBeforeClose();
|
|
720
|
+
child.emit("close", exitCode);
|
|
721
|
+
});
|
|
722
|
+
return child;
|
|
723
|
+
};
|
|
724
|
+
}
|