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.
@@ -70,8 +70,6 @@
70
70
  */
71
71
 
72
72
  import { existsSync, readFileSync } from "node:fs";
73
- import { execFileSync } from "node:child_process";
74
- import { isAbsolute } from "node:path";
75
73
  import { writeFileAtomic } from "../fs-utils.mjs";
76
74
  import { SESSION_HOOK_FINGERPRINT } from "../artifact-registry.mjs";
77
75
  import {
@@ -80,7 +78,7 @@ import {
80
78
  } from "../paths.mjs";
81
79
  import { diskError, validationError } from "../errors.mjs";
82
80
  import { removeSettingsSessionHook } from "../removers/settings.mjs";
83
- import { isNpxInvocation } from "../cli-config.mjs";
81
+ import { resolveBinaryOnPath } from "../binary-locator.mjs";
84
82
  import { platformConventions } from "../platform.mjs";
85
83
 
86
84
  /**
@@ -90,15 +88,31 @@ import { platformConventions } from "../platform.mjs";
90
88
  * Shell shape is platform-specific — see `platform.mjs` for the full
91
89
  * rationale. Summary:
92
90
  *
93
- * - **POSIX** (macOS, Linux): `<path> update --session-hook 2>&1 || true`.
91
+ * - **POSIX** (macOS, Linux): `'<path>' update --session-hook 2>&1 || true`.
94
92
  * `|| true` catches any non-zero exit at the shell level; primary
95
93
  * defense is the `--session-hook` flag contract in the Node process.
96
- * - **Windows** (cmd.exe / PowerShell): `<path> update --session-hook 2>&1`.
94
+ * - **Windows** (cmd.exe / PowerShell): `"<path>" update --session-hook 2>&1`.
97
95
  * `|| true` omitted because cmd.exe doesn't know the `true` builtin.
98
96
  * `--session-hook` contract is the only defense; consequences of
99
97
  * binary-vanished scenarios are slightly noisier in Claude Code's
100
98
  * session log but still non-blocking.
101
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
+ *
102
116
  * The suffix is supplied by `platformConventions().hookShellSuffix` —
103
117
  * this function doesn't know which OS it's targeting, it just
104
118
  * concatenates the convention's suffix.
@@ -116,69 +130,57 @@ export function buildHookCommand(binaryPath, { platform: platformOverride } = {}
116
130
  );
117
131
  }
118
132
  const conv = platformConventions({ platform: platformOverride });
119
- return `${binaryPath} update --session-hook 2>&1${conv.hookShellSuffix}`;
133
+ const quotedPath = quoteShellPath(binaryPath, conv.family);
134
+ return `${quotedPath} update --session-hook 2>&1${conv.hookShellSuffix}`;
120
135
  }
121
136
 
122
137
  /**
123
- * Resolve the absolute path of the `skillrepo` binary via `which`.
124
- * Returns null if resolution fails (e.g. user ran `npx skillrepo init`
125
- * without a global install) — the caller should skip hook installation
126
- * 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.
127
140
  *
128
- * @returns {string | null}
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.
129
158
  */
130
- export function resolveSkillrepoBinary({ platform: platformOverride } = {}) {
131
- // npx-invocation guard. Returns null early before any OS-specific
132
- // logic runs — npx detection is platform-neutral (argv and env
133
- // checks only) so it doesn't need the conventions object.
134
- if (isNpxInvocation()) {
135
- return null;
159
+ function quoteShellPath(path, family) {
160
+ if (family === "windows") {
161
+ return `"${path.replace(/"/g, '\\"')}"`;
136
162
  }
163
+ // POSIX
164
+ return `'${path.replace(/'/g, "'\\''")}'`;
165
+ }
137
166
 
138
- // Platform-specific binary locator name comes from the single
139
- // source of truth in platform.mjs. Adding a new locator for a
140
- // new platform is one edit in platform.mjs, not a scattered
141
- // search for `platform() === "win32"` conditionals. See
142
- // platform.mjs for the full rationale.
143
- const conv = platformConventions({ platform: platformOverride });
144
-
145
- try {
146
- // 3-second timeout `which`/`where` typically return in
147
- // milliseconds, but a PATH that includes a network filesystem
148
- // or a shell alias that does I/O could hang indefinitely.
149
- // Bounding the call ensures `skillrepo init` never stalls on
150
- // binary resolution.
151
- const raw = execFileSync(conv.binaryLocator, ["skillrepo"], {
152
- encoding: "utf-8",
153
- stdio: ["ignore", "pipe", "ignore"],
154
- timeout: 3000,
155
- });
156
- // Windows `where` can return multiple matching paths (one per
157
- // PATH entry containing the binary) on separate lines. Take
158
- // only the first. `which` always returns a single path but the
159
- // split is harmless there. This is the one line where the
160
- // platform difference actually leaks through — all platforms
161
- // receive potentially-multi-line output that we canonicalize
162
- // the same way.
163
- const result = raw.split(/\r?\n/)[0].trim();
164
- if (!result) return null;
165
- // Sanity: the resolved path must be absolute. A relative
166
- // result would be meaningless at session-start time because
167
- // the Claude Code hook runner's cwd is undefined. `isAbsolute`
168
- // handles both POSIX (`/foo/bar`) and Windows (`C:\foo\bar`)
169
- // path styles — it's Node's built-in cross-platform check,
170
- // not a platform-conditional we need to own.
171
- if (!isAbsolute(result)) return null;
172
- return result;
173
- } catch {
174
- // Locator exits non-zero if the binary isn't on PATH, or throws
175
- // ENOENT if the locator itself isn't available (e.g. a minimal
176
- // container image without `which`, or a Windows system with
177
- // `where.exe` missing which is effectively never — but still
178
- // safe-handled). Either way: null → caller routes to the
179
- // architect-specified "requires stable install" skip message.
180
- return null;
181
- }
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
+ });
182
184
  }
183
185
 
184
186
  /**
@@ -237,18 +239,28 @@ export function mergeSessionHook({
237
239
  // 2. `which skillrepo` returned nothing — no global install
238
240
  // exists at all.
239
241
  // Both are the same problem from the hook's perspective: we
240
- // can't produce a command that will still work later. The
241
- // architect's #884 design specified the same warning text for
242
- // both cases.
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.
243
256
  return {
244
257
  path: displayPath,
245
258
  action: "skipped",
246
259
  reason:
247
260
  "Session sync requires a stable `skillrepo` binary on PATH. " +
248
- "Under `npx skillrepo ...` or without a global install, the " +
249
- "hook would bind to a transient path that eventually breaks. " +
250
- "Install globally with `npm install -g skillrepo` and re-run " +
251
- "`skillrepo session-sync enable`.",
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`.",
252
264
  };
253
265
  }
254
266
 
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Transient package-runner detection (#894 / v3.1.2).
3
+ *
4
+ * The CLI cares about transient package runners — npx, pnpm dlx,
5
+ * yarn berry dlx, bunx — in two distinct ways:
6
+ *
7
+ * 1. **Detect when the current process IS a transient invocation**
8
+ * (`detectTransientRunner` / `isTransientRunnerInvocation`).
9
+ * Used by `init` to gate the auto-install-global flow and to
10
+ * pick the right runner-prefix in Next-Steps output, and by
11
+ * `mergers/session-hook` to refuse baking a transient cache
12
+ * path into a long-lived hook command.
13
+ *
14
+ * 2. **Detect when a candidate filesystem path is INSIDE a runner's
15
+ * transient cache** (`isTransientCachePath`). Used by the binary
16
+ * locator (`lib/binary-locator.mjs`) to filter `where`/`which`
17
+ * output so a cache-located binary doesn't shadow a real global
18
+ * install.
19
+ *
20
+ * The substring patterns for each runner's cache are the same data
21
+ * for both use cases; centralizing here is the single-source-of-truth
22
+ * fix for the duplication that existed in v3.1.2's first cleanup
23
+ * pass (cli-config.mjs and global-install.mjs each carried their own
24
+ * frozen array of the same substrings).
25
+ *
26
+ * ## Adding a new runner
27
+ *
28
+ * Add an entry to `TRANSIENT_RUNNERS` with:
29
+ * - `name`: the canonical runner-command string used in Next-Steps
30
+ * output (e.g. `"npx"`, `"pnpx"`, `"yarn dlx"`, `"bunx"`).
31
+ * - `cacheSubstrings`: substrings (POSIX and Windows separator
32
+ * variants) that uniquely identify the runner's per-invocation
33
+ * cache directory inside an absolute path. Both `argv[1]`-style
34
+ * paths and `where`/`which` output paths are matched against
35
+ * this list.
36
+ * - `commandSuffixes`: launcher-binary names that, when set as
37
+ * `process.env._` (the shell's "last command" var), uniquely
38
+ * identify this runner. Suffix-matched. Empty array if the
39
+ * runner has no canonical `_` form (e.g. `yarn dlx` with a
40
+ * space — `_` only gets the binary name, not the full command).
41
+ */
42
+
43
+ /**
44
+ * @typedef {Object} TransientRunner
45
+ * @property {string} name - Display name used in user-facing output
46
+ * (also the prefix for `<name> skillrepo list` next-step
47
+ * hints).
48
+ * @property {readonly string[]} cacheSubstrings - Path substrings
49
+ * that uniquely identify the runner's cache directory.
50
+ * @property {readonly string[]} commandSuffixes - `_` env-var
51
+ * suffix patterns matching the launcher binary name.
52
+ * @property {string} globalInstallCommand - The canonical "install
53
+ * skillrepo globally with this package manager" command
54
+ * we suggest to a user who just ran via this runner.
55
+ * Used by init's Next-Steps Tip when no global is yet
56
+ * active. Yarn berry uses `npm install -g` because yarn
57
+ * berry intentionally has no `yarn global add` equivalent
58
+ * (it directs users to `dlx` for one-offs and away from
59
+ * globals); npm-installed binaries land on the user's
60
+ * PATH the same regardless of which runner they used to
61
+ * bootstrap.
62
+ */
63
+
64
+ /**
65
+ * Catalog of supported transient runners. Order matters only for
66
+ * tie-breaking when multiple runners' substrings match the same
67
+ * path (extremely unlikely given the specificity of each); the
68
+ * first match wins.
69
+ */
70
+ export const TRANSIENT_RUNNERS = Object.freeze([
71
+ Object.freeze({
72
+ name: "npx",
73
+ // npx writes to `~/.npm/_npx/<hash>/...`.
74
+ cacheSubstrings: Object.freeze(["/_npx/", "\\_npx\\"]),
75
+ commandSuffixes: Object.freeze(["/npx", "\\npx"]),
76
+ globalInstallCommand: "npm install -g skillrepo",
77
+ }),
78
+ Object.freeze({
79
+ name: "pnpx",
80
+ // pnpm dlx writes to `<store>/dlx-<hash>/...`. Both pnpm dlx
81
+ // and the legacy `pnpx` shim hit this cache.
82
+ cacheSubstrings: Object.freeze(["/dlx-", "\\dlx-"]),
83
+ commandSuffixes: Object.freeze(["/pnpx", "\\pnpx"]),
84
+ globalInstallCommand: "pnpm add -g skillrepo",
85
+ }),
86
+ Object.freeze({
87
+ name: "yarn dlx",
88
+ // yarn berry dlx caches under `.yarn/berry/cache/...` and
89
+ // resolves PnP virtuals under `.yarn/$$virtual/...`. The
90
+ // launcher is `yarn dlx <pkg>` which sets `_` to `yarn`, NOT
91
+ // `yarn dlx` — so commandSuffixes is empty; argv-path detection
92
+ // is the only signal.
93
+ cacheSubstrings: Object.freeze([
94
+ "/.yarn/berry/",
95
+ "\\.yarn\\berry\\",
96
+ "/.yarn/$$virtual/",
97
+ "\\.yarn\\$$virtual\\",
98
+ ]),
99
+ commandSuffixes: Object.freeze([]),
100
+ // Yarn berry deliberately doesn't ship a `yarn global add`
101
+ // (the team directs users away from globals toward `yarn dlx`
102
+ // for one-offs). For users who DO want a persistent global,
103
+ // `npm install -g` is the universal fallback that works
104
+ // alongside yarn berry without conflict.
105
+ globalInstallCommand: "npm install -g skillrepo",
106
+ }),
107
+ Object.freeze({
108
+ name: "bunx",
109
+ cacheSubstrings: Object.freeze([
110
+ "/.bun/install/cache/",
111
+ "\\.bun\\install\\cache\\",
112
+ ]),
113
+ commandSuffixes: Object.freeze(["/bunx", "\\bunx"]),
114
+ globalInstallCommand: "bun add -g skillrepo",
115
+ }),
116
+ ]);
117
+
118
+ /**
119
+ * Look up the canonical global-install command for a runner display
120
+ * name (as returned by `detectTransientRunner`). Returns null when
121
+ * the name doesn't match any registered runner — caller should fall
122
+ * back to the universal `npm install -g skillrepo` text.
123
+ *
124
+ * @param {string | null} runnerName
125
+ * @returns {string | null}
126
+ */
127
+ export function globalInstallCommandFor(runnerName) {
128
+ if (!runnerName) return null;
129
+ const runner = TRANSIENT_RUNNERS.find((r) => r.name === runnerName);
130
+ return runner ? runner.globalInstallCommand : null;
131
+ }
132
+
133
+ /**
134
+ * Identify the transient runner that launched the current process,
135
+ * if any.
136
+ *
137
+ * Detection signals (first match wins):
138
+ * 1. `process.argv[1]` contains one of the runner's
139
+ * `cacheSubstrings`. The executable's path itself names the
140
+ * runner's cache directory.
141
+ * 2. `process.env._` ends with one of the runner's
142
+ * `commandSuffixes`. Defensive against shim layouts where
143
+ * `argv[1]` has been symlinked through a path that doesn't
144
+ * contain the cache substring.
145
+ *
146
+ * Why NOT `process.env.npm_command === "exec"`: that signal also
147
+ * fires for stable-install users running `npm exec skillrepo ...`
148
+ * directly or via a `package.json` lifecycle script. Adding it
149
+ * trades a minor coverage gain for a real false-positive surface
150
+ * affecting users with a real global install.
151
+ *
152
+ * @param {object} [options]
153
+ * @param {string[]} [options.argv=process.argv] - Test override.
154
+ * @param {NodeJS.ProcessEnv} [options.env=process.env] - Test override.
155
+ * @returns {string | null} The runner's display name, or `null` if
156
+ * the process is NOT a transient invocation.
157
+ */
158
+ export function detectTransientRunner({
159
+ argv = process.argv,
160
+ env = process.env,
161
+ } = {}) {
162
+ const execPath = argv[1] ?? "";
163
+ for (const runner of TRANSIENT_RUNNERS) {
164
+ for (const substring of runner.cacheSubstrings) {
165
+ if (execPath.includes(substring)) return runner.name;
166
+ }
167
+ }
168
+ const underscore = env._ ?? "";
169
+ for (const runner of TRANSIENT_RUNNERS) {
170
+ for (const suffix of runner.commandSuffixes) {
171
+ if (underscore.endsWith(suffix)) return runner.name;
172
+ }
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * Boolean shortcut over `detectTransientRunner`. Use when a caller
179
+ * only needs to know "is this a transient invocation at all?" and
180
+ * doesn't care which runner.
181
+ *
182
+ * @returns {boolean}
183
+ */
184
+ export function isTransientRunnerInvocation() {
185
+ return detectTransientRunner() !== null;
186
+ }
187
+
188
+ /**
189
+ * True when `absPath` is inside any registered runner's transient
190
+ * cache directory. Used by the binary locator to filter
191
+ * `where`/`which` output so a cache-located binary doesn't get
192
+ * baked into a long-lived hook command.
193
+ *
194
+ * @param {string} absPath - An absolute filesystem path.
195
+ * @returns {boolean}
196
+ */
197
+ export function isTransientCachePath(absPath) {
198
+ for (const runner of TRANSIENT_RUNNERS) {
199
+ for (const substring of runner.cacheSubstrings) {
200
+ if (absPath.includes(substring)) return true;
201
+ }
202
+ }
203
+ return false;
204
+ }