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.
@@ -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 → v2 .last-sync migration round-trip", () => {
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 v2 with SHA map populated", async () => {
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 be v2 and carry the per-skill SHA
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(after.schemaVersion, 2, "must persist as v2");
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 is v2 with the
506
- // skills map populated. The skill is on disk in the claudeProject
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, 2);
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, 2);
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, {