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
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npm install -g skillrepo@<version>` wrapper for v3.1.2 init's
|
|
3
|
+
* auto-install-global feature (#894).
|
|
4
|
+
*
|
|
5
|
+
* Why this exists
|
|
6
|
+
* ---------------
|
|
7
|
+
* Under `npx skillrepo init`, the v3.1.0/v3.1.1 SessionStart hook
|
|
8
|
+
* design fails because the npx cache path is transient and unsuitable
|
|
9
|
+
* for a long-lived hook command. v3.1.1 worked around this by
|
|
10
|
+
* skipping the hook with a "install globally first" warning — the
|
|
11
|
+
* prompt-then-fail UX bug that v3.1.2 fixes.
|
|
12
|
+
*
|
|
13
|
+
* v3.1.2 fixes it by having init OFFER to run the global install
|
|
14
|
+
* itself when invoked under npx. This module is the spawn wrapper
|
|
15
|
+
* that runs `npm install -g skillrepo@<version>` and reports a
|
|
16
|
+
* structured result.
|
|
17
|
+
*
|
|
18
|
+
* Design constraints
|
|
19
|
+
* ------------------
|
|
20
|
+
* 1. **No new runtime dependencies.** The CLI's only dep is
|
|
21
|
+
* `cli-table3`. This module uses Node built-ins (`child_process`,
|
|
22
|
+
* `path`) only.
|
|
23
|
+
*
|
|
24
|
+
* 2. **Spawn is injectable.** Tests pass a stub spawn so they
|
|
25
|
+
* never actually shell out to npm. Production callers leave
|
|
26
|
+
* `spawn` unset and get the real `child_process.spawn`.
|
|
27
|
+
*
|
|
28
|
+
* 3. **Cross-platform spawn shape.** On Windows, `npm` is a
|
|
29
|
+
* `.cmd` script, not a native binary. `spawn("npm", ...)` on
|
|
30
|
+
* Windows without `shell: true` throws ENOENT. The locator
|
|
31
|
+
* name comes from `platformConventions()` so we don't sprinkle
|
|
32
|
+
* `process.platform === "win32"` checks across the codebase.
|
|
33
|
+
* We use `shell: false` on both platforms — it sidesteps the
|
|
34
|
+
* argument-quoting surprises `shell: true` introduces on Windows
|
|
35
|
+
* and is unnecessary because we control all spawn args.
|
|
36
|
+
*
|
|
37
|
+
* 4. **`stdio` mode is caller-controlled.** Default is `inherit`
|
|
38
|
+
* so npm's progress output streams to the user's terminal during
|
|
39
|
+
* the install (the install can take 10-30 seconds — silent would
|
|
40
|
+
* look hung). `--json` mode passes `outputMode: "silent"` to
|
|
41
|
+
* suppress npm output that would otherwise pollute the JSON
|
|
42
|
+
* stdout.
|
|
43
|
+
*
|
|
44
|
+
* 5. **Always returns a result, never throws on user-recoverable
|
|
45
|
+
* failure.** Init must continue past auto-install failures —
|
|
46
|
+
* the rest of init (config, MCP, first sync) succeeded and the
|
|
47
|
+
* user's library is on disk. Throwing here would abort init.
|
|
48
|
+
* Programmer errors (e.g. missing version arg) DO throw.
|
|
49
|
+
*
|
|
50
|
+
* 6. **5-minute timeout.** Slow registries and corporate proxies
|
|
51
|
+
* can take longer than the typical 10-30 seconds. 5 minutes is
|
|
52
|
+
* well past any reasonable network worst-case but bounds the
|
|
53
|
+
* hang time so init can never wait forever.
|
|
54
|
+
*
|
|
55
|
+
* 7. **Verify success post-install.** A 0 exit code from npm is
|
|
56
|
+
* necessary but not sufficient — the user's npm prefix bin
|
|
57
|
+
* directory might not be on PATH (a common nvm misconfiguration).
|
|
58
|
+
* We re-resolve the binary via `where`/`which`, filtering out
|
|
59
|
+
* `_npx` cache paths, to confirm the install actually produced
|
|
60
|
+
* a usable binary at a stable location.
|
|
61
|
+
*
|
|
62
|
+
* Result enum
|
|
63
|
+
* -----------
|
|
64
|
+
* - `success: true` — npm exited 0 AND the resulting binary is at
|
|
65
|
+
* a stable absolute path. `binaryPath` is set.
|
|
66
|
+
* - `errorCode: "eacces"` — permission denied on the npm prefix.
|
|
67
|
+
* User needs sudo (or to fix npm prefix). `error` carries the
|
|
68
|
+
* actionable message.
|
|
69
|
+
* - `errorCode: "enoent-npm"` — `npm` itself not found on PATH.
|
|
70
|
+
* User needs to install Node or fix PATH.
|
|
71
|
+
* - `errorCode: "npm-nonzero"` — npm ran but exited non-zero for
|
|
72
|
+
* some other reason (network, registry 500, package not found).
|
|
73
|
+
* `error` includes the first ~200 chars of stderr.
|
|
74
|
+
* - `errorCode: "timeout"` — exceeded the 5-minute deadline. We
|
|
75
|
+
* killed the child.
|
|
76
|
+
* - `errorCode: "path-not-updated"` — npm exited 0 but the binary
|
|
77
|
+
* isn't on PATH at a stable location. Either the install actually
|
|
78
|
+
* failed silently or the user's npm prefix bin dir isn't on PATH.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
import { spawn as defaultSpawn } from "node:child_process";
|
|
82
|
+
import { platformConventions } from "./platform.mjs";
|
|
83
|
+
import { resolveBinaryOnPath } from "./binary-locator.mjs";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @typedef {Object} GlobalInstallResult
|
|
87
|
+
* @property {boolean} success
|
|
88
|
+
* @property {string | null} binaryPath - Absolute path to the resulting
|
|
89
|
+
* global `skillrepo` binary on success; null on failure.
|
|
90
|
+
* @property {string} [error] - Human-readable failure reason. Set
|
|
91
|
+
* when `success` is false.
|
|
92
|
+
* @property {"eacces" | "enoent-npm" | "npm-nonzero" | "timeout" | "path-not-updated"} [errorCode]
|
|
93
|
+
* Categorized failure code. Set when `success` is false.
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Default timeout for `npm install -g`. 5 minutes covers the worst
|
|
98
|
+
* case of slow registries, corporate proxies, or first-time cache
|
|
99
|
+
* population without letting init hang indefinitely.
|
|
100
|
+
*/
|
|
101
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Run `npm install -g skillrepo@<version>` and return a structured
|
|
105
|
+
* result. Never throws on user-recoverable failure (npm exit codes,
|
|
106
|
+
* permission errors, missing npm) — those are reported via the
|
|
107
|
+
* `errorCode` field. Programmer errors (missing args) DO throw.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} options
|
|
110
|
+
* @param {string} options.version - Semver string to pin
|
|
111
|
+
* (e.g. "3.1.2"). Required.
|
|
112
|
+
* @param {"inherit" | "silent"} [options.outputMode="inherit"] -
|
|
113
|
+
* How to handle npm's stdout/stderr.
|
|
114
|
+
* - "inherit": stream npm output through this process's
|
|
115
|
+
* stdio (the user sees install progress in real time).
|
|
116
|
+
* - "silent": capture and discard. Used in `--json` mode
|
|
117
|
+
* so npm output doesn't pollute the JSON stdout.
|
|
118
|
+
* @param {Function} [options.spawn] - Injected for tests. Defaults
|
|
119
|
+
* to the real `child_process.spawn`. Tests pass a stub so the
|
|
120
|
+
* suite never actually shells out to npm.
|
|
121
|
+
* @param {NodeJS.Platform} [options.platform] - Override for tests
|
|
122
|
+
* that need to exercise Windows spawn semantics on a non-Windows
|
|
123
|
+
* host. Production callers leave this unset.
|
|
124
|
+
* @param {number} [options.timeoutMs=300000] - Maximum time to wait
|
|
125
|
+
* before killing the child and returning `errorCode: "timeout"`.
|
|
126
|
+
* @returns {Promise<GlobalInstallResult>}
|
|
127
|
+
*/
|
|
128
|
+
export async function installSkillrepoGlobally({
|
|
129
|
+
version,
|
|
130
|
+
outputMode = "inherit",
|
|
131
|
+
spawn = defaultSpawn,
|
|
132
|
+
platform: platformOverride,
|
|
133
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
134
|
+
} = {}) {
|
|
135
|
+
if (typeof version !== "string" || version.length === 0) {
|
|
136
|
+
// Programmer error — no recovery, throw.
|
|
137
|
+
throw new Error(
|
|
138
|
+
"installSkillrepoGlobally: `version` must be a non-empty string.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const conv = platformConventions({ platform: platformOverride });
|
|
143
|
+
// Windows `npm` ships as `npm.cmd` — a batch script. spawn() with
|
|
144
|
+
// `shell: false` requires the literal name on disk, which is
|
|
145
|
+
// `npm.cmd` on Windows and `npm` everywhere else. Same pattern as
|
|
146
|
+
// `binaryLocator` ("which" vs "where").
|
|
147
|
+
const npmCmd = conv.family === "windows" ? "npm.cmd" : "npm";
|
|
148
|
+
const args = ["install", "-g", `skillrepo@${version}`];
|
|
149
|
+
|
|
150
|
+
// stdio mapping:
|
|
151
|
+
// - inherit: npm output streams to user's terminal in real time.
|
|
152
|
+
// - silent (--json mode): pipe stdout/stderr so we can capture
|
|
153
|
+
// them for error categorization, but don't let them touch the
|
|
154
|
+
// terminal. The captured stderr is useful for the
|
|
155
|
+
// "first 200 chars of stderr" failure message.
|
|
156
|
+
const stdio = outputMode === "silent"
|
|
157
|
+
? ["ignore", "pipe", "pipe"]
|
|
158
|
+
: "inherit";
|
|
159
|
+
|
|
160
|
+
// ── Spawn the child ─────────────────────────────────────────────
|
|
161
|
+
// We use `shell: false` (the spawn default) on both platforms.
|
|
162
|
+
// shell: true on Windows introduces argument-quoting surprises
|
|
163
|
+
// (cmd.exe quoting rules differ from POSIX shells in subtle ways);
|
|
164
|
+
// we control all args, so we don't need shell expansion.
|
|
165
|
+
let child;
|
|
166
|
+
try {
|
|
167
|
+
child = spawn(npmCmd, args, { stdio });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
// Synchronous spawn failure (rare; some Node versions surface
|
|
170
|
+
// ENOENT this way instead of via the `error` event). Treat
|
|
171
|
+
// identically to the async ENOENT path.
|
|
172
|
+
if (err && err.code === "ENOENT") {
|
|
173
|
+
return {
|
|
174
|
+
success: false,
|
|
175
|
+
binaryPath: null,
|
|
176
|
+
errorCode: "enoent-npm",
|
|
177
|
+
error:
|
|
178
|
+
"`npm` was not found on PATH. Install Node.js " +
|
|
179
|
+
"(which bundles npm) or ensure npm is on your PATH, " +
|
|
180
|
+
"then re-run init.",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// Any other synchronous spawn failure is unexpected — surface
|
|
184
|
+
// as npm-nonzero with the message.
|
|
185
|
+
return {
|
|
186
|
+
success: false,
|
|
187
|
+
binaryPath: null,
|
|
188
|
+
errorCode: "npm-nonzero",
|
|
189
|
+
error: `Failed to spawn npm: ${err?.message ?? String(err)}`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Capture output (silent mode) ───────────────────────────────
|
|
194
|
+
// Buffer stderr for failure-message extraction. stdout is captured
|
|
195
|
+
// too in case we need it later, but we don't currently use it.
|
|
196
|
+
let stderrChunks = [];
|
|
197
|
+
if (outputMode === "silent") {
|
|
198
|
+
if (child.stderr) {
|
|
199
|
+
child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
|
|
200
|
+
}
|
|
201
|
+
if (child.stdout) {
|
|
202
|
+
// Drain to prevent backpressure; we don't actually need the
|
|
203
|
+
// content. Discarding is the goal of silent mode.
|
|
204
|
+
child.stdout.on("data", () => {});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Wait for completion or timeout ─────────────────────────────
|
|
209
|
+
const result = await new Promise((resolve) => {
|
|
210
|
+
let settled = false;
|
|
211
|
+
const settle = (value) => {
|
|
212
|
+
if (settled) return;
|
|
213
|
+
settled = true;
|
|
214
|
+
resolve(value);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const timer = setTimeout(() => {
|
|
218
|
+
// The `kill()` may not immediately stop a child that's
|
|
219
|
+
// doing network I/O on a slow socket. We don't await child
|
|
220
|
+
// exit after kill — the timeout result is what we report;
|
|
221
|
+
// the OS reaps the child whenever it actually exits.
|
|
222
|
+
try {
|
|
223
|
+
child.kill();
|
|
224
|
+
} catch {
|
|
225
|
+
// Already exited — fine.
|
|
226
|
+
}
|
|
227
|
+
settle({ kind: "timeout" });
|
|
228
|
+
}, timeoutMs);
|
|
229
|
+
|
|
230
|
+
child.on("error", (err) => {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
// Async spawn errors. ENOENT here means `npm` not on PATH.
|
|
233
|
+
if (err && err.code === "ENOENT") {
|
|
234
|
+
settle({ kind: "enoent-npm" });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// EACCES at spawn time (rare, usually surfaces in npm output
|
|
238
|
+
// instead). Treat as npm-nonzero with the error message.
|
|
239
|
+
settle({ kind: "spawn-error", message: err?.message ?? String(err) });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
child.on("close", (code) => {
|
|
243
|
+
clearTimeout(timer);
|
|
244
|
+
settle({ kind: "exit", code });
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (result.kind === "timeout") {
|
|
249
|
+
return {
|
|
250
|
+
success: false,
|
|
251
|
+
binaryPath: null,
|
|
252
|
+
errorCode: "timeout",
|
|
253
|
+
error:
|
|
254
|
+
`npm install -g skillrepo@${version} did not complete within ` +
|
|
255
|
+
`${Math.round(timeoutMs / 1000)} seconds. Check your network ` +
|
|
256
|
+
"connection or npm registry status, then re-run init.",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (result.kind === "enoent-npm") {
|
|
261
|
+
return {
|
|
262
|
+
success: false,
|
|
263
|
+
binaryPath: null,
|
|
264
|
+
errorCode: "enoent-npm",
|
|
265
|
+
error:
|
|
266
|
+
"`npm` was not found on PATH. Install Node.js " +
|
|
267
|
+
"(which bundles npm) or ensure npm is on your PATH, " +
|
|
268
|
+
"then re-run init.",
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (result.kind === "spawn-error") {
|
|
273
|
+
return {
|
|
274
|
+
success: false,
|
|
275
|
+
binaryPath: null,
|
|
276
|
+
errorCode: "npm-nonzero",
|
|
277
|
+
error: `Failed to run npm: ${result.message}`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// result.kind === "exit"
|
|
282
|
+
const exitCode = result.code;
|
|
283
|
+
// Only build the stderr string when we actually captured it
|
|
284
|
+
// (silent mode). In inherit mode, `stderrChunks` is always empty
|
|
285
|
+
// because the stream wasn't piped to us.
|
|
286
|
+
const stderrText =
|
|
287
|
+
outputMode === "silent"
|
|
288
|
+
? Buffer.concat(stderrChunks).toString("utf-8")
|
|
289
|
+
: "";
|
|
290
|
+
|
|
291
|
+
if (exitCode !== 0) {
|
|
292
|
+
// EACCES is a common case worth distinguishing — the actionable
|
|
293
|
+
// remediation differs (sudo or fix prefix vs check the npm
|
|
294
|
+
// output). We have two signals available:
|
|
295
|
+
// 1. stderr text contains "EACCES" — only available in silent
|
|
296
|
+
// mode (`--json`) where we capture stderr.
|
|
297
|
+
// 2. exit code 243 — npm's exit code for EACCES on POSIX.
|
|
298
|
+
// Documented in npm's source as the dedicated permission-
|
|
299
|
+
// error code. Available in BOTH inherit and silent modes
|
|
300
|
+
// because exit code is always observable.
|
|
301
|
+
// Trying both gives the user a categorized error in both modes.
|
|
302
|
+
if (stderrText.includes("EACCES") || exitCode === 243) {
|
|
303
|
+
return {
|
|
304
|
+
success: false,
|
|
305
|
+
binaryPath: null,
|
|
306
|
+
errorCode: "eacces",
|
|
307
|
+
error:
|
|
308
|
+
"npm reported a permissions error (EACCES). Run with sudo " +
|
|
309
|
+
"or fix npm's prefix to a writable location: " +
|
|
310
|
+
"https://docs.npmjs.com/resolving-eacces-permissions-errors",
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
// Generic npm failure. In silent mode (--json) we have stderr
|
|
314
|
+
// text and include the first 200 chars for diagnosis. In inherit
|
|
315
|
+
// mode the user already saw npm's real output stream past, so
|
|
316
|
+
// we add a short hint pointing at the two most common
|
|
317
|
+
// remediations (permissions, network) without trying to guess
|
|
318
|
+
// which applies.
|
|
319
|
+
const trimmedStderr =
|
|
320
|
+
stderrText.length > 0 ? ` ${stderrText.slice(0, 200).trim()}` : "";
|
|
321
|
+
const inheritHint =
|
|
322
|
+
stderrText.length === 0
|
|
323
|
+
? " Common causes: permissions (try sudo, or fix npm's prefix) " +
|
|
324
|
+
"or network/registry issues (check your internet connection)."
|
|
325
|
+
: "";
|
|
326
|
+
return {
|
|
327
|
+
success: false,
|
|
328
|
+
binaryPath: null,
|
|
329
|
+
errorCode: "npm-nonzero",
|
|
330
|
+
error: `npm install -g exited with code ${exitCode}.${trimmedStderr}${inheritHint}`,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── Verify the install actually produced a usable binary ──────
|
|
335
|
+
const binaryPath = resolveGlobalBinary({ platform: platformOverride });
|
|
336
|
+
if (!binaryPath) {
|
|
337
|
+
const isWindows = conv.family === "windows";
|
|
338
|
+
// Windows-specific addendum: PATH changes from `npm install -g`
|
|
339
|
+
// are NOT visible in the current terminal session — the user
|
|
340
|
+
// has to open a new terminal for the change to propagate. POSIX
|
|
341
|
+
// shells inherit the new PATH naturally because npm writes to
|
|
342
|
+
// a directory the user's shell already has on PATH.
|
|
343
|
+
const platformAddendum = isWindows
|
|
344
|
+
? " On Windows, `npm install -g` does not refresh PATH in the " +
|
|
345
|
+
"current terminal. Open a new PowerShell or cmd.exe window, " +
|
|
346
|
+
"then run `skillrepo session-sync enable`."
|
|
347
|
+
: "";
|
|
348
|
+
return {
|
|
349
|
+
success: false,
|
|
350
|
+
binaryPath: null,
|
|
351
|
+
errorCode: "path-not-updated",
|
|
352
|
+
error:
|
|
353
|
+
"npm install -g succeeded but `skillrepo` was not found on " +
|
|
354
|
+
"PATH. Your npm prefix bin directory may not be on PATH. " +
|
|
355
|
+
"Run `npm config get prefix` and add `<prefix>/bin` to PATH, " +
|
|
356
|
+
"then run `skillrepo session-sync enable`." +
|
|
357
|
+
platformAddendum,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
success: true,
|
|
363
|
+
binaryPath,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Resolve the absolute path of a STABLE (non-cache) `skillrepo`
|
|
369
|
+
* binary on PATH. Used after `npm install -g` to confirm the install
|
|
370
|
+
* produced a usable binary at a path safe to bake into the
|
|
371
|
+
* SessionStart hook command.
|
|
372
|
+
*
|
|
373
|
+
* Thin wrapper over `resolveBinaryOnPath` with `filterTransient: true`
|
|
374
|
+
* preset. The `skipIfTransient` flag is intentionally false — we
|
|
375
|
+
* WANT to find the newly-installed global even when we're running
|
|
376
|
+
* under a transient runner ourselves.
|
|
377
|
+
*
|
|
378
|
+
* @param {object} [options]
|
|
379
|
+
* @param {NodeJS.Platform} [options.platform] - Override for tests.
|
|
380
|
+
* @returns {string | null}
|
|
381
|
+
*/
|
|
382
|
+
export function resolveGlobalBinary({ platform: platformOverride } = {}) {
|
|
383
|
+
return resolveBinaryOnPath("skillrepo", {
|
|
384
|
+
filterTransient: true,
|
|
385
|
+
platform: platformOverride,
|
|
386
|
+
});
|
|
387
|
+
}
|
package/src/lib/mcp-merge.mjs
CHANGED
|
@@ -75,16 +75,27 @@ import { validationError } from "./errors.mjs";
|
|
|
75
75
|
* @param {object} [options.io] - Injected streams for testability
|
|
76
76
|
* @param {NodeJS.WritableStream} [options.io.stdout=process.stdout]
|
|
77
77
|
* @param {NodeJS.WritableStream} [options.io.stderr=process.stderr]
|
|
78
|
-
* @param {
|
|
78
|
+
* @param {object} [options.deps] - Test-only dependency injection.
|
|
79
|
+
* Production callers leave this empty. Standardized name
|
|
80
|
+
* across CLI commands (init, init-session-sync, etc.) so all
|
|
81
|
+
* injection points live under a single `deps` namespace.
|
|
82
|
+
* @param {(prompt: string, defaultYes?: boolean) => Promise<boolean>} [options.deps.confirmFn]
|
|
79
83
|
* Optional injection point for the y/n prompt. Defaults to
|
|
80
84
|
* the real `confirm` from prompt.mjs. Tests pass a stub to
|
|
81
|
-
* avoid spawning a readline interface.
|
|
82
|
-
*
|
|
83
|
-
*
|
|
85
|
+
* avoid spawning a readline interface. ESM module exports
|
|
86
|
+
* are frozen and cannot be reassigned, so dependency
|
|
87
|
+
* injection is the only clean way to substitute.
|
|
84
88
|
* @returns {Promise<McpMergeResult[]>}
|
|
85
89
|
*/
|
|
86
90
|
export async function mergeMcpForVendors(options) {
|
|
87
|
-
const {
|
|
91
|
+
const {
|
|
92
|
+
vendors,
|
|
93
|
+
mcpUrl,
|
|
94
|
+
yes = false,
|
|
95
|
+
io = {},
|
|
96
|
+
deps = {},
|
|
97
|
+
} = options;
|
|
98
|
+
const confirmFn = deps.confirmFn ?? realConfirm;
|
|
88
99
|
const stdout = io.stdout ?? process.stdout;
|
|
89
100
|
const stderr = io.stderr ?? process.stderr;
|
|
90
101
|
|
|
@@ -70,7 +70,6 @@
|
|
|
70
70
|
*/
|
|
71
71
|
|
|
72
72
|
import { existsSync, readFileSync } from "node:fs";
|
|
73
|
-
import { execFileSync } from "node:child_process";
|
|
74
73
|
import { writeFileAtomic } from "../fs-utils.mjs";
|
|
75
74
|
import { SESSION_HOOK_FINGERPRINT } from "../artifact-registry.mjs";
|
|
76
75
|
import {
|
|
@@ -79,54 +78,109 @@ import {
|
|
|
79
78
|
} from "../paths.mjs";
|
|
80
79
|
import { diskError, validationError } from "../errors.mjs";
|
|
81
80
|
import { removeSettingsSessionHook } from "../removers/settings.mjs";
|
|
81
|
+
import { resolveBinaryOnPath } from "../binary-locator.mjs";
|
|
82
|
+
import { platformConventions } from "../platform.mjs";
|
|
82
83
|
|
|
83
84
|
/**
|
|
84
85
|
* Build the hook command string for a given absolute path. Exported
|
|
85
86
|
* so tests can assert the exact bytes the installer writes.
|
|
86
87
|
*
|
|
88
|
+
* Shell shape is platform-specific — see `platform.mjs` for the full
|
|
89
|
+
* rationale. Summary:
|
|
90
|
+
*
|
|
91
|
+
* - **POSIX** (macOS, Linux): `'<path>' update --session-hook 2>&1 || true`.
|
|
92
|
+
* `|| true` catches any non-zero exit at the shell level; primary
|
|
93
|
+
* defense is the `--session-hook` flag contract in the Node process.
|
|
94
|
+
* - **Windows** (cmd.exe / PowerShell): `"<path>" update --session-hook 2>&1`.
|
|
95
|
+
* `|| true` omitted because cmd.exe doesn't know the `true` builtin.
|
|
96
|
+
* `--session-hook` contract is the only defense; consequences of
|
|
97
|
+
* binary-vanished scenarios are slightly noisier in Claude Code's
|
|
98
|
+
* session log but still non-blocking.
|
|
99
|
+
*
|
|
100
|
+
* Path quoting is mandatory: real-world install paths contain spaces
|
|
101
|
+
* (`C:\Program Files\nodejs\skillrepo.cmd`, `/Users/First Last/.npm-global/bin/skillrepo`)
|
|
102
|
+
* and parentheses (`C:\Program Files (x86)\...`). An unquoted path
|
|
103
|
+
* makes the shell parse the command as multiple arguments and the
|
|
104
|
+
* hook silently fails on session start.
|
|
105
|
+
*
|
|
106
|
+
* The fingerprint constant (`SESSION_HOOK_FINGERPRINT` =
|
|
107
|
+
* ` update --session-hook` with leading space) MUST appear in the
|
|
108
|
+
* resulting command for the uninstaller and idempotency walks to
|
|
109
|
+
* find it. Single/double quotes don't break the fingerprint because
|
|
110
|
+
* the leading space sits between the closing quote and `update`.
|
|
111
|
+
* Backward-compat: existing v3.1.0/v3.1.1 hooks with unquoted paths
|
|
112
|
+
* also match the fingerprint (the space sits between the path and
|
|
113
|
+
* `update`), so re-running init detects them and updates in place
|
|
114
|
+
* to the new quoted shape.
|
|
115
|
+
*
|
|
116
|
+
* The suffix is supplied by `platformConventions().hookShellSuffix` —
|
|
117
|
+
* this function doesn't know which OS it's targeting, it just
|
|
118
|
+
* concatenates the convention's suffix.
|
|
119
|
+
*
|
|
87
120
|
* @param {string} binaryPath - Absolute path to the `skillrepo` binary.
|
|
121
|
+
* @param {object} [options]
|
|
122
|
+
* @param {NodeJS.Platform} [options.platform] - Override for testing.
|
|
123
|
+
* Default: `os.platform()`.
|
|
88
124
|
* @returns {string} The full shell command string.
|
|
89
125
|
*/
|
|
90
|
-
export function buildHookCommand(binaryPath) {
|
|
126
|
+
export function buildHookCommand(binaryPath, { platform: platformOverride } = {}) {
|
|
91
127
|
if (typeof binaryPath !== "string" || binaryPath.length === 0) {
|
|
92
128
|
throw validationError(
|
|
93
129
|
"buildHookCommand: binaryPath must be a non-empty string.",
|
|
94
130
|
);
|
|
95
131
|
}
|
|
96
|
-
|
|
132
|
+
const conv = platformConventions({ platform: platformOverride });
|
|
133
|
+
const quotedPath = quoteShellPath(binaryPath, conv.family);
|
|
134
|
+
return `${quotedPath} update --session-hook 2>&1${conv.hookShellSuffix}`;
|
|
97
135
|
}
|
|
98
136
|
|
|
99
137
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* without a global install) — the caller should skip hook installation
|
|
103
|
-
* with a clear warning rather than fail init.
|
|
138
|
+
* Quote a filesystem path for inclusion in a shell command, using the
|
|
139
|
+
* conventions of the target shell family.
|
|
104
140
|
*
|
|
105
|
-
*
|
|
141
|
+
* - **POSIX**: wrap in single quotes; escape any embedded single
|
|
142
|
+
* quote with the standard `'\''` trick (close quote, escaped
|
|
143
|
+
* literal quote, reopen quote). Single quotes suppress ALL shell
|
|
144
|
+
* interpretation, so spaces, `$`, `*`, parens, double quotes,
|
|
145
|
+
* backticks, and backslashes pass through verbatim.
|
|
146
|
+
*
|
|
147
|
+
* - **Windows** (cmd.exe): wrap in double quotes; escape any
|
|
148
|
+
* embedded double quote with `\"`. Backslashes inside the path
|
|
149
|
+
* pass through unchanged (cmd.exe does not interpret backslashes
|
|
150
|
+
* as escapes inside double quotes). Filesystem rules forbid
|
|
151
|
+
* literal `"` in NTFS path components, so the `\"` escape is
|
|
152
|
+
* defensive — paths in the wild won't contain it.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} path
|
|
155
|
+
* @param {"posix" | "windows"} family
|
|
156
|
+
* @returns {string} The quoted path, ready to be interpolated into
|
|
157
|
+
* a shell command.
|
|
106
158
|
*/
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// but a PATH that includes a network filesystem or a `which`
|
|
111
|
-
// alias that does I/O could hang indefinitely. Bounding the
|
|
112
|
-
// call ensures `skillrepo init` never stalls on binary
|
|
113
|
-
// resolution. Per code-reviewer round-1 LOW finding.
|
|
114
|
-
const result = execFileSync("which", ["skillrepo"], {
|
|
115
|
-
encoding: "utf-8",
|
|
116
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
117
|
-
timeout: 3000,
|
|
118
|
-
}).trim();
|
|
119
|
-
if (!result) return null;
|
|
120
|
-
// Sanity: the resolved path must be absolute. A relative result
|
|
121
|
-
// would be meaningless at session-start time because the Claude
|
|
122
|
-
// Code hook runner's cwd is undefined.
|
|
123
|
-
if (!result.startsWith("/")) return null;
|
|
124
|
-
return result;
|
|
125
|
-
} catch {
|
|
126
|
-
// `which` exits non-zero if the binary isn't found. Treat as
|
|
127
|
-
// "no global install, skip session-sync" — caller handles.
|
|
128
|
-
return null;
|
|
159
|
+
function quoteShellPath(path, family) {
|
|
160
|
+
if (family === "windows") {
|
|
161
|
+
return `"${path.replace(/"/g, '\\"')}"`;
|
|
129
162
|
}
|
|
163
|
+
// POSIX
|
|
164
|
+
return `'${path.replace(/'/g, "'\\''")}'`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the absolute path of the `skillrepo` binary on PATH,
|
|
169
|
+
* skipping resolution entirely when the current process is itself
|
|
170
|
+
* a transient-runner invocation (npx, pnpx, yarn dlx, bunx) — those
|
|
171
|
+
* cache paths must not be baked into the long-lived hook command.
|
|
172
|
+
*
|
|
173
|
+
* Thin wrapper over `resolveBinaryOnPath` with the
|
|
174
|
+
* `skipIfTransient` flag preset. Kept as a named export for
|
|
175
|
+
* call-site readability and so existing tests keep working.
|
|
176
|
+
*
|
|
177
|
+
* @returns {string | null}
|
|
178
|
+
*/
|
|
179
|
+
export function resolveSkillrepoBinary({ platform: platformOverride } = {}) {
|
|
180
|
+
return resolveBinaryOnPath("skillrepo", {
|
|
181
|
+
skipIfTransient: true,
|
|
182
|
+
platform: platformOverride,
|
|
183
|
+
});
|
|
130
184
|
}
|
|
131
185
|
|
|
132
186
|
/**
|
|
@@ -140,6 +194,16 @@ export function resolveSkillrepoBinary() {
|
|
|
140
194
|
* @param {boolean} [options.global=false] - When true, installs to the
|
|
141
195
|
* user-global `~/.claude/settings.local.json` instead of the
|
|
142
196
|
* project-local file.
|
|
197
|
+
* @param {NodeJS.Platform} [options.platform] - Override for testing.
|
|
198
|
+
* Propagated to both `resolveSkillrepoBinary` and
|
|
199
|
+
* `buildHookCommand` so a test can exercise the full
|
|
200
|
+
* installer path under simulated Windows semantics on a
|
|
201
|
+
* non-Windows host. Production callers leave this unset so
|
|
202
|
+
* both helpers see the real `os.platform()`. This option is
|
|
203
|
+
* the mechanism that closes the architect's round-3 HIGH
|
|
204
|
+
* finding — without it, a Windows-shaped `binaryPath` passed
|
|
205
|
+
* in the test still got a POSIX-shaped command back because
|
|
206
|
+
* `buildHookCommand` read `os.platform()` directly.
|
|
143
207
|
* @returns {{
|
|
144
208
|
* path: string;
|
|
145
209
|
* action: "installed" | "updated" | "unchanged" | "skipped";
|
|
@@ -158,23 +222,51 @@ export function resolveSkillrepoBinary() {
|
|
|
158
222
|
export function mergeSessionHook({
|
|
159
223
|
binaryPath: binaryPathOpt,
|
|
160
224
|
global = false,
|
|
225
|
+
platform: platformOverride,
|
|
161
226
|
} = {}) {
|
|
162
227
|
const filePath = global ? claudeSettingsLocalGlobal() : claudeSettingsLocal();
|
|
163
228
|
const displayPath = global
|
|
164
229
|
? "~/.claude/settings.local.json"
|
|
165
230
|
: ".claude/settings.local.json";
|
|
166
231
|
|
|
167
|
-
const binaryPath =
|
|
232
|
+
const binaryPath =
|
|
233
|
+
binaryPathOpt ?? resolveSkillrepoBinary({ platform: platformOverride });
|
|
168
234
|
if (!binaryPath) {
|
|
235
|
+
// Two reasons binaryPath can be null:
|
|
236
|
+
// 1. `isNpxInvocation()` returned true — the user ran
|
|
237
|
+
// `npx skillrepo ...`. The npx cache path is transient and
|
|
238
|
+
// unsuitable for baking into a long-lived hook command.
|
|
239
|
+
// 2. `which skillrepo` returned nothing — no global install
|
|
240
|
+
// exists at all.
|
|
241
|
+
// Both are the same problem from the hook's perspective: we
|
|
242
|
+
// can't produce a command that will still work later.
|
|
243
|
+
//
|
|
244
|
+
// v3.1.2 (#894): `init` bypasses this path under npx by running
|
|
245
|
+
// `npm install -g skillrepo@<version>` itself and then calling
|
|
246
|
+
// `mergeSessionHook` with the resulting `binaryPath` explicitly.
|
|
247
|
+
// The remaining callers that hit this branch are:
|
|
248
|
+
// - `skillrepo session-sync enable` invoked under npx (does
|
|
249
|
+
// not auto-install — a deliberate, explicit user invocation
|
|
250
|
+
// should not silently mutate global package state).
|
|
251
|
+
// - The rare bare-install case where `which skillrepo` fails
|
|
252
|
+
// even though we're not under npx (PATH misconfiguration).
|
|
253
|
+
//
|
|
254
|
+
// Both cases get the same actionable hint pointing the user at
|
|
255
|
+
// `skillrepo init`, which DOES auto-install under npx.
|
|
169
256
|
return {
|
|
170
257
|
path: displayPath,
|
|
171
258
|
action: "skipped",
|
|
172
259
|
reason:
|
|
173
|
-
"
|
|
260
|
+
"Session sync requires a stable `skillrepo` binary on PATH. " +
|
|
261
|
+
"Run `npm install -g skillrepo` (or use `skillrepo init`, " +
|
|
262
|
+
"which offers to install globally for you under npx), then " +
|
|
263
|
+
"re-run `skillrepo session-sync enable`.",
|
|
174
264
|
};
|
|
175
265
|
}
|
|
176
266
|
|
|
177
|
-
const desiredCommand = buildHookCommand(binaryPath
|
|
267
|
+
const desiredCommand = buildHookCommand(binaryPath, {
|
|
268
|
+
platform: platformOverride,
|
|
269
|
+
});
|
|
178
270
|
|
|
179
271
|
// Parse existing file (or start fresh). A corrupt-but-present file
|
|
180
272
|
// is a hard error: silently overwriting it would destroy any user-
|