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
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
+
originalHomeEnv = captureHome();
|
|
41
47
|
process.chdir(join(sandbox, "project"));
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
+
originalHomeEnv = captureHome();
|
|
50
56
|
process.chdir(join(sandbox, "project"));
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
originalHomeEnv = captureHome();
|
|
46
52
|
process.chdir(join(sandbox, "project"));
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
63
|
+
originalHomeEnv = captureHome();
|
|
55
64
|
process.chdir(join(sandbox, "project"));
|
|
56
|
-
|
|
65
|
+
setSandboxHome(join(sandbox, "home"));
|
|
57
66
|
|
|
58
67
|
// Put a predictable `skillrepo` shim at the front of PATH so
|
|
59
|
-
// mergeSessionHook's
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
80
|
+
originalHomeEnv = captureHome();
|
|
73
81
|
process.chdir(projectDir);
|
|
74
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
58
|
+
originalHomeEnv = captureHome();
|
|
53
59
|
process.chdir(join(sandbox, "project"));
|
|
54
|
-
|
|
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
|
-
|
|
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
|
+
}
|