skillrepo 4.8.3 → 4.9.0
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 +3 -1
- package/package.json +1 -1
- package/src/commands/update.mjs +13 -0
- package/src/lib/http.mjs +19 -0
- package/src/lib/sync.mjs +258 -24
- package/src/test/commands/publish.test.mjs +1 -1
- package/src/test/commands/push.test.mjs +24 -0
- package/src/test/commands/update.test.mjs +166 -0
- package/src/test/integration/update-list-contract.integration.test.mjs +15 -11
- package/src/test/lib/http.test.mjs +87 -0
- package/src/test/lib/sync.test.mjs +497 -9
|
@@ -18,7 +18,9 @@ import {
|
|
|
18
18
|
writeLastSync,
|
|
19
19
|
FULL_RESYNC_FLOOR,
|
|
20
20
|
cliVersionBelowFloor,
|
|
21
|
+
LAST_SYNC_SCHEMA_VERSION,
|
|
21
22
|
} from "../../lib/sync.mjs";
|
|
23
|
+
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
22
24
|
import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
|
|
23
25
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
24
26
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
@@ -689,3 +691,167 @@ describe("runUpdate — #1911 membership heal lands the skill on disk", () => {
|
|
|
689
691
|
);
|
|
690
692
|
});
|
|
691
693
|
});
|
|
694
|
+
|
|
695
|
+
// ── SessionStart throttle through the `update` command (#2174) ─────────
|
|
696
|
+
//
|
|
697
|
+
// The throttle lives in runSync, but what matters at the product boundary
|
|
698
|
+
// is that `skillrepo update --session-hook` (Claude Code) and the
|
|
699
|
+
// `--silent` cohort hook make ZERO HTTP calls when re-run within the
|
|
700
|
+
// window, while a bare interactive `skillrepo update` always syncs. These
|
|
701
|
+
// drive the real command entry point against the mock server's request
|
|
702
|
+
// spies — the acceptance criterion, end to end.
|
|
703
|
+
|
|
704
|
+
describe("runUpdate — SessionStart throttle (#2174)", () => {
|
|
705
|
+
beforeEach(setup);
|
|
706
|
+
afterEach(teardown);
|
|
707
|
+
|
|
708
|
+
it("ACCEPTANCE: two `update --session-hook` runs within the window → the 2nd makes zero HTTP calls", async () => {
|
|
709
|
+
server.setEtag('"v1"');
|
|
710
|
+
server.setLibraryResponse({
|
|
711
|
+
skills: [makeSkill("throttle-me")],
|
|
712
|
+
removals: [],
|
|
713
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// Session 1: the hook runs a real sync (no prior attempt).
|
|
717
|
+
await runUpdate(
|
|
718
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
719
|
+
{ stdout },
|
|
720
|
+
);
|
|
721
|
+
assert.ok(server.getLibraryRequestCount() >= 1, "session 1 hit the library GET");
|
|
722
|
+
assert.match(stdout.text(), /Library synced/, "session 1 reports the sync");
|
|
723
|
+
|
|
724
|
+
// Session 2 (same 15-min window): the hook must make NO HTTP calls.
|
|
725
|
+
server.resetLibraryInspection();
|
|
726
|
+
server.resetReceipts();
|
|
727
|
+
stdout.clear();
|
|
728
|
+
await runUpdate(
|
|
729
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
730
|
+
{ stdout },
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
assert.equal(
|
|
734
|
+
server.getLibraryRequestCount(),
|
|
735
|
+
0,
|
|
736
|
+
"throttled session makes NO library GET",
|
|
737
|
+
);
|
|
738
|
+
assert.equal(
|
|
739
|
+
server.getReceiptRequestCount(),
|
|
740
|
+
0,
|
|
741
|
+
"throttled session makes NO receipt POST",
|
|
742
|
+
);
|
|
743
|
+
assert.equal(
|
|
744
|
+
stdout.text(),
|
|
745
|
+
"",
|
|
746
|
+
"throttled session-hook stays silent (nothing changed to report)",
|
|
747
|
+
);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("a bare interactive `skillrepo update` always syncs, even seconds after a hook sync", async () => {
|
|
751
|
+
server.setEtag('"v1"');
|
|
752
|
+
server.setLibraryResponse({
|
|
753
|
+
skills: [makeSkill("fresh")],
|
|
754
|
+
removals: [],
|
|
755
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
756
|
+
});
|
|
757
|
+
// Hook sync arms the throttle clock.
|
|
758
|
+
await runUpdate(
|
|
759
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
760
|
+
{ stdout },
|
|
761
|
+
);
|
|
762
|
+
server.resetLibraryInspection();
|
|
763
|
+
server.resetReceipts();
|
|
764
|
+
stdout.clear();
|
|
765
|
+
|
|
766
|
+
// Interactive `update` (no hook flag) must contact the server despite
|
|
767
|
+
// the fresh clock — explicit intent always syncs.
|
|
768
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
769
|
+
assert.ok(
|
|
770
|
+
server.getLibraryRequestCount() >= 1,
|
|
771
|
+
"interactive update is never throttled",
|
|
772
|
+
);
|
|
773
|
+
assert.match(
|
|
774
|
+
stdout.text(),
|
|
775
|
+
/up to date/,
|
|
776
|
+
"interactive 304 prints the up-to-date line",
|
|
777
|
+
);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("the --silent cohort hook throttles too (2nd run: zero HTTP, still emits `{}`)", async () => {
|
|
781
|
+
server.setEtag('"v1"');
|
|
782
|
+
server.setLibraryResponse({
|
|
783
|
+
skills: [makeSkill("cohort")],
|
|
784
|
+
removals: [],
|
|
785
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
786
|
+
});
|
|
787
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--silent"], { stdout });
|
|
788
|
+
server.resetLibraryInspection();
|
|
789
|
+
server.resetReceipts();
|
|
790
|
+
stdout.clear();
|
|
791
|
+
|
|
792
|
+
await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--silent"], { stdout });
|
|
793
|
+
assert.equal(
|
|
794
|
+
server.getLibraryRequestCount(),
|
|
795
|
+
0,
|
|
796
|
+
"throttled --silent makes NO library GET",
|
|
797
|
+
);
|
|
798
|
+
assert.equal(
|
|
799
|
+
server.getReceiptRequestCount(),
|
|
800
|
+
0,
|
|
801
|
+
"throttled --silent makes NO receipt POST",
|
|
802
|
+
);
|
|
803
|
+
// The JSON hook contract still holds: stdout is exactly `{}` even when
|
|
804
|
+
// the sync was throttled away.
|
|
805
|
+
assert.equal(stdout.text(), "{}\n");
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it("upgrade path: a real v2 .last-sync (etag + skills, no lastAttemptAt) → first hook sync proceeds and upgrades to v3", async () => {
|
|
809
|
+
// Every existing install upgrading to 4.9.0 hits exactly this state:
|
|
810
|
+
// a v2 file with a genuine etag/skills baseline written by the old
|
|
811
|
+
// CLI. The first --session-hook run after upgrade must NOT throttle
|
|
812
|
+
// (no lastAttemptAt = never attempted), must keep the cheap 304 (etag
|
|
813
|
+
// preserved in place), and must leave a v3 file with the stamp.
|
|
814
|
+
server.setEtag('"v1"');
|
|
815
|
+
server.setLibraryResponse({
|
|
816
|
+
skills: [makeSkill("veteran")],
|
|
817
|
+
removals: [],
|
|
818
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
819
|
+
});
|
|
820
|
+
// Arm a fully-populated state via a real sync, then rewrite it as the
|
|
821
|
+
// v2 shape a pre-#2174 CLI would have left behind.
|
|
822
|
+
await runUpdate(
|
|
823
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
824
|
+
{ stdout },
|
|
825
|
+
);
|
|
826
|
+
const prior = readLastSync();
|
|
827
|
+
const v2File = {
|
|
828
|
+
schemaVersion: 2,
|
|
829
|
+
etag: prior.etag,
|
|
830
|
+
syncedAt: prior.syncedAt,
|
|
831
|
+
cliVersion: prior.cliVersion,
|
|
832
|
+
skills: prior.skills,
|
|
833
|
+
};
|
|
834
|
+
writeFileSync(globalLastSyncPath(), JSON.stringify(v2File, null, 2) + "\n");
|
|
835
|
+
server.resetLibraryInspection();
|
|
836
|
+
server.resetReceipts();
|
|
837
|
+
stdout.clear();
|
|
838
|
+
|
|
839
|
+
await runUpdate(
|
|
840
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
841
|
+
{ stdout },
|
|
842
|
+
);
|
|
843
|
+
assert.ok(
|
|
844
|
+
server.getLibraryRequestCount() >= 1,
|
|
845
|
+
"first post-upgrade hook sync is NOT throttled (v2 = never attempted)",
|
|
846
|
+
);
|
|
847
|
+
assert.equal(
|
|
848
|
+
server.getLastLibraryIfNoneMatch(),
|
|
849
|
+
prior.etag,
|
|
850
|
+
"the v2 etag was accepted in place — the post-upgrade sync stays a cheap 304",
|
|
851
|
+
);
|
|
852
|
+
const after = readLastSync();
|
|
853
|
+
assert.equal(after.schemaVersion, LAST_SYNC_SCHEMA_VERSION, "file upgraded to v3");
|
|
854
|
+
assert.equal(typeof after.lastAttemptAt, "string", "throttle clock stamped");
|
|
855
|
+
assert.deepEqual(after.skills, prior.skills, "per-skill SHA baseline preserved");
|
|
856
|
+
});
|
|
857
|
+
});
|
|
@@ -57,7 +57,7 @@ import { runAdd } from "../../commands/add.mjs";
|
|
|
57
57
|
import { runRemove } from "../../commands/remove.mjs";
|
|
58
58
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
59
59
|
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
60
|
-
import { readLastSync } from "../../lib/sync.mjs";
|
|
60
|
+
import { readLastSync, LAST_SYNC_SCHEMA_VERSION } from "../../lib/sync.mjs";
|
|
61
61
|
import { createMockServer } from "../e2e/mock-server.mjs";
|
|
62
62
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
63
63
|
import {
|
|
@@ -391,11 +391,11 @@ describe("update → list cross-command contract (#1574)", () => {
|
|
|
391
391
|
// is the only way to verify the v2 file that lands on disk has the
|
|
392
392
|
// expected shape AND the ETag carry-forward took effect.
|
|
393
393
|
|
|
394
|
-
describe("v1 →
|
|
394
|
+
describe("v1 → current-schema .last-sync migration round-trip", () => {
|
|
395
395
|
beforeEach(setup);
|
|
396
396
|
afterEach(teardown);
|
|
397
397
|
|
|
398
|
-
it("reads v1 .last-sync, performs sync, writes
|
|
398
|
+
it("reads v1 .last-sync, performs sync, writes the current schema with SHA map populated", async () => {
|
|
399
399
|
// Seed a v1 state file at the documented path. Both fields are
|
|
400
400
|
// strings — that's the v1 shape `readLastSync` migrates from.
|
|
401
401
|
const v1Path = globalLastSyncPath();
|
|
@@ -421,14 +421,18 @@ describe("v1 → v2 .last-sync migration round-trip", () => {
|
|
|
421
421
|
// in readLastSync and the v2 write at the end of runSync ──
|
|
422
422
|
await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
|
|
423
423
|
|
|
424
|
-
// The on-disk file must now
|
|
425
|
-
// map. The pre-existing v1 etag/syncedAt are NOT preserved
|
|
424
|
+
// The on-disk file must now carry the CURRENT schema and the per-skill
|
|
425
|
+
// SHA map. The pre-existing v1 etag/syncedAt are NOT preserved
|
|
426
426
|
// because the sync actually completed and got a fresh ETag from
|
|
427
427
|
// the server. Carry-forward applies to the per-skill SHA map
|
|
428
428
|
// (which was empty in v1), not to the library ETag itself.
|
|
429
429
|
const afterRaw = readFileSync(v1Path, "utf-8");
|
|
430
430
|
const after = JSON.parse(afterRaw);
|
|
431
|
-
assert.equal(
|
|
431
|
+
assert.equal(
|
|
432
|
+
after.schemaVersion,
|
|
433
|
+
LAST_SYNC_SCHEMA_VERSION,
|
|
434
|
+
"must persist as the current schema (v1 migrates forward on write)",
|
|
435
|
+
);
|
|
432
436
|
assert.equal(after.etag, '"v2-etag"', "etag must reflect server's response");
|
|
433
437
|
assert.ok(
|
|
434
438
|
after.skills && typeof after.skills === "object",
|
|
@@ -502,11 +506,11 @@ describe("v1 → v2 .last-sync migration round-trip", () => {
|
|
|
502
506
|
"v1 migration must drop If-None-Match (placementsAreComplete returns false for empty map)",
|
|
503
507
|
);
|
|
504
508
|
|
|
505
|
-
// After the recovery sync, the on-disk file
|
|
506
|
-
// skills map populated. The skill is on disk in the
|
|
507
|
-
// placement — the user-visible recovery.
|
|
509
|
+
// After the recovery sync, the on-disk file carries the current
|
|
510
|
+
// schema with the skills map populated. The skill is on disk in the
|
|
511
|
+
// claudeProject placement — the user-visible recovery.
|
|
508
512
|
const after = readLastSync();
|
|
509
|
-
assert.equal(after.schemaVersion,
|
|
513
|
+
assert.equal(after.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
|
|
510
514
|
assert.ok(after.skills["alice/unmigrated"]);
|
|
511
515
|
assert.ok(existsSync(resolvePlacementDir("claudeProject", "unmigrated")));
|
|
512
516
|
|
|
@@ -1014,7 +1018,7 @@ describe("additional coverage from production-readiness audit", () => {
|
|
|
1014
1018
|
"runUpdate must survive a corrupt .last-sync and complete the sync",
|
|
1015
1019
|
);
|
|
1016
1020
|
const after = readLastSync();
|
|
1017
|
-
assert.equal(after.schemaVersion,
|
|
1021
|
+
assert.equal(after.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
|
|
1018
1022
|
assert.equal(after.etag, '"fresh"');
|
|
1019
1023
|
assert.ok(after.skills["alice/recovered"]);
|
|
1020
1024
|
});
|
|
@@ -1296,6 +1296,93 @@ describe("pushSkill", () => {
|
|
|
1296
1296
|
}
|
|
1297
1297
|
});
|
|
1298
1298
|
|
|
1299
|
+
it("maps 422 handle_required to validationError with a Settings hint on the server's own host (#2310)", async () => {
|
|
1300
|
+
const SERVER_MESSAGE = "Set your Author ID before adding skills.";
|
|
1301
|
+
const srv = await makePushServer((req, res) => {
|
|
1302
|
+
jsonRes(res, 422, {
|
|
1303
|
+
error: SERVER_MESSAGE,
|
|
1304
|
+
code: "handle_required",
|
|
1305
|
+
});
|
|
1306
|
+
});
|
|
1307
|
+
try {
|
|
1308
|
+
await assert.rejects(
|
|
1309
|
+
() =>
|
|
1310
|
+
pushSkill(srv.url, VALID_KEY, {
|
|
1311
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1312
|
+
}),
|
|
1313
|
+
(err) =>
|
|
1314
|
+
err instanceof CliError &&
|
|
1315
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
1316
|
+
// Line 1: the server's message verbatim; line 2 (hint): the
|
|
1317
|
+
// Settings path on the REQUEST host (staging users get a
|
|
1318
|
+
// staging link), not hardcoded prod.
|
|
1319
|
+
err.message === SERVER_MESSAGE &&
|
|
1320
|
+
err.hint === `Set it in Settings → Account Profile: ${srv.url}/app/settings`,
|
|
1321
|
+
);
|
|
1322
|
+
} finally {
|
|
1323
|
+
await srv.close();
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
it("handle_required hint falls back to the prod host when the request URL is unparseable (#2310)", async () => {
|
|
1328
|
+
// The `new URL(url)` origin-derivation inside the handle_required
|
|
1329
|
+
// mapping can only throw if the URL that reached fetch was itself
|
|
1330
|
+
// unparseable — impossible over a real socket (undici validates the
|
|
1331
|
+
// URL before connecting), so stub fetch to hand back the 422
|
|
1332
|
+
// directly. `normalizeUrl` passes any non-empty string through, so
|
|
1333
|
+
// the raw serverUrl below reaches mapErrorResponse verbatim.
|
|
1334
|
+
const SERVER_MESSAGE = "Set your Author ID before adding skills.";
|
|
1335
|
+
const realFetch = globalThis.fetch;
|
|
1336
|
+
globalThis.fetch = async () =>
|
|
1337
|
+
new Response(
|
|
1338
|
+
JSON.stringify({ error: SERVER_MESSAGE, code: "handle_required" }),
|
|
1339
|
+
{ status: 422, headers: { "Content-Type": "application/json" } },
|
|
1340
|
+
);
|
|
1341
|
+
try {
|
|
1342
|
+
await assert.rejects(
|
|
1343
|
+
() =>
|
|
1344
|
+
pushSkill("not a url", VALID_KEY, {
|
|
1345
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1346
|
+
}),
|
|
1347
|
+
(err) =>
|
|
1348
|
+
err instanceof CliError &&
|
|
1349
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
1350
|
+
err.message === SERVER_MESSAGE &&
|
|
1351
|
+
// Parse-failure fallback: the hint pins the prod host instead
|
|
1352
|
+
// of the (unusable) request origin.
|
|
1353
|
+
err.hint ===
|
|
1354
|
+
"Set it in Settings → Account Profile: https://skillrepo.dev/app/settings",
|
|
1355
|
+
);
|
|
1356
|
+
} finally {
|
|
1357
|
+
globalThis.fetch = realFetch;
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
it("does NOT special-case a 422 without the handle_required code (generic validation)", async () => {
|
|
1362
|
+
// A 422 carrying a different code (e.g. idempotency_key_reused) must
|
|
1363
|
+
// keep the pre-#2310 generic mapping: validationError, no Settings hint.
|
|
1364
|
+
const srv = await makePushServer((req, res) => {
|
|
1365
|
+
jsonRes(res, 422, {
|
|
1366
|
+
error: "Idempotency-Key was reused with a different request body.",
|
|
1367
|
+
code: "idempotency_key_reused",
|
|
1368
|
+
});
|
|
1369
|
+
});
|
|
1370
|
+
try {
|
|
1371
|
+
await assert.rejects(
|
|
1372
|
+
() =>
|
|
1373
|
+
pushSkill(srv.url, VALID_KEY, {
|
|
1374
|
+
files: [{ relativePath: "SKILL.md", content: SKILL_MD }],
|
|
1375
|
+
}),
|
|
1376
|
+
(err) =>
|
|
1377
|
+
err instanceof CliError &&
|
|
1378
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
1379
|
+
err.hint === undefined,
|
|
1380
|
+
);
|
|
1381
|
+
} finally {
|
|
1382
|
+
await srv.close();
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1299
1386
|
it("maps 413 payload_too_large through mapErrorResponse", async () => {
|
|
1300
1387
|
const srv = await makePushServer((req, res) => {
|
|
1301
1388
|
jsonRes(res, 413, {
|