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.
Files changed (45) hide show
  1. package/README.md +6 -2
  2. package/package.json +1 -1
  3. package/src/commands/init-session-sync.mjs +307 -0
  4. package/src/commands/init.mjs +111 -101
  5. package/src/commands/session-sync-actions.mjs +92 -0
  6. package/src/lib/artifact-registry.mjs +43 -3
  7. package/src/lib/binary-locator.mjs +99 -0
  8. package/src/lib/cli-config.mjs +16 -3
  9. package/src/lib/cli-version.mjs +56 -0
  10. package/src/lib/config.mjs +6 -3
  11. package/src/lib/file-write.mjs +8 -3
  12. package/src/lib/fs-utils.mjs +9 -10
  13. package/src/lib/global-install.mjs +387 -0
  14. package/src/lib/mcp-merge.mjs +16 -5
  15. package/src/lib/mergers/session-hook.mjs +125 -33
  16. package/src/lib/platform.mjs +124 -0
  17. package/src/lib/sync.mjs +26 -0
  18. package/src/lib/transient-runners.mjs +204 -0
  19. package/src/test/commands/add.test.mjs +10 -4
  20. package/src/test/commands/get.test.mjs +10 -4
  21. package/src/test/commands/init.test.mjs +889 -15
  22. package/src/test/commands/list.test.mjs +10 -4
  23. package/src/test/commands/remove.test.mjs +10 -4
  24. package/src/test/commands/search.test.mjs +10 -4
  25. package/src/test/commands/session-sync-actions.test.mjs +74 -0
  26. package/src/test/commands/session-sync.test.mjs +25 -23
  27. package/src/test/commands/uninstall.test.mjs +20 -14
  28. package/src/test/commands/update.test.mjs +10 -4
  29. package/src/test/helpers/mock-spawn.mjs +121 -0
  30. package/src/test/helpers/sandbox-home.mjs +161 -0
  31. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  32. package/src/test/integration/file-write.integration.test.mjs +10 -4
  33. package/src/test/lib/cli-config.test.mjs +182 -4
  34. package/src/test/lib/cli-version.test.mjs +47 -0
  35. package/src/test/lib/config.test.mjs +10 -4
  36. package/src/test/lib/file-write.test.mjs +24 -10
  37. package/src/test/lib/global-install.test.mjs +424 -0
  38. package/src/test/lib/mcp-merge.test.mjs +13 -7
  39. package/src/test/lib/paths.test.mjs +10 -4
  40. package/src/test/lib/platform.test.mjs +135 -0
  41. package/src/test/lib/sync.test.mjs +20 -4
  42. package/src/test/lib/transient-runners.test.mjs +270 -0
  43. package/src/test/mergers/session-hook.test.mjs +722 -22
  44. package/src/test/mergers/uninstall-settings.test.mjs +12 -1
  45. 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
- let originalHome;
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
- originalHome = process.env.HOME;
52
+ originalHomeEnv = captureHome();
47
53
  process.chdir(join(sandbox, "project"));
48
- process.env.HOME = join(sandbox, "home");
54
+ setSandboxHome(join(sandbox, "home"));
49
55
  }
50
56
 
51
57
  function teardownSandbox() {
52
58
  process.chdir(originalCwd);
53
- process.env.HOME = originalHome;
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
- let originalHome;
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
- originalHome = process.env.HOME;
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
- process.env.HOME = join(sandbox, "home");
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
- process.env.HOME = originalHome;
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
- let originalHome;
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
- originalHome = process.env.HOME;
36
- process.env.HOME = join(sandbox, "home");
41
+ originalHomeEnv = captureHome();
42
+ setSandboxHome(join(sandbox, "home"));
37
43
  }
38
44
 
39
45
  function teardownSandbox() {
40
- process.env.HOME = originalHome;
46
+ restoreHome(originalHomeEnv);
41
47
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
42
48
  }
43
49