skillrepo 3.1.1 → 3.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +1 -1
- package/src/commands/init-session-sync.mjs +307 -0
- package/src/commands/init.mjs +74 -111
- package/src/commands/session-sync-actions.mjs +92 -0
- package/src/lib/binary-locator.mjs +99 -0
- package/src/lib/cli-config.mjs +7 -72
- package/src/lib/cli-version.mjs +56 -0
- package/src/lib/global-install.mjs +387 -0
- package/src/lib/mcp-merge.mjs +16 -5
- package/src/lib/mergers/session-hook.mjs +80 -68
- package/src/lib/transient-runners.mjs +204 -0
- package/src/test/commands/init.test.mjs +662 -1
- package/src/test/commands/session-sync-actions.test.mjs +74 -0
- package/src/test/helpers/mock-spawn.mjs +121 -0
- package/src/test/lib/cli-config.test.mjs +66 -9
- package/src/test/lib/cli-version.test.mjs +47 -0
- package/src/test/lib/global-install.test.mjs +424 -0
- package/src/test/lib/mcp-merge.test.mjs +3 -3
- package/src/test/lib/transient-runners.test.mjs +270 -0
- package/src/test/mergers/session-hook.test.mjs +284 -14
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/commands/session-sync-actions.mjs (#894 / v3.1.2).
|
|
3
|
+
*
|
|
4
|
+
* The action-enum module is tiny but it's the SINGLE SOURCE OF TRUTH
|
|
5
|
+
* for the `sessionSync.action` JSON field. Downstream consumers
|
|
6
|
+
* (CI scripts, programmatic callers) parse this enum. The tests
|
|
7
|
+
* below lock in:
|
|
8
|
+
* 1. The enum's keys (programmer-facing names) and values
|
|
9
|
+
* (consumer-facing strings) are stable.
|
|
10
|
+
* 2. The set of valid values reflects all 8 documented actions.
|
|
11
|
+
* 3. The frozen object can't be silently mutated at runtime.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
SessionSyncAction,
|
|
19
|
+
SESSION_SYNC_ACTION_VALUES,
|
|
20
|
+
} from "../../commands/session-sync-actions.mjs";
|
|
21
|
+
|
|
22
|
+
describe("SessionSyncAction enum", () => {
|
|
23
|
+
it("exposes all 8 documented action values with the exact wire strings", () => {
|
|
24
|
+
// Wire strings are what downstream consumers rely on; changing
|
|
25
|
+
// them is a breaking change. This test fails loudly on any
|
|
26
|
+
// accidental rename.
|
|
27
|
+
assert.equal(SessionSyncAction.Installed, "installed");
|
|
28
|
+
assert.equal(SessionSyncAction.Updated, "updated");
|
|
29
|
+
assert.equal(SessionSyncAction.Unchanged, "unchanged");
|
|
30
|
+
assert.equal(SessionSyncAction.OptedOut, "opted-out");
|
|
31
|
+
assert.equal(SessionSyncAction.Declined, "declined");
|
|
32
|
+
assert.equal(SessionSyncAction.NotApplicable, "not-applicable");
|
|
33
|
+
assert.equal(SessionSyncAction.Skipped, "skipped");
|
|
34
|
+
assert.equal(SessionSyncAction.Failed, "failed");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("is frozen (cannot be mutated at runtime)", () => {
|
|
38
|
+
// Without the freeze, a runtime monkey-patch could change the
|
|
39
|
+
// wire value of an action and silently break downstream consumers.
|
|
40
|
+
assert.throws(
|
|
41
|
+
() => {
|
|
42
|
+
SessionSyncAction.Installed = "different";
|
|
43
|
+
},
|
|
44
|
+
TypeError,
|
|
45
|
+
"SessionSyncAction must be frozen",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("SESSION_SYNC_ACTION_VALUES contains all 8 wire strings", () => {
|
|
50
|
+
assert.equal(SESSION_SYNC_ACTION_VALUES.length, 8);
|
|
51
|
+
for (const value of [
|
|
52
|
+
"installed",
|
|
53
|
+
"updated",
|
|
54
|
+
"unchanged",
|
|
55
|
+
"opted-out",
|
|
56
|
+
"declined",
|
|
57
|
+
"not-applicable",
|
|
58
|
+
"skipped",
|
|
59
|
+
"failed",
|
|
60
|
+
]) {
|
|
61
|
+
assert.ok(
|
|
62
|
+
SESSION_SYNC_ACTION_VALUES.includes(value),
|
|
63
|
+
`expected SESSION_SYNC_ACTION_VALUES to contain "${value}"`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("SESSION_SYNC_ACTION_VALUES is frozen (cannot push, pop, or splice)", () => {
|
|
69
|
+
assert.throws(
|
|
70
|
+
() => SESSION_SYNC_ACTION_VALUES.push("new-action"),
|
|
71
|
+
/frozen|mutate|read-?only|extensible/i,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock spawn factory for v3.1.2 global-install tests.
|
|
3
|
+
*
|
|
4
|
+
* `installSkillrepoGlobally` accepts an injected `spawn` so tests
|
|
5
|
+
* never actually shell out to npm. This helper produces spawn stubs
|
|
6
|
+
* that mimic `child_process.spawn`'s event-emitter shape:
|
|
7
|
+
*
|
|
8
|
+
* {
|
|
9
|
+
* stdout: EventEmitter | undefined,
|
|
10
|
+
* stderr: EventEmitter | undefined,
|
|
11
|
+
* on: (event, cb) => this,
|
|
12
|
+
* kill: () => void,
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* The factory returns the spawn function directly. A `.calls` array
|
|
16
|
+
* is attached as a side property so tests can assert how spawn was
|
|
17
|
+
* invoked (cmd name, args, options).
|
|
18
|
+
*
|
|
19
|
+
* Behavioral knobs:
|
|
20
|
+
* - exitCode (number) : the close event fires with this code.
|
|
21
|
+
* - error (Error) : if set, fires the `error` event
|
|
22
|
+
* instead of `close`. Use for ENOENT
|
|
23
|
+
* tests (`{ code: "ENOENT" }`).
|
|
24
|
+
* - stderrText (string) : emitted as a single Buffer chunk on
|
|
25
|
+
* the child's stderr stream. Used by
|
|
26
|
+
* EACCES + npm-nonzero categorization
|
|
27
|
+
* tests.
|
|
28
|
+
* - hang (boolean) : never fires `close` or `error`.
|
|
29
|
+
* Used for timeout tests.
|
|
30
|
+
* - asyncDelayMs (number) : delay before firing close/error.
|
|
31
|
+
* Default 0 (uses process.nextTick).
|
|
32
|
+
*
|
|
33
|
+
* Tests do NOT use `child_process.spawn` directly — they import this
|
|
34
|
+
* helper and pass its return value as `spawn` to
|
|
35
|
+
* `installSkillrepoGlobally({ spawn })`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { EventEmitter } from "node:events";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {Object} MockSpawnOptions
|
|
42
|
+
* @property {number} [exitCode=0]
|
|
43
|
+
* @property {Error & {code?: string}} [error]
|
|
44
|
+
* @property {string} [stderrText]
|
|
45
|
+
* @property {boolean} [hang]
|
|
46
|
+
* @property {number} [asyncDelayMs=0]
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a spawn stub. The returned function has shape:
|
|
51
|
+
* (cmd: string, args: string[], opts: object) => ChildProcessMock
|
|
52
|
+
*
|
|
53
|
+
* @param {MockSpawnOptions} [options]
|
|
54
|
+
* @returns {Function & { calls: Array<{cmd: string, args: string[], opts: object}>, killed: boolean }}
|
|
55
|
+
*/
|
|
56
|
+
export function makeMockSpawn(options = {}) {
|
|
57
|
+
const {
|
|
58
|
+
exitCode = 0,
|
|
59
|
+
error = null,
|
|
60
|
+
stderrText = "",
|
|
61
|
+
hang = false,
|
|
62
|
+
asyncDelayMs = 0,
|
|
63
|
+
} = options;
|
|
64
|
+
|
|
65
|
+
const calls = [];
|
|
66
|
+
|
|
67
|
+
function mockSpawn(cmd, args, opts) {
|
|
68
|
+
calls.push({ cmd, args, opts });
|
|
69
|
+
|
|
70
|
+
// Mimic child_process.ChildProcess shape via plain EventEmitter.
|
|
71
|
+
// We create the streams unconditionally so the production code's
|
|
72
|
+
// `if (child.stderr)` checks work consistently.
|
|
73
|
+
const stdout = new EventEmitter();
|
|
74
|
+
const stderr = new EventEmitter();
|
|
75
|
+
const child = new EventEmitter();
|
|
76
|
+
child.stdout = stdout;
|
|
77
|
+
child.stderr = stderr;
|
|
78
|
+
child.kill = () => {
|
|
79
|
+
mockSpawn.killed = true;
|
|
80
|
+
};
|
|
81
|
+
// (`mockSpawn.killed` is initialized to `false` once on the
|
|
82
|
+
// outer factory below — we deliberately do NOT re-initialize
|
|
83
|
+
// here because re-initializing on every spawn call would
|
|
84
|
+
// overwrite `true` if the same spawn instance were called
|
|
85
|
+
// twice and the first call's kill had already fired.)
|
|
86
|
+
|
|
87
|
+
if (hang) {
|
|
88
|
+
// Never emit close or error. Caller's timeout will fire.
|
|
89
|
+
return child;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fire = () => {
|
|
93
|
+
// Emit stderr data BEFORE close so production code sees the
|
|
94
|
+
// text in the buffer when categorizing the failure.
|
|
95
|
+
if (stderrText) {
|
|
96
|
+
stderr.emit("data", Buffer.from(stderrText, "utf-8"));
|
|
97
|
+
}
|
|
98
|
+
if (error) {
|
|
99
|
+
child.emit("error", error);
|
|
100
|
+
} else {
|
|
101
|
+
child.emit("close", exitCode);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (asyncDelayMs > 0) {
|
|
106
|
+
setTimeout(fire, asyncDelayMs);
|
|
107
|
+
} else {
|
|
108
|
+
// process.nextTick mimics spawn's "events fire on next tick"
|
|
109
|
+
// contract — production code's `child.on("close", ...)`
|
|
110
|
+
// attaches listeners synchronously after spawn returns, and
|
|
111
|
+
// the events arrive on the next tick. setImmediate also works
|
|
112
|
+
// but is one tick later than necessary.
|
|
113
|
+
process.nextTick(fire);
|
|
114
|
+
}
|
|
115
|
+
return child;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
mockSpawn.calls = calls;
|
|
119
|
+
mockSpawn.killed = false;
|
|
120
|
+
return mockSpawn;
|
|
121
|
+
}
|
|
@@ -17,7 +17,8 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
|
|
20
|
-
import { resolveFlags, effectiveVendors
|
|
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";
|
|
22
23
|
import {
|
|
23
24
|
captureHome,
|
|
@@ -412,7 +413,7 @@ describe("effectiveVendors", () => {
|
|
|
412
413
|
});
|
|
413
414
|
});
|
|
414
415
|
|
|
415
|
-
// ──
|
|
416
|
+
// ── isTransientRunnerInvocation ───────────────────────────────────────────────────
|
|
416
417
|
//
|
|
417
418
|
// The helper detects whether the CLI was launched via `npx skillrepo`
|
|
418
419
|
// vs. a stable global install. v3.1.0 shipped a bug where the session-
|
|
@@ -420,7 +421,7 @@ describe("effectiveVendors", () => {
|
|
|
420
421
|
// as a stable install location — this helper is the fix mechanism.
|
|
421
422
|
// Three independent signals are checked; any one is sufficient.
|
|
422
423
|
|
|
423
|
-
describe("
|
|
424
|
+
describe("isTransientRunnerInvocation", () => {
|
|
424
425
|
let originalArgv;
|
|
425
426
|
let originalNpmCommand;
|
|
426
427
|
let originalUnderscore;
|
|
@@ -443,7 +444,7 @@ describe("isNpxInvocation", () => {
|
|
|
443
444
|
|
|
444
445
|
it("returns false for a vanilla node invocation", () => {
|
|
445
446
|
process.argv = ["/usr/local/bin/node", "/usr/local/bin/skillrepo"];
|
|
446
|
-
assert.equal(
|
|
447
|
+
assert.equal(isTransientRunnerInvocation(), false);
|
|
447
448
|
});
|
|
448
449
|
|
|
449
450
|
it("detects npx via the argv[1] _npx cache path", () => {
|
|
@@ -453,7 +454,7 @@ describe("isNpxInvocation", () => {
|
|
|
453
454
|
"/usr/local/bin/node",
|
|
454
455
|
"/Users/alice/.npm/_npx/dc129a78aca3fc9c/node_modules/.bin/skillrepo",
|
|
455
456
|
];
|
|
456
|
-
assert.equal(
|
|
457
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
457
458
|
});
|
|
458
459
|
|
|
459
460
|
it("does NOT treat npm_command=exec as an npx signal (false-positive guard)", () => {
|
|
@@ -471,13 +472,13 @@ describe("isNpxInvocation", () => {
|
|
|
471
472
|
// var, the invocation must be treated as a stable install.
|
|
472
473
|
process.argv = ["/usr/local/bin/node", "/some/other/path/skillrepo"];
|
|
473
474
|
process.env.npm_command = "exec";
|
|
474
|
-
assert.equal(
|
|
475
|
+
assert.equal(isTransientRunnerInvocation(), false);
|
|
475
476
|
});
|
|
476
477
|
|
|
477
478
|
it("detects npx via the _ env var ending in /npx (legacy fallback)", () => {
|
|
478
479
|
process.argv = ["/usr/local/bin/node", "/path/skillrepo"];
|
|
479
480
|
process.env._ = "/usr/local/bin/npx";
|
|
480
|
-
assert.equal(
|
|
481
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
481
482
|
});
|
|
482
483
|
|
|
483
484
|
it("handles Windows _npx path separator", () => {
|
|
@@ -485,7 +486,7 @@ describe("isNpxInvocation", () => {
|
|
|
485
486
|
"C:\\Program Files\\nodejs\\node.exe",
|
|
486
487
|
"C:\\Users\\alice\\.npm\\_npx\\abc123\\node_modules\\.bin\\skillrepo",
|
|
487
488
|
];
|
|
488
|
-
assert.equal(
|
|
489
|
+
assert.equal(isTransientRunnerInvocation(), true);
|
|
489
490
|
});
|
|
490
491
|
|
|
491
492
|
it("does NOT false-positive on a path containing 'npx' substring but not in _npx cache", () => {
|
|
@@ -493,7 +494,63 @@ describe("isNpxInvocation", () => {
|
|
|
493
494
|
// check matches `/_npx/` specifically (with leading slash), not
|
|
494
495
|
// substring "npx", so this should be safely negative.
|
|
495
496
|
process.argv = ["/usr/local/bin/node", "/opt/npxtools/bin/skillrepo"];
|
|
496
|
-
assert.equal(
|
|
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);
|
|
497
554
|
});
|
|
498
555
|
});
|
|
499
556
|
|
|
@@ -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
|
+
});
|