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,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform binary locator with optional transient-runner
|
|
3
|
+
* filtering (#894 / v3.1.2).
|
|
4
|
+
*
|
|
5
|
+
* Wraps `where` (Windows) / `which` (POSIX) to find a named binary
|
|
6
|
+
* on PATH and returns its absolute path. The two flag knobs
|
|
7
|
+
* `skipIfTransient` and `filterTransient` were extracted from the
|
|
8
|
+
* v3.1.2 first cleanup pass: previously two near-identical functions
|
|
9
|
+
* (`resolveSkillrepoBinary` in `mergers/session-hook.mjs`,
|
|
10
|
+
* `resolveGlobalBinary` in `lib/global-install.mjs`) duplicated this
|
|
11
|
+
* logic with the only differences being:
|
|
12
|
+
*
|
|
13
|
+
* - `resolveSkillrepoBinary` had an early-return guard against
|
|
14
|
+
* `isNpxInvocation()` (now `isTransientRunnerInvocation`) — the
|
|
15
|
+
* `skipIfTransient` flag captures that.
|
|
16
|
+
*
|
|
17
|
+
* - `resolveGlobalBinary` filtered `_npx`-cache results from the
|
|
18
|
+
* locator output — the `filterTransient` flag (powered by
|
|
19
|
+
* `isTransientCachePath` from `lib/transient-runners.mjs`)
|
|
20
|
+
* captures that and extends it to all package runners (pnpm
|
|
21
|
+
* dlx, yarn berry dlx, bunx).
|
|
22
|
+
*
|
|
23
|
+
* Both flags default to false so the function behaves like a plain
|
|
24
|
+
* `which`/`where` wrapper unless the caller opts into the extra
|
|
25
|
+
* semantics.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execFileSync } from "node:child_process";
|
|
29
|
+
import { isAbsolute } from "node:path";
|
|
30
|
+
import { platformConventions } from "./platform.mjs";
|
|
31
|
+
import {
|
|
32
|
+
isTransientCachePath,
|
|
33
|
+
isTransientRunnerInvocation,
|
|
34
|
+
} from "./transient-runners.mjs";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the absolute path of `binaryName` on PATH.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} binaryName - The bare command name (e.g.
|
|
40
|
+
* `"skillrepo"`). Resolved via the platform's binary locator
|
|
41
|
+
* (`which` on POSIX, `where` on Windows).
|
|
42
|
+
* @param {object} [options]
|
|
43
|
+
* @param {boolean} [options.skipIfTransient=false] - When true,
|
|
44
|
+
* return `null` immediately if the current process is itself
|
|
45
|
+
* a transient-runner invocation. Used by callers that bake
|
|
46
|
+
* the resolved path into long-lived state (e.g. a SessionStart
|
|
47
|
+
* hook command) and must not bind to a transient cache path.
|
|
48
|
+
* @param {boolean} [options.filterTransient=false] - When true,
|
|
49
|
+
* ignore locator output lines that point inside a transient
|
|
50
|
+
* runner's cache directory. Used by callers that explicitly
|
|
51
|
+
* want a STABLE global install at a non-cache path.
|
|
52
|
+
* @param {NodeJS.Platform} [options.platform] - Override for tests.
|
|
53
|
+
* Production callers leave unset.
|
|
54
|
+
* @returns {string | null} The absolute path of the first matching
|
|
55
|
+
* non-filtered locator output line, or `null` if no match.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveBinaryOnPath(
|
|
58
|
+
binaryName,
|
|
59
|
+
{ skipIfTransient = false, filterTransient = false, platform: platformOverride } = {},
|
|
60
|
+
) {
|
|
61
|
+
if (skipIfTransient && isTransientRunnerInvocation()) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const conv = platformConventions({ platform: platformOverride });
|
|
66
|
+
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
// 3-second cap — `which`/`where` typically return in
|
|
70
|
+
// milliseconds, but a pathological PATH (network filesystem,
|
|
71
|
+
// hung shell alias) could otherwise stall the whole CLI.
|
|
72
|
+
raw = execFileSync(conv.binaryLocator, [binaryName], {
|
|
73
|
+
encoding: "utf-8",
|
|
74
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
75
|
+
timeout: 3000,
|
|
76
|
+
});
|
|
77
|
+
} catch {
|
|
78
|
+
// Locator exits non-zero when the binary isn't on PATH, OR
|
|
79
|
+
// throws ENOENT when the locator itself doesn't exist (rare —
|
|
80
|
+
// a Windows install missing `where.exe`, or a minimal POSIX
|
|
81
|
+
// image without `which`). Both collapse to "binary not
|
|
82
|
+
// resolvable" from the caller's perspective.
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Windows `where.exe` returns one match per line; POSIX `which`
|
|
87
|
+
// returns a single line. Take the first match that survives the
|
|
88
|
+
// absolute-path and (optional) transient-cache filters.
|
|
89
|
+
const lines = raw
|
|
90
|
+
.split(/\r?\n/)
|
|
91
|
+
.map((s) => s.trim())
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (!isAbsolute(line)) continue;
|
|
95
|
+
if (filterTransient && isTransientCachePath(line)) continue;
|
|
96
|
+
return line;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
package/src/lib/cli-config.mjs
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared credential + flag resolution for command modules.
|
|
3
3
|
*
|
|
4
|
-
* Also houses process-environment helpers that multiple command
|
|
5
|
-
* modules need — specifically `isNpxInvocation()` which several
|
|
6
|
-
* surfaces use to decide whether the user has a stable global
|
|
7
|
-
* install or is running a transient npx download.
|
|
8
|
-
*
|
|
9
4
|
* Every command needs to:
|
|
10
5
|
* 1. Resolve `--key`/`--url`/`--ide`/`--global`/`--json` flags
|
|
11
6
|
* 2. Fall back to ~/.claude/skillrepo/config.json
|
|
@@ -13,9 +8,13 @@
|
|
|
13
8
|
* 4. Hard-error with an actionable hint pointing at `init` if no
|
|
14
9
|
* key is configured
|
|
15
10
|
*
|
|
16
|
-
* Centralizing this here keeps the
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* Centralizing this here keeps the command modules thin and means
|
|
12
|
+
* a future change to credential resolution (e.g., adding a keychain
|
|
13
|
+
* backend) is a single edit instead of N.
|
|
14
|
+
*
|
|
15
|
+
* Process-environment helpers for transient package-runner detection
|
|
16
|
+
* (npx, pnpm dlx, yarn dlx, bunx) live in `lib/transient-runners.mjs`
|
|
17
|
+
* and `lib/binary-locator.mjs` — import from there directly.
|
|
19
18
|
*/
|
|
20
19
|
|
|
21
20
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -26,70 +25,6 @@ import { authError, validationError } from "./errors.mjs";
|
|
|
26
25
|
const VALID_VENDORS = new Set(["claudeCode", "cursor", "windsurf", "vscode"]);
|
|
27
26
|
const VENDOR_ALIASES = { claude: "claudeCode" };
|
|
28
27
|
|
|
29
|
-
/**
|
|
30
|
-
* True when the current process was launched via `npx skillrepo ...`
|
|
31
|
-
* rather than from a stable global install.
|
|
32
|
-
*
|
|
33
|
-
* Why this matters:
|
|
34
|
-
*
|
|
35
|
-
* - `npx skillrepo init` downloads the package into `~/.npm/_npx/<hash>/`
|
|
36
|
-
* and exposes its `.bin/skillrepo` on PATH for the subprocess only.
|
|
37
|
-
* `execFileSync("which", ["skillrepo"])` DOES find that path, but it
|
|
38
|
-
* is a transient cache location. npm eviction, a version bump, or
|
|
39
|
-
* `npm cache clean` later invalidates the absolute path, so any
|
|
40
|
-
* on-disk reference to it (e.g. a SessionStart hook command baked
|
|
41
|
-
* in at install time) silently breaks.
|
|
42
|
-
*
|
|
43
|
-
* - The architect design for #884 explicitly specified that npx
|
|
44
|
-
* users should skip the session-sync step with a "requires a global
|
|
45
|
-
* install" warning. The `which`-based resolver in
|
|
46
|
-
* `mergers/session-hook.mjs` alone is too permissive — it finds the
|
|
47
|
-
* npx cache path and treats it as stable. This helper closes that
|
|
48
|
-
* gap by detecting npx unambiguously.
|
|
49
|
-
*
|
|
50
|
-
* - `init`'s "Next steps" output also needs to know: under npx, the
|
|
51
|
-
* right hint is `npx skillrepo list` (or "install globally first"),
|
|
52
|
-
* not bare `skillrepo list` (which would fail for the user).
|
|
53
|
-
*
|
|
54
|
-
* Detection uses two signals, either one sufficient:
|
|
55
|
-
*
|
|
56
|
-
* 1. `process.argv[1]` contains `/_npx/` (or Windows `\_npx\`) —
|
|
57
|
-
* the primary signal. npx-launched scripts literally live inside
|
|
58
|
-
* `~/.npm/_npx/<hash>/node_modules/.bin/...` so the executable
|
|
59
|
-
* path itself names the cache directory. Highest reliability,
|
|
60
|
-
* no false-positive surface.
|
|
61
|
-
*
|
|
62
|
-
* 2. `process.env._` ends with `/npx` (or `\npx` on Windows) —
|
|
63
|
-
* legacy fallback for shells that set `_` to the launched
|
|
64
|
-
* command. Defensive against shim layouts where argv[1] has
|
|
65
|
-
* been symlinked through a path that doesn't contain `_npx`.
|
|
66
|
-
*
|
|
67
|
-
* Why NOT `process.env.npm_command === "exec"`: this signal was
|
|
68
|
-
* considered but rejected in v3.1.1 review. `npm_command=exec` is
|
|
69
|
-
* also set when a stable-install user runs `skillrepo init` from a
|
|
70
|
-
* `package.json` lifecycle script (e.g. `"postinstall": "skillrepo
|
|
71
|
-
* init --yes"`) or invokes `npm exec skillrepo ...` directly. In
|
|
72
|
-
* those cases the user has a real global install and should NOT
|
|
73
|
-
* have session-sync skipped or see `npx skillrepo` in Next Steps.
|
|
74
|
-
* The argv[1] signal already catches real npx invocations
|
|
75
|
-
* unambiguously; adding npm_command trades a minor coverage gain
|
|
76
|
-
* (shim layouts) for a false-positive surface that affects real
|
|
77
|
-
* users. See v3.1.1 PR review cycle for the full discussion.
|
|
78
|
-
*
|
|
79
|
-
* @returns {boolean}
|
|
80
|
-
*/
|
|
81
|
-
export function isNpxInvocation() {
|
|
82
|
-
const execPath = process.argv[1] ?? "";
|
|
83
|
-
if (execPath.includes("/_npx/") || execPath.includes("\\_npx\\")) {
|
|
84
|
-
return true;
|
|
85
|
-
}
|
|
86
|
-
const underscore = process.env._ ?? "";
|
|
87
|
-
if (underscore.endsWith("/npx") || underscore.endsWith("\\npx")) {
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
28
|
/**
|
|
94
29
|
* @typedef {Object} ResolvedFlags
|
|
95
30
|
* @property {string} serverUrl
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the running CLI's own version from the package.json that ships
|
|
3
|
+
* with the tarball.
|
|
4
|
+
*
|
|
5
|
+
* Why a dedicated module
|
|
6
|
+
* ----------------------
|
|
7
|
+
* v3.1.2's init auto-install feature needs to run
|
|
8
|
+
* `npm install -g skillrepo@<version>` with the SAME version as the
|
|
9
|
+
* CLI currently running init. Pinning is what keeps the fresh global
|
|
10
|
+
* install in lockstep with the npx-cache copy that just executed init,
|
|
11
|
+
* so there's no silent version drift between the two.
|
|
12
|
+
*
|
|
13
|
+
* `process.env.npm_package_version` is NOT reliable here — it's only
|
|
14
|
+
* set when npm itself spawns the process (e.g. `npm run`), not under
|
|
15
|
+
* a plain `npx skillrepo ...` or `node ./bin/skillrepo.mjs` run. So
|
|
16
|
+
* we read the package.json off disk.
|
|
17
|
+
*
|
|
18
|
+
* The read is relative to `import.meta.url`, which resolves to this
|
|
19
|
+
* module's own file URL. From `src/lib/cli-version.mjs`, two levels
|
|
20
|
+
* up is the package root — the `"files"` array in package.json
|
|
21
|
+
* includes `bin/` and `src/` alongside the package.json itself, so
|
|
22
|
+
* the file is always present in the shipped tarball.
|
|
23
|
+
*
|
|
24
|
+
* A failed read is a programming error (missing or malformed
|
|
25
|
+
* package.json in a shipped tarball means the build pipeline broke
|
|
26
|
+
* invariants). The function throws rather than soft-handling; there
|
|
27
|
+
* is no meaningful user recovery path.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync } from "node:fs";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Return the CLI's own semver version as a string (e.g. "3.1.2").
|
|
34
|
+
*
|
|
35
|
+
* @returns {string}
|
|
36
|
+
* @throws {Error} if the package.json cannot be read or parsed, or
|
|
37
|
+
* if the `version` field is missing or not a string.
|
|
38
|
+
*/
|
|
39
|
+
export function getCliVersion() {
|
|
40
|
+
// Resolve `../../package.json` relative to THIS file. This module
|
|
41
|
+
// lives at `packages/cli/src/lib/cli-version.mjs`, so two `..`
|
|
42
|
+
// segments land at the package root. Using `import.meta.url` means
|
|
43
|
+
// the resolution is invariant under tarball/symlink layout — it
|
|
44
|
+
// always points to where THIS source file actually lives on disk
|
|
45
|
+
// at runtime.
|
|
46
|
+
const pkgUrl = new URL("../../package.json", import.meta.url);
|
|
47
|
+
const raw = readFileSync(pkgUrl, "utf-8");
|
|
48
|
+
const pkg = JSON.parse(raw);
|
|
49
|
+
if (typeof pkg.version !== "string" || pkg.version.length === 0) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"cli-version: package.json has no valid `version` string. " +
|
|
52
|
+
"This indicates a broken build/publish pipeline.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return pkg.version;
|
|
56
|
+
}
|
|
@@ -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
|
|