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
@@ -12,12 +12,18 @@ import { runList } from "../../commands/list.mjs";
12
12
  import { CliError, EXIT_AUTH } from "../../lib/errors.mjs";
13
13
  import { createMockServer } from "../e2e/mock-server.mjs";
14
14
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
15
+ import {
16
+ captureHome,
17
+ setSandboxHome,
18
+ restoreHome,
19
+ } from "../helpers/sandbox-home.mjs";
15
20
 
16
21
  let sandbox;
17
22
  let server;
18
23
  let serverUrl;
19
24
  let originalCwd;
20
- let originalHome;
25
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
26
+ let originalHomeEnv;
21
27
  let stdout;
22
28
  const VALID_KEY = "sk_live_test";
23
29
 
@@ -37,9 +43,9 @@ async function setup() {
37
43
  mkdirSync(join(sandbox, "project"), { recursive: true });
38
44
  mkdirSync(join(sandbox, "home"), { recursive: true });
39
45
  originalCwd = process.cwd();
40
- originalHome = process.env.HOME;
46
+ originalHomeEnv = captureHome();
41
47
  process.chdir(join(sandbox, "project"));
42
- process.env.HOME = join(sandbox, "home");
48
+ setSandboxHome(join(sandbox, "home"));
43
49
  delete process.env.SKILLREPO_ACCESS_KEY;
44
50
 
45
51
  server = createMockServer({});
@@ -52,7 +58,7 @@ async function setup() {
52
58
  async function teardown() {
53
59
  if (server) await server.stop();
54
60
  process.chdir(originalCwd);
55
- process.env.HOME = originalHome;
61
+ restoreHome(originalHomeEnv);
56
62
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
57
63
  server = null;
58
64
  }
@@ -13,12 +13,18 @@ import { writeSkillDir, resolvePlacementDir } from "../../lib/file-write.mjs";
13
13
  import { CliError, EXIT_VALIDATION, EXIT_AUTH, EXIT_SCOPE } from "../../lib/errors.mjs";
14
14
  import { createMockServer } from "../e2e/mock-server.mjs";
15
15
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
16
+ import {
17
+ captureHome,
18
+ setSandboxHome,
19
+ restoreHome,
20
+ } from "../helpers/sandbox-home.mjs";
16
21
 
17
22
  let sandbox;
18
23
  let server;
19
24
  let serverUrl;
20
25
  let originalCwd;
21
- let originalHome;
26
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
27
+ let originalHomeEnv;
22
28
  let stdout;
23
29
  const VALID_KEY = "sk_live_test";
24
30
 
@@ -46,9 +52,9 @@ async function setup() {
46
52
  mkdirSync(join(sandbox, "project"), { recursive: true });
47
53
  mkdirSync(join(sandbox, "home"), { recursive: true });
48
54
  originalCwd = process.cwd();
49
- originalHome = process.env.HOME;
55
+ originalHomeEnv = captureHome();
50
56
  process.chdir(join(sandbox, "project"));
51
- process.env.HOME = join(sandbox, "home");
57
+ setSandboxHome(join(sandbox, "home"));
52
58
  delete process.env.SKILLREPO_ACCESS_KEY;
53
59
 
54
60
  server = createMockServer({});
@@ -61,7 +67,7 @@ async function setup() {
61
67
  async function teardown() {
62
68
  if (server) await server.stop();
63
69
  process.chdir(originalCwd);
64
- process.env.HOME = originalHome;
70
+ restoreHome(originalHomeEnv);
65
71
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
66
72
  server = null;
67
73
  }
@@ -12,12 +12,18 @@ import { runSearch } from "../../commands/search.mjs";
12
12
  import { CliError, EXIT_VALIDATION, EXIT_AUTH } from "../../lib/errors.mjs";
13
13
  import { createMockServer } from "../e2e/mock-server.mjs";
14
14
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
15
+ import {
16
+ captureHome,
17
+ setSandboxHome,
18
+ restoreHome,
19
+ } from "../helpers/sandbox-home.mjs";
15
20
 
16
21
  let sandbox;
17
22
  let server;
18
23
  let serverUrl;
19
24
  let originalCwd;
20
- let originalHome;
25
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
26
+ let originalHomeEnv;
21
27
  let stdout;
22
28
  let stderr;
23
29
  const VALID_KEY = "sk_live_test";
@@ -42,9 +48,9 @@ async function setup() {
42
48
  mkdirSync(join(sandbox, "project"), { recursive: true });
43
49
  mkdirSync(join(sandbox, "home"), { recursive: true });
44
50
  originalCwd = process.cwd();
45
- originalHome = process.env.HOME;
51
+ originalHomeEnv = captureHome();
46
52
  process.chdir(join(sandbox, "project"));
47
- process.env.HOME = join(sandbox, "home");
53
+ setSandboxHome(join(sandbox, "home"));
48
54
  delete process.env.SKILLREPO_ACCESS_KEY;
49
55
 
50
56
  server = createMockServer({});
@@ -58,7 +64,7 @@ async function setup() {
58
64
  async function teardown() {
59
65
  if (server) await server.stop();
60
66
  process.chdir(originalCwd);
61
- process.env.HOME = originalHome;
67
+ restoreHome(originalHomeEnv);
62
68
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
63
69
  server = null;
64
70
  }
@@ -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
+ });
@@ -22,7 +22,6 @@ import {
22
22
  readFileSync,
23
23
  writeFileSync,
24
24
  existsSync,
25
- chmodSync,
26
25
  } from "node:fs";
27
26
  import { join } from "node:path";
28
27
  import { tmpdir } from "node:os";
@@ -32,18 +31,28 @@ import { buildHookCommand } from "../../lib/mergers/session-hook.mjs";
32
31
  import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
33
32
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
34
33
  import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
34
+ import {
35
+ captureHome,
36
+ setSandboxHome,
37
+ restoreHome,
38
+ assertHomeIsolated,
39
+ } from "../helpers/sandbox-home.mjs";
40
+ import { installShim, uninstallShim } from "../helpers/skillrepo-shim.mjs";
35
41
 
36
42
  let sandbox;
37
43
  let originalCwd;
38
- let originalHome;
44
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
45
+ let originalHomeEnv;
46
+ /** @type {ReturnType<typeof installShim> | undefined} */
47
+ let shimHandle;
39
48
  let stdout;
40
49
  let stderr;
41
50
 
42
51
  function ASSERT_HOME_ISOLATED() {
43
- assert.ok(
44
- process.env.HOME && process.env.HOME.startsWith(tmpdir()),
45
- `HOME must point inside tmpdir. Current HOME="${process.env.HOME}"`,
46
- );
52
+ // Thin wrapper around the shared helper so the call sites in tests
53
+ // stay short. Checks BOTH HOME and USERPROFILE so Windows is
54
+ // actually guarded (os.homedir() reads USERPROFILE on Windows).
55
+ assertHomeIsolated(tmpdir(), "session-sync tests");
47
56
  }
48
57
 
49
58
  function setup() {
@@ -51,19 +60,16 @@ function setup() {
51
60
  mkdirSync(join(sandbox, "project"), { recursive: true });
52
61
  mkdirSync(join(sandbox, "home"), { recursive: true });
53
62
  originalCwd = process.cwd();
54
- originalHome = process.env.HOME;
63
+ originalHomeEnv = captureHome();
55
64
  process.chdir(join(sandbox, "project"));
56
- process.env.HOME = join(sandbox, "home");
65
+ setSandboxHome(join(sandbox, "home"));
57
66
 
58
67
  // Put a predictable `skillrepo` shim at the front of PATH so
59
- // mergeSessionHook's `which skillrepo` resolves to it. Saves having
60
- // to inject a binaryPath at every call site.
61
- const binDir = join(process.env.HOME, "bin");
62
- mkdirSync(binDir, { recursive: true });
63
- const shim = join(binDir, "skillrepo");
64
- writeFileSync(shim, "#!/bin/sh\nexit 0\n");
65
- chmodSync(shim, 0o755);
66
- process.env.PATH = `${binDir}:${process.env.PATH}`;
68
+ // mergeSessionHook's binary resolver finds it. Saves having to
69
+ // inject a binaryPath at every call site. The shim helper handles
70
+ // cross-platform differences (POSIX extension-less shell script
71
+ // vs Windows .cmd file + PATHEXT lookup + PATH delimiter).
72
+ shimHandle = installShim(process.env.HOME);
67
73
 
68
74
  ASSERT_HOME_ISOLATED();
69
75
 
@@ -73,14 +79,10 @@ function setup() {
73
79
 
74
80
  function teardown() {
75
81
  process.chdir(originalCwd);
76
- process.env.HOME = originalHome;
82
+ uninstallShim(shimHandle);
83
+ shimHandle = undefined;
84
+ restoreHome(originalHomeEnv);
77
85
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
78
- // Strip the prepended bin dir from PATH to keep subsequent tests clean
79
- if (process.env.PATH?.startsWith(join(sandbox ?? "", "home", "bin"))) {
80
- process.env.PATH = process.env.PATH.slice(
81
- join(sandbox, "home", "bin").length + 1,
82
- );
83
- }
84
86
  }
85
87
 
86
88
  // ──────────────────────────────────────────────────────────────────
@@ -36,13 +36,21 @@ import { join } from "node:path";
36
36
  import { tmpdir } from "node:os";
37
37
 
38
38
  import { runUninstall } from "../../commands/uninstall.mjs";
39
+ import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
39
40
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
41
+ import {
42
+ captureHome,
43
+ setSandboxHome,
44
+ restoreHome,
45
+ assertHomeIsolated,
46
+ } from "../helpers/sandbox-home.mjs";
40
47
 
41
48
  let sandbox;
42
49
  let projectDir;
43
50
  let homeDir;
44
51
  let originalCwd;
45
- let originalHome;
52
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
53
+ let originalHomeEnv;
46
54
  let stdout;
47
55
  let stderr;
48
56
 
@@ -54,11 +62,11 @@ let stderr;
54
62
  * architect tightening #2 asked for.
55
63
  */
56
64
  function ASSERT_HOME_ISOLATED() {
57
- assert.ok(
58
- process.env.HOME && process.env.HOME.startsWith(tmpdir()),
59
- `HOME must point inside os.tmpdir() during uninstall tests. ` +
60
- `Current HOME="${process.env.HOME}" setup() forgot the override.`,
61
- );
65
+ // Thin wrapper around the shared helper. Checks BOTH HOME and
66
+ // USERPROFILE so Windows is actually guarded — `os.homedir()`
67
+ // reads USERPROFILE on Windows, and an assertion that checked
68
+ // only HOME would pass while real user state was at risk.
69
+ assertHomeIsolated(tmpdir(), "uninstall tests");
62
70
  }
63
71
 
64
72
  function setup() {
@@ -69,9 +77,9 @@ function setup() {
69
77
  mkdirSync(homeDir, { recursive: true });
70
78
 
71
79
  originalCwd = process.cwd();
72
- originalHome = process.env.HOME;
80
+ originalHomeEnv = captureHome();
73
81
  process.chdir(projectDir);
74
- process.env.HOME = homeDir;
82
+ setSandboxHome(homeDir);
75
83
 
76
84
  ASSERT_HOME_ISOLATED();
77
85
 
@@ -81,7 +89,7 @@ function setup() {
81
89
 
82
90
  function teardown() {
83
91
  process.chdir(originalCwd);
84
- process.env.HOME = originalHome;
92
+ restoreHome(originalHomeEnv);
85
93
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
86
94
  }
87
95
 
@@ -404,7 +412,7 @@ describe("runUninstall — --global", () => {
404
412
  "user-authored hook must survive uninstall",
405
413
  );
406
414
  assert.ok(
407
- !allCommands.some((c) => c.includes("skillrepo update --session-hook")),
415
+ !allCommands.some((c) => c.includes(SESSION_HOOK_FINGERPRINT)),
408
416
  "SkillRepo hook must be gone",
409
417
  );
410
418
  });
@@ -487,7 +495,7 @@ describe("runUninstall — --global", () => {
487
495
  .flatMap((group) => group?.hooks ?? [])
488
496
  .map((h) => h?.command);
489
497
  assert.ok(
490
- !allCommands.some((c) => c?.includes("skillrepo update --session-hook")),
498
+ !allCommands.some((c) => c?.includes(SESSION_HOOK_FINGERPRINT)),
491
499
  "global SkillRepo hook must be gone after uninstall --global",
492
500
  );
493
501
  assert.deepEqual(
@@ -552,9 +560,7 @@ describe("runUninstall — --global", () => {
552
560
  .flatMap((g) => g?.hooks ?? [])
553
561
  .map((h) => h?.command);
554
562
  assert.ok(
555
- !projectCommands.some((c) =>
556
- c?.includes("skillrepo update --session-hook"),
557
- ),
563
+ !projectCommands.some((c) => c?.includes(SESSION_HOOK_FINGERPRINT)),
558
564
  "project-local hook still gets cleaned up",
559
565
  );
560
566
  });
@@ -16,12 +16,18 @@ import { resolvePlacementDir } from "../../lib/file-write.mjs";
16
16
  import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
17
17
  import { createMockServer } from "../e2e/mock-server.mjs";
18
18
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
19
+ import {
20
+ captureHome,
21
+ setSandboxHome,
22
+ restoreHome,
23
+ } from "../helpers/sandbox-home.mjs";
19
24
 
20
25
  let sandbox;
21
26
  let server;
22
27
  let serverUrl;
23
28
  let originalCwd;
24
- let originalHome;
29
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
30
+ let originalHomeEnv;
25
31
  let stdout;
26
32
  const VALID_KEY = "sk_live_test";
27
33
 
@@ -49,9 +55,9 @@ async function setup() {
49
55
  mkdirSync(join(sandbox, "project"), { recursive: true });
50
56
  mkdirSync(join(sandbox, "home"), { recursive: true });
51
57
  originalCwd = process.cwd();
52
- originalHome = process.env.HOME;
58
+ originalHomeEnv = captureHome();
53
59
  process.chdir(join(sandbox, "project"));
54
- process.env.HOME = join(sandbox, "home");
60
+ setSandboxHome(join(sandbox, "home"));
55
61
  delete process.env.SKILLREPO_ACCESS_KEY;
56
62
  delete process.env.SKILLREPO_URL;
57
63
 
@@ -65,7 +71,7 @@ async function setup() {
65
71
  async function teardown() {
66
72
  if (server) await server.stop();
67
73
  process.chdir(originalCwd);
68
- process.env.HOME = originalHome;
74
+ restoreHome(originalHomeEnv);
69
75
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
70
76
  server = null;
71
77
  }
@@ -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
+ }