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
package/README.md
CHANGED
|
@@ -370,7 +370,7 @@ By default `skillrepo init` prompts you to install this hook. If you said no (or
|
|
|
370
370
|
|
|
371
371
|
#### Auto-refresh hooks for other agents
|
|
372
372
|
|
|
373
|
-
For Cursor, Gemini CLI, Codex CLI, and VS Code + Copilot, `skillrepo init` writes a SessionStart hook to each agent's user-scope hook config so your library
|
|
373
|
+
For Cursor, Gemini CLI, Codex CLI, and VS Code + Copilot, `skillrepo init` writes a SessionStart hook to each agent's user-scope hook config so your library stays current across your sessions without a separate command. Each hook invokes `npx --yes skillrepo update --silent`, so it works without a global `skillrepo` install.
|
|
374
374
|
|
|
375
375
|
| Agent | Hook config path | Notes |
|
|
376
376
|
|-------|------------------|-------|
|
|
@@ -393,6 +393,8 @@ Auto-refresh hooks for Windsurf and Cline are not yet supported — those agents
|
|
|
393
393
|
|
|
394
394
|
**On 304 (nothing changed) the hook is silent.** You only see output when your library actually syncs or a failure happens. No "Syncing…" noise on every session.
|
|
395
395
|
|
|
396
|
+
**Throttled to skip redundant syncs (v4.9.0+).** Because the hook fires on *every* editor session, the hook-triggered `update` (`--session-hook` / `--silent`) syncs at most once per ~15 minutes: within that window it exits immediately with **no network call**. This bounds redundant per-session load. The tradeoff is that a brand-new session started shortly after a recent sync may be up to ~15 minutes behind a just-published change — and the same bound applies to local repair: if you delete or corrupt a synced skill's folder on disk, the hook restores it on its next non-throttled run, up to ~15 minutes later. A bare `skillrepo update` **you** run yourself is never throttled — it always syncs immediately (repairing any local damage) and resets the window. `skillrepo list` is likewise never throttled (it always queries the server live), though it doesn't reset the window.
|
|
397
|
+
|
|
396
398
|
Flags:
|
|
397
399
|
|
|
398
400
|
- `--global` — operates on `~/.claude/settings.local.json` so the hook fires in every Claude Code session across all projects on your machine.
|
package/package.json
CHANGED
package/src/commands/update.mjs
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
* full contract. When this flag is absent, the
|
|
21
21
|
* command behaves as before (exit non-zero on
|
|
22
22
|
* network / auth / disk failures).
|
|
23
|
+
* Throttled (#2174): within MIN_SYNC_INTERVAL_MS of the
|
|
24
|
+
* last sync attempt this makes ZERO network calls (the
|
|
25
|
+
* hook fires every session; a bare `update` is never
|
|
26
|
+
* throttled).
|
|
23
27
|
*
|
|
24
28
|
* v4.1.0 silent mode (#1240):
|
|
25
29
|
* --silent Suppress stdout: write `{}` on success, propagate
|
|
@@ -144,6 +148,11 @@ export async function runUpdate(argv, io = {}) {
|
|
|
144
148
|
apiKey: flags.apiKey,
|
|
145
149
|
vendors,
|
|
146
150
|
global: flags.global,
|
|
151
|
+
// Throttle SessionStart syncs (#2174): this hook fires on every
|
|
152
|
+
// Claude Code session, so within MIN_SYNC_INTERVAL_MS of the last
|
|
153
|
+
// attempt runSync short-circuits with zero network calls. ONLY hook
|
|
154
|
+
// invocations pass this — a bare `skillrepo update` always syncs.
|
|
155
|
+
throttle: true,
|
|
147
156
|
io: { stdout: BLACK_HOLE_STREAM, stderr: BLACK_HOLE_STREAM },
|
|
148
157
|
});
|
|
149
158
|
const total = summary.added + summary.updated + summary.removed;
|
|
@@ -218,6 +227,10 @@ export async function runUpdate(argv, io = {}) {
|
|
|
218
227
|
apiKey: flags.apiKey,
|
|
219
228
|
vendors,
|
|
220
229
|
global: flags.global,
|
|
230
|
+
// Throttle the cohort SessionStart hook too (#2174) — same reasoning
|
|
231
|
+
// as --session-hook: it fires every session, so skip the network
|
|
232
|
+
// within MIN_SYNC_INTERVAL_MS of the last attempt.
|
|
233
|
+
throttle: true,
|
|
221
234
|
// sync.mjs surfaces non-fatal warnings (e.g. failed to persist
|
|
222
235
|
// last-sync state) via stderr; preserve that channel so a real
|
|
223
236
|
// operator running `update --silent` from a terminal can still
|
package/src/lib/http.mjs
CHANGED
|
@@ -370,6 +370,25 @@ async function mapErrorResponse(res, url) {
|
|
|
370
370
|
if (res.status === 404) {
|
|
371
371
|
return null; // Caller decides
|
|
372
372
|
}
|
|
373
|
+
if (res.status === 422 && code === "handle_required") {
|
|
374
|
+
// #2310: the push would CREATE a skill while the account's Author ID
|
|
375
|
+
// is still an unchosen placeholder. Line 1 is the server's message
|
|
376
|
+
// ("Set your Author ID before adding skills."); the hint points at
|
|
377
|
+
// the Settings surface that unblocks it. Derive the base URL from
|
|
378
|
+
// the request URL's origin (same posture as the 401 auth hint above)
|
|
379
|
+
// so staging users get a staging link, with the prod host as the
|
|
380
|
+
// parse-failure fallback.
|
|
381
|
+
let settingsBase = "https://skillrepo.dev";
|
|
382
|
+
try {
|
|
383
|
+
settingsBase = new URL(url).origin;
|
|
384
|
+
} catch {
|
|
385
|
+
// URL parse failure shouldn't happen — `safeFetch` already
|
|
386
|
+
// validated. Fall back to the prod default.
|
|
387
|
+
}
|
|
388
|
+
return validationError(message, {
|
|
389
|
+
hint: `Set it in Settings → Account Profile: ${settingsBase}/app/settings`,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
373
392
|
if (res.status === 429) {
|
|
374
393
|
// 429 hits mapErrorResponse in two cases: (1) a read-only
|
|
375
394
|
// endpoint's retry budget was exhausted (safeFetch with
|
package/src/lib/sync.mjs
CHANGED
|
@@ -36,20 +36,34 @@
|
|
|
36
36
|
* absence is meaningful (a state file with no `cliVersion` was
|
|
37
37
|
* written by a pre-#1911 CLI and triggers the one-time membership
|
|
38
38
|
* heal). See `FULL_RESYNC_FLOOR` / `cliVersionBelowFloor`.
|
|
39
|
+
* v3 — Adds `lastAttemptAt` (#2174): an ISO timestamp of the last
|
|
40
|
+
* network sync ATTEMPT, powering the SessionStart staleness
|
|
41
|
+
* throttle in `runSync`. Stamped on the 304 short-circuit and on
|
|
42
|
+
* every successful write; NOT stamped on a throttled skip (so a
|
|
43
|
+
* run of quick restarts can't push the clock forward and starve
|
|
44
|
+
* the sync). A v2 file (no `lastAttemptAt`) reads as "never
|
|
45
|
+
* attempted" and syncs — additive, mirroring how absent
|
|
46
|
+
* `cliVersion` was handled.
|
|
39
47
|
*
|
|
40
48
|
* Forward/backward compatibility
|
|
41
49
|
* ------------------------------
|
|
42
|
-
* • Old CLI reading a
|
|
43
|
-
*
|
|
44
|
-
* upgrade again.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
50
|
+
* • Old CLI reading a NEWER file (e.g. a v2 CLI reading v3) → unknown
|
|
51
|
+
* schemaVersion → null → full sync → writes its own older shape. The
|
|
52
|
+
* user loses the newer fields until they upgrade again. Harmless: a
|
|
53
|
+
* full re-fetch is idempotent.
|
|
54
|
+
* • New CLI reading a v1 file → in-memory migration to the current
|
|
55
|
+
* shape with the existing `etag` + `syncedAt` preserved and an EMPTY
|
|
56
|
+
* `skills` map. The next successful sync writes a current-schema file
|
|
57
|
+
* on disk. This is safe because an empty map means "we don't know
|
|
49
58
|
* per-skill state from this sync" — callers must treat absent
|
|
50
59
|
* entries as unknown, not as "missing from library." Preserving
|
|
51
60
|
* the etag also keeps the 304 short-circuit working, so an
|
|
52
61
|
* upgrade-then-no-changes path stays cheap.
|
|
62
|
+
* • New CLI reading a v2 file → accepted in place (v2 is v3 minus
|
|
63
|
+
* `lastAttemptAt`): etag/syncedAt/skills/cliVersion are preserved so
|
|
64
|
+
* the first post-upgrade sync stays a cheap 304, and the absent
|
|
65
|
+
* `lastAttemptAt` makes the throttle treat it as "never attempted"
|
|
66
|
+
* so that first sync proceeds and then stamps a v3 file.
|
|
53
67
|
* • Future unknown schemaVersion → null → full sync. Forward-compat
|
|
54
68
|
* without crashing.
|
|
55
69
|
*
|
|
@@ -58,6 +72,18 @@
|
|
|
58
72
|
* @property {number} updated - Skills overwritten on disk
|
|
59
73
|
* @property {number} removed - Tombstones applied
|
|
60
74
|
* @property {boolean} notModified - True if 304 short-circuit fired
|
|
75
|
+
* @property {boolean} [throttled] - True when the SessionStart staleness
|
|
76
|
+
* throttle skipped this run entirely — no
|
|
77
|
+
* library GET, no receipt POST (#2174).
|
|
78
|
+
* Only set on the throttled early-exit;
|
|
79
|
+
* absent (falsy) on every other path.
|
|
80
|
+
* NOTE: no production caller branches on
|
|
81
|
+
* this today (hook printers key on the
|
|
82
|
+
* zero counters) — it is the contract
|
|
83
|
+
* discriminator that lets tests tell a
|
|
84
|
+
* throttled skip from a 304, mirroring
|
|
85
|
+
* the `minSyncIntervalMs` test-seam
|
|
86
|
+
* pattern.
|
|
61
87
|
* @property {boolean | null} fullSync - True if this was a full (non-delta)
|
|
62
88
|
* sync — i.e. no prior `.last-sync` state
|
|
63
89
|
* existed so no `since` was sent. False if
|
|
@@ -73,7 +99,6 @@
|
|
|
73
99
|
* Consumers must distinguish these to
|
|
74
100
|
* render accurate user messages (see
|
|
75
101
|
* init.mjs step 7).
|
|
76
|
-
* @property {string} [syncedAt]
|
|
77
102
|
* @property {string} syncedAt - ISO timestamp of the sync: the server
|
|
78
103
|
* response `syncedAt` on a 200, or the
|
|
79
104
|
* previously-cached sync timestamp on a
|
|
@@ -97,6 +122,10 @@
|
|
|
97
122
|
* wrote this state. Absent on files written
|
|
98
123
|
* by a pre-#1911 CLI; absence triggers the
|
|
99
124
|
* one-time membership heal.
|
|
125
|
+
* @property {string} [lastAttemptAt] - v3+ (#2174). ISO timestamp of the last
|
|
126
|
+
* network sync attempt. Absent on a v2 file
|
|
127
|
+
* (or any pre-v3 writer) → the throttle
|
|
128
|
+
* treats it as "never attempted" and syncs.
|
|
100
129
|
* @property {Record<string, SyncedSkillEntry>} skills - v2+. Keyed by `"<owner>/<name>"`.
|
|
101
130
|
*/
|
|
102
131
|
|
|
@@ -127,8 +156,49 @@ import { computeSkillShas } from "./crypto-shas.mjs";
|
|
|
127
156
|
*
|
|
128
157
|
* v2 (#1553): adds `skills` map keyed by `"<owner>/<name>"` →
|
|
129
158
|
* `{ version, skillMdSha256, filesSha256, syncedAt }`.
|
|
159
|
+
* v3 (#2174): adds `lastAttemptAt` — an ISO timestamp of the last
|
|
160
|
+
* network sync ATTEMPT (stamped on the 304 short-circuit and on every
|
|
161
|
+
* successful write, NOT on a throttled skip). Powers the SessionStart
|
|
162
|
+
* staleness throttle. Additive: a v2 file (no `lastAttemptAt`) reads as
|
|
163
|
+
* "never attempted" and syncs, so the bump is forward-compatible and a
|
|
164
|
+
* v2 file is accepted in place — its etag/skills are preserved (no
|
|
165
|
+
* forced re-fetch, unlike the v1→v2 migration).
|
|
130
166
|
*/
|
|
131
|
-
export const LAST_SYNC_SCHEMA_VERSION =
|
|
167
|
+
export const LAST_SYNC_SCHEMA_VERSION = 3;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Client-side SessionStart sync throttle window (#2174).
|
|
171
|
+
*
|
|
172
|
+
* A hook-triggered `skillrepo update` (the `--session-hook` and
|
|
173
|
+
* `--silent` cohort hooks) skips the network entirely — NO library GET,
|
|
174
|
+
* NO sync-receipt POST — when the last sync ATTEMPT was less than this
|
|
175
|
+
* long ago. This caps the highest-QPS uncacheable vector: the library
|
|
176
|
+
* sync fires on every editor session, and at 100k users the per-request
|
|
177
|
+
* cost (auth + rate-limit + per-account ETag query + heartbeat, then a
|
|
178
|
+
* second request for the receipt) dominates even on a 304. See #2170.
|
|
179
|
+
*
|
|
180
|
+
* SCOPE: hook invocations ONLY. A bare interactive `skillrepo update`
|
|
181
|
+
* always syncs (explicit intent), and it too stamps `lastAttemptAt`, so
|
|
182
|
+
* a manual sync resets the window for subsequent hook syncs.
|
|
183
|
+
*
|
|
184
|
+
* FRESHNESS: this bounds how stale a hook-skipped session can be to at
|
|
185
|
+
* most this window, measured from the last ACTUAL sync — NOT from a
|
|
186
|
+
* publish time, and NOT between raw session starts (a skipped session
|
|
187
|
+
* does not advance the clock, so continuous restarts can't starve the
|
|
188
|
+
* sync). Sessions spaced further apart than this window always sync.
|
|
189
|
+
* The SAME bound applies to LOCAL damage, not just server-side changes:
|
|
190
|
+
* a throttled skip returns before the placement-presence check below,
|
|
191
|
+
* so a skill directory the user deleted/corrupted on disk is repaired
|
|
192
|
+
* on the next non-throttled sync — up to this window later, or
|
|
193
|
+
* immediately via an interactive `update` (never throttled). Pre-#2174
|
|
194
|
+
* every session start repaired placements immediately; "my skill
|
|
195
|
+
* briefly disappeared after I deleted its folder" within this window
|
|
196
|
+
* is expected behavior, not a placement-check bug.
|
|
197
|
+
*
|
|
198
|
+
* 15 minutes (#2174, product-confirmed). Override per-call via
|
|
199
|
+
* `runSync({ minSyncIntervalMs })` — a test seam used to pin the boundary.
|
|
200
|
+
*/
|
|
201
|
+
export const MIN_SYNC_INTERVAL_MS = 15 * 60 * 1000;
|
|
132
202
|
|
|
133
203
|
/**
|
|
134
204
|
* One-time heal floor (#1911).
|
|
@@ -317,11 +387,12 @@ export function readLastSync() {
|
|
|
317
387
|
return null;
|
|
318
388
|
}
|
|
319
389
|
|
|
320
|
-
// v1 →
|
|
321
|
-
// delta sync still benefits from the prior state; start
|
|
322
|
-
// empty `skills` map
|
|
323
|
-
//
|
|
324
|
-
//
|
|
390
|
+
// v1 → current-schema in-memory migration. Preserve the etag and
|
|
391
|
+
// syncedAt so delta sync still benefits from the prior state; start
|
|
392
|
+
// with an empty `skills` map (and no `lastAttemptAt`, so the throttle
|
|
393
|
+
// treats this as "never attempted"). The next successful runSync
|
|
394
|
+
// writes the current schema to disk. We do NOT write here — readers
|
|
395
|
+
// should be pure with respect to the filesystem.
|
|
325
396
|
if (parsed.schemaVersion === 1) {
|
|
326
397
|
return {
|
|
327
398
|
schemaVersion: LAST_SYNC_SCHEMA_VERSION,
|
|
@@ -331,7 +402,24 @@ export function readLastSync() {
|
|
|
331
402
|
};
|
|
332
403
|
}
|
|
333
404
|
|
|
334
|
-
|
|
405
|
+
// Accept the current schema AND the immediately-prior v2 (which is v3
|
|
406
|
+
// minus `lastAttemptAt`, #2174). A v2 file is read IN PLACE — its
|
|
407
|
+
// etag/syncedAt/skills/cliVersion are preserved, so the first
|
|
408
|
+
// post-upgrade sync stays a cheap 304 rather than a forced re-fetch;
|
|
409
|
+
// the absent `lastAttemptAt` makes the throttle treat it as "never
|
|
410
|
+
// attempted" so that first sync still proceeds, then writes a v3 file.
|
|
411
|
+
// Any OTHER version (v1 is migrated above; a future v4+ is unknown) →
|
|
412
|
+
// null → full sync. Forward-compat without crashing.
|
|
413
|
+
//
|
|
414
|
+
// MAINTAINER: the `!== 2` literal is the "accept the immediately-prior
|
|
415
|
+
// schema in place" branch — it is NOT auto-derived from
|
|
416
|
+
// LAST_SYNC_SCHEMA_VERSION. The next bump (v3 → v4) must add its own
|
|
417
|
+
// `!== 3` branch here (accept v3 in place; v2 then falls through to a
|
|
418
|
+
// full sync), the same way this branch was added for v2 → v3.
|
|
419
|
+
if (
|
|
420
|
+
parsed.schemaVersion !== LAST_SYNC_SCHEMA_VERSION &&
|
|
421
|
+
parsed.schemaVersion !== 2
|
|
422
|
+
) {
|
|
335
423
|
return null;
|
|
336
424
|
}
|
|
337
425
|
|
|
@@ -351,6 +439,16 @@ export function readLastSync() {
|
|
|
351
439
|
parsed.skills = {};
|
|
352
440
|
}
|
|
353
441
|
|
|
442
|
+
// `lastAttemptAt` (v3+, #2174) must be an ISO string to be usable by
|
|
443
|
+
// the throttle. A v2 file omits it; a malformed value (number, object,
|
|
444
|
+
// truncated write) is coerced to absent so the throttle treats the
|
|
445
|
+
// state as "never attempted" and syncs rather than trusting a bogus
|
|
446
|
+
// clock. `Date.parse` in runSync is a second guard, but normalizing
|
|
447
|
+
// here keeps the returned shape honest for every caller.
|
|
448
|
+
if (typeof parsed.lastAttemptAt !== "string") {
|
|
449
|
+
delete parsed.lastAttemptAt;
|
|
450
|
+
}
|
|
451
|
+
|
|
354
452
|
return parsed;
|
|
355
453
|
}
|
|
356
454
|
|
|
@@ -375,19 +473,31 @@ export function readLastSync() {
|
|
|
375
473
|
* never re-healed. A caller-supplied `cliVersion` overrides the stamp (used
|
|
376
474
|
* only by tests to simulate a pre-fix writer); production callers omit it.
|
|
377
475
|
*
|
|
476
|
+
* v3 (#2174): `lastAttemptAt` is the ISO timestamp of the last network
|
|
477
|
+
* sync attempt, used by the throttle. It is NOT auto-stamped: callers pass
|
|
478
|
+
* it explicitly so intent is unambiguous. `runSync` passes "now" on the 304
|
|
479
|
+
* and successful-write paths; the single-skill helpers (`upsertLastSyncEntry`
|
|
480
|
+
* / `deleteLastSyncEntry`) preserve the prior value verbatim because a
|
|
481
|
+
* single-skill add/remove is not a full-library sync and must not advance
|
|
482
|
+
* the throttle clock. Omitting it persists `null` — an explicit "never
|
|
483
|
+
* attempted" that never wrongly throttles the next sync.
|
|
484
|
+
*
|
|
378
485
|
* @param {object} state
|
|
379
486
|
* @param {string | null} [state.etag]
|
|
380
487
|
* @param {string} [state.syncedAt]
|
|
381
488
|
* @param {Record<string, SyncedSkillEntry>} [state.skills]
|
|
382
489
|
* @param {string | null} [state.cliVersion] - Override the writer-version
|
|
383
490
|
* stamp. Defaults to the running CLI's version.
|
|
491
|
+
* @param {string | null} [state.lastAttemptAt] - ISO timestamp of the last
|
|
492
|
+
* sync attempt (#2174). Omitting it persists `null`.
|
|
384
493
|
*/
|
|
385
|
-
export function writeLastSync({ etag, syncedAt, skills, cliVersion } = {}) {
|
|
494
|
+
export function writeLastSync({ etag, syncedAt, skills, cliVersion, lastAttemptAt } = {}) {
|
|
386
495
|
const path = globalLastSyncPath();
|
|
387
496
|
const body = {
|
|
388
497
|
schemaVersion: LAST_SYNC_SCHEMA_VERSION,
|
|
389
498
|
etag: etag ?? null,
|
|
390
499
|
syncedAt: syncedAt ?? new Date().toISOString(),
|
|
500
|
+
lastAttemptAt: lastAttemptAt ?? null,
|
|
391
501
|
cliVersion: cliVersion !== undefined ? cliVersion : safeCliVersion(),
|
|
392
502
|
skills: skills && typeof skills === "object" && !Array.isArray(skills) ? skills : {},
|
|
393
503
|
};
|
|
@@ -480,6 +590,11 @@ export function upsertLastSyncEntry(skill) {
|
|
|
480
590
|
// carries a pre-fix file's absent version forward as an explicit null
|
|
481
591
|
// so the next `update` still heals. Same rationale as etag/syncedAt.
|
|
482
592
|
cliVersion: prior?.cliVersion ?? null,
|
|
593
|
+
// Preserve the throttle clock (#2174) — a single-skill `add`/`get` is
|
|
594
|
+
// not a full-library sync, so it must NOT advance `lastAttemptAt` (that
|
|
595
|
+
// would wrongly throttle the next SessionStart sync). Same rationale as
|
|
596
|
+
// cliVersion above.
|
|
597
|
+
lastAttemptAt: prior?.lastAttemptAt ?? null,
|
|
483
598
|
});
|
|
484
599
|
}
|
|
485
600
|
|
|
@@ -507,6 +622,9 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
507
622
|
// reconciliation, so it must not advance the one-time heal gate.
|
|
508
623
|
// See upsertLastSyncEntry for the full rationale.
|
|
509
624
|
cliVersion: prior.cliVersion ?? null,
|
|
625
|
+
// Preserve the throttle clock (#2174) — `remove` is not a full sync,
|
|
626
|
+
// so it must not advance `lastAttemptAt`. Same rationale as cliVersion.
|
|
627
|
+
lastAttemptAt: prior.lastAttemptAt ?? null,
|
|
510
628
|
});
|
|
511
629
|
}
|
|
512
630
|
|
|
@@ -514,12 +632,16 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
514
632
|
* Run a library sync against the server.
|
|
515
633
|
*
|
|
516
634
|
* Strategy:
|
|
517
|
-
* 1.
|
|
518
|
-
*
|
|
635
|
+
* 1. Read the last-sync state, then apply the SessionStart throttle
|
|
636
|
+
* (#2174): a hook-triggered sync (`throttle: true`) within
|
|
637
|
+
* MIN_SYNC_INTERVAL_MS of the last attempt short-circuits HERE with
|
|
638
|
+
* ZERO network calls. A bare interactive `update` never throttles.
|
|
639
|
+
* 2. Clean up any orphan .tmp/.old directories from a crashed prior run
|
|
519
640
|
* 3. Call GET /api/v1/library with `If-None-Match` (if we have a
|
|
520
641
|
* cached ETag) AND `since` (always, for delta semantics)
|
|
521
642
|
* 4. On 304 — re-assert the on-disk state via a receipt (liveness +
|
|
522
|
-
* heal) and short-circuit
|
|
643
|
+
* heal), stamp the throttle clock (`lastAttemptAt`), and short-circuit
|
|
644
|
+
* with `notModified: true`
|
|
523
645
|
* 5. For each skill in the response:
|
|
524
646
|
* a. Write it via `writeSkillDir` — overwrites if changed
|
|
525
647
|
* b. Skip writing skills with `filesIncomplete: true` (see comment)
|
|
@@ -546,10 +668,20 @@ export function deleteLastSyncEntry(owner, name) {
|
|
|
546
668
|
* when `writeLastSync` fails uses io.stderr so tests that
|
|
547
669
|
* inject a capture stream don't lose that output.
|
|
548
670
|
* @param {NodeJS.WritableStream} [options.io.stderr=process.stderr]
|
|
671
|
+
* @param {boolean} [options.throttle] - Hook-triggered syncs ONLY
|
|
672
|
+
* (update.mjs's --session-hook / --silent branches) pass
|
|
673
|
+
* true. When the last attempt was within the interval, the
|
|
674
|
+
* whole sync short-circuits with zero network calls and a
|
|
675
|
+
* `throttled: true` summary (#2174). A bare interactive
|
|
676
|
+
* `skillrepo update` omits this and always syncs.
|
|
677
|
+
* @param {number} [options.minSyncIntervalMs] - Override the throttle window
|
|
678
|
+
* (defaults to MIN_SYNC_INTERVAL_MS). A test seam to pin the
|
|
679
|
+
* boundary; production callers omit it.
|
|
549
680
|
* @returns {Promise<SyncSummary>}
|
|
550
681
|
*/
|
|
551
682
|
export async function runSync(options) {
|
|
552
|
-
const { serverUrl, apiKey, vendors, global, io } =
|
|
683
|
+
const { serverUrl, apiKey, vendors, global, io, throttle, minSyncIntervalMs } =
|
|
684
|
+
options;
|
|
553
685
|
// Coalesce both `undefined` AND `null` to {}. Destructuring with
|
|
554
686
|
// `io = {}` only handles `undefined`, so an explicit `io: null`
|
|
555
687
|
// would otherwise blow up at the .stderr access below. Both
|
|
@@ -564,12 +696,70 @@ export async function runSync(options) {
|
|
|
564
696
|
throw validationError("runSync: apiKey is required");
|
|
565
697
|
}
|
|
566
698
|
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
//
|
|
699
|
+
// Read prior state FIRST — the throttle decision below needs it, and
|
|
700
|
+
// reading one small JSON file is far cheaper than the orphan scan.
|
|
701
|
+
// Reordered ahead of cleanupOrphans (#2174) so a throttled hook sync
|
|
702
|
+
// does no filesystem walking either.
|
|
571
703
|
const lastSync = readLastSync();
|
|
572
704
|
|
|
705
|
+
// SessionStart staleness throttle (#2174). Hook-triggered syncs
|
|
706
|
+
// (`throttle: true`, set ONLY by update.mjs's --session-hook and
|
|
707
|
+
// --silent branches) skip ALL network work — no library GET, no receipt
|
|
708
|
+
// POST — when the last ATTEMPT was within the interval. A bare
|
|
709
|
+
// interactive `skillrepo update` passes no `throttle`, so it always
|
|
710
|
+
// syncs (explicit intent). `lastAttemptAt` is absent on a fresh install
|
|
711
|
+
// and on a v2 file (never attempted) → those never throttle. A skipped
|
|
712
|
+
// run does NOT re-stamp `lastAttemptAt` (only an actual sync does), so
|
|
713
|
+
// continuous restarts can't push the clock forward and starve the sync:
|
|
714
|
+
// the window is measured from the last REAL sync, not the last session.
|
|
715
|
+
const intervalMs =
|
|
716
|
+
typeof minSyncIntervalMs === "number" && minSyncIntervalMs >= 0
|
|
717
|
+
? minSyncIntervalMs
|
|
718
|
+
: MIN_SYNC_INTERVAL_MS;
|
|
719
|
+
if (throttle && lastSync && typeof lastSync.lastAttemptAt === "string") {
|
|
720
|
+
const lastAttemptMs = Date.parse(lastSync.lastAttemptAt);
|
|
721
|
+
const now = Date.now();
|
|
722
|
+
const sinceLastAttemptMs = now - lastAttemptMs;
|
|
723
|
+
// Guard BOTH ends of the window. A FUTURE `lastAttemptAt` (negative
|
|
724
|
+
// delta — backward clock jump, VM snapshot restore, hand-edited or
|
|
725
|
+
// corrupt state file) must SYNC, not throttle: a plain `< intervalMs`
|
|
726
|
+
// check stays true until the wall clock catches up to the bogus
|
|
727
|
+
// stamp, silently disabling the hook sync for that whole span (hook
|
|
728
|
+
// modes are contract-silent, so the user would never see it).
|
|
729
|
+
// Syncing instead re-stamps a sane "now" — one pass self-heals.
|
|
730
|
+
if (
|
|
731
|
+
Number.isFinite(lastAttemptMs) &&
|
|
732
|
+
sinceLastAttemptMs >= 0 &&
|
|
733
|
+
sinceLastAttemptMs < intervalMs
|
|
734
|
+
) {
|
|
735
|
+
// SKILLREPO_VERBOSE gives the acceptance check a human-observable
|
|
736
|
+
// signal alongside the request-count spy — one dim stderr line only;
|
|
737
|
+
// stdout stays clean for the hook contracts.
|
|
738
|
+
if (process.env.SKILLREPO_VERBOSE) {
|
|
739
|
+
const agoS = Math.round(sinceLastAttemptMs / 1000);
|
|
740
|
+
const winS = Math.round(intervalMs / 1000);
|
|
741
|
+
stderr.write(
|
|
742
|
+
` [skillrepo] sync throttled: last attempt ${agoS}s ago < ${winS}s window; skipping network.\n`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
added: 0,
|
|
747
|
+
updated: 0,
|
|
748
|
+
removed: 0,
|
|
749
|
+
notModified: false,
|
|
750
|
+
throttled: true,
|
|
751
|
+
// Prior state existed (we had a lastAttemptAt) → never a first
|
|
752
|
+
// sync. Surface the cached syncedAt so consumers that read it
|
|
753
|
+
// (e.g. init.mjs) see the last known-good sync time.
|
|
754
|
+
fullSync: false,
|
|
755
|
+
syncedAt: lastSync.syncedAt ?? new Date(now).toISOString(),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Step 2: clean orphans from prior crashes BEFORE doing any new writes
|
|
761
|
+
cleanupOrphans({ vendors, global });
|
|
762
|
+
|
|
573
763
|
// Step 3: fetch with conditional headers
|
|
574
764
|
const opts = {};
|
|
575
765
|
// The ETag short-circuit is only safe when EVERY skill in
|
|
@@ -638,6 +828,20 @@ export async function runSync(options) {
|
|
|
638
828
|
// 4.8.0 heal, its `writeLastSync` re-emits a file with no `cliVersion`, so
|
|
639
829
|
// the next 4.8.0 run heals again. That loop is harmless — a full re-fetch
|
|
640
830
|
// is idempotent — and self-resolves once the older install is upgraded.
|
|
831
|
+
//
|
|
832
|
+
// Same-machine SPLIT-FLEET churn (#2174 rollout, tracked via #2181): the
|
|
833
|
+
// cohort hooks run `npx --yes skillrepo update --silent` (npm `latest` on
|
|
834
|
+
// every session), while the Claude Code hook pins the globally-installed
|
|
835
|
+
// binary. Until that global install is upgraded past the v3 schema bump,
|
|
836
|
+
// the two alternate writers ping-pong `.last-sync`: the ≥4.9.0 npx run
|
|
837
|
+
// writes schemaVersion 3; the ≤4.8.x pinned binary doesn't recognize v3 →
|
|
838
|
+
// null → FULL non-conditional fetch → writes v2 back (dropping
|
|
839
|
+
// `lastAttemptAt`), and the next npx run re-upgrades. Consequences while
|
|
840
|
+
// the split persists: the pinned binary never gets a 304 short-circuit
|
|
841
|
+
// and the throttle keeps getting disarmed — bounded by the once-daily
|
|
842
|
+
// upgrade nudge (bin/skillrepo.mjs), self-resolving on `npm install -g
|
|
843
|
+
// skillrepo@latest`. Harmless to data (full re-fetch is idempotent);
|
|
844
|
+
// do NOT add a compat shim for it here.
|
|
641
845
|
const needsFullResync =
|
|
642
846
|
!!lastSync && cliVersionBelowFloor(lastSync.cliVersion, FULL_RESYNC_FLOOR);
|
|
643
847
|
|
|
@@ -679,6 +883,31 @@ export async function runSync(options) {
|
|
|
679
883
|
new Date().toISOString(),
|
|
680
884
|
stderr,
|
|
681
885
|
);
|
|
886
|
+
|
|
887
|
+
// Stamp the throttle clock (#2174). LOAD-BEARING: a deliberately-
|
|
888
|
+
// stable library is a 304 on every session, so if we didn't advance
|
|
889
|
+
// `lastAttemptAt` here the throttle would never re-arm and every future
|
|
890
|
+
// hook sync would hit the network again — defeating the whole feature.
|
|
891
|
+
// Preserve etag/syncedAt/skills verbatim (the library is unchanged; only
|
|
892
|
+
// the attempt clock moves) and preserve cliVersion — a 304 is NOT a
|
|
893
|
+
// membership heal, and reaching a 304 already implies we sent
|
|
894
|
+
// If-None-Match, i.e. cliVersion was >= the heal floor. Best-effort: a
|
|
895
|
+
// failed stamp just means the next hook sync isn't throttled, never a
|
|
896
|
+
// sync failure.
|
|
897
|
+
try {
|
|
898
|
+
writeLastSync({
|
|
899
|
+
etag: lastSync?.etag ?? null,
|
|
900
|
+
syncedAt: lastSync?.syncedAt,
|
|
901
|
+
skills: lastSync?.skills ?? {},
|
|
902
|
+
cliVersion: lastSync?.cliVersion ?? null,
|
|
903
|
+
lastAttemptAt: new Date().toISOString(),
|
|
904
|
+
});
|
|
905
|
+
} catch (err) {
|
|
906
|
+
stderr.write(
|
|
907
|
+
` warning: failed to persist last-sync state (${err.message}). ` +
|
|
908
|
+
`Next sync will not be throttled.\n`,
|
|
909
|
+
);
|
|
910
|
+
}
|
|
682
911
|
return {
|
|
683
912
|
added: 0,
|
|
684
913
|
updated: 0,
|
|
@@ -827,6 +1056,11 @@ export async function runSync(options) {
|
|
|
827
1056
|
etag: result.etag,
|
|
828
1057
|
syncedAt: result.syncedAt,
|
|
829
1058
|
skills: skillsMap,
|
|
1059
|
+
// Stamp the throttle clock on every successful sync (#2174). Gated
|
|
1060
|
+
// by the same `!anyIncomplete` guard as the ETag: a partial payload
|
|
1061
|
+
// does NOT advance the clock, so a broken sync is retried on the
|
|
1062
|
+
// next session rather than throttled away.
|
|
1063
|
+
lastAttemptAt: new Date().toISOString(),
|
|
830
1064
|
});
|
|
831
1065
|
} catch (err) {
|
|
832
1066
|
// Non-fatal — the skills are on disk, we just won't get the
|
|
@@ -220,7 +220,7 @@ describe("runPublish — error paths", () => {
|
|
|
220
220
|
server.setPublishResponse("alice", "my-skill", {
|
|
221
221
|
status: 422,
|
|
222
222
|
body: {
|
|
223
|
-
error: "
|
|
223
|
+
error: "Set your Author ID before publishing.",
|
|
224
224
|
code: "namespace_unset",
|
|
225
225
|
},
|
|
226
226
|
});
|
|
@@ -453,6 +453,30 @@ describe("runPush — server error mapping", () => {
|
|
|
453
453
|
);
|
|
454
454
|
});
|
|
455
455
|
|
|
456
|
+
it("maps server 422 handle_required to EXIT_VALIDATION with the Settings hint (#2310, #2345)", async () => {
|
|
457
|
+
// The dedicated #2310 branch in mapErrorResponse: friendly message
|
|
458
|
+
// plus the Settings pointer. Previously pinned only at the
|
|
459
|
+
// http.test.mjs layer against hand-built JSON — this runs the whole
|
|
460
|
+
// runPush chain against the same wire shape the server emits
|
|
461
|
+
// (route.ts:724: `{ error, code: "handle_required" }`).
|
|
462
|
+
file("SKILL.md", VALID_SKILL_MD);
|
|
463
|
+
server.setPushResponse({
|
|
464
|
+
status: 422,
|
|
465
|
+
body: {
|
|
466
|
+
error: "Set your Author ID before adding skills.",
|
|
467
|
+
code: "handle_required",
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
await assert.rejects(
|
|
471
|
+
runPush(["--key", VALID_KEY, "--url", serverUrl, "skill"], { stdout }),
|
|
472
|
+
(err) =>
|
|
473
|
+
err instanceof CliError &&
|
|
474
|
+
err.exitCode === EXIT_VALIDATION &&
|
|
475
|
+
/author id/i.test(err.message) &&
|
|
476
|
+
/settings/i.test(err.hint ?? ""),
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
456
480
|
it("maps server 413 payload_too_large to EXIT_VALIDATION (generic 4xx)", async () => {
|
|
457
481
|
// 413 has NO explicit branch in mapErrorResponse (only 401/403/404/
|
|
458
482
|
// 429/5xx are special-cased). It therefore falls through to the
|