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
|
@@ -890,8 +890,16 @@ describe("runInit — v3.1.1 Next steps prefix (bug 1)", () => {
|
|
|
890
890
|
];
|
|
891
891
|
|
|
892
892
|
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
893
|
+
// v3.1.2: pass --no-session-sync to scope this test to the
|
|
894
|
+
// Next-Steps prefix/tip behavior (its actual intent). Without
|
|
895
|
+
// the flag, step 6 would attempt the new auto-install-global
|
|
896
|
+
// flow, which actually shells out to `npm install -g skillrepo`
|
|
897
|
+
// (a test pollutant — slow, network-dependent, and could
|
|
898
|
+
// mutate the developer's global node_modules). Adding
|
|
899
|
+
// --no-session-sync skips step 6 entirely while leaving the
|
|
900
|
+
// npx-detection prefix and Tip behaviors fully exercised.
|
|
893
901
|
await runInit(
|
|
894
|
-
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
902
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
|
|
895
903
|
{ stdout, stderr },
|
|
896
904
|
);
|
|
897
905
|
|
|
@@ -907,4 +915,657 @@ describe("runInit — v3.1.1 Next steps prefix (bug 1)", () => {
|
|
|
907
915
|
"npx invocation must show the Next-Steps global-install tip",
|
|
908
916
|
);
|
|
909
917
|
});
|
|
918
|
+
|
|
919
|
+
// ── v3.1.2: per-runner Next-Steps prefix ────────────────────────
|
|
920
|
+
//
|
|
921
|
+
// v3.1.1 hardcoded `npx skillrepo` for ALL transient runners. After
|
|
922
|
+
// extending detection to pnpm dlx / yarn berry dlx / bunx, the
|
|
923
|
+
// prefix must match the actual runner so the user can copy-paste
|
|
924
|
+
// the suggested command.
|
|
925
|
+
|
|
926
|
+
it("v3.1.2: shows `pnpx skillrepo` prefix when launched via pnpm dlx", async () => {
|
|
927
|
+
process.argv = [
|
|
928
|
+
"/usr/local/bin/node",
|
|
929
|
+
"/Users/alice/.local/share/pnpm/store/dlx-abc/node_modules/.bin/skillrepo",
|
|
930
|
+
];
|
|
931
|
+
|
|
932
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
933
|
+
await runInit(
|
|
934
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
|
|
935
|
+
{ stdout, stderr },
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
const out = stdout.text();
|
|
939
|
+
// Match the bullet line exactly so we don't accidentally match
|
|
940
|
+
// `pnpx skillrepo list` while looking for `npx skillrepo list`
|
|
941
|
+
// (the substring overlap is real — pnpx ends with "npx").
|
|
942
|
+
assert.match(out, /^ +• pnpx skillrepo list/m);
|
|
943
|
+
assert.doesNotMatch(
|
|
944
|
+
out,
|
|
945
|
+
/^ +• npx skillrepo list/m,
|
|
946
|
+
"must NOT show bare `npx` prefix when invoked via pnpx",
|
|
947
|
+
);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it("v3.1.2: shows `pnpm add -g` install command in Tip when launched via pnpm dlx", async () => {
|
|
951
|
+
// Round-3 fix for the architect's NOTE 1: the Tip line was
|
|
952
|
+
// hardcoded `npm install -g skillrepo` even for pnpx/yarn dlx/
|
|
953
|
+
// bunx users. This test locks the per-runner Tip behavior.
|
|
954
|
+
process.argv = [
|
|
955
|
+
"/usr/local/bin/node",
|
|
956
|
+
"/Users/alice/.local/share/pnpm/store/dlx-abc/node_modules/.bin/skillrepo",
|
|
957
|
+
];
|
|
958
|
+
|
|
959
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
960
|
+
await runInit(
|
|
961
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
|
|
962
|
+
{ stdout, stderr },
|
|
963
|
+
);
|
|
964
|
+
|
|
965
|
+
const out = stdout.text();
|
|
966
|
+
assert.match(
|
|
967
|
+
out,
|
|
968
|
+
/Tip: `pnpm add -g skillrepo`/,
|
|
969
|
+
"pnpx user must see the pnpm install command, not npm",
|
|
970
|
+
);
|
|
971
|
+
assert.doesNotMatch(
|
|
972
|
+
out,
|
|
973
|
+
/Tip: `npm install -g skillrepo`/,
|
|
974
|
+
"must NOT show the bare npm command for a pnpx user",
|
|
975
|
+
);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it("v3.1.2: shows `bun add -g` install command in Tip when launched via bunx", async () => {
|
|
979
|
+
process.argv = [
|
|
980
|
+
"/usr/local/bin/node",
|
|
981
|
+
"/Users/alice/.bun/install/cache/skillrepo@3.1.2/node_modules/.bin/skillrepo",
|
|
982
|
+
];
|
|
983
|
+
|
|
984
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
985
|
+
await runInit(
|
|
986
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
|
|
987
|
+
{ stdout, stderr },
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
assert.match(stdout.text(), /Tip: `bun add -g skillrepo`/);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
it("v3.1.2: yarn berry dlx Tip falls back to `npm install -g` (yarn berry has no global add)", async () => {
|
|
994
|
+
// Yarn berry deliberately ships no `yarn global add` — see the
|
|
995
|
+
// comment on TRANSIENT_RUNNERS in transient-runners.mjs. Users
|
|
996
|
+
// who want a persistent global on yarn-berry get the universal
|
|
997
|
+
// npm fallback.
|
|
998
|
+
process.argv = [
|
|
999
|
+
"/usr/local/bin/node",
|
|
1000
|
+
"/Users/alice/proj/.yarn/berry/cache/skillrepo-npm-3.1.2-abc/node_modules/skillrepo/bin/skillrepo.mjs",
|
|
1001
|
+
];
|
|
1002
|
+
|
|
1003
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1004
|
+
await runInit(
|
|
1005
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
|
|
1006
|
+
{ stdout, stderr },
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
assert.match(stdout.text(), /Tip: `npm install -g skillrepo`/);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it("v3.1.2: shows `yarn dlx skillrepo` prefix when launched via yarn berry dlx", async () => {
|
|
1013
|
+
process.argv = [
|
|
1014
|
+
"/usr/local/bin/node",
|
|
1015
|
+
"/Users/alice/proj/.yarn/berry/cache/skillrepo-npm-3.1.2-abc/node_modules/skillrepo/bin/skillrepo.mjs",
|
|
1016
|
+
];
|
|
1017
|
+
|
|
1018
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1019
|
+
await runInit(
|
|
1020
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
|
|
1021
|
+
{ stdout, stderr },
|
|
1022
|
+
);
|
|
1023
|
+
|
|
1024
|
+
const out = stdout.text();
|
|
1025
|
+
assert.match(out, /yarn dlx skillrepo list/);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("v3.1.2: shows `bunx skillrepo` prefix when launched via bunx", async () => {
|
|
1029
|
+
process.argv = [
|
|
1030
|
+
"/usr/local/bin/node",
|
|
1031
|
+
"/Users/alice/.bun/install/cache/skillrepo@3.1.2/node_modules/.bin/skillrepo",
|
|
1032
|
+
];
|
|
1033
|
+
|
|
1034
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1035
|
+
await runInit(
|
|
1036
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
|
|
1037
|
+
{ stdout, stderr },
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
const out = stdout.text();
|
|
1041
|
+
assert.match(out, /bunx skillrepo list/);
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
// ── v3.1.2 (#894): init step 6 auto-install-global behavior ─────────
|
|
1046
|
+
//
|
|
1047
|
+
// Six branches in init.mjs step 6 under v3.1.2:
|
|
1048
|
+
//
|
|
1049
|
+
// 1. --no-session-sync → opted-out, no install attempted
|
|
1050
|
+
// 2. non-Claude target → not-applicable, no install attempted
|
|
1051
|
+
// 3. non-npx, no global on PATH → mergeSessionHook returns "skipped"
|
|
1052
|
+
// (existing v3.1.1 behavior, preserved)
|
|
1053
|
+
// 4. npx + EXISTING global → use existing path, no auto-install
|
|
1054
|
+
// 5. npx + NO global, decline → "declined", manual instructions printed
|
|
1055
|
+
// 6. npx + NO global, accept → auto-install runs (mocked spawn).
|
|
1056
|
+
// On success, hook installed via the
|
|
1057
|
+
// explicit binaryPath bypass.
|
|
1058
|
+
//
|
|
1059
|
+
// All tests mock spawn via the new `deps.spawn` injection point so
|
|
1060
|
+
// no test ever shells out to `npm install -g`.
|
|
1061
|
+
|
|
1062
|
+
describe("runInit — v3.1.2 step 6 auto-install (npx)", () => {
|
|
1063
|
+
// Process-state isolation: same as the v3.1.1 npx prefix tests
|
|
1064
|
+
// above. We control isNpxInvocation()'s output via process.argv[1]
|
|
1065
|
+
// and process.env._.
|
|
1066
|
+
let originalArgv;
|
|
1067
|
+
let originalUnderscore;
|
|
1068
|
+
let originalNpmCommand;
|
|
1069
|
+
let shimSandbox;
|
|
1070
|
+
let shimHandle;
|
|
1071
|
+
|
|
1072
|
+
beforeEach(async () => {
|
|
1073
|
+
await setup();
|
|
1074
|
+
originalArgv = process.argv;
|
|
1075
|
+
originalUnderscore = process.env._;
|
|
1076
|
+
originalNpmCommand = process.env.npm_command;
|
|
1077
|
+
shimSandbox = null;
|
|
1078
|
+
shimHandle = null;
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
afterEach(async () => {
|
|
1082
|
+
process.argv = originalArgv;
|
|
1083
|
+
if (originalUnderscore === undefined) delete process.env._;
|
|
1084
|
+
else process.env._ = originalUnderscore;
|
|
1085
|
+
if (originalNpmCommand === undefined) delete process.env.npm_command;
|
|
1086
|
+
else process.env.npm_command = originalNpmCommand;
|
|
1087
|
+
if (shimHandle) {
|
|
1088
|
+
const { uninstallShim } = await import("../helpers/skillrepo-shim.mjs");
|
|
1089
|
+
uninstallShim(shimHandle);
|
|
1090
|
+
}
|
|
1091
|
+
if (shimSandbox) rmSync(shimSandbox, { recursive: true, force: true });
|
|
1092
|
+
await teardown();
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
function makeNpxArgv() {
|
|
1096
|
+
process.argv = [
|
|
1097
|
+
"/usr/local/bin/node",
|
|
1098
|
+
"/Users/alice/.npm/_npx/dc129a78aca3fc9c/node_modules/.bin/skillrepo",
|
|
1099
|
+
];
|
|
1100
|
+
delete process.env._;
|
|
1101
|
+
delete process.env.npm_command;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// NOTE: the "Branch 5: npx + --json without --yes → declined"
|
|
1105
|
+
// path is defensive code that is unreachable through `runInit`
|
|
1106
|
+
// today. Step 5 (MCP auto-merge) prompts unconditionally
|
|
1107
|
+
// per-vendor when `--yes` is not passed, so a non-interactive
|
|
1108
|
+
// `--json` invocation hangs at step 5 before reaching step 6.
|
|
1109
|
+
// The branch exists so a future refactor that makes step 5
|
|
1110
|
+
// non-interactive under `--json` (e.g. default-yes for MCP merge
|
|
1111
|
+
// under --json) gets the right step 6 semantics for free. Until
|
|
1112
|
+
// that refactor lands, we cannot integration-test the branch
|
|
1113
|
+
// without injecting a `confirmFn` into both step 5 and step 6,
|
|
1114
|
+
// which would be more test scaffolding than the defensive code
|
|
1115
|
+
// is worth. Coverage for the underlying "no install + manual
|
|
1116
|
+
// instructions" semantic is provided by the failure-path tests
|
|
1117
|
+
// below (Branch 6 with mock-failed spawn) which assert the
|
|
1118
|
+
// identical printed message.
|
|
1119
|
+
|
|
1120
|
+
// NOTE: a "Branch 6 success" integration test (npx + --yes +
|
|
1121
|
+
// spawn-success + binary appears on PATH post-install) cannot be
|
|
1122
|
+
// cleanly written through `runInit`. The structural problem: if
|
|
1123
|
+
// we install the shim on PATH BEFORE calling runInit, init's
|
|
1124
|
+
// `resolveGlobalBinary()` finds it during the npx-mode check and
|
|
1125
|
+
// takes Branch 4 (pre-existing global) instead. Installing the
|
|
1126
|
+
// shim AFTER spawn fires would require a callback hook in
|
|
1127
|
+
// mock-spawn that runs in the child's close handler — extra
|
|
1128
|
+
// scaffolding for a path that's already covered by the
|
|
1129
|
+
// composition of:
|
|
1130
|
+
// - global-install.test.mjs "happy path" (spawn → success →
|
|
1131
|
+
// resolveGlobalBinary finds shim → success result)
|
|
1132
|
+
// - session-hook.test.mjs "v3.1.2 bypass" (binaryPath param
|
|
1133
|
+
// bypasses npx guard → hook installed)
|
|
1134
|
+
// - init.test.mjs Branch 4 below (when a global exists, init
|
|
1135
|
+
// uses it directly via the binaryPath parameter)
|
|
1136
|
+
// These three together cover the same end-to-end semantic.
|
|
1137
|
+
|
|
1138
|
+
it("Branch 6 failure: npx + --yes + spawn-fail (npm exit 1) → init exits 0, manual instructions printed", async () => {
|
|
1139
|
+
makeNpxArgv();
|
|
1140
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1141
|
+
|
|
1142
|
+
const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
|
|
1143
|
+
const spawn = makeMockSpawn({
|
|
1144
|
+
exitCode: 1,
|
|
1145
|
+
stderrText: "npm ERR! some failure",
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
// No exception thrown — init must continue past auto-install
|
|
1149
|
+
// failure and complete the rest of its steps.
|
|
1150
|
+
await runInit(
|
|
1151
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
1152
|
+
{ stdout, stderr },
|
|
1153
|
+
{ spawn },
|
|
1154
|
+
);
|
|
1155
|
+
|
|
1156
|
+
// Hook NOT installed.
|
|
1157
|
+
assert.equal(
|
|
1158
|
+
existsSync(join(process.cwd(), ".claude", "settings.local.json")),
|
|
1159
|
+
false,
|
|
1160
|
+
);
|
|
1161
|
+
|
|
1162
|
+
// Output mentions failure + manual instructions + the npm error
|
|
1163
|
+
// snippet for diagnosis.
|
|
1164
|
+
const out = stdout.text();
|
|
1165
|
+
assert.match(out, /Could not install skillrepo globally/);
|
|
1166
|
+
assert.match(out, /npm install -g skillrepo/);
|
|
1167
|
+
// Sync (step 7) still ran — no early abort
|
|
1168
|
+
assert.match(out, /Library is up to date|No skills in library yet/);
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
it("Branch 6 EACCES (--json mode): spawn EACCES → eacces-categorized error in JSON sessionSync block", async () => {
|
|
1172
|
+
// EACCES detection requires stderr capture, which only happens
|
|
1173
|
+
// in --json mode (`outputMode: "silent"`). In non-json/inherit
|
|
1174
|
+
// mode, npm's stderr goes to the user's terminal directly so
|
|
1175
|
+
// we don't capture it — meaning categorization falls back to
|
|
1176
|
+
// generic "npm-nonzero." That asymmetry is intentional: the
|
|
1177
|
+
// user always sees the real npm output one way or another.
|
|
1178
|
+
//
|
|
1179
|
+
// This test exercises the categorization path under --json
|
|
1180
|
+
// where the helper's structured error is the only signal the
|
|
1181
|
+
// (machine) consumer has.
|
|
1182
|
+
makeNpxArgv();
|
|
1183
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1184
|
+
|
|
1185
|
+
const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
|
|
1186
|
+
const spawn = makeMockSpawn({
|
|
1187
|
+
exitCode: 243,
|
|
1188
|
+
stderrText: "npm ERR! Error: EACCES: permission denied",
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
await runInit(
|
|
1192
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
1193
|
+
{ stdout, stderr },
|
|
1194
|
+
{ spawn },
|
|
1195
|
+
);
|
|
1196
|
+
|
|
1197
|
+
// In --json mode the human warnings go to stderr, but the
|
|
1198
|
+
// machine-readable JSON on stdout is the contract.
|
|
1199
|
+
const json = JSON.parse(stdout.text());
|
|
1200
|
+
assert.equal(json.sessionSync.action, "skipped");
|
|
1201
|
+
// Init still succeeded overall — the sync block is present and
|
|
1202
|
+
// the JSON parses cleanly.
|
|
1203
|
+
assert.ok(json.sync, "sync block must be present after EACCES failure");
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it("Branch 6 generic failure (non-json mode, non-EACCES exit code): falls back to generic error message", async () => {
|
|
1207
|
+
// In non-json/inherit mode, stderr is NOT captured (it streams
|
|
1208
|
+
// to the user's terminal directly). The helper categorizes the
|
|
1209
|
+
// failure based ONLY on the exit code in inherit mode:
|
|
1210
|
+
// - 243 → EACCES (npm's documented permission-error code)
|
|
1211
|
+
// - any other non-zero → generic "npm-nonzero"
|
|
1212
|
+
// This test exercises the generic path with a non-243 exit
|
|
1213
|
+
// (e.g., a network or registry-side failure). Since the user
|
|
1214
|
+
// already saw the real npm output via inherit, the helper's
|
|
1215
|
+
// human message just confirms the failure happened.
|
|
1216
|
+
makeNpxArgv();
|
|
1217
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1218
|
+
|
|
1219
|
+
const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
|
|
1220
|
+
const spawn = makeMockSpawn({
|
|
1221
|
+
exitCode: 1,
|
|
1222
|
+
stderrText: "irrelevant — won't be captured in inherit mode",
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
await runInit(
|
|
1226
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
1227
|
+
{ stdout, stderr },
|
|
1228
|
+
{ spawn },
|
|
1229
|
+
);
|
|
1230
|
+
|
|
1231
|
+
const out = stdout.text();
|
|
1232
|
+
// Generic categorization message — exit code is mentioned but
|
|
1233
|
+
// no specific category like "EACCES" or "ENOENT".
|
|
1234
|
+
assert.match(out, /Could not install skillrepo globally/);
|
|
1235
|
+
assert.match(out, /exited with code 1/);
|
|
1236
|
+
// Manual-install instructions printed.
|
|
1237
|
+
assert.match(out, /npm install -g skillrepo/);
|
|
1238
|
+
// Init still completed.
|
|
1239
|
+
assert.match(out, /Library is up to date|No skills in library yet/);
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
it("Branch 6 EACCES via exit code 243 (non-json mode): categorized as eacces even without stderr capture", async () => {
|
|
1243
|
+
// The v3.1.2 review surfaced an issue: EACCES detection in
|
|
1244
|
+
// inherit mode was impossible because stderr wasn't captured.
|
|
1245
|
+
// Fix: detect via exit code 243 (npm's documented EACCES exit
|
|
1246
|
+
// code on POSIX). This test locks in that exit-code-based
|
|
1247
|
+
// categorization works in inherit mode.
|
|
1248
|
+
makeNpxArgv();
|
|
1249
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1250
|
+
|
|
1251
|
+
const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
|
|
1252
|
+
const spawn = makeMockSpawn({
|
|
1253
|
+
exitCode: 243,
|
|
1254
|
+
// No stderrText — inherit mode wouldn't capture it anyway,
|
|
1255
|
+
// and we're proving the exit-code path works without stderr.
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
await runInit(
|
|
1259
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
1260
|
+
{ stdout, stderr },
|
|
1261
|
+
{ spawn },
|
|
1262
|
+
);
|
|
1263
|
+
|
|
1264
|
+
const out = stdout.text();
|
|
1265
|
+
// EACCES-categorized message — the actionable hint about sudo
|
|
1266
|
+
// and the npm permissions docs URL must appear.
|
|
1267
|
+
assert.match(out, /permissions error \(EACCES\)/);
|
|
1268
|
+
assert.match(out, /sudo/);
|
|
1269
|
+
assert.match(out, /npmjs.com\/resolving-eacces/);
|
|
1270
|
+
// Init still completed.
|
|
1271
|
+
assert.match(out, /Library is up to date|No skills in library yet/);
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
it("Branch 4: npx + EXISTING global on PATH → no auto-install, hook uses existing", async () => {
|
|
1275
|
+
// Pre-stage a shim that resolveGlobalBinary will find — this
|
|
1276
|
+
// simulates the user already having `npm install -g skillrepo`
|
|
1277
|
+
// done before running `npx skillrepo init`. Init must detect
|
|
1278
|
+
// the pre-existing global, skip the install offer, and use the
|
|
1279
|
+
// existing path for the hook.
|
|
1280
|
+
makeNpxArgv();
|
|
1281
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1282
|
+
|
|
1283
|
+
const { installShim } = await import("../helpers/skillrepo-shim.mjs");
|
|
1284
|
+
shimSandbox = mkdtempSync(join(tmpdir(), "sr-init-preexisting-"));
|
|
1285
|
+
shimHandle = installShim(shimSandbox);
|
|
1286
|
+
|
|
1287
|
+
const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
|
|
1288
|
+
const spawn = makeMockSpawn({ exitCode: 0 });
|
|
1289
|
+
|
|
1290
|
+
await runInit(
|
|
1291
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
1292
|
+
{ stdout, stderr },
|
|
1293
|
+
{ spawn },
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
// Spawn was NEVER called — pre-existing global was used directly.
|
|
1297
|
+
assert.equal(
|
|
1298
|
+
spawn.calls.length,
|
|
1299
|
+
0,
|
|
1300
|
+
"spawn must NOT be called when a global is already on PATH",
|
|
1301
|
+
);
|
|
1302
|
+
|
|
1303
|
+
// Hook installed.
|
|
1304
|
+
const settingsPath = join(
|
|
1305
|
+
process.cwd(),
|
|
1306
|
+
".claude",
|
|
1307
|
+
"settings.local.json",
|
|
1308
|
+
);
|
|
1309
|
+
assert.ok(existsSync(settingsPath));
|
|
1310
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
1311
|
+
const hookCmd = settings.hooks.SessionStart[0].hooks[0].command;
|
|
1312
|
+
// The path is now shell-quoted (v3.1.2: see buildHookCommand
|
|
1313
|
+
// docstring for rationale), so it does not appear at position 0
|
|
1314
|
+
// verbatim — it appears after the opening quote character.
|
|
1315
|
+
//
|
|
1316
|
+
// Cross-platform path comparison via the UNIQUE SANDBOX-DIR
|
|
1317
|
+
// BASENAME (e.g. `sr-init-preexisting-XYZ`): on Windows,
|
|
1318
|
+
// `os.tmpdir()` returns the 8.3 short-name form
|
|
1319
|
+
// (`C:\Users\RUNNER~1\AppData\Local\Temp\...`) while `where.exe`
|
|
1320
|
+
// returns the long-name form (`C:\Users\runneradmin\...`) for
|
|
1321
|
+
// the SAME directory. The two strings differ, and `realpathSync`
|
|
1322
|
+
// does NOT expand 8.3 short-names (those are filesystem aliases,
|
|
1323
|
+
// not symlinks). The basename of the sandbox dir, however, is
|
|
1324
|
+
// identical in both forms — it's the random suffix `mkdtempSync`
|
|
1325
|
+
// appended, which doesn't have a short-name alias. Asserting the
|
|
1326
|
+
// basename appears in the hookCmd is robust across platforms
|
|
1327
|
+
// and path-form representations.
|
|
1328
|
+
const { basename } = await import("node:path");
|
|
1329
|
+
const sandboxName = basename(shimSandbox);
|
|
1330
|
+
assert.ok(
|
|
1331
|
+
hookCmd.includes(sandboxName) && hookCmd.includes("skillrepo"),
|
|
1332
|
+
`expected hook command to contain sandbox ${sandboxName} and binary "skillrepo", got: ${hookCmd}`,
|
|
1333
|
+
);
|
|
1334
|
+
|
|
1335
|
+
// Output communicates the action (not silent).
|
|
1336
|
+
const out = stdout.text();
|
|
1337
|
+
assert.match(out, /Found global skillrepo at/);
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
it("Branch 1: npx + --no-session-sync → spawn never called, action = opted-out", async () => {
|
|
1341
|
+
makeNpxArgv();
|
|
1342
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1343
|
+
|
|
1344
|
+
const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
|
|
1345
|
+
const spawn = makeMockSpawn({ exitCode: 0 });
|
|
1346
|
+
|
|
1347
|
+
await runInit(
|
|
1348
|
+
[
|
|
1349
|
+
"--key",
|
|
1350
|
+
VALID_KEY,
|
|
1351
|
+
"--url",
|
|
1352
|
+
serverUrl,
|
|
1353
|
+
"--yes",
|
|
1354
|
+
"--no-session-sync",
|
|
1355
|
+
"--json",
|
|
1356
|
+
],
|
|
1357
|
+
{ stdout, stderr },
|
|
1358
|
+
{ spawn },
|
|
1359
|
+
);
|
|
1360
|
+
|
|
1361
|
+
assert.equal(spawn.calls.length, 0);
|
|
1362
|
+
const json = JSON.parse(stdout.text());
|
|
1363
|
+
assert.equal(json.sessionSync.action, "opted-out");
|
|
1364
|
+
assert.equal(
|
|
1365
|
+
existsSync(join(process.cwd(), ".claude", "settings.local.json")),
|
|
1366
|
+
false,
|
|
1367
|
+
);
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
it("--json mode: stdout contains ONLY JSON (no npm output mixed in) when auto-install runs", async () => {
|
|
1371
|
+
// Critical regression guard: in --json mode, npm output must
|
|
1372
|
+
// NOT pollute stdout. The `outputMode: "silent"` switch in
|
|
1373
|
+
// installSkillrepoGlobally is what enforces this; the test
|
|
1374
|
+
// proves the wiring is correct.
|
|
1375
|
+
makeNpxArgv();
|
|
1376
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1377
|
+
|
|
1378
|
+
// Use a spawn mock that "writes to stdout" — except since we're
|
|
1379
|
+
// in silent mode, child.stdout is piped (not inherited) and
|
|
1380
|
+
// production code drains it. The mock doesn't actually emit
|
|
1381
|
+
// stdout data, but the test's value is that the entire stdout
|
|
1382
|
+
// stream is parseable JSON regardless.
|
|
1383
|
+
const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
|
|
1384
|
+
const spawn = makeMockSpawn({
|
|
1385
|
+
exitCode: 1,
|
|
1386
|
+
stderrText: "noisy npm error output",
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
await runInit(
|
|
1390
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
1391
|
+
{ stdout, stderr },
|
|
1392
|
+
{ spawn },
|
|
1393
|
+
);
|
|
1394
|
+
|
|
1395
|
+
// The entire stdout text must be valid JSON.
|
|
1396
|
+
let parsed;
|
|
1397
|
+
assert.doesNotThrow(() => {
|
|
1398
|
+
parsed = JSON.parse(stdout.text());
|
|
1399
|
+
}, "stdout must be valid JSON");
|
|
1400
|
+
assert.equal(parsed.sessionSync.action, "skipped");
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
it("Branch 2: --ide cursor (non-Claude target) → spawn never called, action = not-applicable", async () => {
|
|
1404
|
+
// QA gap fix: previously had no test for the non-Claude branch.
|
|
1405
|
+
// Even under npx, if the user targets a non-Claude IDE, the
|
|
1406
|
+
// SessionStart hook (Claude-specific) is skipped without an
|
|
1407
|
+
// install offer.
|
|
1408
|
+
makeNpxArgv();
|
|
1409
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1410
|
+
|
|
1411
|
+
const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
|
|
1412
|
+
const spawn = makeMockSpawn({ exitCode: 0 });
|
|
1413
|
+
|
|
1414
|
+
await runInit(
|
|
1415
|
+
[
|
|
1416
|
+
"--key",
|
|
1417
|
+
VALID_KEY,
|
|
1418
|
+
"--url",
|
|
1419
|
+
serverUrl,
|
|
1420
|
+
"--yes",
|
|
1421
|
+
"--ide",
|
|
1422
|
+
"cursor",
|
|
1423
|
+
"--json",
|
|
1424
|
+
],
|
|
1425
|
+
{ stdout, stderr },
|
|
1426
|
+
{ spawn },
|
|
1427
|
+
);
|
|
1428
|
+
|
|
1429
|
+
assert.equal(spawn.calls.length, 0);
|
|
1430
|
+
const json = JSON.parse(stdout.text());
|
|
1431
|
+
assert.equal(json.sessionSync.action, "not-applicable");
|
|
1432
|
+
assert.equal(json.sessionSync.path, null);
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
it("--json contract: sessionSync.action = 'failed' is reachable when settings.local.json is corrupt", async () => {
|
|
1436
|
+
// QA gap fix (BLOCKING): The "failed" action enum value was
|
|
1437
|
+
// documented in the JSON output block but never exercised by
|
|
1438
|
+
// a --json integration test. Pre-seed a corrupt
|
|
1439
|
+
// settings.local.json before init runs the existing-global
|
|
1440
|
+
// branch. mergeSessionHook throws diskError → init catches →
|
|
1441
|
+
// sessionSyncAction = "failed" → JSON serialization.
|
|
1442
|
+
makeNpxArgv();
|
|
1443
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1444
|
+
|
|
1445
|
+
// Pre-seed a corrupt settings file so mergeSessionHook throws.
|
|
1446
|
+
const claudeDir = join(process.cwd(), ".claude");
|
|
1447
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
1448
|
+
writeFileSync(
|
|
1449
|
+
join(claudeDir, "settings.local.json"),
|
|
1450
|
+
"{ this is not valid JSON",
|
|
1451
|
+
);
|
|
1452
|
+
|
|
1453
|
+
// Pre-existing global on PATH so init takes Branch 4 (which
|
|
1454
|
+
// calls mergeSessionHook directly with binaryPath).
|
|
1455
|
+
const { installShim } = await import("../helpers/skillrepo-shim.mjs");
|
|
1456
|
+
shimSandbox = mkdtempSync(join(tmpdir(), "sr-init-failed-"));
|
|
1457
|
+
shimHandle = installShim(shimSandbox);
|
|
1458
|
+
|
|
1459
|
+
const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
|
|
1460
|
+
const spawn = makeMockSpawn({ exitCode: 0 });
|
|
1461
|
+
|
|
1462
|
+
await runInit(
|
|
1463
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
|
|
1464
|
+
{ stdout, stderr },
|
|
1465
|
+
{ spawn },
|
|
1466
|
+
);
|
|
1467
|
+
|
|
1468
|
+
// JSON output is still valid (no unhandled exception aborted
|
|
1469
|
+
// serialization) and reports the "failed" action enum value.
|
|
1470
|
+
const json = JSON.parse(stdout.text());
|
|
1471
|
+
assert.equal(
|
|
1472
|
+
json.sessionSync.action,
|
|
1473
|
+
"failed",
|
|
1474
|
+
"corrupt settings.local.json must surface as action='failed'",
|
|
1475
|
+
);
|
|
1476
|
+
// Sync block still present — failure was non-fatal.
|
|
1477
|
+
assert.ok(json.sync, "sync block must be present after disk error");
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
it("getCliVersion crash recovery: init exits 0 when getCliVersion throws (defensive)", async () => {
|
|
1481
|
+
// QA gap fix (BLOCKING): If the CLI's package.json is corrupt,
|
|
1482
|
+
// getCliVersion throws — but init must not crash. We exercise
|
|
1483
|
+
// the recovery path by stubbing the cli-version module to throw.
|
|
1484
|
+
//
|
|
1485
|
+
// We can't easily mock the import statically, so we use a
|
|
1486
|
+
// dynamic import + property override pattern: import the module,
|
|
1487
|
+
// monkey-patch getCliVersion, run init, restore.
|
|
1488
|
+
makeNpxArgv();
|
|
1489
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1490
|
+
|
|
1491
|
+
// Inject a `getCliVersion` stub via the deps parameter that
|
|
1492
|
+
// throws as if the CLI's package.json were broken. ESM exports
|
|
1493
|
+
// can't be monkey-patched, so deps injection is the only way
|
|
1494
|
+
// to exercise the defensive try/catch.
|
|
1495
|
+
const throwingGetCliVersion = () => {
|
|
1496
|
+
throw new Error("synthetic: package.json missing version");
|
|
1497
|
+
};
|
|
1498
|
+
|
|
1499
|
+
const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
|
|
1500
|
+
const spawn = makeMockSpawn({ exitCode: 0 });
|
|
1501
|
+
|
|
1502
|
+
// Should NOT throw — init's defensive try/catch must catch
|
|
1503
|
+
// the version-read error and continue.
|
|
1504
|
+
await runInit(
|
|
1505
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
1506
|
+
{ stdout, stderr },
|
|
1507
|
+
{ spawn, getCliVersion: throwingGetCliVersion },
|
|
1508
|
+
);
|
|
1509
|
+
|
|
1510
|
+
const out = stdout.text();
|
|
1511
|
+
assert.match(
|
|
1512
|
+
out,
|
|
1513
|
+
/Could not determine CLI version for global install/,
|
|
1514
|
+
"init must surface the version-read failure with actionable instructions",
|
|
1515
|
+
);
|
|
1516
|
+
assert.match(
|
|
1517
|
+
out,
|
|
1518
|
+
/npm install -g skillrepo/,
|
|
1519
|
+
"fallback message must include manual install hint",
|
|
1520
|
+
);
|
|
1521
|
+
// Spawn must NOT have been called (we never had a version to
|
|
1522
|
+
// pin to).
|
|
1523
|
+
assert.equal(spawn.calls.length, 0);
|
|
1524
|
+
// Init still completed.
|
|
1525
|
+
assert.match(out, /Library is up to date|No skills in library yet/);
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
it("idempotency: re-running init after a successful auto-install does NOT spawn again", async () => {
|
|
1529
|
+
// Simulate the second run: a global skillrepo is already on
|
|
1530
|
+
// PATH from a prior init. resolveGlobalBinary finds it and
|
|
1531
|
+
// init skips the auto-install branch entirely (Branch 4).
|
|
1532
|
+
makeNpxArgv();
|
|
1533
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
1534
|
+
|
|
1535
|
+
const { installShim } = await import("../helpers/skillrepo-shim.mjs");
|
|
1536
|
+
shimSandbox = mkdtempSync(join(tmpdir(), "sr-init-idempotent-"));
|
|
1537
|
+
shimHandle = installShim(shimSandbox);
|
|
1538
|
+
|
|
1539
|
+
const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
|
|
1540
|
+
const spawn = makeMockSpawn({ exitCode: 0 });
|
|
1541
|
+
|
|
1542
|
+
// First run with shim already in place — install branch skipped.
|
|
1543
|
+
await runInit(
|
|
1544
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
1545
|
+
{ stdout, stderr },
|
|
1546
|
+
{ spawn },
|
|
1547
|
+
);
|
|
1548
|
+
assert.equal(spawn.calls.length, 0, "first run with pre-existing global");
|
|
1549
|
+
|
|
1550
|
+
// Second run — same. Spawn must STILL be 0.
|
|
1551
|
+
const stdout2 = createCaptureStream();
|
|
1552
|
+
const stderr2 = createCaptureStream();
|
|
1553
|
+
await runInit(
|
|
1554
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
1555
|
+
{ stdout: stdout2, stderr: stderr2 },
|
|
1556
|
+
{ spawn },
|
|
1557
|
+
);
|
|
1558
|
+
assert.equal(spawn.calls.length, 0, "second run also uses existing global");
|
|
1559
|
+
|
|
1560
|
+
// The hook should be unchanged.
|
|
1561
|
+
const settingsPath = join(
|
|
1562
|
+
process.cwd(),
|
|
1563
|
+
".claude",
|
|
1564
|
+
"settings.local.json",
|
|
1565
|
+
);
|
|
1566
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
1567
|
+
// Only one SessionStart group, only one hook in it — no duplicates.
|
|
1568
|
+
assert.equal(settings.hooks.SessionStart.length, 1);
|
|
1569
|
+
assert.equal(settings.hooks.SessionStart[0].hooks.length, 1);
|
|
1570
|
+
});
|
|
910
1571
|
});
|