skillrepo 3.1.0 → 3.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/package.json +1 -1
- package/src/commands/init-session-sync.mjs +307 -0
- package/src/commands/init.mjs +111 -101
- package/src/commands/session-sync-actions.mjs +92 -0
- package/src/lib/artifact-registry.mjs +43 -3
- package/src/lib/binary-locator.mjs +99 -0
- package/src/lib/cli-config.mjs +16 -3
- package/src/lib/cli-version.mjs +56 -0
- package/src/lib/config.mjs +6 -3
- package/src/lib/file-write.mjs +8 -3
- package/src/lib/fs-utils.mjs +9 -10
- package/src/lib/global-install.mjs +387 -0
- package/src/lib/mcp-merge.mjs +16 -5
- package/src/lib/mergers/session-hook.mjs +125 -33
- package/src/lib/platform.mjs +124 -0
- package/src/lib/sync.mjs +26 -0
- package/src/lib/transient-runners.mjs +204 -0
- package/src/test/commands/add.test.mjs +10 -4
- package/src/test/commands/get.test.mjs +10 -4
- package/src/test/commands/init.test.mjs +889 -15
- package/src/test/commands/list.test.mjs +10 -4
- package/src/test/commands/remove.test.mjs +10 -4
- package/src/test/commands/search.test.mjs +10 -4
- package/src/test/commands/session-sync-actions.test.mjs +74 -0
- package/src/test/commands/session-sync.test.mjs +25 -23
- package/src/test/commands/uninstall.test.mjs +20 -14
- package/src/test/commands/update.test.mjs +10 -4
- package/src/test/helpers/mock-spawn.mjs +121 -0
- package/src/test/helpers/sandbox-home.mjs +161 -0
- package/src/test/helpers/skillrepo-shim.mjs +133 -0
- package/src/test/integration/file-write.integration.test.mjs +10 -4
- package/src/test/lib/cli-config.test.mjs +182 -4
- package/src/test/lib/cli-version.test.mjs +47 -0
- package/src/test/lib/config.test.mjs +10 -4
- package/src/test/lib/file-write.test.mjs +24 -10
- package/src/test/lib/global-install.test.mjs +424 -0
- package/src/test/lib/mcp-merge.test.mjs +13 -7
- package/src/test/lib/paths.test.mjs +10 -4
- package/src/test/lib/platform.test.mjs +135 -0
- package/src/test/lib/sync.test.mjs +20 -4
- package/src/test/lib/transient-runners.test.mjs +270 -0
- package/src/test/mergers/session-hook.test.mjs +722 -22
- package/src/test/mergers/uninstall-settings.test.mjs +12 -1
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +10 -4
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform sandbox HOME isolation for CLI tests.
|
|
3
|
+
*
|
|
4
|
+
* Problem this solves
|
|
5
|
+
* -------------------
|
|
6
|
+
* Every CLI test that touches the filesystem runs in a temp-dir sandbox and
|
|
7
|
+
* redirects "home" so writes under `~/.claude/` or `~/.claude/skills/` land
|
|
8
|
+
* inside the sandbox instead of polluting the developer's real home.
|
|
9
|
+
*
|
|
10
|
+
* The pre-v3.1.1 pattern set `process.env.HOME` and relied on `os.homedir()`
|
|
11
|
+
* reading from HOME. That works on POSIX — `homedir()` reads HOME first —
|
|
12
|
+
* but it silently fails on Windows. On Windows, `homedir()` reads
|
|
13
|
+
* `USERPROFILE` (NOT HOME), so the tests' HOME redirect is a no-op: the
|
|
14
|
+
* CLI writes to `C:\Users\<runner>\.claude\` (the real user home) instead
|
|
15
|
+
* of the sandbox, and the post-assert `dir.startsWith(process.env.HOME)`
|
|
16
|
+
* fails because the string paths don't match.
|
|
17
|
+
*
|
|
18
|
+
* This surfaced the first time `.github/workflows/ci.yml` gained a
|
|
19
|
+
* `windows-latest` runner in PR #892 — 99 tests failed, none of them real
|
|
20
|
+
* CLI regressions, all because the sandbox isolation wasn't cross-platform.
|
|
21
|
+
*
|
|
22
|
+
* The contract
|
|
23
|
+
* ------------
|
|
24
|
+
* `captureHome()` snapshots BOTH env vars before you overwrite them. The
|
|
25
|
+
* snapshot correctly distinguishes "was undefined" from "was empty
|
|
26
|
+
* string" so `restoreHome()` can `delete` the var rather than set it to
|
|
27
|
+
* the literal string `"undefined"` (which `process.env.X = undefined`
|
|
28
|
+
* would do in Node — verified on Node 20).
|
|
29
|
+
*
|
|
30
|
+
* `setSandboxHome(path)` sets BOTH HOME and USERPROFILE to the same
|
|
31
|
+
* sandbox path. Setting both guarantees `homedir()` lands in the sandbox
|
|
32
|
+
* on every platform without the caller having to know which env var the
|
|
33
|
+
* current platform prefers.
|
|
34
|
+
*
|
|
35
|
+
* `restoreHome(snapshot)` restores the original state, with the delete-
|
|
36
|
+
* on-undefined pattern.
|
|
37
|
+
*
|
|
38
|
+
* Usage
|
|
39
|
+
* -----
|
|
40
|
+
* import { captureHome, setSandboxHome, restoreHome } from "../helpers/sandbox-home.mjs";
|
|
41
|
+
*
|
|
42
|
+
* let originalHomeEnv;
|
|
43
|
+
* function setup() {
|
|
44
|
+
* sandbox = mkdtempSync(join(tmpdir(), "cli-foo-"));
|
|
45
|
+
* originalHomeEnv = captureHome();
|
|
46
|
+
* setSandboxHome(join(sandbox, "home"));
|
|
47
|
+
* }
|
|
48
|
+
* function teardown() {
|
|
49
|
+
* restoreHome(originalHomeEnv);
|
|
50
|
+
* rmSync(sandbox, { recursive: true, force: true });
|
|
51
|
+
* }
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @typedef {Object} HomeEnvSnapshot
|
|
56
|
+
* @property {string | undefined} HOME
|
|
57
|
+
* @property {string | undefined} USERPROFILE
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Snapshot HOME and USERPROFILE as they exist right now. Call before
|
|
62
|
+
* any `setSandboxHome` so `restoreHome` has something to put back.
|
|
63
|
+
*
|
|
64
|
+
* @returns {HomeEnvSnapshot}
|
|
65
|
+
*/
|
|
66
|
+
export function captureHome() {
|
|
67
|
+
return {
|
|
68
|
+
HOME: process.env.HOME,
|
|
69
|
+
USERPROFILE: process.env.USERPROFILE,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Set both HOME and USERPROFILE to the sandbox path. After this call,
|
|
75
|
+
* `os.homedir()` will resolve to `path` on every supported platform.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} path - Absolute path to the sandbox "home" directory.
|
|
78
|
+
* Caller is responsible for creating the directory if needed.
|
|
79
|
+
*/
|
|
80
|
+
export function setSandboxHome(path) {
|
|
81
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
82
|
+
throw new Error("setSandboxHome: path must be a non-empty string");
|
|
83
|
+
}
|
|
84
|
+
process.env.HOME = path;
|
|
85
|
+
process.env.USERPROFILE = path;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Restore HOME and USERPROFILE to whatever they were at the time of
|
|
90
|
+
* `captureHome()`. Deletes the env var when the snapshot value was
|
|
91
|
+
* `undefined` — setting `process.env.X = undefined` in Node produces
|
|
92
|
+
* the literal string `"undefined"`, which would corrupt subsequent
|
|
93
|
+
* tests that read the env var.
|
|
94
|
+
*
|
|
95
|
+
* @param {HomeEnvSnapshot} snapshot - Return value of `captureHome()`.
|
|
96
|
+
*/
|
|
97
|
+
export function restoreHome(snapshot) {
|
|
98
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
99
|
+
throw new Error("restoreHome: snapshot must be the object from captureHome()");
|
|
100
|
+
}
|
|
101
|
+
if (snapshot.HOME === undefined) delete process.env.HOME;
|
|
102
|
+
else process.env.HOME = snapshot.HOME;
|
|
103
|
+
if (snapshot.USERPROFILE === undefined) delete process.env.USERPROFILE;
|
|
104
|
+
else process.env.USERPROFILE = snapshot.USERPROFILE;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Assert both HOME and USERPROFILE point inside a sandbox rooted at
|
|
109
|
+
* `tmpdirRoot` (typically `os.tmpdir()`). This is the integrity check
|
|
110
|
+
* that prevents a test from accidentally writing into the developer's
|
|
111
|
+
* real home when `setSandboxHome()` was forgotten or bypassed.
|
|
112
|
+
*
|
|
113
|
+
* BOTH env vars are checked because `os.homedir()` reads `HOME` on
|
|
114
|
+
* POSIX and `USERPROFILE` on Windows. An assertion that checks only
|
|
115
|
+
* `HOME` would pass on Windows even when USERPROFILE still points at
|
|
116
|
+
* the real user home — defeating the purpose of the guard. The
|
|
117
|
+
* guards migrated from `session-sync.test.mjs`, `uninstall.test.mjs`,
|
|
118
|
+
* and `session-hook.test.mjs` into this helper after a review-round-4
|
|
119
|
+
* finding spotted the USERPROFILE gap.
|
|
120
|
+
*
|
|
121
|
+
* `USERPROFILE` is checked only when it's defined — on POSIX it's
|
|
122
|
+
* typically absent and `setSandboxHome()` does set it alongside
|
|
123
|
+
* HOME so it should be defined under the isolation contract, but we
|
|
124
|
+
* tolerate the "not set at all" state as well since that's
|
|
125
|
+
* equivalent to "can't leak outside the sandbox via it."
|
|
126
|
+
*
|
|
127
|
+
* @param {string} tmpdirRoot - Root path the HOME/USERPROFILE must
|
|
128
|
+
* be inside. Typically `os.tmpdir()`.
|
|
129
|
+
* @param {string} [contextMessage] - Optional context to include in
|
|
130
|
+
* the assertion failure message so the developer can tell
|
|
131
|
+
* which test suite's setup forgot the override.
|
|
132
|
+
* @throws {AssertionError} If either env var is set but falls
|
|
133
|
+
* outside `tmpdirRoot`.
|
|
134
|
+
*/
|
|
135
|
+
export function assertHomeIsolated(tmpdirRoot, contextMessage = "") {
|
|
136
|
+
if (typeof tmpdirRoot !== "string" || tmpdirRoot.length === 0) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"assertHomeIsolated: tmpdirRoot must be a non-empty string " +
|
|
139
|
+
"(usually os.tmpdir())",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const context = contextMessage ? ` (${contextMessage})` : "";
|
|
143
|
+
if (!process.env.HOME || !process.env.HOME.startsWith(tmpdirRoot)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`HOME must point inside "${tmpdirRoot}"${context}. ` +
|
|
146
|
+
`Current HOME="${process.env.HOME}" — setup() forgot setSandboxHome() ` +
|
|
147
|
+
`or something else overwrote it.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
process.env.USERPROFILE !== undefined &&
|
|
152
|
+
!process.env.USERPROFILE.startsWith(tmpdirRoot)
|
|
153
|
+
) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`USERPROFILE must point inside "${tmpdirRoot}"${context} ` +
|
|
156
|
+
`when defined. Current USERPROFILE="${process.env.USERPROFILE}" — ` +
|
|
157
|
+
`on Windows, os.homedir() reads USERPROFILE, so a stray real-host ` +
|
|
158
|
+
`value leaks tests into the real user home.`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform `skillrepo` binary shim for CLI tests.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists
|
|
5
|
+
* ---------------
|
|
6
|
+
* Several tests (init session-sync, session-sync command) invoke
|
|
7
|
+
* `runInit` or `runSessionSync`, which internally call
|
|
8
|
+
* `resolveSkillrepoBinary()` → `execFileSync("which" or "where",
|
|
9
|
+
* ["skillrepo"])`. For those calls to succeed deterministically —
|
|
10
|
+
* regardless of whether the developer has a global `skillrepo`
|
|
11
|
+
* install on PATH — the tests drop a fake `skillrepo` binary into
|
|
12
|
+
* a sandbox bin dir and prepend that dir to PATH.
|
|
13
|
+
*
|
|
14
|
+
* The original pattern was POSIX-only. On Windows it failed two ways:
|
|
15
|
+
* 1. The shim was created as a bash script (`#!/bin/sh\nexit 0\n`)
|
|
16
|
+
* which `where.exe` won't match — `where` only resolves files
|
|
17
|
+
* whose extension is in PATHEXT (.CMD by default), not
|
|
18
|
+
* extension-less shell scripts.
|
|
19
|
+
* 2. PATH was joined with `:` which is the POSIX separator;
|
|
20
|
+
* Windows uses `;`.
|
|
21
|
+
*
|
|
22
|
+
* This helper produces shims correctly for both families.
|
|
23
|
+
*
|
|
24
|
+
* Usage
|
|
25
|
+
* -----
|
|
26
|
+
* import { installShim, uninstallShim } from "../helpers/skillrepo-shim.mjs";
|
|
27
|
+
*
|
|
28
|
+
* let shim;
|
|
29
|
+
* function setup() {
|
|
30
|
+
* // ...sandbox + HOME/USERPROFILE setup...
|
|
31
|
+
* shim = installShim(join(sandbox, "home"));
|
|
32
|
+
* }
|
|
33
|
+
* function teardown() {
|
|
34
|
+
* uninstallShim(shim);
|
|
35
|
+
* // ...HOME/USERPROFILE restore + sandbox cleanup...
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* `installShim(homePath)` returns a `ShimHandle` carrying the path
|
|
39
|
+
* prepended to PATH and the original PATH value — pass it back to
|
|
40
|
+
* `uninstallShim` for restoration.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
|
44
|
+
import { join, delimiter } from "node:path";
|
|
45
|
+
import { platformConventions } from "../../lib/platform.mjs";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {Object} ShimHandle
|
|
49
|
+
* @property {string} binDir - The directory we created and prepended.
|
|
50
|
+
* @property {string | undefined} originalPath - PATH as it was before
|
|
51
|
+
* we prepended. Distinguishes "was undefined" from "was empty
|
|
52
|
+
* string" so `uninstallShim` can `delete` rather than set the
|
|
53
|
+
* env var to the literal string `"undefined"` (Node 20
|
|
54
|
+
* coerces `env.X = undefined` to that string, verified).
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Drop a `skillrepo` shim into `<homePath>/bin` and prepend that dir
|
|
59
|
+
* to PATH so the CLI's binary resolver finds it on both POSIX and
|
|
60
|
+
* Windows.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} homePath - Absolute path to the sandbox home dir.
|
|
63
|
+
* Must already exist (caller typically just ran `mkdirSync`).
|
|
64
|
+
* @returns {ShimHandle}
|
|
65
|
+
*/
|
|
66
|
+
export function installShim(homePath) {
|
|
67
|
+
if (typeof homePath !== "string" || homePath.length === 0) {
|
|
68
|
+
throw new Error("installShim: homePath must be a non-empty string");
|
|
69
|
+
}
|
|
70
|
+
const conv = platformConventions();
|
|
71
|
+
const binDir = join(homePath, "bin");
|
|
72
|
+
mkdirSync(binDir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
if (conv.family === "windows") {
|
|
75
|
+
// On Windows we create BOTH a `.cmd` and an extension-less shim.
|
|
76
|
+
// `where.exe skillrepo` matches anything whose extension is in
|
|
77
|
+
// PATHEXT (.CMD is default, empty extension is NOT in default
|
|
78
|
+
// PATHEXT) — so the `.cmd` is what production `resolveSkillrepoBinary`
|
|
79
|
+
// finds. The extension-less shim is a courtesy so any test that
|
|
80
|
+
// happens to invoke `bash -c './bin/skillrepo'` directly gets a
|
|
81
|
+
// working file — hence the POSIX shebang content, NOT cmd.exe
|
|
82
|
+
// syntax. (An earlier revision wrote `@exit 0` to both files; the
|
|
83
|
+
// round-3c review caught that the extension-less file with
|
|
84
|
+
// cmd.exe syntax would fail under bash. The `.cmd` gets cmd.exe
|
|
85
|
+
// syntax; the bare file gets POSIX shebang syntax.)
|
|
86
|
+
const cmdShim = join(binDir, "skillrepo.cmd");
|
|
87
|
+
writeFileSync(cmdShim, "@exit 0\r\n");
|
|
88
|
+
const bareShim = join(binDir, "skillrepo");
|
|
89
|
+
writeFileSync(bareShim, "#!/bin/sh\nexit 0\n");
|
|
90
|
+
// On Windows file-system permissions don't gate executability,
|
|
91
|
+
// but chmodSync is a no-op on Windows anyway so we skip it to
|
|
92
|
+
// avoid any platform-conditional chmod paths. Production uses
|
|
93
|
+
// `platformConventions().supportsPosixPermissions` for the same
|
|
94
|
+
// reason in `fs-utils.mjs`.
|
|
95
|
+
} else {
|
|
96
|
+
// POSIX shim — bash script + executable bit.
|
|
97
|
+
const shim = join(binDir, "skillrepo");
|
|
98
|
+
writeFileSync(shim, "#!/bin/sh\nexit 0\n");
|
|
99
|
+
chmodSync(shim, 0o755);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Capture PATH as-is, including the `undefined` case. `??
|
|
103
|
+
// ""` would coalesce to empty string, but then `uninstallShim`
|
|
104
|
+
// can't tell "PATH was undefined" from "PATH was empty" — the
|
|
105
|
+
// restore would then write `""` into an env that had no PATH set,
|
|
106
|
+
// potentially breaking subsequent subprocess spawns that inherit
|
|
107
|
+
// env.
|
|
108
|
+
const originalPath = process.env.PATH;
|
|
109
|
+
// `path.delimiter` is `:` on POSIX, `;` on Windows — the correct
|
|
110
|
+
// separator for whichever platform we're running on.
|
|
111
|
+
process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`;
|
|
112
|
+
return { binDir, originalPath };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Restore PATH to what it was before `installShim` was called.
|
|
117
|
+
* Deletes the env var if PATH was undefined at capture time —
|
|
118
|
+
* setting `process.env.PATH = undefined` produces the literal
|
|
119
|
+
* string `"undefined"` in Node, which would silently break
|
|
120
|
+
* binary lookups in subsequent tests.
|
|
121
|
+
*
|
|
122
|
+
* @param {ShimHandle | null | undefined} handle - Return value of
|
|
123
|
+
* `installShim`, or null/undefined for a safe no-op on tests
|
|
124
|
+
* that bailed before install ran.
|
|
125
|
+
*/
|
|
126
|
+
export function uninstallShim(handle) {
|
|
127
|
+
if (!handle) return;
|
|
128
|
+
if (handle.originalPath === undefined) {
|
|
129
|
+
delete process.env.PATH;
|
|
130
|
+
} else {
|
|
131
|
+
process.env.PATH = handle.originalPath;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -33,24 +33,30 @@ import {
|
|
|
33
33
|
cleanupOrphans,
|
|
34
34
|
resolvePlacementDir,
|
|
35
35
|
} from "../../lib/file-write.mjs";
|
|
36
|
+
import {
|
|
37
|
+
captureHome,
|
|
38
|
+
setSandboxHome,
|
|
39
|
+
restoreHome,
|
|
40
|
+
} from "../helpers/sandbox-home.mjs";
|
|
36
41
|
|
|
37
42
|
let sandbox;
|
|
38
43
|
let originalCwd;
|
|
39
|
-
|
|
44
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
45
|
+
let originalHomeEnv;
|
|
40
46
|
|
|
41
47
|
function setupSandbox() {
|
|
42
48
|
sandbox = mkdtempSync(join(tmpdir(), "cli-fw-int-"));
|
|
43
49
|
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
44
50
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
45
51
|
originalCwd = process.cwd();
|
|
46
|
-
|
|
52
|
+
originalHomeEnv = captureHome();
|
|
47
53
|
process.chdir(join(sandbox, "project"));
|
|
48
|
-
|
|
54
|
+
setSandboxHome(join(sandbox, "home"));
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
function teardownSandbox() {
|
|
52
58
|
process.chdir(originalCwd);
|
|
53
|
-
|
|
59
|
+
restoreHome(originalHomeEnv);
|
|
54
60
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
55
61
|
}
|
|
56
62
|
|
|
@@ -18,28 +18,35 @@ import { join } from "node:path";
|
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
|
|
20
20
|
import { resolveFlags, effectiveVendors } from "../../lib/cli-config.mjs";
|
|
21
|
+
import { isTransientRunnerInvocation } from "../../lib/transient-runners.mjs";
|
|
21
22
|
import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
23
|
+
import {
|
|
24
|
+
captureHome,
|
|
25
|
+
setSandboxHome,
|
|
26
|
+
restoreHome,
|
|
27
|
+
} from "../helpers/sandbox-home.mjs";
|
|
22
28
|
|
|
23
29
|
let sandbox;
|
|
24
|
-
|
|
30
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
31
|
+
let originalHomeEnv;
|
|
25
32
|
let originalEnv;
|
|
26
33
|
|
|
27
34
|
function setupSandbox() {
|
|
28
35
|
sandbox = mkdtempSync(join(tmpdir(), "cli-config-"));
|
|
29
36
|
mkdirSync(join(sandbox, "home", ".claude", "skillrepo"), { recursive: true });
|
|
30
|
-
|
|
37
|
+
originalHomeEnv = captureHome();
|
|
31
38
|
// Snapshot the env vars we mutate so tests don't bleed into each other
|
|
32
39
|
originalEnv = {
|
|
33
40
|
SKILLREPO_ACCESS_KEY: process.env.SKILLREPO_ACCESS_KEY,
|
|
34
41
|
SKILLREPO_URL: process.env.SKILLREPO_URL,
|
|
35
42
|
};
|
|
36
|
-
|
|
43
|
+
setSandboxHome(join(sandbox, "home"));
|
|
37
44
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
38
45
|
delete process.env.SKILLREPO_URL;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
function teardownSandbox() {
|
|
42
|
-
|
|
49
|
+
restoreHome(originalHomeEnv);
|
|
43
50
|
if (originalEnv.SKILLREPO_ACCESS_KEY === undefined) {
|
|
44
51
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
45
52
|
} else {
|
|
@@ -405,3 +412,174 @@ describe("effectiveVendors", () => {
|
|
|
405
412
|
);
|
|
406
413
|
});
|
|
407
414
|
});
|
|
415
|
+
|
|
416
|
+
// ── isTransientRunnerInvocation ───────────────────────────────────────────────────
|
|
417
|
+
//
|
|
418
|
+
// The helper detects whether the CLI was launched via `npx skillrepo`
|
|
419
|
+
// vs. a stable global install. v3.1.0 shipped a bug where the session-
|
|
420
|
+
// hook installer's `which skillrepo` check treated the npx cache path
|
|
421
|
+
// as a stable install location — this helper is the fix mechanism.
|
|
422
|
+
// Three independent signals are checked; any one is sufficient.
|
|
423
|
+
|
|
424
|
+
describe("isTransientRunnerInvocation", () => {
|
|
425
|
+
let originalArgv;
|
|
426
|
+
let originalNpmCommand;
|
|
427
|
+
let originalUnderscore;
|
|
428
|
+
|
|
429
|
+
beforeEach(() => {
|
|
430
|
+
originalArgv = process.argv;
|
|
431
|
+
originalNpmCommand = process.env.npm_command;
|
|
432
|
+
originalUnderscore = process.env._;
|
|
433
|
+
delete process.env.npm_command;
|
|
434
|
+
delete process.env._;
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
afterEach(() => {
|
|
438
|
+
process.argv = originalArgv;
|
|
439
|
+
if (originalNpmCommand === undefined) delete process.env.npm_command;
|
|
440
|
+
else process.env.npm_command = originalNpmCommand;
|
|
441
|
+
if (originalUnderscore === undefined) delete process.env._;
|
|
442
|
+
else process.env._ = originalUnderscore;
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("returns false for a vanilla node invocation", () => {
|
|
446
|
+
process.argv = ["/usr/local/bin/node", "/usr/local/bin/skillrepo"];
|
|
447
|
+
assert.equal(isTransientRunnerInvocation(), false);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("detects npx via the argv[1] _npx cache path", () => {
|
|
451
|
+
// Primary signal — the executable's path literally lives inside
|
|
452
|
+
// ~/.npm/_npx/<hash>/. Most reliable.
|
|
453
|
+
process.argv = [
|
|
454
|
+
"/usr/local/bin/node",
|
|
455
|
+
"/Users/alice/.npm/_npx/dc129a78aca3fc9c/node_modules/.bin/skillrepo",
|
|
456
|
+
];
|
|
457
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("does NOT treat npm_command=exec as an npx signal (false-positive guard)", () => {
|
|
461
|
+
// v3.1.1 review rejected `npm_command === "exec"` as an npx
|
|
462
|
+
// signal because it also fires when a stable-install user runs
|
|
463
|
+
// `skillrepo` from a package.json lifecycle script (e.g.
|
|
464
|
+
// `"postinstall": "skillrepo init --yes"`) or via `npm exec
|
|
465
|
+
// skillrepo ...`. Those users have a real global install and
|
|
466
|
+
// should NOT see session-sync skipped or `npx skillrepo` in
|
|
467
|
+
// Next Steps. The argv[1] _npx-path signal already catches
|
|
468
|
+
// real npx invocations unambiguously.
|
|
469
|
+
//
|
|
470
|
+
// This test locks the decision: even with `npm_command=exec`
|
|
471
|
+
// set, without an `_npx` path in argv[1] or an `/npx` `_` env
|
|
472
|
+
// var, the invocation must be treated as a stable install.
|
|
473
|
+
process.argv = ["/usr/local/bin/node", "/some/other/path/skillrepo"];
|
|
474
|
+
process.env.npm_command = "exec";
|
|
475
|
+
assert.equal(isTransientRunnerInvocation(), false);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("detects npx via the _ env var ending in /npx (legacy fallback)", () => {
|
|
479
|
+
process.argv = ["/usr/local/bin/node", "/path/skillrepo"];
|
|
480
|
+
process.env._ = "/usr/local/bin/npx";
|
|
481
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("handles Windows _npx path separator", () => {
|
|
485
|
+
process.argv = [
|
|
486
|
+
"C:\\Program Files\\nodejs\\node.exe",
|
|
487
|
+
"C:\\Users\\alice\\.npm\\_npx\\abc123\\node_modules\\.bin\\skillrepo",
|
|
488
|
+
];
|
|
489
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("does NOT false-positive on a path containing 'npx' substring but not in _npx cache", () => {
|
|
493
|
+
// E.g., a user installs into /opt/npxtools/bin/skillrepo. The
|
|
494
|
+
// check matches `/_npx/` specifically (with leading slash), not
|
|
495
|
+
// substring "npx", so this should be safely negative.
|
|
496
|
+
process.argv = ["/usr/local/bin/node", "/opt/npxtools/bin/skillrepo"];
|
|
497
|
+
assert.equal(isTransientRunnerInvocation(), false);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// ── v3.1.2: extended detection for pnpx, yarn dlx, bunx ──────────
|
|
501
|
+
|
|
502
|
+
it("detects pnpm dlx invocation via cache substring '/dlx-'", () => {
|
|
503
|
+
// pnpm dlx caches in `<store>/dlx-<hash>/...` per-invocation.
|
|
504
|
+
process.argv = [
|
|
505
|
+
"/usr/local/bin/node",
|
|
506
|
+
"/Users/alice/.local/share/pnpm/store/dlx-abc123/node_modules/.bin/skillrepo",
|
|
507
|
+
];
|
|
508
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("detects pnpm dlx invocation on Windows", () => {
|
|
512
|
+
process.argv = [
|
|
513
|
+
"C:\\Program Files\\nodejs\\node.exe",
|
|
514
|
+
"C:\\Users\\alice\\AppData\\Local\\pnpm\\store\\dlx-abc123\\node_modules\\.bin\\skillrepo.cmd",
|
|
515
|
+
];
|
|
516
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("detects yarn berry dlx invocation via '/.yarn/berry/' cache substring", () => {
|
|
520
|
+
process.argv = [
|
|
521
|
+
"/usr/local/bin/node",
|
|
522
|
+
"/Users/alice/project/.yarn/berry/cache/skillrepo-npm-3.1.2-abc/node_modules/skillrepo/bin/skillrepo.mjs",
|
|
523
|
+
];
|
|
524
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("detects bunx invocation via '/.bun/install/cache/' cache substring", () => {
|
|
528
|
+
process.argv = [
|
|
529
|
+
"/usr/local/bin/node",
|
|
530
|
+
"/Users/alice/.bun/install/cache/skillrepo@3.1.2/node_modules/.bin/skillrepo",
|
|
531
|
+
];
|
|
532
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("detects pnpx via process.env._ suffix", () => {
|
|
536
|
+
process.argv = ["/usr/local/bin/node", "/somewhere/skillrepo"];
|
|
537
|
+
process.env._ = "/usr/local/bin/pnpx";
|
|
538
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("detects bunx via process.env._ suffix", () => {
|
|
542
|
+
process.argv = ["/usr/local/bin/node", "/somewhere/skillrepo"];
|
|
543
|
+
process.env._ = "/Users/alice/.bun/bin/bunx";
|
|
544
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("does NOT false-positive on a directory name containing 'dlx' that is not pnpm dlx", () => {
|
|
548
|
+
// E.g. `/Users/alice/dlx-utils/bin/skillrepo` — the substring is
|
|
549
|
+
// present but it's not in a pnpm cache. The fingerprint requires
|
|
550
|
+
// a leading separator so `/dlx-` matches a path SEGMENT named
|
|
551
|
+
// `dlx-...`, which a user-named directory wouldn't typically be.
|
|
552
|
+
process.argv = ["/usr/local/bin/node", "/Users/alice/utility-dlx/bin/skillrepo"];
|
|
553
|
+
assert.equal(isTransientRunnerInvocation(), false);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// ── resolveFlags accepts --verbose ───────────────────────────────────
|
|
558
|
+
|
|
559
|
+
describe("resolveFlags — --verbose flag (bug 4 fix)", () => {
|
|
560
|
+
beforeEach(setupSandbox);
|
|
561
|
+
afterEach(teardownSandbox);
|
|
562
|
+
|
|
563
|
+
it("accepts --verbose without throwing Unknown argument", () => {
|
|
564
|
+
// Before this fix: commands passing argv through resolveFlags
|
|
565
|
+
// rejected --verbose as unknown, despite the flag being documented
|
|
566
|
+
// in the top-level --help. The dispatcher reads
|
|
567
|
+
// process.argv.includes("--verbose") to set SKILLREPO_VERBOSE=1
|
|
568
|
+
// but doesn't strip it from argv — so resolveFlags saw the raw
|
|
569
|
+
// flag and threw validationError.
|
|
570
|
+
//
|
|
571
|
+
// Locks the fix: resolveFlags ignores --verbose (the dispatcher
|
|
572
|
+
// already consumed its effect via the env var).
|
|
573
|
+
process.env.SKILLREPO_ACCESS_KEY = "sk_live_x";
|
|
574
|
+
assert.doesNotThrow(() =>
|
|
575
|
+
resolveFlags(["--verbose", "--json"]),
|
|
576
|
+
);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("--verbose anywhere in argv — not just at a specific position", () => {
|
|
580
|
+
process.env.SKILLREPO_ACCESS_KEY = "sk_live_x";
|
|
581
|
+
assert.doesNotThrow(() =>
|
|
582
|
+
resolveFlags(["--json", "--verbose", "--global"]),
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/cli-version.mjs (#894 / v3.1.2).
|
|
3
|
+
*
|
|
4
|
+
* Tiny module, tiny suite — but the cases below lock in the
|
|
5
|
+
* contract that `installSkillrepoGlobally({ version })` depends on.
|
|
6
|
+
* If the version read silently regressed (returned undefined,
|
|
7
|
+
* stale, or threw on a valid tarball), every npx user's
|
|
8
|
+
* auto-install would silently fail or pin to garbage.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
|
|
15
|
+
import { getCliVersion } from "../../lib/cli-version.mjs";
|
|
16
|
+
|
|
17
|
+
describe("getCliVersion", () => {
|
|
18
|
+
it("returns the version string from the CLI's own package.json", () => {
|
|
19
|
+
// FACT-based: read the package.json the same way the SUT does,
|
|
20
|
+
// then compare. If the version field changes (e.g. v3.1.3
|
|
21
|
+
// bump), this test passes automatically — it's not a literal
|
|
22
|
+
// assertion against "3.1.2", which would create churn on every
|
|
23
|
+
// version bump.
|
|
24
|
+
const pkgUrl = new URL(
|
|
25
|
+
"../../../package.json",
|
|
26
|
+
import.meta.url,
|
|
27
|
+
);
|
|
28
|
+
const pkg = JSON.parse(readFileSync(pkgUrl, "utf-8"));
|
|
29
|
+
assert.equal(getCliVersion(), pkg.version);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns a valid semver-shaped string (X.Y.Z)", () => {
|
|
33
|
+
// Defense against a future regression where the package.json
|
|
34
|
+
// version is corrupted to a non-string (boolean, number,
|
|
35
|
+
// object). The CLI install command would silently produce
|
|
36
|
+
// garbage like `npm install -g skillrepo@true` without this
|
|
37
|
+
// shape check.
|
|
38
|
+
const v = getCliVersion();
|
|
39
|
+
assert.equal(typeof v, "string");
|
|
40
|
+
assert.match(v, /^\d+\.\d+\.\d+/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns a non-empty string", () => {
|
|
44
|
+
const v = getCliVersion();
|
|
45
|
+
assert.ok(v.length > 0, "version must be non-empty");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -25,19 +25,25 @@ import {
|
|
|
25
25
|
} from "../../lib/config.mjs";
|
|
26
26
|
import { globalConfigPath } from "../../lib/paths.mjs";
|
|
27
27
|
import { CliError, EXIT_VALIDATION, EXIT_DISK } from "../../lib/errors.mjs";
|
|
28
|
+
import {
|
|
29
|
+
captureHome,
|
|
30
|
+
setSandboxHome,
|
|
31
|
+
restoreHome,
|
|
32
|
+
} from "../helpers/sandbox-home.mjs";
|
|
28
33
|
|
|
29
34
|
let sandbox;
|
|
30
|
-
|
|
35
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
36
|
+
let originalHomeEnv;
|
|
31
37
|
|
|
32
38
|
function setupSandbox() {
|
|
33
39
|
sandbox = mkdtempSync(join(tmpdir(), "cli-config-mjs-"));
|
|
34
40
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
originalHomeEnv = captureHome();
|
|
42
|
+
setSandboxHome(join(sandbox, "home"));
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
function teardownSandbox() {
|
|
40
|
-
|
|
46
|
+
restoreHome(originalHomeEnv);
|
|
41
47
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
42
48
|
}
|
|
43
49
|
|