skillrepo 4.8.0 → 4.8.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/bin/skillrepo.mjs +6 -3
- package/package.json +1 -1
- package/src/commands/add.mjs +2 -1
- package/src/commands/init.mjs +15 -5
- package/src/lib/http.mjs +25 -11
- package/src/test/commands/add.test.mjs +79 -2
- package/src/test/commands/get.test.mjs +131 -2
- package/src/test/commands/init-session-sync.test.mjs +724 -0
- package/src/test/commands/init.test.mjs +159 -2
- package/src/test/commands/list.test.mjs +573 -1
- package/src/test/commands/publish.test.mjs +136 -3
- package/src/test/commands/push.test.mjs +280 -1
- package/src/test/commands/remove.test.mjs +221 -2
- package/src/test/commands/search.test.mjs +203 -1
- package/src/test/commands/session-sync.test.mjs +227 -1
- package/src/test/commands/uninstall.test.mjs +216 -0
- package/src/test/commands/update.test.mjs +218 -0
- package/src/test/dispatcher.test.mjs +103 -2
- package/src/test/e2e/advertised-surface.test.mjs +207 -0
- package/src/test/e2e/cli-commands.test.mjs +1 -1
- package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
- package/src/test/e2e/update-check-suppression.test.mjs +135 -0
- package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
- package/src/test/lib/browser-open.test.mjs +43 -0
- package/src/test/lib/config.test.mjs +87 -0
- package/src/test/lib/crypto-shas.test.mjs +17 -0
- package/src/test/lib/file-write.test.mjs +244 -0
- package/src/test/lib/fs-utils.test.mjs +259 -0
- package/src/test/lib/global-install.test.mjs +134 -0
- package/src/test/lib/http-timeout.test.mjs +114 -0
- package/src/test/lib/http.test.mjs +616 -1
- package/src/test/lib/mcp-merge.test.mjs +157 -0
- package/src/test/lib/npm-update-check.test.mjs +180 -0
- package/src/test/lib/placement-walk.test.mjs +132 -0
- package/src/test/lib/skill-walk.test.mjs +39 -1
- package/src/test/lib/sync.test.mjs +139 -5
- package/src/test/lib/telemetry.test.mjs +34 -0
- package/src/test/mergers/claude-mcp.test.mjs +30 -0
- package/src/test/mergers/cursor-mcp.test.mjs +115 -0
- package/src/test/mergers/env-local.test.mjs +126 -0
- package/src/test/mergers/vscode-mcp.test.mjs +177 -0
- package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
- package/src/test/resolve-key.test.mjs +33 -0
|
@@ -13,6 +13,12 @@ import { tmpdir } from "node:os";
|
|
|
13
13
|
|
|
14
14
|
import { runUpdate } from "../../commands/update.mjs";
|
|
15
15
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
16
|
+
import {
|
|
17
|
+
readLastSync,
|
|
18
|
+
writeLastSync,
|
|
19
|
+
FULL_RESYNC_FLOOR,
|
|
20
|
+
cliVersionBelowFloor,
|
|
21
|
+
} from "../../lib/sync.mjs";
|
|
16
22
|
import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
17
23
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
18
24
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
@@ -111,6 +117,43 @@ describe("runUpdate — happy path", () => {
|
|
|
111
117
|
});
|
|
112
118
|
});
|
|
113
119
|
|
|
120
|
+
describe("runUpdate — summary printer (removed line)", () => {
|
|
121
|
+
beforeEach(setup);
|
|
122
|
+
afterEach(teardown);
|
|
123
|
+
|
|
124
|
+
it("prints the removed line in the human summary when a tombstone deletes a skill", async () => {
|
|
125
|
+
// printSummary only emits `− N removed` when `s.removed > 0`. Every
|
|
126
|
+
// other human-mode test produces added/updated-only summaries, so the
|
|
127
|
+
// `if (s.removed > 0)` true branch was never taken. Sync a skill,
|
|
128
|
+
// then re-sync with it tombstoned so `removed` is non-zero and the
|
|
129
|
+
// line is rendered.
|
|
130
|
+
server.setLibraryResponse({
|
|
131
|
+
skills: [makeSkill("doomed")],
|
|
132
|
+
removals: [],
|
|
133
|
+
syncedAt: "x",
|
|
134
|
+
});
|
|
135
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"], { stdout });
|
|
136
|
+
assert.ok(existsSync(resolvePlacementDir("claudeProject", "doomed")), "precondition: skill on disk");
|
|
137
|
+
|
|
138
|
+
stdout.clear();
|
|
139
|
+
server.resetLibraryInspection?.();
|
|
140
|
+
server.setLibraryResponse({
|
|
141
|
+
skills: [],
|
|
142
|
+
removals: [{ owner: "alice", name: "doomed", removedAt: "x" }],
|
|
143
|
+
syncedAt: "y",
|
|
144
|
+
});
|
|
145
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"], { stdout });
|
|
146
|
+
|
|
147
|
+
const out = stdout.text();
|
|
148
|
+
assert.match(out, /Library sync complete/);
|
|
149
|
+
assert.match(out, /−\s*1 removed/, "the removed line must render when removed > 0");
|
|
150
|
+
assert.ok(
|
|
151
|
+
!existsSync(resolvePlacementDir("claudeProject", "doomed")),
|
|
152
|
+
"the tombstoned skill is gone from disk",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
114
157
|
describe("runUpdate — credential resolution", () => {
|
|
115
158
|
beforeEach(setup);
|
|
116
159
|
afterEach(teardown);
|
|
@@ -335,6 +378,48 @@ describe("runUpdate — --session-hook contract", () => {
|
|
|
335
378
|
);
|
|
336
379
|
assert.equal(stdout.text(), "", "zero-delta 200 is silent");
|
|
337
380
|
});
|
|
381
|
+
|
|
382
|
+
it("an UNEXPECTED positional arg is rejected, then caught into the exit-0 failure line", async () => {
|
|
383
|
+
// The session-hook `acceptPositional` callback accepts only
|
|
384
|
+
// --session-hook / --silent and returns false for anything else, so
|
|
385
|
+
// a stray bare positional (`extra`) makes resolveFlags throw
|
|
386
|
+
// "Unexpected"/"extra argument". Because that throw is INSIDE the
|
|
387
|
+
// session-hook try/catch, the contract holds: no rejection, and the
|
|
388
|
+
// one-line failure marker is emitted. Covers the acceptPositional
|
|
389
|
+
// `return false` branch in session-hook mode.
|
|
390
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
391
|
+
await assert.doesNotReject(
|
|
392
|
+
() =>
|
|
393
|
+
runUpdate(
|
|
394
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook", "stray-positional"],
|
|
395
|
+
{ stdout },
|
|
396
|
+
),
|
|
397
|
+
"an unexpected positional must be caught by the exit-0 contract, not thrown",
|
|
398
|
+
);
|
|
399
|
+
assert.match(stdout.text(), /\[SkillRepo\] Sync failed:/);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("swallows a throwing stdout.write in the failure path (closed-pipe safety)", async () => {
|
|
403
|
+
// The failure branch wraps its `stdout.write` in try/catch so a
|
|
404
|
+
// closed pipe (the hook runner went away) can't turn a sync failure
|
|
405
|
+
// into an unhandled exception. Inject a stdout whose write throws and
|
|
406
|
+
// point at an unreachable server so the failure path runs. The call
|
|
407
|
+
// must still resolve without throwing. Covers the inner write catch.
|
|
408
|
+
const throwingStdout = {
|
|
409
|
+
write() {
|
|
410
|
+
throw new Error("EPIPE: write to closed hook pipe");
|
|
411
|
+
},
|
|
412
|
+
isTTY: false,
|
|
413
|
+
};
|
|
414
|
+
await assert.doesNotReject(
|
|
415
|
+
() =>
|
|
416
|
+
runUpdate(
|
|
417
|
+
["--key", VALID_KEY, "--url", "http://127.0.0.1:1", "--session-hook"],
|
|
418
|
+
{ stdout: throwingStdout },
|
|
419
|
+
),
|
|
420
|
+
"a throwing stdout.write in the failure path must be swallowed",
|
|
421
|
+
);
|
|
422
|
+
});
|
|
338
423
|
});
|
|
339
424
|
|
|
340
425
|
// ── --silent mode (#1240) ─────────────────────────────────────────────
|
|
@@ -437,6 +522,22 @@ describe("runUpdate — --silent contract", () => {
|
|
|
437
522
|
);
|
|
438
523
|
});
|
|
439
524
|
|
|
525
|
+
it("rejects an UNEXPECTED positional arg under --silent (propagates, not silent-swallowed)", async () => {
|
|
526
|
+
// The silent-mode `acceptPositional` callback accepts ONLY --silent
|
|
527
|
+
// and returns false for anything else. A stray positional therefore
|
|
528
|
+
// makes resolveFlags throw a validationError, which — unlike
|
|
529
|
+
// session-hook mode — is NOT caught: silent mode propagates real exit
|
|
530
|
+
// codes. Covers the silent-mode acceptPositional `return false` branch.
|
|
531
|
+
await assert.rejects(
|
|
532
|
+
() =>
|
|
533
|
+
runUpdate(
|
|
534
|
+
["--key", VALID_KEY, "--url", serverUrl, "--silent", "stray-positional"],
|
|
535
|
+
{ stdout },
|
|
536
|
+
),
|
|
537
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
|
|
440
541
|
it("--silent + --session-hook simultaneously: session-hook wins (defensive precedence) (#1239 QA)", async () => {
|
|
441
542
|
// INTENT: defensive precedence test. The two flags have different
|
|
442
543
|
// exit-code contracts: --session-hook is exit-0-on-everything
|
|
@@ -471,3 +572,120 @@ describe("runUpdate — --silent contract", () => {
|
|
|
471
572
|
);
|
|
472
573
|
});
|
|
473
574
|
});
|
|
575
|
+
|
|
576
|
+
// ── #1911 membership heal — through the `update` COMMAND ──────────────
|
|
577
|
+
//
|
|
578
|
+
// The engine-level heal is locked in sync.test.mjs. But #1911 escaped
|
|
579
|
+
// because the engine and the command layer were tested separately: a
|
|
580
|
+
// stuck client runs `skillrepo update` (or the SessionStart hook runs it
|
|
581
|
+
// for them), and what matters to the user is that the missing skill
|
|
582
|
+
// actually lands on DISK via that command — not just that runSync would
|
|
583
|
+
// have fetched it. These tests drive the heal through runUpdate end to
|
|
584
|
+
// end (normal mode AND the --session-hook black-hole) and assert the
|
|
585
|
+
// delivery a 304 would have silently skipped.
|
|
586
|
+
function placedSkillMd(name) {
|
|
587
|
+
return join(resolvePlacementDir("claudeProject", name), "SKILL.md");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
describe("runUpdate — #1911 membership heal lands the skill on disk", () => {
|
|
591
|
+
beforeEach(setup);
|
|
592
|
+
afterEach(teardown);
|
|
593
|
+
|
|
594
|
+
it("a pre-fix .last-sync forces a full re-fetch that delivers a skill a 304 would have skipped", async () => {
|
|
595
|
+
// 1) First sync: library = [alpha]. alpha lands; .last-sync stamped
|
|
596
|
+
// with the CURRENT (>= floor) version and etag v1.
|
|
597
|
+
server.setEtag('"v1"');
|
|
598
|
+
server.setLibraryResponse({
|
|
599
|
+
skills: [makeSkill("alpha")],
|
|
600
|
+
removals: [],
|
|
601
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
602
|
+
});
|
|
603
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"], { stdout });
|
|
604
|
+
assert.ok(existsSync(placedSkillMd("alpha")), "precondition: alpha on disk after first sync");
|
|
605
|
+
|
|
606
|
+
// 2) Simulate the stuck pre-fix client: a NEW skill (beta) is now in
|
|
607
|
+
// the library, but the etag is UNCHANGED (the #1911 bug — the
|
|
608
|
+
// client cached an etag that still 304s), and .last-sync was
|
|
609
|
+
// written by a pre-fix CLI (4.7.0). A normal conditional sync
|
|
610
|
+
// would send If-None-Match v1 → 304 → beta NEVER delivered.
|
|
611
|
+
server.setLibraryResponse({
|
|
612
|
+
skills: [makeSkill("alpha"), makeSkill("beta")],
|
|
613
|
+
removals: [],
|
|
614
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
615
|
+
});
|
|
616
|
+
const prior = readLastSync();
|
|
617
|
+
writeLastSync({
|
|
618
|
+
etag: prior.etag,
|
|
619
|
+
syncedAt: prior.syncedAt,
|
|
620
|
+
skills: prior.skills,
|
|
621
|
+
cliVersion: "4.7.0",
|
|
622
|
+
});
|
|
623
|
+
assert.ok(!existsSync(placedSkillMd("beta")), "precondition: beta NOT yet on disk");
|
|
624
|
+
server.resetLibraryInspection();
|
|
625
|
+
stdout.clear();
|
|
626
|
+
|
|
627
|
+
// 3) `skillrepo update` heals: drops BOTH conditional headers, full
|
|
628
|
+
// fetch, beta lands on disk. This is the delivery #1911 dropped.
|
|
629
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"], { stdout });
|
|
630
|
+
assert.equal(server.getLastLibraryIfNoneMatch(), null, "heal must NOT send If-None-Match");
|
|
631
|
+
assert.equal(server.getLastLibrarySince(), null, "heal must NOT send since");
|
|
632
|
+
assert.ok(
|
|
633
|
+
existsSync(placedSkillMd("beta")),
|
|
634
|
+
"the skill a 304 would have skipped MUST land on disk via `update`",
|
|
635
|
+
);
|
|
636
|
+
assert.match(stdout.text(), /added/, "user sees a non-empty sync summary");
|
|
637
|
+
|
|
638
|
+
// 4) The heal re-stamped the running version → the next `update`
|
|
639
|
+
// resumes normal delta (sends the conditional, gets a 304).
|
|
640
|
+
assert.equal(
|
|
641
|
+
cliVersionBelowFloor(readLastSync().cliVersion, FULL_RESYNC_FLOOR),
|
|
642
|
+
false,
|
|
643
|
+
"heal must restamp cliVersion at/above the floor so it heals only once",
|
|
644
|
+
);
|
|
645
|
+
server.resetLibraryInspection();
|
|
646
|
+
stdout.clear();
|
|
647
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude"], { stdout });
|
|
648
|
+
assert.equal(
|
|
649
|
+
server.getLastLibraryIfNoneMatch(),
|
|
650
|
+
'"v1"',
|
|
651
|
+
"post-heal `update` resumes delta (sends the conditional)",
|
|
652
|
+
);
|
|
653
|
+
assert.match(stdout.text(), /up to date/, "post-heal steady state is a normal 304");
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("the heal also delivers under --session-hook (the SessionStart path) without throwing", async () => {
|
|
657
|
+
// The SessionStart hook runs `skillrepo update --session-hook`. A
|
|
658
|
+
// stuck active user is MORE exposed (the hook runs every session, so
|
|
659
|
+
// `since` is always recent). The heal must still land the missing
|
|
660
|
+
// skill, the command must not throw (exit-0 contract), and the user
|
|
661
|
+
// gets the one-line summary.
|
|
662
|
+
server.setEtag('"v1"');
|
|
663
|
+
server.setLibraryResponse({
|
|
664
|
+
skills: [makeSkill("alpha")],
|
|
665
|
+
removals: [],
|
|
666
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
667
|
+
});
|
|
668
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude", "--session-hook"], { stdout });
|
|
669
|
+
|
|
670
|
+
server.setLibraryResponse({
|
|
671
|
+
skills: [makeSkill("alpha"), makeSkill("beta")],
|
|
672
|
+
removals: [],
|
|
673
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
674
|
+
});
|
|
675
|
+
const prior = readLastSync();
|
|
676
|
+
writeLastSync({ etag: prior.etag, syncedAt: prior.syncedAt, skills: prior.skills, cliVersion: "4.7.0" });
|
|
677
|
+
server.resetLibraryInspection();
|
|
678
|
+
stdout.clear();
|
|
679
|
+
|
|
680
|
+
await assert.doesNotReject(() =>
|
|
681
|
+
runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "claude", "--session-hook"], { stdout }),
|
|
682
|
+
);
|
|
683
|
+
assert.equal(server.getLastLibraryIfNoneMatch(), null, "session-hook heal drops If-None-Match");
|
|
684
|
+
assert.ok(existsSync(placedSkillMd("beta")), "session-hook heal lands the missing skill on disk");
|
|
685
|
+
assert.match(
|
|
686
|
+
stdout.text(),
|
|
687
|
+
/^\[SkillRepo\] Library synced: \d+ added, \d+ updated, \d+ removed\.\n$/,
|
|
688
|
+
"session-hook emits exactly one summary line after a healing sync",
|
|
689
|
+
);
|
|
690
|
+
});
|
|
691
|
+
});
|
|
@@ -71,7 +71,7 @@ describe("dispatcher — top-level help", () => {
|
|
|
71
71
|
assert.equal(r.status, 0);
|
|
72
72
|
assert.match(r.stdout, /SkillRepo CLI/);
|
|
73
73
|
// All 7 commands listed
|
|
74
|
-
for (const cmd of ["init", "update", "get", "add", "push", "remove", "list", "search"]) {
|
|
74
|
+
for (const cmd of ["init", "update", "get", "add", "push", "publish", "unpublish", "remove", "list", "search", "uninstall", "session-sync"]) {
|
|
75
75
|
assert.match(r.stdout, new RegExp(`\\b${cmd}\\b`), `expected to see "${cmd}" in help`);
|
|
76
76
|
}
|
|
77
77
|
});
|
|
@@ -123,7 +123,7 @@ describe("dispatcher — unknown command", () => {
|
|
|
123
123
|
describe("dispatcher — per-command help", () => {
|
|
124
124
|
// PR1 only ships init for real; the other 6 are stubs but still
|
|
125
125
|
// route --help correctly.
|
|
126
|
-
for (const cmd of ["init", "update", "get", "add", "push", "remove", "list", "search"]) {
|
|
126
|
+
for (const cmd of ["init", "update", "get", "add", "push", "publish", "unpublish", "remove", "list", "search", "uninstall", "session-sync"]) {
|
|
127
127
|
it(`\`skillrepo ${cmd} --help\` prints command-specific help`, async () => {
|
|
128
128
|
const r = await runCli([cmd, "--help"]);
|
|
129
129
|
assert.equal(r.status, 0);
|
|
@@ -221,6 +221,107 @@ describe("dispatcher — implemented commands route to their modules", () => {
|
|
|
221
221
|
});
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
+
describe("dispatcher — publish/unpublish/uninstall/session-sync route to their modules", () => {
|
|
225
|
+
// These four were never covered by the routing loop above. They are
|
|
226
|
+
// real registered commands users hit; prove the dispatcher routes
|
|
227
|
+
// each to a real implementation (not a stub, not an unknown-command).
|
|
228
|
+
it("`skillrepo publish` is wired (not a stub / unknown command)", async () => {
|
|
229
|
+
const r = await runCli(["publish", "@alice/x"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
230
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
231
|
+
assert.doesNotMatch(r.stderr, /Unknown command/);
|
|
232
|
+
assert.ok([1, 2, 5].includes(r.status), `expected 1/2/5, got ${r.status}`);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("`skillrepo unpublish` is wired (not a stub / unknown command)", async () => {
|
|
236
|
+
const r = await runCli(["unpublish", "@alice/x"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
237
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
238
|
+
assert.doesNotMatch(r.stderr, /Unknown command/);
|
|
239
|
+
assert.ok([1, 2, 5].includes(r.status), `expected 1/2/5, got ${r.status}`);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("`skillrepo uninstall --dry-run` is wired (not a stub / unknown command)", async () => {
|
|
243
|
+
// --dry-run needs no credentials — it inspects local state only, so
|
|
244
|
+
// it exits 0 with nothing to remove. Either way it must not be a stub.
|
|
245
|
+
const r = await runCli(["uninstall", "--dry-run"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
246
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
247
|
+
assert.doesNotMatch(r.stderr, /Unknown command/);
|
|
248
|
+
assert.ok([0, 1, 2, 5].includes(r.status), `expected 0/1/2/5, got ${r.status}`);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("`skillrepo session-sync` (no subcommand) is wired and validation-errors, not a stub", async () => {
|
|
252
|
+
const r = await runCli(["session-sync"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
253
|
+
assert.doesNotMatch(r.stderr, /Not yet implemented/);
|
|
254
|
+
assert.doesNotMatch(r.stderr, /Unknown command/);
|
|
255
|
+
assert.match(r.stderr, /subcommand/);
|
|
256
|
+
assert.equal(r.status, 5);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("dispatcher — error rendering (--verbose stack + hint line)", () => {
|
|
261
|
+
// The dispatcher's catch block renders the message, an optional dim
|
|
262
|
+
// hint line, and — only under --verbose — the error's stack trace
|
|
263
|
+
// (plus a `Caused by:` chain when err.cause has a stack). None of this
|
|
264
|
+
// was covered; a regression could silently drop stacks operators rely
|
|
265
|
+
// on, or leak stacks by default.
|
|
266
|
+
it("renders a CliError's hint line on a thrown error", async () => {
|
|
267
|
+
// No key configured → authError carries a hint pointing at `init`.
|
|
268
|
+
const r = await runCli(["get", "@alice/x"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
269
|
+
assert.match(r.stderr, /Error:/);
|
|
270
|
+
assert.match(r.stderr, /skillrepo init/, "the CliError hint line must render");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("prints a stack trace under --verbose", async () => {
|
|
274
|
+
const r = await runCli(["get", "@alice/x", "--verbose"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
275
|
+
assert.match(r.stderr, /Error:/);
|
|
276
|
+
assert.match(r.stderr, /\n\s+at\s/, "--verbose must print the error stack (frames like ` at ...`)");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("does NOT print a stack trace without --verbose", async () => {
|
|
280
|
+
const r = await runCli(["get", "@alice/x"], { env: { HOME: "/tmp/no-skillrepo-config" } });
|
|
281
|
+
assert.doesNotMatch(
|
|
282
|
+
r.stderr,
|
|
283
|
+
/\n\s+at\s/,
|
|
284
|
+
"stacks must be suppressed unless --verbose is passed",
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("renders the `Caused by:` chain under --verbose when err.cause has a stack", async () => {
|
|
289
|
+
// The authError path (no key) carries a hint but NO cause, so it
|
|
290
|
+
// never reaches the `err.cause.stack` branch. To exercise it we
|
|
291
|
+
// need a CliError whose `.cause` is a real Error with a stack.
|
|
292
|
+
// `getSkill` builds exactly that: a network failure wraps the
|
|
293
|
+
// underlying fetch TypeError as `networkError(..., { cause: err })`
|
|
294
|
+
// (src/lib/http.mjs safeFetch). Pointing SKILLREPO_URL at a refused
|
|
295
|
+
// port (127.0.0.1:1) with a key configured (so auth passes and the
|
|
296
|
+
// fetch is attempted) produces that wrapped error deterministically.
|
|
297
|
+
const r = await runCli(["get", "@alice/x", "--verbose"], {
|
|
298
|
+
env: {
|
|
299
|
+
HOME: "/tmp/no-skillrepo-config",
|
|
300
|
+
// Non-empty key so auth passes and the command reaches the
|
|
301
|
+
// network call (the base env zeroes SKILLREPO_ACCESS_KEY).
|
|
302
|
+
SKILLREPO_ACCESS_KEY: "sk_test_dummy",
|
|
303
|
+
// Refused connection → networkError wrapping a fetch TypeError.
|
|
304
|
+
SKILLREPO_URL: "http://127.0.0.1:1",
|
|
305
|
+
// Keep retry backoff bounded so the subprocess stays fast.
|
|
306
|
+
SKILLREPO_TIMEOUT_MS: "800",
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
assert.equal(r.status, 1, "a network failure exits EXIT_NETWORK (1)");
|
|
310
|
+
// The top-level stack (line 296) AND the cause chain (298-299).
|
|
311
|
+
assert.match(r.stderr, /\n\s+at\s/, "--verbose prints the top-level stack");
|
|
312
|
+
assert.match(
|
|
313
|
+
r.stderr,
|
|
314
|
+
/Caused by:/,
|
|
315
|
+
"the underlying fetch error's stack must render under --verbose",
|
|
316
|
+
);
|
|
317
|
+
assert.match(
|
|
318
|
+
r.stderr,
|
|
319
|
+
/TypeError: fetch failed/,
|
|
320
|
+
"the cause's own stack header must appear in the chain",
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
224
325
|
describe("dispatcher — init still works (PR1 keeps existing init untouched)", () => {
|
|
225
326
|
it("`skillrepo init --help` prints init help", async () => {
|
|
226
327
|
const r = await runCli(["init", "--help"]);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Advertised-surface contract test.
|
|
3
|
+
*
|
|
4
|
+
* Invariant under test: **every flag the CLI advertises in its own
|
|
5
|
+
* `--help` output must actually be accepted by the command.** If the
|
|
6
|
+
* dispatcher's usage string promises `--foo` but the command rejects it
|
|
7
|
+
* as "Unknown argument" / "Unexpected extra argument", that is a defect
|
|
8
|
+
* — the CLI is lying to the user about its surface.
|
|
9
|
+
*
|
|
10
|
+
* This is the regression guard for the class of bug where help text and
|
|
11
|
+
* command behavior drift apart (e.g. `push` advertising `--version` /
|
|
12
|
+
* `--changelog` long after those flags were removed from `push.mjs`).
|
|
13
|
+
* It deliberately derives the flag list from the LIVE `--help` output
|
|
14
|
+
* rather than a hardcoded table, so a future edit to a usage string is
|
|
15
|
+
* automatically held to the same contract.
|
|
16
|
+
*
|
|
17
|
+
* Mechanism: spawn the real binary (same approach as dispatcher.test.mjs).
|
|
18
|
+
* 1. `skillrepo <cmd> --help` → parse the Usage line → flag list.
|
|
19
|
+
* 2. For each advertised flag, spawn `skillrepo <cmd> <dummy positional>
|
|
20
|
+
* <flag> [dummy value]` and assert stderr does NOT contain
|
|
21
|
+
* "Unknown argument: <flag>" or "Unexpected extra argument: <flag>".
|
|
22
|
+
*
|
|
23
|
+
* The command still fails for OTHER reasons (no credentials, no server,
|
|
24
|
+
* missing SKILL.md, etc.) — we only assert the failure is not a
|
|
25
|
+
* flag-rejection naming the advertised flag. A flag that parses cleanly
|
|
26
|
+
* proceeds past `resolveFlags` to the network/validation layer, which is
|
|
27
|
+
* exactly what we want to confirm.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it } from "node:test";
|
|
31
|
+
import assert from "node:assert/strict";
|
|
32
|
+
import { execFileSync } from "node:child_process";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { dirname, resolve } from "node:path";
|
|
35
|
+
import { tmpdir } from "node:os";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
|
|
38
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
// This file lives at src/test/e2e/ — three levels below packages/cli/.
|
|
40
|
+
const CLI_BIN = resolve(__dirname, "../../../bin/skillrepo.mjs");
|
|
41
|
+
|
|
42
|
+
// Every command the dispatcher registers. Hardcoded because the command
|
|
43
|
+
// SET is stable and the registry is not importable (bin/skillrepo.mjs
|
|
44
|
+
// self-invokes main() on import). The FLAG set per command is derived
|
|
45
|
+
// live from --help below — that's the part that drifts.
|
|
46
|
+
const COMMANDS = [
|
|
47
|
+
"init",
|
|
48
|
+
"update",
|
|
49
|
+
"get",
|
|
50
|
+
"add",
|
|
51
|
+
"push",
|
|
52
|
+
"publish",
|
|
53
|
+
"unpublish",
|
|
54
|
+
"remove",
|
|
55
|
+
"list",
|
|
56
|
+
"search",
|
|
57
|
+
"uninstall",
|
|
58
|
+
"session-sync",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// Dummy positional arguments per command so a leading required positional
|
|
62
|
+
// (`<@owner/name>`, `<path>`, `<query>`, `<enable|disable>`) doesn't swallow
|
|
63
|
+
// the flag we're probing. These are stable; the flags are not.
|
|
64
|
+
const POSITIONALS = {
|
|
65
|
+
init: [],
|
|
66
|
+
update: [],
|
|
67
|
+
get: ["@acme/widget"],
|
|
68
|
+
add: ["@acme/widget"],
|
|
69
|
+
push: ["."],
|
|
70
|
+
publish: ["@acme/widget"],
|
|
71
|
+
unpublish: ["@acme/widget"],
|
|
72
|
+
remove: ["@acme/widget"],
|
|
73
|
+
list: [],
|
|
74
|
+
search: ["query"],
|
|
75
|
+
uninstall: [],
|
|
76
|
+
"session-sync": ["enable"],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Env that makes every command fail FAST at a layer AFTER flag parsing:
|
|
80
|
+
// no key in env, an empty HOME so no global config is read, an
|
|
81
|
+
// unroutable default URL, and a short timeout.
|
|
82
|
+
const NO_HOME = join(tmpdir(), "advertised-surface-no-home");
|
|
83
|
+
const SPAWN_ENV = {
|
|
84
|
+
...process.env,
|
|
85
|
+
NO_COLOR: "1",
|
|
86
|
+
HOME: NO_HOME,
|
|
87
|
+
USERPROFILE: NO_HOME,
|
|
88
|
+
SKILLREPO_ACCESS_KEY: "",
|
|
89
|
+
SKILLREPO_URL: "http://127.0.0.1:9",
|
|
90
|
+
SKILLREPO_TIMEOUT_MS: "1500",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run the binary synchronously, capturing stdout/stderr/status whether
|
|
95
|
+
* it exits 0 or not. execFileSync throws on non-zero exit; the thrown
|
|
96
|
+
* error carries `.stdout`/`.stderr`/`.status`.
|
|
97
|
+
*/
|
|
98
|
+
function runSync(args) {
|
|
99
|
+
try {
|
|
100
|
+
const stdout = execFileSync(process.execPath, [CLI_BIN, ...args], {
|
|
101
|
+
encoding: "utf-8",
|
|
102
|
+
timeout: 10_000,
|
|
103
|
+
env: SPAWN_ENV,
|
|
104
|
+
});
|
|
105
|
+
return { stdout: stdout ?? "", stderr: "", status: 0 };
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return {
|
|
108
|
+
stdout: err.stdout?.toString?.() ?? "",
|
|
109
|
+
stderr: err.stderr?.toString?.() ?? "",
|
|
110
|
+
status: typeof err.status === "number" ? err.status : 1,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract the advertised flag list for a command from its `--help`.
|
|
117
|
+
* Returns `{ flags: [{ flag, takesValue }] }` on success or
|
|
118
|
+
* `{ error: string }` if the Usage line can't be located — an
|
|
119
|
+
* unparseable help block is itself a contract failure, surfaced as a
|
|
120
|
+
* failing test rather than a collection-time crash.
|
|
121
|
+
*/
|
|
122
|
+
function advertisedFlags(cmd) {
|
|
123
|
+
const { stdout } = runSync([cmd, "--help"]);
|
|
124
|
+
const lines = stdout.split("\n");
|
|
125
|
+
const usageIdx = lines.findIndex((l) => /^\s*Usage:/.test(l));
|
|
126
|
+
if (usageIdx < 0) {
|
|
127
|
+
return { error: `\`skillrepo ${cmd} --help\` printed no "Usage:" section. stdout:\n${stdout}` };
|
|
128
|
+
}
|
|
129
|
+
// The usage string is the next non-blank line after "Usage:".
|
|
130
|
+
const usageLine = lines.slice(usageIdx + 1).find((l) => l.trim().length > 0);
|
|
131
|
+
if (!usageLine) {
|
|
132
|
+
return { error: `\`skillrepo ${cmd} --help\` Usage: section was empty` };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const tokens = usageLine.trim().split(/\s+/);
|
|
136
|
+
const flags = [];
|
|
137
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
138
|
+
const m = tokens[i].match(/(--[a-z][a-z-]*)/);
|
|
139
|
+
if (!m) continue;
|
|
140
|
+
const flag = m[1];
|
|
141
|
+
// A `<...>` placeholder as the next token marks a value flag, e.g.
|
|
142
|
+
// `[--key <key>]` → tokens `[--key` then `<key>]`.
|
|
143
|
+
const next = tokens[i + 1] ?? "";
|
|
144
|
+
const takesValue = /^<.*>\]?$/.test(next);
|
|
145
|
+
flags.push({ flag, takesValue });
|
|
146
|
+
}
|
|
147
|
+
return { flags };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Resolve the advertised surface for every command up front. A spawn or
|
|
151
|
+
// parse failure is captured as `{ error }` so it becomes a failing test
|
|
152
|
+
// below instead of aborting the whole test file at collection time.
|
|
153
|
+
const SURFACE = COMMANDS.map((cmd) => ({ cmd, ...advertisedFlags(cmd) }));
|
|
154
|
+
|
|
155
|
+
/** A throwaway value appropriate to the flag, so value flags get one. */
|
|
156
|
+
function dummyValueFor(flag) {
|
|
157
|
+
switch (flag) {
|
|
158
|
+
case "--url":
|
|
159
|
+
return "http://127.0.0.1:9";
|
|
160
|
+
case "--agent":
|
|
161
|
+
return "claude";
|
|
162
|
+
case "--limit":
|
|
163
|
+
return "5";
|
|
164
|
+
case "--key":
|
|
165
|
+
return "sk_live_dummy";
|
|
166
|
+
default:
|
|
167
|
+
return "x";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe("advertised surface — every documented flag is accepted by its command", () => {
|
|
172
|
+
for (const { cmd, flags, error } of SURFACE) {
|
|
173
|
+
// Sanity: a command with a usage string should advertise at least one
|
|
174
|
+
// flag (every command exposes at least --json). If parsing failed or
|
|
175
|
+
// found none, the parser (or the help format) regressed.
|
|
176
|
+
it(`\`skillrepo ${cmd} --help\` advertises a parseable flag set`, () => {
|
|
177
|
+
assert.ok(!error, error);
|
|
178
|
+
assert.ok(
|
|
179
|
+
flags.length > 0,
|
|
180
|
+
`parsed zero flags from \`skillrepo ${cmd} --help\` — help format changed?`,
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
for (const { flag, takesValue } of flags ?? []) {
|
|
185
|
+
it(`\`skillrepo ${cmd}\` accepts advertised flag ${flag}`, () => {
|
|
186
|
+
const args = [
|
|
187
|
+
cmd,
|
|
188
|
+
...POSITIONALS[cmd],
|
|
189
|
+
flag,
|
|
190
|
+
...(takesValue ? [dummyValueFor(flag)] : []),
|
|
191
|
+
];
|
|
192
|
+
const { stderr } = runSync(args);
|
|
193
|
+
assert.ok(
|
|
194
|
+
!stderr.includes(`Unknown argument: ${flag}`),
|
|
195
|
+
`\`skillrepo ${cmd}\` advertises ${flag} in --help but rejected it ` +
|
|
196
|
+
`as an unknown argument. stderr:\n${stderr}`,
|
|
197
|
+
);
|
|
198
|
+
assert.ok(
|
|
199
|
+
!stderr.includes(`Unexpected extra argument: ${flag}`),
|
|
200
|
+
`\`skillrepo ${cmd}\` advertises ${flag} in --help but rejected it ` +
|
|
201
|
+
`as an unexpected extra argument (the flag name is not wired into ` +
|
|
202
|
+
`the command's parser). stderr:\n${stderr}`,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
@@ -363,7 +363,7 @@ describe("CLI E2E — read commands", () => {
|
|
|
363
363
|
assert.match(r.stderr, /write-scoped key|scope/);
|
|
364
364
|
});
|
|
365
365
|
|
|
366
|
-
it("add with 403 plan_limit exits 5
|
|
366
|
+
it("add with 403 plan_limit exits 5 (validation, server message shown)", async () => {
|
|
367
367
|
server.setAddResponseForAny({
|
|
368
368
|
status: 403,
|
|
369
369
|
body: { error: "Your free plan allows up to 5 library skills.", code: "plan_limit" },
|