skillrepo 4.4.0 → 4.5.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/src/lib/sync.mjs CHANGED
@@ -22,6 +22,32 @@
22
22
  * can evolve it without breaking older CLIs catastrophically. Future
23
23
  * versions should bump the schema when fields are added/removed.
24
24
  *
25
+ * Schema history
26
+ * --------------
27
+ * v1 — `{ schemaVersion, etag, syncedAt }`. Library-level state only;
28
+ * no per-skill information persisted. Sufficient for ETag-based
29
+ * short-circuit, insufficient for per-skill drift detection.
30
+ * v2 — Adds a `skills` map keyed by `"<owner>/<name>"` carrying the
31
+ * version + content SHAs of every skill written in the last
32
+ * successful sync. Unblocks per-skill drift detection in `list`
33
+ * (#1555) and any future `status`-style command.
34
+ *
35
+ * Forward/backward compatibility
36
+ * ------------------------------
37
+ * • Old CLI reading a v2 file → unknown schemaVersion → null → full
38
+ * sync → writes v1 file. User loses per-skill metadata until they
39
+ * upgrade again.
40
+ * • New CLI reading a v1 file → in-memory migration to a v2 shape
41
+ * with the existing `etag` + `syncedAt` preserved and an EMPTY
42
+ * `skills` map. The next successful sync writes a v2 file on
43
+ * disk. This is safe because an empty map means "we don't know
44
+ * per-skill state from this sync" — callers must treat absent
45
+ * entries as unknown, not as "missing from library." Preserving
46
+ * the etag also keeps the 304 short-circuit working, so an
47
+ * upgrade-then-no-changes path stays cheap.
48
+ * • Future unknown schemaVersion → null → full sync. Forward-compat
49
+ * without crashing.
50
+ *
25
51
  * @typedef {Object} SyncSummary
26
52
  * @property {number} added - Skills newly written that were not on disk
27
53
  * @property {number} updated - Skills overwritten on disk
@@ -45,14 +71,20 @@
45
71
  * @property {string} [syncedAt]
46
72
  * @property {string} syncedAt - ISO timestamp from the server response
47
73
  *
74
+ * @typedef {Object} SyncedSkillEntry
75
+ * @property {string} version - Version of the skill as synced (e.g. "1.4.0").
76
+ * @property {string} skillMdSha256 - Hex SHA-256 of SKILL.md content as written.
77
+ * @property {string} filesSha256 - Hex SHA-256 of the full file projection.
78
+ * @property {string} syncedAt - ISO timestamp when this skill was synced.
79
+ *
48
80
  * @typedef {Object} SyncStateFile
49
81
  * @property {number} schemaVersion
50
82
  * @property {string|null} etag
51
83
  * @property {string} syncedAt
84
+ * @property {Record<string, SyncedSkillEntry>} skills - v2+. Keyed by `"<owner>/<name>"`.
52
85
  */
53
86
 
54
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
55
- import { dirname } from "node:path";
87
+ import { existsSync, readFileSync } from "node:fs";
56
88
 
57
89
  import { getLibrary } from "./http.mjs";
58
90
  import {
@@ -62,16 +94,21 @@ import {
62
94
  placementTargetsFor,
63
95
  resolvePlacementDir,
64
96
  } from "./file-write.mjs";
97
+ import { writeFileAtomic } from "./fs-utils.mjs";
65
98
  import { globalLastSyncPath } from "./paths.mjs";
66
99
  import { diskError, validationError } from "./errors.mjs";
100
+ import { computeSkillShas } from "./crypto-shas.mjs";
67
101
 
68
102
  /**
69
103
  * Current schema version of the .last-sync file. Bump on any
70
104
  * structural change. The reader treats unknown future versions as
71
105
  * "ignore the cache and do a full sync" — forward-compat without
72
106
  * crashing.
107
+ *
108
+ * v2 (#1553): adds `skills` map keyed by `"<owner>/<name>"` →
109
+ * `{ version, skillMdSha256, filesSha256, syncedAt }`.
73
110
  */
74
- export const LAST_SYNC_SCHEMA_VERSION = 1;
111
+ export const LAST_SYNC_SCHEMA_VERSION = 2;
75
112
 
76
113
  /**
77
114
  * Read the persisted last-sync state from ~/.claude/skillrepo/.last-sync.
@@ -99,12 +136,42 @@ export function readLastSync() {
99
136
  return null;
100
137
  }
101
138
 
139
+ if (!parsed || typeof parsed !== "object") {
140
+ return null;
141
+ }
142
+
143
+ // v1 → v2 in-memory migration. Preserve the etag and syncedAt so
144
+ // delta sync still benefits from the prior state; start with an
145
+ // empty `skills` map. The next successful runSync writes v2 to
146
+ // disk. We do NOT write here — readers should be pure with
147
+ // respect to the filesystem.
148
+ if (parsed.schemaVersion === 1) {
149
+ return {
150
+ schemaVersion: LAST_SYNC_SCHEMA_VERSION,
151
+ etag: typeof parsed.etag === "string" ? parsed.etag : null,
152
+ syncedAt: typeof parsed.syncedAt === "string" ? parsed.syncedAt : null,
153
+ skills: {},
154
+ };
155
+ }
156
+
157
+ if (parsed.schemaVersion !== LAST_SYNC_SCHEMA_VERSION) {
158
+ return null;
159
+ }
160
+
161
+ // Defense: coerce a malformed `skills` field to {}. Both an array
162
+ // (`typeof === "object"` but not a real map) and a missing/null
163
+ // field can otherwise sneak past callers that only check
164
+ // `typeof === "object"`. Specifically: `list.mjs` does direct
165
+ // dictionary access on `lastSync.skills`, and `lastSync.skills[key]`
166
+ // on an array returns `undefined`, which would render every skill
167
+ // as `missing` — wrong, not safe-fail. Normalizing here means every
168
+ // caller sees a real Record<string, ...>.
102
169
  if (
103
- !parsed ||
104
- typeof parsed !== "object" ||
105
- parsed.schemaVersion !== LAST_SYNC_SCHEMA_VERSION
170
+ !parsed.skills ||
171
+ typeof parsed.skills !== "object" ||
172
+ Array.isArray(parsed.skills)
106
173
  ) {
107
- return null;
174
+ parsed.skills = {};
108
175
  }
109
176
 
110
177
  return parsed;
@@ -116,22 +183,36 @@ export function readLastSync() {
116
183
  * error. Most callers should treat a state-file write failure as
117
184
  * non-fatal because the actual skill files were already written
118
185
  * successfully.
186
+ *
187
+ * v2 (#1553): `skills` is the per-skill version+SHA map. Omitting it
188
+ * persists an empty `{}` — the schema bump is structural even when
189
+ * the caller has no skills to record (e.g. a 304 short-circuit before
190
+ * runSync ran any writes). An empty map means "we know the library
191
+ * was up-to-date at syncedAt but have no per-skill details from this
192
+ * sync"; consumers should treat absent entries as "unknown" rather
193
+ * than as "missing from library."
194
+ *
195
+ * @param {object} state
196
+ * @param {string | null} [state.etag]
197
+ * @param {string} [state.syncedAt]
198
+ * @param {Record<string, SyncedSkillEntry>} [state.skills]
119
199
  */
120
- export function writeLastSync({ etag, syncedAt }) {
200
+ export function writeLastSync({ etag, syncedAt, skills } = {}) {
121
201
  const path = globalLastSyncPath();
122
- const dir = dirname(path);
123
- try {
124
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
125
- } catch (err) {
126
- throw diskError(`Cannot create ${dir}: ${err.message}`, { cause: err });
127
- }
128
202
  const body = {
129
203
  schemaVersion: LAST_SYNC_SCHEMA_VERSION,
130
204
  etag: etag ?? null,
131
205
  syncedAt: syncedAt ?? new Date().toISOString(),
206
+ skills: skills && typeof skills === "object" && !Array.isArray(skills) ? skills : {},
132
207
  };
133
208
  try {
134
- writeFileSync(path, JSON.stringify(body, null, 2) + "\n", "utf-8");
209
+ // writeFileAtomic handles parent-dir creation and uses a
210
+ // per-process unique temp suffix so two concurrent writers
211
+ // (e.g., a SessionStart hook running `skillrepo update` while
212
+ // the user runs another command) can't collide on the same
213
+ // .tmp path and produce a torn final file. The cohort hooks
214
+ // (#1240) made this race observable in production.
215
+ writeFileAtomic(path, JSON.stringify(body, null, 2) + "\n");
135
216
  } catch (err) {
136
217
  throw diskError(`Cannot write ${path}: ${err.message}`, { cause: err });
137
218
  }
@@ -270,7 +351,50 @@ export async function runSync(options) {
270
351
  // a partial skill would leave the user with broken resource
271
352
  // references (the SKILL.md body would point to files that don't
272
353
  // exist on disk).
354
+ //
355
+ // v2 of `.last-sync` carries a per-skill version + SHA map. We
356
+ // compute SHAs from the in-memory `skill.files` array — same bytes
357
+ // we're about to write to disk via writeSkillDir — so the persisted
358
+ // SHA exactly matches what landed on the user's filesystem. SHAs are
359
+ // recorded ONLY for skills we actually wrote (skipped on
360
+ // filesIncomplete, never persisted on a write failure since the
361
+ // throw propagates and the whole state file write is bypassed).
273
362
  let anyIncomplete = false;
363
+ /** @type {Record<string, SyncedSkillEntry>} */
364
+ const skillsMap = {};
365
+ // Carry forward entries from the previous sync for skills we did
366
+ // NOT re-fetch this round. A delta sync that touched 2 of 50
367
+ // skills must keep the other 48's metadata — otherwise the
368
+ // persisted map shrinks every delta sync until only the most
369
+ // recently-touched skills remain, defeating `list`'s drift
370
+ // detection. The previous ETag is only persisted again when no
371
+ // filesIncomplete fired, so this carry-forward is safe even on
372
+ // partial syncs.
373
+ //
374
+ // We reuse `lastSync` (captured before the network call) rather
375
+ // than re-reading from disk. No write happens between line ~330
376
+ // and here, so the values are identical — but re-reading would
377
+ // be a footgun if a future change ever persisted partial state
378
+ // between those points (the carry-forward would silently pick up
379
+ // the freshly-written data instead of the truly-prior state).
380
+ if (lastSync && lastSync.skills && typeof lastSync.skills === "object") {
381
+ for (const [key, entry] of Object.entries(lastSync.skills)) {
382
+ if (entry && typeof entry === "object") {
383
+ skillsMap[key] = entry;
384
+ }
385
+ }
386
+ }
387
+ // Tombstones invalidate any prior map entry: a skill that was
388
+ // removed from the library should not linger in our local
389
+ // version/SHA cache. Apply this BEFORE re-adding entries from the
390
+ // current response so a re-add in the same sync window (handled
391
+ // earlier under "removals applied first") still ends up in the
392
+ // map with fresh data.
393
+ for (const tombstone of result.removals) {
394
+ const key = `${tombstone.owner}/${tombstone.name}`;
395
+ delete skillsMap[key];
396
+ }
397
+
274
398
  for (const skill of result.skills) {
275
399
  if (skill.filesIncomplete) {
276
400
  anyIncomplete = true;
@@ -283,12 +407,34 @@ export async function runSync(options) {
283
407
  } else {
284
408
  summary.added++;
285
409
  }
410
+
411
+ // Compute SHAs from the same content writeSkillDir just persisted
412
+ // to disk. If the skill has no SKILL.md (malformed payload that
413
+ // somehow passed validateSkill), `skillMdSha256` is null and we
414
+ // skip the map entry — recording a partial entry would poison
415
+ // future drift detection. validateSkill in file-write.mjs already
416
+ // rejects skills without SKILL.md, so this branch is defense in
417
+ // depth; if it ever fires, our cache is correct (no entry > bad
418
+ // entry).
419
+ const shas = computeSkillShas(skill.files);
420
+ if (shas.skillMdSha256 !== null) {
421
+ skillsMap[`${skill.owner}/${skill.name}`] = {
422
+ version: typeof skill.version === "string" ? skill.version : "",
423
+ skillMdSha256: shas.skillMdSha256,
424
+ filesSha256: shas.filesSha256,
425
+ syncedAt: result.syncedAt,
426
+ };
427
+ }
286
428
  }
287
429
 
288
- // Step 7: persist new ETag (only if the response was complete)
430
+ // Step 7: persist new ETag + skills map (only if the response was complete)
289
431
  if (!anyIncomplete && result.etag) {
290
432
  try {
291
- writeLastSync({ etag: result.etag, syncedAt: result.syncedAt });
433
+ writeLastSync({
434
+ etag: result.etag,
435
+ syncedAt: result.syncedAt,
436
+ skills: skillsMap,
437
+ });
292
438
  } catch (err) {
293
439
  // Non-fatal — the skills are on disk, we just won't get the
294
440
  // 304 short-circuit on the next run. Surface a warning via