skillrepo 3.1.1 → 3.1.3

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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Cross-platform binary locator with optional transient-runner
3
+ * filtering (#894 / v3.1.2, fixed in v3.1.3).
4
+ *
5
+ * ## v3.1.3 fix history
6
+ *
7
+ * v3.1.2 used `execFileSync("which" or "where", [name])` to find
8
+ * the binary. That broke under `npx`: POSIX `which` returns ONLY
9
+ * the FIRST match. The npx-launched process has the npx-cache bin
10
+ * dir at the front of PATH, so `which skillrepo` returns the npx
11
+ * cache copy. With `filterTransient: true`, we filter that out and
12
+ * return null — even though a stable global IS on PATH at a later
13
+ * entry. The user-visible symptom: `npm install -g skillrepo`
14
+ * succeeds, but `installSkillrepoGlobally`'s post-install
15
+ * verification reports "binary not found on PATH" and init shows
16
+ * the auto-install as failed when it actually worked.
17
+ *
18
+ * The fix: scan PATH directly in Node, returning the FIRST
19
+ * non-transient match. We see ALL candidates the way Windows
20
+ * `where.exe` does, so we can correctly reject transient cache
21
+ * paths in favor of stable global installs that appear later in
22
+ * PATH. No shell-out, faster, deterministic.
23
+ *
24
+ * ## Two flag knobs
25
+ *
26
+ * - `skipIfTransient`: short-circuit to null if the CURRENT
27
+ * process is itself a transient-runner invocation. Used by
28
+ * callers that bake the resolved path into long-lived state
29
+ * (e.g. a SessionStart hook command) when the running process
30
+ * ITSELF can't supply a stable absolute path.
31
+ *
32
+ * - `filterTransient`: ignore PATH entries that point inside a
33
+ * transient runner's cache directory. Used by callers that
34
+ * explicitly want a STABLE global install at a non-cache path
35
+ * even when running under a transient runner (typically
36
+ * post-`npm install -g`, looking for the just-installed
37
+ * binary).
38
+ *
39
+ * Both flags default to false so the function behaves like a plain
40
+ * PATH lookup unless the caller opts into the extra semantics.
41
+ */
42
+
43
+ import { existsSync } from "node:fs";
44
+ import { delimiter, isAbsolute, join } from "node:path";
45
+ import { platformConventions } from "./platform.mjs";
46
+ import {
47
+ isTransientCachePath,
48
+ isTransientRunnerInvocation,
49
+ } from "./transient-runners.mjs";
50
+
51
+ /**
52
+ * Resolve the absolute path of `binaryName` on PATH.
53
+ *
54
+ * @param {string} binaryName - The bare command name (e.g.
55
+ * `"skillrepo"`). On Windows we also probe for `<name>.cmd`
56
+ * and `<name>.exe` since npm-installed CLIs land as `.cmd`
57
+ * shims there.
58
+ * @param {object} [options]
59
+ * @param {boolean} [options.skipIfTransient=false] - When true,
60
+ * return `null` immediately if the current process is itself
61
+ * a transient-runner invocation.
62
+ * @param {boolean} [options.filterTransient=false] - When true,
63
+ * ignore matches inside a transient runner's cache directory.
64
+ * @param {NodeJS.Platform} [options.platform] - Override for tests.
65
+ * Production callers leave unset.
66
+ * @param {string} [options.path] - Override for tests. Defaults to
67
+ * `process.env.PATH`. Tests use this to construct
68
+ * deterministic PATH layouts (e.g. "an _npx cache entry
69
+ * FIRST followed by a stable shim").
70
+ * @returns {string | null}
71
+ */
72
+ export function resolveBinaryOnPath(
73
+ binaryName,
74
+ {
75
+ skipIfTransient = false,
76
+ filterTransient = false,
77
+ platform: platformOverride,
78
+ path: pathOverride,
79
+ } = {},
80
+ ) {
81
+ if (skipIfTransient && isTransientRunnerInvocation()) {
82
+ return null;
83
+ }
84
+
85
+ const conv = platformConventions({ platform: platformOverride });
86
+ const pathStr = pathOverride ?? process.env.PATH ?? "";
87
+ // Drop empty entries AND relative paths in one pass — relative
88
+ // PATH entries are meaningless for baking into a long-lived hook
89
+ // command (cwd-dependent), and empty entries (from `::` or a
90
+ // trailing delimiter) are no-ops we don't want to stat.
91
+ const dirs = pathStr
92
+ .split(delimiter)
93
+ .filter((d) => d && isAbsolute(d));
94
+
95
+ // On Windows, npm installs global CLIs as `<name>.cmd` shims
96
+ // (and very occasionally `<name>.exe` for native binaries).
97
+ // Order matters: `.cmd` is what npm always emits, so check it
98
+ // first to short-circuit the lookup. Bare `<name>` is included
99
+ // as a defensive fallback for unusual installer layouts (Git
100
+ // Bash on Windows, MSYS2, etc., where POSIX-style shims may
101
+ // accompany the .cmd ones).
102
+ // POSIX has no extension convention; the bare name suffices.
103
+ const candidates =
104
+ conv.family === "windows"
105
+ ? [`${binaryName}.cmd`, `${binaryName}.exe`, binaryName]
106
+ : [binaryName];
107
+
108
+ for (const dir of dirs) {
109
+ for (const name of candidates) {
110
+ const full = join(dir, name);
111
+ let exists;
112
+ try {
113
+ exists = existsSync(full);
114
+ } catch {
115
+ // Unreadable directory or transient FS error — treat as
116
+ // "not here" and move on. Without this guard a single
117
+ // bad PATH entry would crash the lookup.
118
+ continue;
119
+ }
120
+ if (!exists) continue;
121
+ if (filterTransient && isTransientCachePath(full)) continue;
122
+ return full;
123
+ }
124
+ }
125
+ return null;
126
+ }
@@ -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 four command modules thin and
17
- * means a future change to credential resolution (e.g., adding a
18
- * keychain backend) is a single edit instead of four.
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,386 @@
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.
146
+ const npmCmd = conv.family === "windows" ? "npm.cmd" : "npm";
147
+ const args = ["install", "-g", `skillrepo@${version}`];
148
+
149
+ // stdio mapping:
150
+ // - inherit: npm output streams to user's terminal in real time.
151
+ // - silent (--json mode): pipe stdout/stderr so we can capture
152
+ // them for error categorization, but don't let them touch the
153
+ // terminal. The captured stderr is useful for the
154
+ // "first 200 chars of stderr" failure message.
155
+ const stdio = outputMode === "silent"
156
+ ? ["ignore", "pipe", "pipe"]
157
+ : "inherit";
158
+
159
+ // ── Spawn the child ─────────────────────────────────────────────
160
+ // We use `shell: false` (the spawn default) on both platforms.
161
+ // shell: true on Windows introduces argument-quoting surprises
162
+ // (cmd.exe quoting rules differ from POSIX shells in subtle ways);
163
+ // we control all args, so we don't need shell expansion.
164
+ let child;
165
+ try {
166
+ child = spawn(npmCmd, args, { stdio });
167
+ } catch (err) {
168
+ // Synchronous spawn failure (rare; some Node versions surface
169
+ // ENOENT this way instead of via the `error` event). Treat
170
+ // identically to the async ENOENT path.
171
+ if (err && err.code === "ENOENT") {
172
+ return {
173
+ success: false,
174
+ binaryPath: null,
175
+ errorCode: "enoent-npm",
176
+ error:
177
+ "`npm` was not found on PATH. Install Node.js " +
178
+ "(which bundles npm) or ensure npm is on your PATH, " +
179
+ "then re-run init.",
180
+ };
181
+ }
182
+ // Any other synchronous spawn failure is unexpected — surface
183
+ // as npm-nonzero with the message.
184
+ return {
185
+ success: false,
186
+ binaryPath: null,
187
+ errorCode: "npm-nonzero",
188
+ error: `Failed to spawn npm: ${err?.message ?? String(err)}`,
189
+ };
190
+ }
191
+
192
+ // ── Capture output (silent mode) ───────────────────────────────
193
+ // Buffer stderr for failure-message extraction. stdout is captured
194
+ // too in case we need it later, but we don't currently use it.
195
+ let stderrChunks = [];
196
+ if (outputMode === "silent") {
197
+ if (child.stderr) {
198
+ child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
199
+ }
200
+ if (child.stdout) {
201
+ // Drain to prevent backpressure; we don't actually need the
202
+ // content. Discarding is the goal of silent mode.
203
+ child.stdout.on("data", () => {});
204
+ }
205
+ }
206
+
207
+ // ── Wait for completion or timeout ─────────────────────────────
208
+ const result = await new Promise((resolve) => {
209
+ let settled = false;
210
+ const settle = (value) => {
211
+ if (settled) return;
212
+ settled = true;
213
+ resolve(value);
214
+ };
215
+
216
+ const timer = setTimeout(() => {
217
+ // The `kill()` may not immediately stop a child that's
218
+ // doing network I/O on a slow socket. We don't await child
219
+ // exit after kill — the timeout result is what we report;
220
+ // the OS reaps the child whenever it actually exits.
221
+ try {
222
+ child.kill();
223
+ } catch {
224
+ // Already exited — fine.
225
+ }
226
+ settle({ kind: "timeout" });
227
+ }, timeoutMs);
228
+
229
+ child.on("error", (err) => {
230
+ clearTimeout(timer);
231
+ // Async spawn errors. ENOENT here means `npm` not on PATH.
232
+ if (err && err.code === "ENOENT") {
233
+ settle({ kind: "enoent-npm" });
234
+ return;
235
+ }
236
+ // EACCES at spawn time (rare, usually surfaces in npm output
237
+ // instead). Treat as npm-nonzero with the error message.
238
+ settle({ kind: "spawn-error", message: err?.message ?? String(err) });
239
+ });
240
+
241
+ child.on("close", (code) => {
242
+ clearTimeout(timer);
243
+ settle({ kind: "exit", code });
244
+ });
245
+ });
246
+
247
+ if (result.kind === "timeout") {
248
+ return {
249
+ success: false,
250
+ binaryPath: null,
251
+ errorCode: "timeout",
252
+ error:
253
+ `npm install -g skillrepo@${version} did not complete within ` +
254
+ `${Math.round(timeoutMs / 1000)} seconds. Check your network ` +
255
+ "connection or npm registry status, then re-run init.",
256
+ };
257
+ }
258
+
259
+ if (result.kind === "enoent-npm") {
260
+ return {
261
+ success: false,
262
+ binaryPath: null,
263
+ errorCode: "enoent-npm",
264
+ error:
265
+ "`npm` was not found on PATH. Install Node.js " +
266
+ "(which bundles npm) or ensure npm is on your PATH, " +
267
+ "then re-run init.",
268
+ };
269
+ }
270
+
271
+ if (result.kind === "spawn-error") {
272
+ return {
273
+ success: false,
274
+ binaryPath: null,
275
+ errorCode: "npm-nonzero",
276
+ error: `Failed to run npm: ${result.message}`,
277
+ };
278
+ }
279
+
280
+ // result.kind === "exit"
281
+ const exitCode = result.code;
282
+ // Only build the stderr string when we actually captured it
283
+ // (silent mode). In inherit mode, `stderrChunks` is always empty
284
+ // because the stream wasn't piped to us.
285
+ const stderrText =
286
+ outputMode === "silent"
287
+ ? Buffer.concat(stderrChunks).toString("utf-8")
288
+ : "";
289
+
290
+ if (exitCode !== 0) {
291
+ // EACCES is a common case worth distinguishing — the actionable
292
+ // remediation differs (sudo or fix prefix vs check the npm
293
+ // output). We have two signals available:
294
+ // 1. stderr text contains "EACCES" — only available in silent
295
+ // mode (`--json`) where we capture stderr.
296
+ // 2. exit code 243 — npm's exit code for EACCES on POSIX.
297
+ // Documented in npm's source as the dedicated permission-
298
+ // error code. Available in BOTH inherit and silent modes
299
+ // because exit code is always observable.
300
+ // Trying both gives the user a categorized error in both modes.
301
+ if (stderrText.includes("EACCES") || exitCode === 243) {
302
+ return {
303
+ success: false,
304
+ binaryPath: null,
305
+ errorCode: "eacces",
306
+ error:
307
+ "npm reported a permissions error (EACCES). Run with sudo " +
308
+ "or fix npm's prefix to a writable location: " +
309
+ "https://docs.npmjs.com/resolving-eacces-permissions-errors",
310
+ };
311
+ }
312
+ // Generic npm failure. In silent mode (--json) we have stderr
313
+ // text and include the first 200 chars for diagnosis. In inherit
314
+ // mode the user already saw npm's real output stream past, so
315
+ // we add a short hint pointing at the two most common
316
+ // remediations (permissions, network) without trying to guess
317
+ // which applies.
318
+ const trimmedStderr =
319
+ stderrText.length > 0 ? ` ${stderrText.slice(0, 200).trim()}` : "";
320
+ const inheritHint =
321
+ stderrText.length === 0
322
+ ? " Common causes: permissions (try sudo, or fix npm's prefix) " +
323
+ "or network/registry issues (check your internet connection)."
324
+ : "";
325
+ return {
326
+ success: false,
327
+ binaryPath: null,
328
+ errorCode: "npm-nonzero",
329
+ error: `npm install -g exited with code ${exitCode}.${trimmedStderr}${inheritHint}`,
330
+ };
331
+ }
332
+
333
+ // ── Verify the install actually produced a usable binary ──────
334
+ const binaryPath = resolveGlobalBinary({ platform: platformOverride });
335
+ if (!binaryPath) {
336
+ const isWindows = conv.family === "windows";
337
+ // Windows-specific addendum: PATH changes from `npm install -g`
338
+ // are NOT visible in the current terminal session — the user
339
+ // has to open a new terminal for the change to propagate. POSIX
340
+ // shells inherit the new PATH naturally because npm writes to
341
+ // a directory the user's shell already has on PATH.
342
+ const platformAddendum = isWindows
343
+ ? " On Windows, `npm install -g` does not refresh PATH in the " +
344
+ "current terminal. Open a new PowerShell or cmd.exe window, " +
345
+ "then run `skillrepo session-sync enable`."
346
+ : "";
347
+ return {
348
+ success: false,
349
+ binaryPath: null,
350
+ errorCode: "path-not-updated",
351
+ error:
352
+ "npm install -g succeeded but `skillrepo` was not found on " +
353
+ "PATH. Your npm prefix bin directory may not be on PATH. " +
354
+ "Run `npm config get prefix` and add `<prefix>/bin` to PATH, " +
355
+ "then run `skillrepo session-sync enable`." +
356
+ platformAddendum,
357
+ };
358
+ }
359
+
360
+ return {
361
+ success: true,
362
+ binaryPath,
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Resolve the absolute path of a STABLE (non-cache) `skillrepo`
368
+ * binary on PATH. Used after `npm install -g` to confirm the install
369
+ * produced a usable binary at a path safe to bake into the
370
+ * SessionStart hook command.
371
+ *
372
+ * Thin wrapper over `resolveBinaryOnPath` with `filterTransient: true`
373
+ * preset. The `skipIfTransient` flag is intentionally false — we
374
+ * WANT to find the newly-installed global even when we're running
375
+ * under a transient runner ourselves.
376
+ *
377
+ * @param {object} [options]
378
+ * @param {NodeJS.Platform} [options.platform] - Override for tests.
379
+ * @returns {string | null}
380
+ */
381
+ export function resolveGlobalBinary({ platform: platformOverride } = {}) {
382
+ return resolveBinaryOnPath("skillrepo", {
383
+ filterTransient: true,
384
+ platform: platformOverride,
385
+ });
386
+ }