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.
@@ -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, isNpxInvocation } from "../../lib/cli-config.mjs";
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
- // ── isNpxInvocation ───────────────────────────────────────────────────
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("isNpxInvocation", () => {
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(isNpxInvocation(), false);
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(isNpxInvocation(), true);
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(isNpxInvocation(), false);
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(isNpxInvocation(), true);
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(isNpxInvocation(), true);
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(isNpxInvocation(), false);
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
+ });