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 +37 -0
- package/README.md +4 -1
- package/package.json +5 -3
- package/src/lib/binary-locator.mjs +87 -60
- package/src/lib/global-install.mjs +1 -2
- package/src/lib/platform.mjs +11 -12
- package/src/test/commands/init.test.mjs +21 -3
- package/src/test/helpers/path-isolation.mjs +65 -0
- package/src/test/lib/binary-locator.test.mjs +223 -0
- package/src/test/lib/platform.test.mjs +15 -16
- package/src/test/mergers/session-hook.test.mjs +51 -38
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
25
|
-
* semantics.
|
|
40
|
+
* PATH lookup unless the caller opts into the extra semantics.
|
|
26
41
|
*/
|
|
27
42
|
|
|
28
|
-
import {
|
|
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"`).
|
|
41
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
* @
|
|
55
|
-
*
|
|
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
|
-
{
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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.
|
|
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
|
|
package/src/lib/platform.mjs
CHANGED
|
@@ -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. **
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
1064
|
-
//
|
|
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.
|
|
21
|
-
//
|
|
22
|
-
// works
|
|
23
|
-
//
|
|
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
|
|
40
|
+
// INTENT: Windows has three material differences that can't be
|
|
44
41
|
// abstracted away:
|
|
45
|
-
// 1. `
|
|
46
|
-
// 2.
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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.
|
|
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 —
|
|
242
|
-
// These tests exercise the
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
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
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
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
|
-
//
|
|
257
|
-
//
|
|
258
|
-
// platform
|
|
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("
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
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?
|
|
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
|
-
"
|
|
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
|
|
294
|
-
// End-to-end test of the Windows binary-resolution path.
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
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?
|
|
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
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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 () => {
|