skillrepo 3.1.2 → 3.1.4

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/LICENSE ADDED
@@ -0,0 +1,37 @@
1
+ SkillRepo CLI
2
+ Copyright (c) 2026 SkillRepo LLC. All rights reserved.
3
+
4
+ This software is proprietary and confidential. Its installation, use,
5
+ reproduction, modification, and distribution are governed exclusively by
6
+ the SkillRepo End User License Agreement (the "EULA") available at:
7
+
8
+ https://skillrepo.dev/eula
9
+
10
+ By installing, copying, or otherwise using this software you agree to be
11
+ bound by the EULA. If you do not agree to the EULA, do not install or
12
+ use this software.
13
+
14
+ No license or right, whether by implication, estoppel, or otherwise, is
15
+ granted except as expressly set forth in the EULA. Without limiting the
16
+ foregoing, you may not:
17
+
18
+ - reverse engineer, decompile, disassemble, or otherwise attempt to
19
+ derive the source code of this software, except to the extent such
20
+ activity is expressly permitted by applicable law notwithstanding
21
+ this limitation;
22
+ - sell, resell, rent, lease, sublicense, distribute, or otherwise
23
+ transfer this software or access to it to any third party;
24
+ - use this software, its outputs, or any data obtained through it to
25
+ build or operate a service that is substantially similar to or
26
+ competes with the SkillRepo platform; or
27
+ - remove, alter, or obscure any proprietary notices on or in this
28
+ software.
29
+
30
+ THIS SOFTWARE IS PROVIDED "AS IS," WITHOUT WARRANTY OF ANY KIND, EXPRESS
31
+ OR IMPLIED. YOUR USE OF THIS SOFTWARE IS SUBJECT TO THE WARRANTY
32
+ DISCLAIMERS, LIMITATION OF LIABILITY, AND ALL OTHER PROVISIONS OF THE
33
+ EULA, WHICH ARE INCORPORATED INTO THIS NOTICE BY REFERENCE.
34
+
35
+ SkillRepo and the SkillRepo logo are trademarks of SkillRepo LLC.
36
+
37
+ Contact: hello@skillrepo.dev
package/README.md CHANGED
@@ -346,4 +346,7 @@ fully overwrites it.
346
346
 
347
347
  ## License
348
348
 
349
- MIT see [LICENSE](./LICENSE).
349
+ Proprietary. Copyright © 2026 SkillRepo LLC. All rights reserved.
350
+ Use of this CLI is governed by the SkillRepo End User License Agreement
351
+ at [https://skillrepo.dev/eula](https://skillrepo.dev/eula). See
352
+ [LICENSE](./LICENSE) for the full notice.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "3.1.2",
3
+ "version": "3.1.4",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
- "src/"
11
+ "src/",
12
+ "LICENSE"
12
13
  ],
13
14
  "repository": {
14
15
  "type": "git",
@@ -16,7 +17,8 @@
16
17
  "directory": "packages/cli"
17
18
  },
18
19
  "keywords": ["skillrepo", "cli", "mcp", "ai-skills"],
19
- "license": "MIT",
20
+ "author": "SkillRepo LLC",
21
+ "license": "SEE LICENSE IN LICENSE",
20
22
  "dependencies": {
21
23
  "cli-table3": "^0.6.5"
22
24
  }
@@ -1,32 +1,47 @@
1
1
  /**
2
2
  * Cross-platform binary locator with optional transient-runner
3
- * filtering (#894 / v3.1.2).
3
+ * filtering (#894 / v3.1.2, fixed in v3.1.3).
4
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:
5
+ * ## v3.1.3 fix history
12
6
  *
13
- * - `resolveSkillrepoBinary` had an early-return guard against
14
- * `isNpxInvocation()` (now `isTransientRunnerInvocation`) the
15
- * `skipIfTransient` flag captures that.
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.
16
17
  *
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).
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).
22
38
  *
23
39
  * 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.
40
+ * PATH lookup unless the caller opts into the extra semantics.
26
41
  */
27
42
 
28
- import { execFileSync } from "node:child_process";
29
- import { isAbsolute } from "node:path";
43
+ import { existsSync } from "node:fs";
44
+ import { delimiter, isAbsolute, join } from "node:path";
30
45
  import { platformConventions } from "./platform.mjs";
31
46
  import {
32
47
  isTransientCachePath,
@@ -37,63 +52,75 @@ import {
37
52
  * Resolve the absolute path of `binaryName` on PATH.
38
53
  *
39
54
  * @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).
55
+ * `"skillrepo"`). On Windows we also probe for `<name>.cmd`
56
+ * and `<name>.exe` since npm-installed CLIs land as `.cmd`
57
+ * shims there.
42
58
  * @param {object} [options]
43
59
  * @param {boolean} [options.skipIfTransient=false] - When true,
44
60
  * 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.
61
+ * a transient-runner invocation.
48
62
  * @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.
63
+ * ignore matches inside a transient runner's cache directory.
52
64
  * @param {NodeJS.Platform} [options.platform] - Override for tests.
53
65
  * 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.
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}
56
71
  */
57
72
  export function resolveBinaryOnPath(
58
73
  binaryName,
59
- { skipIfTransient = false, filterTransient = false, platform: platformOverride } = {},
74
+ {
75
+ skipIfTransient = false,
76
+ filterTransient = false,
77
+ platform: platformOverride,
78
+ path: pathOverride,
79
+ } = {},
60
80
  ) {
61
81
  if (skipIfTransient && isTransientRunnerInvocation()) {
62
82
  return null;
63
83
  }
64
84
 
65
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));
66
94
 
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
- }
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];
85
107
 
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;
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
+ }
97
124
  }
98
125
  return null;
99
126
  }
@@ -142,8 +142,7 @@ export async function installSkillrepoGlobally({
142
142
  const conv = platformConventions({ platform: platformOverride });
143
143
  // Windows `npm` ships as `npm.cmd` — a batch script. spawn() with
144
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").
145
+ // `npm.cmd` on Windows and `npm` everywhere else.
147
146
  const npmCmd = conv.family === "windows" ? "npm.cmd" : "npm";
148
147
  const args = ["install", "-g", `skillrepo@${version}`];
149
148
 
@@ -8,11 +8,7 @@
8
8
  * real platform differences that can't be abstracted away at the
9
9
  * Node level:
10
10
  *
11
- * 1. **Binary-locator command**. POSIX provides `which`; Windows
12
- * provides `where.exe`. `execFileSync` doesn't spawn a shell,
13
- * so the literal name must exist on disk.
14
- *
15
- * 2. **Hook shell backstop suffix**. The SessionStart hook command
11
+ * 1. **Hook shell backstop suffix**. The SessionStart hook command
16
12
  * relies on a shell-level fallback (`|| true`) to guarantee
17
13
  * exit 0 even if the binary vanishes. POSIX shells support it;
18
14
  * cmd.exe doesn't know the `true` builtin and would emit a
@@ -21,14 +17,14 @@
21
17
  * platform; the shell backstop is belt-and-suspenders that we
22
18
  * lose on Windows.
23
19
  *
24
- * 3. **POSIX file permissions**. `chmodSync(0o600)` silently
20
+ * 2. **POSIX file permissions**. `chmodSync(0o600)` silently
25
21
  * succeeds on Windows but doesn't produce the intended effect —
26
22
  * Windows's ACL model doesn't map to the Unix mode bits. Any
27
23
  * call meant to restrict permissions on credential files must
28
24
  * be guarded so Windows users aren't misled into thinking their
29
25
  * files are access-controlled when they aren't.
30
26
  *
31
- * 4. **Atomic directory replacement semantics**. POSIX's
27
+ * 3. **Atomic directory replacement semantics**. POSIX's
32
28
  * `renameSync` over an existing directory is atomic on the same
33
29
  * filesystem — the swap is instantaneous from the perspective
34
30
  * of any concurrent reader. Windows fails with EEXIST/EPERM if
@@ -38,6 +34,14 @@
38
34
  * which strategy applies so they can surface a meaningful
39
35
  * recovery hint if the Windows path fails mid-sequence.
40
36
  *
37
+ * The v3.1.2 `binaryLocator` field (used by an earlier
38
+ * `which`/`where`-based binary locator) was removed in v3.1.3 when
39
+ * `lib/binary-locator.mjs` switched to scanning PATH directly in
40
+ * Node — POSIX `which` returns ONLY the first match, which broke
41
+ * the npx auto-install verification path (the npx cache copy was
42
+ * the first match and got filtered, so the just-installed global
43
+ * was never seen).
44
+ *
41
45
  * This module exposes a single `platformConventions()` function that
42
46
  * returns a frozen object with every platform-specific value the
43
47
  * CLI needs. New platform-specific surfaces should be added here
@@ -54,9 +58,6 @@ import { platform as osPlatform } from "node:os";
54
58
  /**
55
59
  * @typedef {Object} PlatformConventions
56
60
  * @property {"posix" | "windows"} family - High-level family name.
57
- * @property {string} binaryLocator - Command used to resolve a
58
- * binary's absolute path from PATH. `"which"` on POSIX,
59
- * `"where"` on Windows.
60
61
  * @property {string} hookShellSuffix - Suffix appended to hook
61
62
  * commands to guarantee exit 0 at the shell level. `" || true"`
62
63
  * on POSIX (appended to the base command), empty string on
@@ -81,7 +82,6 @@ import { platform as osPlatform } from "node:os";
81
82
 
82
83
  const POSIX = Object.freeze({
83
84
  family: "posix",
84
- binaryLocator: "which",
85
85
  hookShellSuffix: " || true",
86
86
  supportsPosixPermissions: true,
87
87
  supportsAtomicDirectoryRename: true,
@@ -89,7 +89,6 @@ const POSIX = Object.freeze({
89
89
 
90
90
  const WINDOWS = Object.freeze({
91
91
  family: "windows",
92
- binaryLocator: "where",
93
92
  hookShellSuffix: "",
94
93
  supportsPosixPermissions: false,
95
94
  supportsAtomicDirectoryRename: false,
@@ -25,6 +25,7 @@ import {
25
25
  setSandboxHome,
26
26
  restoreHome,
27
27
  } from "../helpers/sandbox-home.mjs";
28
+ import { isolatePathEnv } from "../helpers/path-isolation.mjs";
28
29
 
29
30
  let sandbox;
30
31
  let server;
@@ -1060,12 +1061,23 @@ describe("runInit — v3.1.1 Next steps prefix (bug 1)", () => {
1060
1061
  // no test ever shells out to `npm install -g`.
1061
1062
 
1062
1063
  describe("runInit — v3.1.2 step 6 auto-install (npx)", () => {
1063
- // Process-state isolation: same as the v3.1.1 npx prefix tests
1064
- // above. We control isNpxInvocation()'s output via process.argv[1]
1065
- // and process.env._.
1064
+ // Process-state isolation:
1065
+ // - We control isTransientRunnerInvocation()'s output via
1066
+ // process.argv[1] and process.env._.
1067
+ // - We CLEAR PATH so resolveGlobalBinary() genuinely returns
1068
+ // null (otherwise the developer's locally-installed `skillrepo`
1069
+ // makes Branch 4 fire when the test expects Branch 6). This
1070
+ // matters even more in v3.1.3+ which scans PATH directly in
1071
+ // Node — any pre-existing skillrepo on the dev's PATH would
1072
+ // be visible to the locator.
1073
+ // Tests that DO want a "global on PATH" scenario explicitly install
1074
+ // a shim via `installShim` in their body (see Branch 4 + idempotency
1075
+ // tests below); the shim helper prepends its own bin dir to PATH
1076
+ // so it survives the cleared baseline.
1066
1077
  let originalArgv;
1067
1078
  let originalUnderscore;
1068
1079
  let originalNpmCommand;
1080
+ let restorePath;
1069
1081
  let shimSandbox;
1070
1082
  let shimHandle;
1071
1083
 
@@ -1074,6 +1086,7 @@ describe("runInit — v3.1.2 step 6 auto-install (npx)", () => {
1074
1086
  originalArgv = process.argv;
1075
1087
  originalUnderscore = process.env._;
1076
1088
  originalNpmCommand = process.env.npm_command;
1089
+ restorePath = isolatePathEnv();
1077
1090
  shimSandbox = null;
1078
1091
  shimHandle = null;
1079
1092
  });
@@ -1089,6 +1102,11 @@ describe("runInit — v3.1.2 step 6 auto-install (npx)", () => {
1089
1102
  uninstallShim(shimHandle);
1090
1103
  }
1091
1104
  if (shimSandbox) rmSync(shimSandbox, { recursive: true, force: true });
1105
+ // Restore PATH last (after uninstallShim, which restores PATH
1106
+ // to its pre-shim state — usually our cleared baseline). The
1107
+ // outer restore puts the dev's real PATH back for the next
1108
+ // test suite.
1109
+ restorePath();
1092
1110
  await teardown();
1093
1111
  });
1094
1112
 
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Test helper: isolate `process.env.PATH` for the duration of a
3
+ * test (introduced in v3.1.3 alongside the binary-locator rewrite).
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * `lib/binary-locator.mjs` (introduced in v3.1.3) scans
8
+ * `process.env.PATH` directly to find the `skillrepo` binary.
9
+ * Tests that exercise the "no skillrepo on PATH" branch — e.g.
10
+ * the auto-install Branch 6 in `init`, the null-binary skipped
11
+ * branch in `mergeSessionHook` — would otherwise be non-
12
+ * deterministic on developer machines where `skillrepo` IS
13
+ * already globally installed: the locator would find the dev's
14
+ * real binary and the test would take the wrong code branch.
15
+ *
16
+ * Pre-v3.1.3 the same tests relied on `which`/`where` returning
17
+ * null for missing binaries, which gave them implicit isolation
18
+ * because `which` only returned the first match. The pure-Node
19
+ * scan removed that incidental isolation, so we make it explicit.
20
+ *
21
+ * Usage
22
+ * -----
23
+ *
24
+ * import { isolatePathEnv } from "../helpers/path-isolation.mjs";
25
+ *
26
+ * beforeEach(() => {
27
+ * restorePath = isolatePathEnv();
28
+ * });
29
+ * afterEach(() => {
30
+ * restorePath();
31
+ * });
32
+ *
33
+ * Or scoped to a single test:
34
+ *
35
+ * it("...", () => {
36
+ * const restore = isolatePathEnv();
37
+ * try {
38
+ * // ... test body
39
+ * } finally {
40
+ * restore();
41
+ * }
42
+ * });
43
+ */
44
+
45
+ /**
46
+ * Replace `process.env.PATH` with a known-empty value and return
47
+ * a restore function that puts the original value back. Handles
48
+ * the `undefined` case correctly: setting `process.env.PATH =
49
+ * undefined` produces the literal string `"undefined"`, so the
50
+ * restore must `delete` the key in that case.
51
+ *
52
+ * @returns {() => void} Restore function. Idempotent — calling it
53
+ * twice is a no-op on the second call.
54
+ */
55
+ export function isolatePathEnv() {
56
+ const originalPath = process.env.PATH;
57
+ process.env.PATH = "/nonexistent-test-isolation-dir";
58
+ let restored = false;
59
+ return function restore() {
60
+ if (restored) return;
61
+ restored = true;
62
+ if (originalPath === undefined) delete process.env.PATH;
63
+ else process.env.PATH = originalPath;
64
+ };
65
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Unit tests for src/lib/binary-locator.mjs (#894 / v3.1.3).
3
+ *
4
+ * The most important test here is the **v3.1.2 regression guard**:
5
+ * a PATH with a transient (_npx) skillrepo entry FIRST followed by
6
+ * a stable skillrepo entry must resolve to the STABLE one when
7
+ * `filterTransient: true` is passed.
8
+ *
9
+ * v3.1.2 used `which`/`where` to look up the binary. POSIX `which`
10
+ * returns ONLY the first match, so the npx cache copy was returned,
11
+ * filtered as transient, and the function returned null — even
12
+ * though a stable global was on PATH. The user's symptom: `npm
13
+ * install -g skillrepo` succeeds, but init reports the install
14
+ * as failed because the post-install verification couldn't see
15
+ * the just-installed binary.
16
+ *
17
+ * v3.1.3 scans PATH directly in Node so it sees ALL candidates
18
+ * and can pick the first non-transient one. This test reproduces
19
+ * the exact PATH layout that broke v3.1.2 and proves the fix.
20
+ */
21
+
22
+ import { describe, it, beforeEach, afterEach } from "node:test";
23
+ import assert from "node:assert/strict";
24
+ import {
25
+ mkdtempSync,
26
+ mkdirSync,
27
+ rmSync,
28
+ writeFileSync,
29
+ chmodSync,
30
+ } from "node:fs";
31
+ import { join, delimiter } from "node:path";
32
+ import { tmpdir } from "node:os";
33
+
34
+ import { resolveBinaryOnPath } from "../../lib/binary-locator.mjs";
35
+
36
+ // Helper: create a directory with a `skillrepo` executable inside.
37
+ // Returns the directory path.
38
+ function makeBinDir(parent, name) {
39
+ const dir = join(parent, name);
40
+ mkdirSync(dir, { recursive: true });
41
+ const binPath = join(dir, "skillrepo");
42
+ writeFileSync(binPath, "#!/bin/sh\nexit 0\n");
43
+ chmodSync(binPath, 0o755);
44
+ return dir;
45
+ }
46
+
47
+ describe("resolveBinaryOnPath — v3.1.3 regression guard for v3.1.2 bug", () => {
48
+ let sandbox;
49
+ let originalArgv;
50
+ let originalUnderscore;
51
+
52
+ beforeEach(() => {
53
+ sandbox = mkdtempSync(join(tmpdir(), "binary-locator-test-"));
54
+ // Clear transient-runner signals so isTransientRunnerInvocation
55
+ // doesn't trip on the dev's actual environment.
56
+ originalArgv = process.argv;
57
+ originalUnderscore = process.env._;
58
+ process.argv = ["/usr/local/bin/node", "/usr/local/bin/test"];
59
+ delete process.env._;
60
+ });
61
+
62
+ afterEach(() => {
63
+ process.argv = originalArgv;
64
+ if (originalUnderscore === undefined) delete process.env._;
65
+ else process.env._ = originalUnderscore;
66
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
67
+ });
68
+
69
+ it("v3.1.2 BUG REPRODUCTION: with `filterTransient: true`, returns the STABLE binary even when a transient cache entry comes FIRST on PATH", () => {
70
+ // This is the EXACT layout that broke v3.1.2:
71
+ // PATH = /tmp/xyz/_npx/abc123/.bin : /tmp/xyz/.npm-global/bin
72
+ // /tmp/xyz/_npx/abc123/.bin/skillrepo EXISTS (transient cache)
73
+ // /tmp/xyz/.npm-global/bin/skillrepo EXISTS (stable global)
74
+ //
75
+ // v3.1.2: `which skillrepo` returned only the FIRST match (the
76
+ // _npx one), filterTransient stripped it, function returned null.
77
+ // v3.1.3: pure-Node PATH scan sees both, returns the stable one.
78
+ //
79
+ // Construct the PATH layout. `isTransientCachePath` matches
80
+ // any path with `/_npx/` (or `\_npx\` on Windows) as a path
81
+ // component, so the parent must be literally named `_npx`.
82
+ const npxParent = join(sandbox, "_npx", "abc123", "bin");
83
+ mkdirSync(npxParent, { recursive: true });
84
+ const transientBin = join(npxParent, "skillrepo");
85
+ writeFileSync(transientBin, "#!/bin/sh\nexit 0\n");
86
+ chmodSync(transientBin, 0o755);
87
+
88
+ const stableDir = makeBinDir(sandbox, "stable-global-bin");
89
+
90
+ // PATH = transient FIRST (the v3.1.2-breaking ordering),
91
+ // stable SECOND.
92
+ const fakePath = [npxParent, stableDir].join(delimiter);
93
+
94
+ const result = resolveBinaryOnPath("skillrepo", {
95
+ filterTransient: true,
96
+ path: fakePath,
97
+ });
98
+
99
+ assert.ok(
100
+ result !== null,
101
+ `expected to find the stable skillrepo binary, got null. ` +
102
+ `This is the v3.1.2 shipped bug — pre-fix, which/where returned ` +
103
+ `only the first match (the _npx one), got filtered, returned null. ` +
104
+ `Pure-Node PATH scan must see ALL candidates and pick the first ` +
105
+ `non-transient.`,
106
+ );
107
+ assert.equal(
108
+ result,
109
+ join(stableDir, "skillrepo"),
110
+ "expected the STABLE-bin path, not the transient cache path",
111
+ );
112
+ // Triple-check: the result must NOT be the transient path.
113
+ assert.ok(
114
+ !result.includes("_npx"),
115
+ `result must not be a _npx cache path, got: ${result}`,
116
+ );
117
+ });
118
+
119
+ it("with `filterTransient: false`, returns the FIRST match including transient paths (legacy behavior)", () => {
120
+ // The default behavior (no flag) should return the first match
121
+ // regardless of whether it's transient — same semantic as a
122
+ // plain `which` call.
123
+ const npxParent = join(sandbox, "_npx", "abc123", "bin");
124
+ mkdirSync(npxParent, { recursive: true });
125
+ const transientBin = join(npxParent, "skillrepo");
126
+ writeFileSync(transientBin, "#!/bin/sh\nexit 0\n");
127
+ chmodSync(transientBin, 0o755);
128
+
129
+ const stableDir = makeBinDir(sandbox, "stable-global-bin");
130
+ const fakePath = [npxParent, stableDir].join(delimiter);
131
+
132
+ const result = resolveBinaryOnPath("skillrepo", {
133
+ filterTransient: false,
134
+ path: fakePath,
135
+ });
136
+
137
+ // First match wins — and the first dir on PATH IS the transient.
138
+ assert.equal(result, transientBin);
139
+ });
140
+
141
+ it("with both `filterTransient: true` AND no stable binary on PATH, returns null", () => {
142
+ // Edge case: only transient binaries exist. Caller asked for
143
+ // stable-only, so null is correct.
144
+ const npxParent = join(sandbox, "_npx", "abc123", "bin");
145
+ mkdirSync(npxParent, { recursive: true });
146
+ const transientBin = join(npxParent, "skillrepo");
147
+ writeFileSync(transientBin, "#!/bin/sh\nexit 0\n");
148
+ chmodSync(transientBin, 0o755);
149
+
150
+ const result = resolveBinaryOnPath("skillrepo", {
151
+ filterTransient: true,
152
+ path: npxParent,
153
+ });
154
+ assert.equal(result, null);
155
+ });
156
+
157
+ it("with empty PATH, returns null without throwing", () => {
158
+ assert.equal(
159
+ resolveBinaryOnPath("skillrepo", { path: "" }),
160
+ null,
161
+ );
162
+ });
163
+
164
+ it("with PATH containing only relative entries, returns null", () => {
165
+ // Relative PATH entries are skipped — they're meaningless for
166
+ // baking into a long-lived hook command.
167
+ assert.equal(
168
+ resolveBinaryOnPath("skillrepo", {
169
+ path: ["./node_modules/.bin", "../tools"].join(delimiter),
170
+ }),
171
+ null,
172
+ );
173
+ });
174
+
175
+ it("`skipIfTransient: true` short-circuits to null when current process IS a transient invocation", () => {
176
+ // Even if a real binary is on PATH, callers that pass
177
+ // skipIfTransient: true (e.g. the SessionStart hook installer)
178
+ // must get null when running under a transient runner — they
179
+ // shouldn't bake the current-process state into long-lived
180
+ // artifacts.
181
+ const stableDir = makeBinDir(sandbox, "stable-bin");
182
+ process.argv = [
183
+ "/usr/local/bin/node",
184
+ "/Users/alice/.npm/_npx/abc/.bin/skillrepo",
185
+ ];
186
+ const result = resolveBinaryOnPath("skillrepo", {
187
+ skipIfTransient: true,
188
+ path: stableDir,
189
+ });
190
+ assert.equal(result, null);
191
+ });
192
+
193
+ it("Windows platform override probes for .cmd and .exe extensions in addition to bare name", () => {
194
+ // npm install -g on Windows lands as `<name>.cmd`. The
195
+ // candidate list for win32 must include .cmd FIRST so the
196
+ // common case is fast.
197
+ const dir = join(sandbox, "win-bin");
198
+ mkdirSync(dir, { recursive: true });
199
+ const cmdShim = join(dir, "skillrepo.cmd");
200
+ writeFileSync(cmdShim, "@exit 0\r\n");
201
+
202
+ const result = resolveBinaryOnPath("skillrepo", {
203
+ platform: "win32",
204
+ path: dir,
205
+ });
206
+ assert.equal(result, cmdShim);
207
+ });
208
+
209
+ it("POSIX platform override probes ONLY the bare name (no extension)", () => {
210
+ // POSIX has no PATHEXT. Even if a `skillrepo.cmd` happens to
211
+ // exist on a Linux dev box, the bare-name search shouldn't
212
+ // pick it up under platform: "linux".
213
+ const dir = join(sandbox, "posix-bin");
214
+ mkdirSync(dir, { recursive: true });
215
+ writeFileSync(join(dir, "skillrepo.cmd"), "@exit 0\n"); // Decoy
216
+ // No bare `skillrepo` file in this dir.
217
+ const result = resolveBinaryOnPath("skillrepo", {
218
+ platform: "linux",
219
+ path: dir,
220
+ });
221
+ assert.equal(result, null);
222
+ });
223
+ });
@@ -17,14 +17,12 @@ import { platformConventions, isWindows } from "../../lib/platform.mjs";
17
17
 
18
18
  describe("platformConventions", () => {
19
19
  it("returns POSIX conventions on macOS (darwin)", () => {
20
- // INTENT: macOS is a POSIX family platform. `which` is the
21
- // binary locator; shell supports `|| true` backstop; chmod
22
- // works meaningfully; atomic directory rename works. A refactor
23
- // that treats macOS as a special case would break every
24
- // developer on the team.
20
+ // INTENT: macOS is a POSIX family platform. Shell supports
21
+ // `|| true` backstop; chmod works meaningfully; atomic directory
22
+ // rename works. A refactor that treats macOS as a special case
23
+ // would break every developer on the team.
25
24
  const conv = platformConventions({ platform: "darwin" });
26
25
  assert.equal(conv.family, "posix");
27
- assert.equal(conv.binaryLocator, "which");
28
26
  assert.equal(conv.hookShellSuffix, " || true");
29
27
  assert.equal(conv.supportsPosixPermissions, true);
30
28
  assert.equal(conv.supportsAtomicDirectoryRename, true);
@@ -33,28 +31,29 @@ describe("platformConventions", () => {
33
31
  it("returns POSIX conventions on Linux", () => {
34
32
  const conv = platformConventions({ platform: "linux" });
35
33
  assert.equal(conv.family, "posix");
36
- assert.equal(conv.binaryLocator, "which");
37
34
  assert.equal(conv.hookShellSuffix, " || true");
38
35
  assert.equal(conv.supportsPosixPermissions, true);
39
36
  assert.equal(conv.supportsAtomicDirectoryRename, true);
40
37
  });
41
38
 
42
39
  it("returns Windows conventions on win32", () => {
43
- // INTENT: Windows has four material differences that can't be
40
+ // INTENT: Windows has three material differences that can't be
44
41
  // abstracted away:
45
- // 1. `where.exe` instead of `which` (no shell binary lookup)
46
- // 2. no `|| true` (cmd.exe doesn't know the `true` builtin)
47
- // 3. chmod mode bits don't map to the ACL model — applying
42
+ // 1. no `|| true` (cmd.exe doesn't know the `true` builtin)
43
+ // 2. chmod mode bits don't map to the ACL model — applying
48
44
  // 0o600 on Windows looks like it worked but doesn't
49
45
  // restrict access the way a POSIX 0600 does
50
- // 4. renameSync fails on existing directory targets, so
46
+ // 3. renameSync fails on existing directory targets, so
51
47
  // directory replacement needs a remove-then-rename dance
52
48
  // with a small non-atomic window
53
- // This test locks all four values for Windows — a refactor
49
+ // This test locks all three values for Windows — a refactor
54
50
  // that breaks any one of them breaks every Windows user.
51
+ //
52
+ // Pre-v3.1.3 there was a fourth (`binaryLocator`: which/where);
53
+ // it was removed when `lib/binary-locator.mjs` switched to a
54
+ // pure-Node PATH scan, eliminating the shell-out entirely.
55
55
  const conv = platformConventions({ platform: "win32" });
56
56
  assert.equal(conv.family, "windows");
57
- assert.equal(conv.binaryLocator, "where");
58
57
  assert.equal(conv.hookShellSuffix, "");
59
58
  assert.equal(conv.supportsPosixPermissions, false);
60
59
  assert.equal(conv.supportsAtomicDirectoryRename, false);
@@ -68,7 +67,7 @@ describe("platformConventions", () => {
68
67
  // matches how `node:path` / `node:fs` handle the same cases.
69
68
  const conv = platformConventions({ platform: "aix" });
70
69
  assert.equal(conv.family, "posix");
71
- assert.equal(conv.binaryLocator, "which");
70
+ assert.equal(conv.hookShellSuffix, " || true");
72
71
  });
73
72
 
74
73
  it("uses os.platform() when called without an override", () => {
@@ -94,7 +93,7 @@ describe("platformConventions", () => {
94
93
  assert.ok(Object.isFrozen(conv), "conventions object must be frozen");
95
94
  assert.throws(
96
95
  () => {
97
- conv.binaryLocator = "pwned";
96
+ conv.hookShellSuffix = "pwned";
98
97
  },
99
98
  /Cannot assign to read only property/,
100
99
  "attempting to mutate a frozen convention must throw in strict mode",
@@ -40,6 +40,7 @@ import {
40
40
  restoreHome,
41
41
  assertHomeIsolated,
42
42
  } from "../helpers/sandbox-home.mjs";
43
+ import { isolatePathEnv } from "../helpers/path-isolation.mjs";
43
44
 
44
45
  let sandbox;
45
46
  let originalCwd;
@@ -238,66 +239,67 @@ describe("buildHookCommand", () => {
238
239
  });
239
240
  });
240
241
 
241
- describe("resolveSkillrepoBinary — v3.1.1 Windows support", () => {
242
- // These tests exercise the platform-specific locator branch
243
- // (`where` on Windows, `which` elsewhere) and the Windows
244
- // absolute-path check (`C:\...` not `/`). They use the
245
- // `{ platform }` option to avoid relying on the host OS.
242
+ describe("resolveSkillrepoBinary — Windows platform support", () => {
243
+ // These tests exercise the Windows-shaped lookup (Windows uses
244
+ // `<name>.cmd` and `<name>.exe` extensions; npm installs CLIs
245
+ // as `.cmd` shims). They use the `{ platform }` option to
246
+ // simulate Windows on a non-Windows host.
246
247
  //
247
- // IMPORTANT: these tests DO NOT run a real `where.exe` subprocess
248
- // on Linux/macOS CI (there is no `where` to run). Instead they
249
- // verify the LOCATOR-SELECTION logic by checking that calling
250
- // with `platform: "win32"` on a Unix host produces a null return
251
- // (because `where` doesn't exist there) rather than a thrown
252
- // error. That's sufficient to prove the platform branch works;
253
- // actual Windows binary resolution is tested by the Windows CI
254
- // smoke job added in .github/workflows/.
248
+ // **Process-state isolation matters here.** v3.1.3 switched
249
+ // `resolveBinaryOnPath` from a `which`/`where` shell-out to a
250
+ // pure-Node PATH scan. The pre-v3.1.3 tests relied on
251
+ // `where.exe` being absent on a Unix host to force the null
252
+ // return; with the pure-Node lookup, the test instead clears
253
+ // PATH so no candidate matches. Same outcome (null), different
254
+ // mechanism.
255
255
  //
256
- // Behavioral coverage requires beforeEach/afterEach to clear npx
257
- // signals (the function returns null early on npx regardless of
258
- // platform). Reuses the isolate-process-state pattern from the
259
- // isNpxInvocation tests.
256
+ // We also clear npx signals (argv[1], _) since the function
257
+ // returns null early on a transient-runner invocation
258
+ // regardless of platform.
260
259
  let originalArgv;
261
260
  let originalUnderscore;
261
+ let restorePath;
262
262
 
263
263
  beforeEach(() => {
264
264
  originalArgv = process.argv;
265
265
  originalUnderscore = process.env._;
266
266
  process.argv = ["/usr/local/bin/node", "/usr/local/bin/skillrepo"];
267
267
  delete process.env._;
268
+ restorePath = isolatePathEnv();
268
269
  });
269
270
 
270
271
  afterEach(() => {
271
272
  process.argv = originalArgv;
272
273
  if (originalUnderscore === undefined) delete process.env._;
273
274
  else process.env._ = originalUnderscore;
275
+ restorePath();
274
276
  });
275
277
 
276
- it("does not throw when called with platform: 'win32' on a non-Windows host", async () => {
277
- // On Linux/macOS there is no `where.exe` on PATH, so the
278
- // execFileSync throws ENOENT. Our try/catch returns null —
279
- // the same safe-skip path that fires when the binary isn't
280
- // on PATH. Verifies the platform branch compiles cleanly
281
- // without requiring a real Windows environment.
278
+ it("returns null under platform: 'win32' when no skillrepo.cmd/.exe is on PATH", async () => {
279
+ // The pure-Node PATH scan probes for `skillrepo.cmd`,
280
+ // `skillrepo.exe`, then bare `skillrepo` in each PATH
281
+ // directory. With PATH cleared to a non-existent dir, none
282
+ // exist, so we get null. This proves the Windows
283
+ // candidate-list branch behaves correctly on hosts that
284
+ // don't have a real Windows skillrepo install.
282
285
  const { resolveSkillrepoBinary } = await import(
283
- "../../lib/mergers/session-hook.mjs?v311-win-test=" + Date.now()
286
+ "../../lib/mergers/session-hook.mjs?v313-win-test=" + Date.now()
284
287
  );
285
288
  const result = resolveSkillrepoBinary({ platform: "win32" });
286
289
  assert.equal(
287
290
  result,
288
291
  null,
289
- "on a Unix host with platform:'win32', where.exe isn't available null return (not throw)",
292
+ "platform:'win32' with no .cmd/.exe on PATH must return null",
290
293
  );
291
294
  });
292
295
 
293
- it("mergeSessionHook under platform:'win32' routes to the architect's skipped path when binary can't be resolved", async () => {
294
- // End-to-end test of the Windows binary-resolution path. When
295
- // `where` isn't available (simulating a Windows environment
296
- // where the user hasn't installed skillrepo globally), the
297
- // installer must skip gracefully with the architect-specified
298
- // reason — same as the Unix "no global install" fallback.
296
+ it("mergeSessionHook under platform:'win32' routes to the actionable skipped reason when binary can't be resolved", async () => {
297
+ // End-to-end test of the Windows binary-resolution path.
298
+ // With PATH cleared, no skillrepo.cmd is visible, so the
299
+ // installer must skip gracefully with the actionable reason —
300
+ // same as the Unix "no global install" fallback.
299
301
  const { mergeSessionHook: mergeFresh } = await import(
300
- "../../lib/mergers/session-hook.mjs?v311-win-integration=" + Date.now()
302
+ "../../lib/mergers/session-hook.mjs?v313-win-integration=" + Date.now()
301
303
  );
302
304
  const tmpSandbox = mkdtempSync(join(tmpdir(), "cli-win-test-"));
303
305
  const originalCwd = process.cwd();
@@ -911,13 +913,24 @@ describe("mergeSessionHook — failure modes", () => {
911
913
  // gracefully with an actionable reason. Init bypasses this path
912
914
  // in v3.1.2 by passing the post-auto-install absolute path
913
915
  // explicitly via `binaryPath`.
916
+ //
917
+ // PATH isolation: v3.1.3's `resolveBinaryOnPath` scans PATH
918
+ // directly. If the dev's machine has skillrepo installed (most
919
+ // SkillRepo developers do), the scan would find it and the
920
+ // null-fallback wouldn't fire. Clear PATH for the duration of
921
+ // this test so the lookup genuinely returns null.
914
922
  ASSERT_HOME_ISOLATED();
915
- const result = mergeSessionHook({ binaryPath: null });
916
- assert.equal(result.action, "skipped");
917
- assert.ok(result.reason);
918
- // The remediation hint must mention `npm install -g` so the user
919
- // has a copy-pasteable next step.
920
- assert.match(result.reason, /npm install -g/);
923
+ const restorePath = isolatePathEnv();
924
+ try {
925
+ const result = mergeSessionHook({ binaryPath: null });
926
+ assert.equal(result.action, "skipped");
927
+ assert.ok(result.reason);
928
+ // The remediation hint must mention `npm install -g` so the user
929
+ // has a copy-pasteable next step.
930
+ assert.match(result.reason, /npm install -g/);
931
+ } finally {
932
+ restorePath();
933
+ }
921
934
  });
922
935
 
923
936
  it("v3.1.1 fix: returns 'skipped' under npx invocation even when `which skillrepo` would succeed", async () => {