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
|
@@ -20,12 +20,18 @@ import { readConfig } from "../../lib/config.mjs";
|
|
|
20
20
|
import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
21
21
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
22
22
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
23
|
+
import {
|
|
24
|
+
captureHome,
|
|
25
|
+
setSandboxHome,
|
|
26
|
+
restoreHome,
|
|
27
|
+
} from "../helpers/sandbox-home.mjs";
|
|
23
28
|
|
|
24
29
|
let sandbox;
|
|
25
30
|
let server;
|
|
26
31
|
let serverUrl;
|
|
27
32
|
let originalCwd;
|
|
28
|
-
|
|
33
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
34
|
+
let originalHomeEnv;
|
|
29
35
|
let stdout;
|
|
30
36
|
let stderr;
|
|
31
37
|
const VALID_KEY = "sk_live_init_test";
|
|
@@ -38,9 +44,9 @@ async function setup() {
|
|
|
38
44
|
mkdirSync(join(sandbox, "project", ".claude"), { recursive: true });
|
|
39
45
|
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
40
46
|
originalCwd = process.cwd();
|
|
41
|
-
|
|
47
|
+
originalHomeEnv = captureHome();
|
|
42
48
|
process.chdir(join(sandbox, "project"));
|
|
43
|
-
|
|
49
|
+
setSandboxHome(join(sandbox, "home"));
|
|
44
50
|
delete process.env.SKILLREPO_ACCESS_KEY;
|
|
45
51
|
delete process.env.SKILLREPO_URL;
|
|
46
52
|
|
|
@@ -55,7 +61,7 @@ async function setup() {
|
|
|
55
61
|
async function teardown() {
|
|
56
62
|
if (server) await server.stop();
|
|
57
63
|
process.chdir(originalCwd);
|
|
58
|
-
|
|
64
|
+
restoreHome(originalHomeEnv);
|
|
59
65
|
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
60
66
|
server = null;
|
|
61
67
|
}
|
|
@@ -499,25 +505,29 @@ describe("runInit — stale-key handling", () => {
|
|
|
499
505
|
// init tests verify the ORCHESTRATION: --yes path, --no-session-sync
|
|
500
506
|
// opt-out, --json output shape, and the non-fatal disk-error path.
|
|
501
507
|
|
|
502
|
-
import { chmodSync as _chmodSync } from "node:fs";
|
|
503
508
|
import { SESSION_HOOK_FINGERPRINT as _FINGERPRINT } from "../../lib/artifact-registry.mjs";
|
|
509
|
+
import { installShim, uninstallShim } from "../helpers/skillrepo-shim.mjs";
|
|
510
|
+
|
|
511
|
+
/** @type {ReturnType<typeof installShim> | undefined} */
|
|
512
|
+
let _shimHandle;
|
|
504
513
|
|
|
505
514
|
async function setupWithShim() {
|
|
506
515
|
await setup();
|
|
507
|
-
// Drop a
|
|
508
|
-
//
|
|
509
|
-
// global install.
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
+
// Drop a cross-platform `skillrepo` shim into HOME/bin so the
|
|
517
|
+
// CLI's binary resolver (which/where) finds it rather than
|
|
518
|
+
// depending on whether the dev/CI machine has a global install.
|
|
519
|
+
_shimHandle = installShim(process.env.HOME);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function teardownWithShim() {
|
|
523
|
+
uninstallShim(_shimHandle);
|
|
524
|
+
_shimHandle = undefined;
|
|
525
|
+
await teardown();
|
|
516
526
|
}
|
|
517
527
|
|
|
518
528
|
describe("runInit — session sync (#884)", () => {
|
|
519
529
|
beforeEach(setupWithShim);
|
|
520
|
-
afterEach(
|
|
530
|
+
afterEach(teardownWithShim);
|
|
521
531
|
|
|
522
532
|
it("--yes installs the hook at step 6 by default", async () => {
|
|
523
533
|
// INTENT: the architect-designed default for --yes mode is
|
|
@@ -695,3 +705,867 @@ describe("runInit — session sync (#884)", () => {
|
|
|
695
705
|
assert.equal(json.sessionSync.path, "~/.claude/settings.local.json");
|
|
696
706
|
});
|
|
697
707
|
});
|
|
708
|
+
|
|
709
|
+
// ── v3.1.1 patch fixes: init UX bugs surfaced by real-world npx use ──
|
|
710
|
+
//
|
|
711
|
+
// Real-user `npx skillrepo@latest init` session surfaced four bugs in
|
|
712
|
+
// the v3.1.0 init output. The tests below lock each fix as a
|
|
713
|
+
// behavioral contract:
|
|
714
|
+
//
|
|
715
|
+
// 1. "Next steps" hardcoded `skillrepo` even under npx — would fail
|
|
716
|
+
// with "command not found" for users without a global install.
|
|
717
|
+
// 2. Session-sync hook installed under npx with a cache-temporary
|
|
718
|
+
// path that breaks on cache eviction. (Covered in
|
|
719
|
+
// session-hook.test.mjs via isNpxInvocation guard test.)
|
|
720
|
+
// 3. Step-7 zero-delta message conflated "empty library" with
|
|
721
|
+
// "nothing changed since last sync" — lied to users who had
|
|
722
|
+
// synced skills but no server-side changes since.
|
|
723
|
+
// 4. `--verbose` rejected as Unknown argument by resolveFlags.
|
|
724
|
+
// (Covered in cli-config.test.mjs.)
|
|
725
|
+
|
|
726
|
+
describe("runInit — v3.1.1 zero-delta message (bug 3)", () => {
|
|
727
|
+
beforeEach(setup);
|
|
728
|
+
afterEach(teardown);
|
|
729
|
+
|
|
730
|
+
it("reports 'Library is up to date' when a delta sync returns zero changes", async () => {
|
|
731
|
+
// Reproduces the real-user bug: a user with an existing
|
|
732
|
+
// .last-sync from a prior v3.0.0 session runs `skillrepo init`
|
|
733
|
+
// again. Init's step 7 sends `since=<prior syncedAt>`. Server
|
|
734
|
+
// returns empty skills[] because nothing changed server-side.
|
|
735
|
+
// Counters (added/updated/removed) all zero.
|
|
736
|
+
//
|
|
737
|
+
// Before the fix: init printed "No skills in library yet"
|
|
738
|
+
// regardless of whether the library was empty or fully synced.
|
|
739
|
+
// After: init distinguishes via the new `fullSync` field in
|
|
740
|
+
// SyncSummary and prints "Library is up to date (no changes
|
|
741
|
+
// since last sync)" for the delta case.
|
|
742
|
+
//
|
|
743
|
+
// Pre-seed a .last-sync file simulating the prior sync state.
|
|
744
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
745
|
+
writeFileSync(
|
|
746
|
+
join(process.env.HOME, ".claude", "skillrepo", ".last-sync"),
|
|
747
|
+
JSON.stringify({
|
|
748
|
+
schemaVersion: 1,
|
|
749
|
+
etag: '"old-v300-format-etag"',
|
|
750
|
+
syncedAt: "2026-04-15T12:00:00.000Z",
|
|
751
|
+
}),
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
// Server returns empty skills[] (no changes since last sync).
|
|
755
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2026-04-16T12:00:00.000Z" });
|
|
756
|
+
|
|
757
|
+
await runInit(
|
|
758
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
759
|
+
{ stdout, stderr },
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
const out = stdout.text();
|
|
763
|
+
assert.match(
|
|
764
|
+
out,
|
|
765
|
+
/Library is up to date/,
|
|
766
|
+
"zero-delta on delta sync must report 'up to date', not 'no skills in library yet'",
|
|
767
|
+
);
|
|
768
|
+
assert.doesNotMatch(
|
|
769
|
+
out,
|
|
770
|
+
/No skills in library yet/,
|
|
771
|
+
"the misleading 'no skills' message must NOT fire when .last-sync existed",
|
|
772
|
+
);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it("still reports 'No skills in library yet' on a true first-run full sync with zero skills", async () => {
|
|
776
|
+
// The other side of bug 3: when there's NO prior .last-sync and
|
|
777
|
+
// the server returns zero skills, the library IS genuinely
|
|
778
|
+
// empty. The message should still say so.
|
|
779
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "2026-04-16T12:00:00.000Z" });
|
|
780
|
+
|
|
781
|
+
await runInit(
|
|
782
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
783
|
+
{ stdout, stderr },
|
|
784
|
+
);
|
|
785
|
+
|
|
786
|
+
const out = stdout.text();
|
|
787
|
+
assert.match(out, /No skills in library yet/);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it("304 Not Modified reports 'Library is up to date' (neutral phrasing)", async () => {
|
|
791
|
+
// 304 path: server returned "nothing changed" at the HTTP
|
|
792
|
+
// level. Definitively up-to-date regardless of whether library
|
|
793
|
+
// is populated or empty.
|
|
794
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
795
|
+
writeFileSync(
|
|
796
|
+
join(process.env.HOME, ".claude", "skillrepo", ".last-sync"),
|
|
797
|
+
JSON.stringify({
|
|
798
|
+
schemaVersion: 1,
|
|
799
|
+
etag: '"match-me"',
|
|
800
|
+
syncedAt: "2026-04-15T12:00:00.000Z",
|
|
801
|
+
}),
|
|
802
|
+
);
|
|
803
|
+
server.setEtag('"match-me"');
|
|
804
|
+
|
|
805
|
+
await runInit(
|
|
806
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
807
|
+
{ stdout, stderr },
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
assert.match(stdout.text(), /Library is up to date/);
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
describe("runInit — v3.1.1 Next steps prefix (bug 1)", () => {
|
|
815
|
+
// Process-state isolation: the tests below depend on controlling
|
|
816
|
+
// isNpxInvocation()'s outputs. The test host's shell environment
|
|
817
|
+
// may have `_` set to some launching command (the enclosing `npm
|
|
818
|
+
// run check`, `node --test`, etc.). beforeEach force-clears the
|
|
819
|
+
// two isNpxInvocation signals (argv[1] and `_`) plus the
|
|
820
|
+
// now-unused `npm_command` — the latter is cleared defensively
|
|
821
|
+
// because older versions of this codebase treated it as an npx
|
|
822
|
+
// signal, and belt-and-suspenders cleanup costs nothing.
|
|
823
|
+
let originalArgv;
|
|
824
|
+
let originalNpmCommand;
|
|
825
|
+
let originalUnderscore;
|
|
826
|
+
|
|
827
|
+
beforeEach(async () => {
|
|
828
|
+
await setup();
|
|
829
|
+
originalArgv = process.argv;
|
|
830
|
+
originalNpmCommand = process.env.npm_command;
|
|
831
|
+
originalUnderscore = process.env._;
|
|
832
|
+
// Non-npx state:
|
|
833
|
+
process.argv = ["/usr/local/bin/node", "/usr/local/bin/skillrepo"];
|
|
834
|
+
delete process.env.npm_command;
|
|
835
|
+
delete process.env._;
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
afterEach(async () => {
|
|
839
|
+
process.argv = originalArgv;
|
|
840
|
+
if (originalNpmCommand === undefined) delete process.env.npm_command;
|
|
841
|
+
else process.env.npm_command = originalNpmCommand;
|
|
842
|
+
if (originalUnderscore === undefined) delete process.env._;
|
|
843
|
+
else process.env._ = originalUnderscore;
|
|
844
|
+
await teardown();
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it("shows bare `skillrepo` prefix when running from a stable install", async () => {
|
|
848
|
+
// With all npx signals cleared (beforeEach above), isNpxInvocation
|
|
849
|
+
// returns false and the bare prefix is used in the Next Steps
|
|
850
|
+
// block.
|
|
851
|
+
//
|
|
852
|
+
// NOTE ON REGEX: "npm install -g skillrepo" appears in TWO places
|
|
853
|
+
// that look alike but come from different code paths:
|
|
854
|
+
//
|
|
855
|
+
// 1. Step 6/7 session-sync skipped message (when the binary
|
|
856
|
+
// can't be resolved — in the test environment, `which
|
|
857
|
+
// skillrepo` doesn't find anything real).
|
|
858
|
+
// 2. Next Steps tip (only shown under npx).
|
|
859
|
+
//
|
|
860
|
+
// We want to assert only #2 is absent. Match the EXACT "Tip:"
|
|
861
|
+
// prefix to scope the assertion to Next Steps.
|
|
862
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
863
|
+
await runInit(
|
|
864
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes"],
|
|
865
|
+
{ stdout, stderr },
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
const out = stdout.text();
|
|
869
|
+
// The commands in Next Steps are bare, not prefixed.
|
|
870
|
+
assert.match(out, /^ +• skillrepo list/m);
|
|
871
|
+
assert.doesNotMatch(
|
|
872
|
+
out,
|
|
873
|
+
/npx skillrepo list/,
|
|
874
|
+
"bare install must NOT show the npx-prefixed hints",
|
|
875
|
+
);
|
|
876
|
+
// The "Tip: …" line appears only under npx.
|
|
877
|
+
assert.doesNotMatch(
|
|
878
|
+
out,
|
|
879
|
+
/Tip: `npm install -g skillrepo`/,
|
|
880
|
+
"bare install must NOT show the npx-mode global-install tip",
|
|
881
|
+
);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it("shows `npx skillrepo` prefix and global-install tip under npx invocation", async () => {
|
|
885
|
+
// Simulate npx by stuffing argv[1] with an _npx cache path.
|
|
886
|
+
// isNpxInvocation() returns true and the output changes shape.
|
|
887
|
+
process.argv = [
|
|
888
|
+
"/usr/local/bin/node",
|
|
889
|
+
"/Users/alice/.npm/_npx/dc129a78aca3fc9c/node_modules/.bin/skillrepo",
|
|
890
|
+
];
|
|
891
|
+
|
|
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.
|
|
901
|
+
await runInit(
|
|
902
|
+
["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
|
|
903
|
+
{ stdout, stderr },
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
const out = stdout.text();
|
|
907
|
+
assert.match(
|
|
908
|
+
out,
|
|
909
|
+
/npx skillrepo list/,
|
|
910
|
+
"npx invocation must prefix commands with `npx`",
|
|
911
|
+
);
|
|
912
|
+
assert.match(
|
|
913
|
+
out,
|
|
914
|
+
/Tip: `npm install -g skillrepo`/,
|
|
915
|
+
"npx invocation must show the Next-Steps global-install tip",
|
|
916
|
+
);
|
|
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
|
+
});
|
|
1571
|
+
});
|