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
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
cliVersionBelowFloor,
|
|
45
45
|
FULL_RESYNC_FLOOR,
|
|
46
46
|
LAST_SYNC_SCHEMA_VERSION,
|
|
47
|
+
MIN_SYNC_INTERVAL_MS,
|
|
47
48
|
} from "../../lib/sync.mjs";
|
|
48
49
|
import { resolvePlacementDir } from "../../lib/file-write.mjs";
|
|
49
50
|
import { globalLastSyncPath } from "../../lib/paths.mjs";
|
|
@@ -124,11 +125,11 @@ describe("readLastSync / writeLastSync", () => {
|
|
|
124
125
|
assert.equal(result.schemaVersion, LAST_SYNC_SCHEMA_VERSION);
|
|
125
126
|
});
|
|
126
127
|
|
|
127
|
-
it("writes
|
|
128
|
-
// Locks the constant so
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
assert.equal(LAST_SYNC_SCHEMA_VERSION,
|
|
128
|
+
it("writes v3 schema (LAST_SYNC_SCHEMA_VERSION === 3)", () => {
|
|
129
|
+
// Locks the constant so an accidental change fails loudly. #2174
|
|
130
|
+
// bumped it 2 → 3 (adds `lastAttemptAt`). v1 files migrate in-memory,
|
|
131
|
+
// v2 files are accepted in place, unknown future versions read null.
|
|
132
|
+
assert.equal(LAST_SYNC_SCHEMA_VERSION, 3);
|
|
132
133
|
});
|
|
133
134
|
|
|
134
135
|
it("persists an empty skills map by default", () => {
|
|
@@ -164,7 +165,7 @@ describe("readLastSync / writeLastSync", () => {
|
|
|
164
165
|
assert.deepEqual(result.skills, skills);
|
|
165
166
|
});
|
|
166
167
|
|
|
167
|
-
it("an old v1 file migrates in-memory to
|
|
168
|
+
it("an old v1 file migrates in-memory to the current-schema shape with empty skills", () => {
|
|
168
169
|
// Backward compat: a user upgrading from a v1-era CLI to v2 still
|
|
169
170
|
// has a v1 `.last-sync` on disk. Returning null would force a
|
|
170
171
|
// full sync on every command (the etag is lost), which is
|
|
@@ -235,9 +236,9 @@ describe("readLastSync / writeLastSync", () => {
|
|
|
235
236
|
});
|
|
236
237
|
|
|
237
238
|
it("a file with an unknown schemaVersion still reads as null", () => {
|
|
238
|
-
// Forward compat: a
|
|
239
|
-
// understand) reads as null, triggering a full sync. We only
|
|
240
|
-
// migrate
|
|
239
|
+
// Forward compat: a v4 or v999 file (from a newer CLI we don't
|
|
240
|
+
// understand) reads as null, triggering a full sync. We only accept
|
|
241
|
+
// KNOWN shapes (v1 migrate, v2/v3 in place), not arbitrary unknown ones.
|
|
241
242
|
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
242
243
|
writeFileSync(
|
|
243
244
|
globalLastSyncPath(),
|
|
@@ -2046,3 +2047,490 @@ describe("placementsAreComplete — direct unit coverage", () => {
|
|
|
2046
2047
|
assert.equal(placementsAreComplete(skills, ["claudeCode"], false), true);
|
|
2047
2048
|
});
|
|
2048
2049
|
});
|
|
2050
|
+
|
|
2051
|
+
// ── .last-sync — lastAttemptAt (v3, #2174) ─────────────────────────────
|
|
2052
|
+
|
|
2053
|
+
describe("readLastSync / writeLastSync — lastAttemptAt (v3, #2174)", () => {
|
|
2054
|
+
beforeEach(setupServer);
|
|
2055
|
+
afterEach(teardownServer);
|
|
2056
|
+
|
|
2057
|
+
it("round-trips a lastAttemptAt ISO string", () => {
|
|
2058
|
+
writeLastSync({
|
|
2059
|
+
etag: '"e"',
|
|
2060
|
+
syncedAt: "s",
|
|
2061
|
+
lastAttemptAt: "2025-01-01T00:00:00.000Z",
|
|
2062
|
+
});
|
|
2063
|
+
assert.equal(readLastSync().lastAttemptAt, "2025-01-01T00:00:00.000Z");
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
it("persists lastAttemptAt: null when omitted, and reads it back as ABSENT", () => {
|
|
2067
|
+
// Omitting the field writes explicit null on disk; readLastSync
|
|
2068
|
+
// normalizes a non-string to absent so the throttle sees "never
|
|
2069
|
+
// attempted" rather than a bogus clock value.
|
|
2070
|
+
writeLastSync({ etag: '"e"', syncedAt: "s" });
|
|
2071
|
+
const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
|
|
2072
|
+
assert.equal(onDisk.lastAttemptAt, null);
|
|
2073
|
+
assert.equal("lastAttemptAt" in readLastSync(), false);
|
|
2074
|
+
});
|
|
2075
|
+
|
|
2076
|
+
it("coerces a non-string lastAttemptAt to absent (bogus clock ignored)", () => {
|
|
2077
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
2078
|
+
writeFileSync(
|
|
2079
|
+
globalLastSyncPath(),
|
|
2080
|
+
JSON.stringify({
|
|
2081
|
+
schemaVersion: 3,
|
|
2082
|
+
etag: "x",
|
|
2083
|
+
syncedAt: "x",
|
|
2084
|
+
skills: {},
|
|
2085
|
+
lastAttemptAt: 12345,
|
|
2086
|
+
}),
|
|
2087
|
+
);
|
|
2088
|
+
assert.equal("lastAttemptAt" in readLastSync(), false);
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
it("accepts a v2 file in place (no lastAttemptAt) — etag + skills preserved", () => {
|
|
2092
|
+
// A v2 file written by a pre-#2174 CLI must be read in place: etag and
|
|
2093
|
+
// skills preserved (so the first post-upgrade sync stays a cheap 304),
|
|
2094
|
+
// lastAttemptAt absent (the throttle treats it as "never attempted").
|
|
2095
|
+
// This is the additive-forward-compat contract, distinct from the
|
|
2096
|
+
// v1→v2 migration which forces a re-fetch via an empty skills map.
|
|
2097
|
+
mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
|
|
2098
|
+
const v2 = {
|
|
2099
|
+
schemaVersion: 2,
|
|
2100
|
+
etag: '"v2etag"',
|
|
2101
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2102
|
+
cliVersion: "4.8.1",
|
|
2103
|
+
skills: {
|
|
2104
|
+
"alice/x": {
|
|
2105
|
+
version: "1.0.0",
|
|
2106
|
+
skillMdSha256: "a".repeat(64),
|
|
2107
|
+
filesSha256: "b".repeat(64),
|
|
2108
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2109
|
+
},
|
|
2110
|
+
},
|
|
2111
|
+
};
|
|
2112
|
+
writeFileSync(globalLastSyncPath(), JSON.stringify(v2));
|
|
2113
|
+
const read = readLastSync();
|
|
2114
|
+
assert.ok(read, "a v2 file must be accepted, not null");
|
|
2115
|
+
assert.equal(read.etag, '"v2etag"');
|
|
2116
|
+
assert.deepEqual(Object.keys(read.skills), ["alice/x"]);
|
|
2117
|
+
assert.equal(
|
|
2118
|
+
"lastAttemptAt" in read,
|
|
2119
|
+
false,
|
|
2120
|
+
"v2 has no lastAttemptAt → the throttle treats it as never attempted",
|
|
2121
|
+
);
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
it("upsertLastSyncEntry preserves the prior lastAttemptAt (single-skill add is not a full sync)", () => {
|
|
2125
|
+
writeLastSync({
|
|
2126
|
+
etag: '"e"',
|
|
2127
|
+
syncedAt: "s",
|
|
2128
|
+
skills: {},
|
|
2129
|
+
lastAttemptAt: "2025-02-02T00:00:00.000Z",
|
|
2130
|
+
});
|
|
2131
|
+
upsertLastSyncEntry({
|
|
2132
|
+
owner: "alice",
|
|
2133
|
+
name: "added",
|
|
2134
|
+
version: "1.0.0",
|
|
2135
|
+
files: [
|
|
2136
|
+
{
|
|
2137
|
+
path: "SKILL.md",
|
|
2138
|
+
content: "---\nname: added\ndescription: d\n---\nbody\n",
|
|
2139
|
+
},
|
|
2140
|
+
],
|
|
2141
|
+
});
|
|
2142
|
+
assert.equal(
|
|
2143
|
+
readLastSync().lastAttemptAt,
|
|
2144
|
+
"2025-02-02T00:00:00.000Z",
|
|
2145
|
+
"a single-skill add must NOT advance the throttle clock",
|
|
2146
|
+
);
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
it("deleteLastSyncEntry preserves the prior lastAttemptAt", () => {
|
|
2150
|
+
writeLastSync({
|
|
2151
|
+
etag: '"e"',
|
|
2152
|
+
syncedAt: "s",
|
|
2153
|
+
skills: {
|
|
2154
|
+
"alice/gone": {
|
|
2155
|
+
version: "1.0.0",
|
|
2156
|
+
skillMdSha256: "a".repeat(64),
|
|
2157
|
+
filesSha256: "b".repeat(64),
|
|
2158
|
+
syncedAt: "s",
|
|
2159
|
+
},
|
|
2160
|
+
},
|
|
2161
|
+
lastAttemptAt: "2025-03-03T00:00:00.000Z",
|
|
2162
|
+
});
|
|
2163
|
+
deleteLastSyncEntry("alice", "gone");
|
|
2164
|
+
assert.equal(readLastSync().lastAttemptAt, "2025-03-03T00:00:00.000Z");
|
|
2165
|
+
});
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
// ── runSync — SessionStart staleness throttle (#2174) ──────────────────
|
|
2169
|
+
//
|
|
2170
|
+
// A hook-triggered sync (`throttle: true`, passed ONLY by update.mjs's
|
|
2171
|
+
// --session-hook / --silent branches) skips ALL network work — no library
|
|
2172
|
+
// GET, no receipt POST — when the last ATTEMPT was within the window. A
|
|
2173
|
+
// bare interactive sync (no `throttle`) always runs. `lastAttemptAt` is
|
|
2174
|
+
// stamped on the 304 path and every successful write, NOT on a throttled
|
|
2175
|
+
// skip, so the window is measured from the last REAL sync — a run of quick
|
|
2176
|
+
// restarts can't push the clock forward and starve the sync.
|
|
2177
|
+
|
|
2178
|
+
describe("runSync — SessionStart throttle (#2174)", () => {
|
|
2179
|
+
beforeEach(setupServer);
|
|
2180
|
+
afterEach(teardownServer);
|
|
2181
|
+
|
|
2182
|
+
it("locks the default window at 15 minutes (a change must be deliberate)", () => {
|
|
2183
|
+
assert.equal(MIN_SYNC_INTERVAL_MS, 15 * 60 * 1000);
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2186
|
+
it("ACCEPTANCE: two throttled syncs within the window → the 2nd makes ZERO HTTP calls", async () => {
|
|
2187
|
+
server.setEtag('"v1"');
|
|
2188
|
+
server.setLibraryResponse({
|
|
2189
|
+
skills: [makeSkill("pdf-helper")],
|
|
2190
|
+
removals: [],
|
|
2191
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
// Run 1 (hook-triggered): a real sync — no prior attempt, so it is NOT
|
|
2195
|
+
// throttled. It stamps lastAttemptAt = now.
|
|
2196
|
+
const first = await runSync({
|
|
2197
|
+
serverUrl,
|
|
2198
|
+
apiKey: VALID_KEY,
|
|
2199
|
+
vendors: ["claudeCode"],
|
|
2200
|
+
throttle: true,
|
|
2201
|
+
});
|
|
2202
|
+
assert.equal(first.throttled ?? false, false, "first run is a real sync");
|
|
2203
|
+
assert.ok(server.getLibraryRequestCount() >= 1, "first run hit the library GET");
|
|
2204
|
+
|
|
2205
|
+
// Reset the request-count spies, then run again immediately. Within the
|
|
2206
|
+
// 15-min window the throttle MUST skip both the library GET and the
|
|
2207
|
+
// receipt POST — zero HTTP, the acceptance criterion.
|
|
2208
|
+
server.resetLibraryInspection();
|
|
2209
|
+
server.resetReceipts();
|
|
2210
|
+
const second = await runSync({
|
|
2211
|
+
serverUrl,
|
|
2212
|
+
apiKey: VALID_KEY,
|
|
2213
|
+
vendors: ["claudeCode"],
|
|
2214
|
+
throttle: true,
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
assert.equal(second.throttled, true, "second run within the window is throttled");
|
|
2218
|
+
assert.equal(server.getLibraryRequestCount(), 0, "throttled run makes NO library GET");
|
|
2219
|
+
assert.equal(server.getReceiptRequestCount(), 0, "throttled run makes NO receipt POST");
|
|
2220
|
+
assert.equal(
|
|
2221
|
+
second.notModified,
|
|
2222
|
+
false,
|
|
2223
|
+
"throttled is distinct from a 304 — we never contacted the server",
|
|
2224
|
+
);
|
|
2225
|
+
});
|
|
2226
|
+
|
|
2227
|
+
it("a bare interactive sync (no throttle) ALWAYS syncs, even within the window", async () => {
|
|
2228
|
+
server.setEtag('"v1"');
|
|
2229
|
+
server.setLibraryResponse({
|
|
2230
|
+
skills: [makeSkill("x")],
|
|
2231
|
+
removals: [],
|
|
2232
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2233
|
+
});
|
|
2234
|
+
// Arm the clock with a hook sync.
|
|
2235
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2236
|
+
server.resetLibraryInspection();
|
|
2237
|
+
server.resetReceipts();
|
|
2238
|
+
// Interactive: `throttle` omitted → must hit the network despite a
|
|
2239
|
+
// fresh lastAttemptAt.
|
|
2240
|
+
const res = await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2241
|
+
assert.equal(res.throttled ?? false, false);
|
|
2242
|
+
assert.ok(
|
|
2243
|
+
server.getLibraryRequestCount() >= 1,
|
|
2244
|
+
"an explicit interactive sync always contacts the server",
|
|
2245
|
+
);
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
it("does NOT throttle a fresh install (no lastAttemptAt yet)", async () => {
|
|
2249
|
+
server.setEtag('"v1"');
|
|
2250
|
+
server.setLibraryResponse({
|
|
2251
|
+
skills: [makeSkill("x")],
|
|
2252
|
+
removals: [],
|
|
2253
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2254
|
+
});
|
|
2255
|
+
// No prior .last-sync → lastAttemptAt absent → never attempted → the
|
|
2256
|
+
// very first hook sync proceeds.
|
|
2257
|
+
const res = await runSync({
|
|
2258
|
+
serverUrl,
|
|
2259
|
+
apiKey: VALID_KEY,
|
|
2260
|
+
vendors: ["claudeCode"],
|
|
2261
|
+
throttle: true,
|
|
2262
|
+
});
|
|
2263
|
+
assert.equal(res.throttled ?? false, false);
|
|
2264
|
+
assert.ok(server.getLibraryRequestCount() >= 1);
|
|
2265
|
+
});
|
|
2266
|
+
|
|
2267
|
+
it("throttle expires: an attempt older than the window syncs again", async () => {
|
|
2268
|
+
server.setEtag('"v1"');
|
|
2269
|
+
server.setLibraryResponse({
|
|
2270
|
+
skills: [makeSkill("x")],
|
|
2271
|
+
removals: [],
|
|
2272
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2273
|
+
});
|
|
2274
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2275
|
+
|
|
2276
|
+
// Backdate lastAttemptAt to just past the window.
|
|
2277
|
+
const s = readLastSync();
|
|
2278
|
+
writeLastSync({
|
|
2279
|
+
etag: s.etag,
|
|
2280
|
+
syncedAt: s.syncedAt,
|
|
2281
|
+
skills: s.skills,
|
|
2282
|
+
cliVersion: s.cliVersion,
|
|
2283
|
+
lastAttemptAt: new Date(Date.now() - MIN_SYNC_INTERVAL_MS - 1000).toISOString(),
|
|
2284
|
+
});
|
|
2285
|
+
server.resetLibraryInspection();
|
|
2286
|
+
server.resetReceipts();
|
|
2287
|
+
|
|
2288
|
+
const res = await runSync({
|
|
2289
|
+
serverUrl,
|
|
2290
|
+
apiKey: VALID_KEY,
|
|
2291
|
+
vendors: ["claudeCode"],
|
|
2292
|
+
throttle: true,
|
|
2293
|
+
});
|
|
2294
|
+
assert.equal(res.throttled ?? false, false, "past the window → not throttled");
|
|
2295
|
+
assert.ok(server.getLibraryRequestCount() >= 1, "expired window re-contacts the server");
|
|
2296
|
+
});
|
|
2297
|
+
|
|
2298
|
+
it("minSyncIntervalMs override pins the boundary (0 disables the throttle)", async () => {
|
|
2299
|
+
server.setEtag('"v1"');
|
|
2300
|
+
server.setLibraryResponse({
|
|
2301
|
+
skills: [makeSkill("x")],
|
|
2302
|
+
removals: [],
|
|
2303
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2304
|
+
});
|
|
2305
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2306
|
+
server.resetLibraryInspection();
|
|
2307
|
+
// interval 0 → `now - last < 0` is never true → always syncs.
|
|
2308
|
+
const res = await runSync({
|
|
2309
|
+
serverUrl,
|
|
2310
|
+
apiKey: VALID_KEY,
|
|
2311
|
+
vendors: ["claudeCode"],
|
|
2312
|
+
throttle: true,
|
|
2313
|
+
minSyncIntervalMs: 0,
|
|
2314
|
+
});
|
|
2315
|
+
assert.equal(res.throttled ?? false, false);
|
|
2316
|
+
assert.ok(server.getLibraryRequestCount() >= 1);
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
it("brackets the window: a 1h-old attempt throttles under a 2h interval, syncs under a 30-min one", async () => {
|
|
2320
|
+
server.setEtag('"v1"');
|
|
2321
|
+
server.setLibraryResponse({
|
|
2322
|
+
skills: [makeSkill("x")],
|
|
2323
|
+
removals: [],
|
|
2324
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2325
|
+
});
|
|
2326
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2327
|
+
const s = readLastSync();
|
|
2328
|
+
writeLastSync({
|
|
2329
|
+
etag: s.etag,
|
|
2330
|
+
syncedAt: s.syncedAt,
|
|
2331
|
+
skills: s.skills,
|
|
2332
|
+
cliVersion: s.cliVersion,
|
|
2333
|
+
lastAttemptAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
|
2334
|
+
});
|
|
2335
|
+
|
|
2336
|
+
// Inside a 2h window → throttled (zero HTTP). A throttled skip does
|
|
2337
|
+
// not re-stamp, so the same 1h-old attempt then drives the second leg.
|
|
2338
|
+
server.resetLibraryInspection();
|
|
2339
|
+
server.resetReceipts();
|
|
2340
|
+
const inside = await runSync({
|
|
2341
|
+
serverUrl,
|
|
2342
|
+
apiKey: VALID_KEY,
|
|
2343
|
+
vendors: ["claudeCode"],
|
|
2344
|
+
throttle: true,
|
|
2345
|
+
minSyncIntervalMs: 2 * 60 * 60 * 1000,
|
|
2346
|
+
});
|
|
2347
|
+
assert.equal(inside.throttled, true, "1h elapsed < 2h window → throttled");
|
|
2348
|
+
assert.equal(server.getLibraryRequestCount(), 0);
|
|
2349
|
+
|
|
2350
|
+
// Outside a 30-min window → syncs.
|
|
2351
|
+
const outside = await runSync({
|
|
2352
|
+
serverUrl,
|
|
2353
|
+
apiKey: VALID_KEY,
|
|
2354
|
+
vendors: ["claudeCode"],
|
|
2355
|
+
throttle: true,
|
|
2356
|
+
minSyncIntervalMs: 30 * 60 * 1000,
|
|
2357
|
+
});
|
|
2358
|
+
assert.equal(outside.throttled ?? false, false, "1h elapsed >= 30-min window → syncs");
|
|
2359
|
+
assert.ok(server.getLibraryRequestCount() >= 1);
|
|
2360
|
+
});
|
|
2361
|
+
|
|
2362
|
+
it("a FUTURE lastAttemptAt (backward clock jump) syncs and self-heals instead of throttling until the clock catches up", async () => {
|
|
2363
|
+
server.setEtag('"v1"');
|
|
2364
|
+
server.setLibraryResponse({
|
|
2365
|
+
skills: [makeSkill("x")],
|
|
2366
|
+
removals: [],
|
|
2367
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2368
|
+
});
|
|
2369
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2370
|
+
|
|
2371
|
+
// A backward clock jump (NTP correction, VM snapshot restore) leaves a
|
|
2372
|
+
// stamp that is now in the FUTURE. `now - lastAttemptMs` goes negative —
|
|
2373
|
+
// an unguarded `< intervalMs` would throttle every hook sync until the
|
|
2374
|
+
// wall clock catches up, silently (hook modes are contract-silent).
|
|
2375
|
+
const s = readLastSync();
|
|
2376
|
+
writeLastSync({
|
|
2377
|
+
etag: s.etag,
|
|
2378
|
+
syncedAt: s.syncedAt,
|
|
2379
|
+
skills: s.skills,
|
|
2380
|
+
cliVersion: s.cliVersion,
|
|
2381
|
+
lastAttemptAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
2382
|
+
});
|
|
2383
|
+
server.resetLibraryInspection();
|
|
2384
|
+
server.resetReceipts();
|
|
2385
|
+
|
|
2386
|
+
const res = await runSync({
|
|
2387
|
+
serverUrl,
|
|
2388
|
+
apiKey: VALID_KEY,
|
|
2389
|
+
vendors: ["claudeCode"],
|
|
2390
|
+
throttle: true,
|
|
2391
|
+
});
|
|
2392
|
+
assert.equal(res.throttled ?? false, false, "a future stamp must NOT throttle");
|
|
2393
|
+
assert.ok(server.getLibraryRequestCount() >= 1, "the hook sync proceeded");
|
|
2394
|
+
// The sync (304 path here) re-stamps a sane "now" — the bogus future
|
|
2395
|
+
// value is gone after ONE pass; the next window measures from reality.
|
|
2396
|
+
const healed = readLastSync().lastAttemptAt;
|
|
2397
|
+
assert.ok(
|
|
2398
|
+
new Date(healed).getTime() <= Date.now(),
|
|
2399
|
+
"the sync self-healed the stamp back to a non-future time",
|
|
2400
|
+
);
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
it("a malformed lastAttemptAt string ('not-a-date') is not trusted — the hook sync proceeds", async () => {
|
|
2404
|
+
server.setEtag('"v1"');
|
|
2405
|
+
server.setLibraryResponse({
|
|
2406
|
+
skills: [makeSkill("x")],
|
|
2407
|
+
removals: [],
|
|
2408
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2409
|
+
});
|
|
2410
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2411
|
+
|
|
2412
|
+
// readLastSync only normalizes NON-string values to absent; a string
|
|
2413
|
+
// that fails Date.parse reaches runSync's Number.isFinite guard. This
|
|
2414
|
+
// pins that guard: bogus clock → sync (safe direction), never throttle.
|
|
2415
|
+
const s = readLastSync();
|
|
2416
|
+
writeLastSync({
|
|
2417
|
+
etag: s.etag,
|
|
2418
|
+
syncedAt: s.syncedAt,
|
|
2419
|
+
skills: s.skills,
|
|
2420
|
+
cliVersion: s.cliVersion,
|
|
2421
|
+
lastAttemptAt: "not-a-date",
|
|
2422
|
+
});
|
|
2423
|
+
server.resetLibraryInspection();
|
|
2424
|
+
server.resetReceipts();
|
|
2425
|
+
|
|
2426
|
+
const res = await runSync({
|
|
2427
|
+
serverUrl,
|
|
2428
|
+
apiKey: VALID_KEY,
|
|
2429
|
+
vendors: ["claudeCode"],
|
|
2430
|
+
throttle: true,
|
|
2431
|
+
});
|
|
2432
|
+
assert.equal(res.throttled ?? false, false, "unparseable stamp must NOT throttle");
|
|
2433
|
+
assert.ok(server.getLibraryRequestCount() >= 1, "the hook sync proceeded");
|
|
2434
|
+
const healed = readLastSync().lastAttemptAt;
|
|
2435
|
+
assert.ok(
|
|
2436
|
+
typeof healed === "string" && Number.isFinite(Date.parse(healed)),
|
|
2437
|
+
"the sync replaced the malformed stamp with a valid timestamp",
|
|
2438
|
+
);
|
|
2439
|
+
});
|
|
2440
|
+
|
|
2441
|
+
it("304 path stamps lastAttemptAt — LOAD-BEARING so the throttle re-arms every steady-state session", async () => {
|
|
2442
|
+
server.setEtag('"v1"');
|
|
2443
|
+
server.setLibraryResponse({
|
|
2444
|
+
skills: [makeSkill("x")],
|
|
2445
|
+
removals: [],
|
|
2446
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2447
|
+
});
|
|
2448
|
+
// First (interactive) sync stamps lastAttemptAt and the etag.
|
|
2449
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2450
|
+
assert.equal(
|
|
2451
|
+
typeof readLastSync().lastAttemptAt,
|
|
2452
|
+
"string",
|
|
2453
|
+
"first sync stamped lastAttemptAt",
|
|
2454
|
+
);
|
|
2455
|
+
|
|
2456
|
+
// Backdate so the next sync is NOT throttled but WILL 304 (etag matches,
|
|
2457
|
+
// placement present).
|
|
2458
|
+
const s = readLastSync();
|
|
2459
|
+
writeLastSync({
|
|
2460
|
+
etag: s.etag,
|
|
2461
|
+
syncedAt: s.syncedAt,
|
|
2462
|
+
skills: s.skills,
|
|
2463
|
+
cliVersion: s.cliVersion,
|
|
2464
|
+
lastAttemptAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
// Snapshot the full pre-304 state — the re-stamp must move ONLY the
|
|
2468
|
+
// attempt clock. etag/skills/cliVersion wiped here (e.g. a future
|
|
2469
|
+
// edit letting `skills` default to {}) would silently destroy every
|
|
2470
|
+
// user's per-skill SHA cache on their next up-to-date sync.
|
|
2471
|
+
const before = readLastSync();
|
|
2472
|
+
|
|
2473
|
+
const res = await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
|
|
2474
|
+
assert.equal(res.notModified, true, "etag match → 304");
|
|
2475
|
+
const after = readLastSync();
|
|
2476
|
+
assert.equal(typeof after.lastAttemptAt, "string");
|
|
2477
|
+
assert.ok(
|
|
2478
|
+
Date.now() - new Date(after.lastAttemptAt).getTime() < 60_000,
|
|
2479
|
+
"the 304 path re-stamped lastAttemptAt to a fresh time (throttle re-arms)",
|
|
2480
|
+
);
|
|
2481
|
+
assert.equal(after.etag, before.etag, "304 re-stamp preserves the etag verbatim");
|
|
2482
|
+
assert.deepEqual(
|
|
2483
|
+
after.skills,
|
|
2484
|
+
before.skills,
|
|
2485
|
+
"304 re-stamp preserves the per-skill SHA map verbatim",
|
|
2486
|
+
);
|
|
2487
|
+
assert.equal(
|
|
2488
|
+
after.cliVersion,
|
|
2489
|
+
before.cliVersion,
|
|
2490
|
+
"304 re-stamp preserves cliVersion (a 304 is not a membership heal)",
|
|
2491
|
+
);
|
|
2492
|
+
assert.equal(after.syncedAt, before.syncedAt, "304 re-stamp preserves syncedAt");
|
|
2493
|
+
});
|
|
2494
|
+
|
|
2495
|
+
it("does NOT stamp lastAttemptAt on a filesIncomplete sync (a broken sync is retried, not throttled)", async () => {
|
|
2496
|
+
const inc = makeSkill("inc");
|
|
2497
|
+
inc.filesIncomplete = true;
|
|
2498
|
+
server.setEtag('"v1"');
|
|
2499
|
+
server.setLibraryResponse({ skills: [inc], removals: [], syncedAt: "x" });
|
|
2500
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2501
|
+
// filesIncomplete → the whole state-file write is skipped → no
|
|
2502
|
+
// lastAttemptAt persisted → the next hook sync is NOT throttled.
|
|
2503
|
+
assert.equal(readLastSync(), null, "no state persisted on filesIncomplete");
|
|
2504
|
+
});
|
|
2505
|
+
|
|
2506
|
+
it("emits a SKILLREPO_VERBOSE stderr line when it throttles", async () => {
|
|
2507
|
+
const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
|
|
2508
|
+
const stderr = createCaptureStream();
|
|
2509
|
+
server.setEtag('"v1"');
|
|
2510
|
+
server.setLibraryResponse({
|
|
2511
|
+
skills: [makeSkill("x")],
|
|
2512
|
+
removals: [],
|
|
2513
|
+
syncedAt: "2025-01-01T00:00:00Z",
|
|
2514
|
+
});
|
|
2515
|
+
await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"], throttle: true });
|
|
2516
|
+
server.resetLibraryInspection();
|
|
2517
|
+
|
|
2518
|
+
const prevVerbose = process.env.SKILLREPO_VERBOSE;
|
|
2519
|
+
process.env.SKILLREPO_VERBOSE = "1";
|
|
2520
|
+
try {
|
|
2521
|
+
const res = await runSync({
|
|
2522
|
+
serverUrl,
|
|
2523
|
+
apiKey: VALID_KEY,
|
|
2524
|
+
vendors: ["claudeCode"],
|
|
2525
|
+
throttle: true,
|
|
2526
|
+
io: { stderr },
|
|
2527
|
+
});
|
|
2528
|
+
assert.equal(res.throttled, true);
|
|
2529
|
+
} finally {
|
|
2530
|
+
if (prevVerbose === undefined) delete process.env.SKILLREPO_VERBOSE;
|
|
2531
|
+
else process.env.SKILLREPO_VERBOSE = prevVerbose;
|
|
2532
|
+
}
|
|
2533
|
+
assert.match(stderr.text(), /throttled/);
|
|
2534
|
+
assert.equal(server.getLibraryRequestCount(), 0, "verbose throttle still makes no GET");
|
|
2535
|
+
});
|
|
2536
|
+
});
|