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/README.md +57 -3
- package/bin/skillrepo.mjs +45 -0
- package/package.json +3 -2
- package/src/commands/list.mjs +328 -56
- package/src/lib/crypto-shas.mjs +131 -0
- package/src/lib/drift.mjs +175 -0
- package/src/lib/file-write.mjs +16 -1
- package/src/lib/npm-update-check.mjs +366 -0
- package/src/lib/paths.mjs +10 -0
- package/src/lib/placement-walk.mjs +285 -0
- package/src/lib/sync.mjs +163 -17
- package/src/test/commands/list.test.mjs +510 -2
- package/src/test/lib/crypto-shas.test.mjs +172 -0
- package/src/test/lib/drift.test.mjs +289 -0
- package/src/test/lib/npm-update-check.test.mjs +670 -0
- package/src/test/lib/placement-walk.test.mjs +453 -0
- package/src/test/lib/sync.test.mjs +409 -1
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm-registry self-staleness checker (#1554, part of epic #1552).
|
|
3
|
+
*
|
|
4
|
+
* The CLI ships with no self-staleness detection today. This module adds
|
|
5
|
+
* a daily-cached check against `https://registry.npmjs.org/skillrepo/latest`
|
|
6
|
+
* so a stale `npx skillrepo`/global install gets nudged toward an upgrade
|
|
7
|
+
* after the primary command completes. The nudge is a HINT, not a feature:
|
|
8
|
+
* every failure mode (network error, non-2xx, parse failure, timeout,
|
|
9
|
+
* read-only FS) returns null and never throws.
|
|
10
|
+
*
|
|
11
|
+
* Design contract
|
|
12
|
+
* ---------------
|
|
13
|
+
*
|
|
14
|
+
* 1. **Fire-and-forget at the call site.** `bin/skillrepo.mjs` invokes
|
|
15
|
+
* this module after `await COMMANDS[command].run(rest)` returns and
|
|
16
|
+
* races the promise with `setTimeout(0)`. On a cache HIT the checker
|
|
17
|
+
* resolves before the timer fires and the nudge is emitted before
|
|
18
|
+
* the binary exits. On a cache MISS the timer wins and the in-flight
|
|
19
|
+
* fetch is left running in the background — it has its own 2s
|
|
20
|
+
* timeout, writes the cache on completion, and lets the NEXT
|
|
21
|
+
* invocation use it. The user's command output appears before the
|
|
22
|
+
* process exits; the residual fetch happens in parallel.
|
|
23
|
+
*
|
|
24
|
+
* 2. **Silent failure mode.** No "couldn't reach npm." Users opening a
|
|
25
|
+
* Network tab to debug a CLI command don't want a second unrelated
|
|
26
|
+
* request explained. If the check can't complete, the user sees no
|
|
27
|
+
* artifact at all.
|
|
28
|
+
*
|
|
29
|
+
* 3. **24h positive cache, 1h negative cache.** A successful read pins
|
|
30
|
+
* the answer for 24 hours — npm publishes are rare enough that more
|
|
31
|
+
* frequent polling is just rude. A failed read pins for 1 hour so a
|
|
32
|
+
* transient outage doesn't blind the CLI for a full day; AND
|
|
33
|
+
* `checkedAt` still updates on failure so we don't hammer a broken
|
|
34
|
+
* endpoint every command in the meantime.
|
|
35
|
+
*
|
|
36
|
+
* 4. **Kill switches.** `SKILLREPO_NO_UPDATE_CHECK=1` is the explicit
|
|
37
|
+
* opt-out. `process.env.CI === "true"` auto-disables (GitHub Actions,
|
|
38
|
+
* GitLab CI, CircleCI, and most other vendors set this). Both are
|
|
39
|
+
* checked before any network or cache I/O.
|
|
40
|
+
*
|
|
41
|
+
* 5. **JSON suppression at the binary level.** The checker itself does
|
|
42
|
+
* NOT inspect `argv` for `--json`; that's the dispatcher's job.
|
|
43
|
+
* When a parent command was invoked with `--json`, the dispatcher
|
|
44
|
+
* skips calling `checkForCliUpdate` entirely so we never inject text
|
|
45
|
+
* into a structured stream — not even on stderr, because some tools
|
|
46
|
+
* (notably ts-node + Vitest hooks) read both streams.
|
|
47
|
+
*
|
|
48
|
+
* 6. **Live version comparison on the read path.** The cache records
|
|
49
|
+
* `currentCliVersion` so a stale cache from a previous binary
|
|
50
|
+
* doesn't keep nudging the user after they upgrade. The render-time
|
|
51
|
+
* comparison is `latestPublishedVersion` vs. the LIVE version
|
|
52
|
+
* (`io.currentVersion`), not the cached one — the cached value is
|
|
53
|
+
* only used to validate freshness.
|
|
54
|
+
*
|
|
55
|
+
* 7. **Windows compat.** All paths use `path.join`; never separator
|
|
56
|
+
* concatenation. The cache directory is created with
|
|
57
|
+
* `mkdir { recursive: true }` so Windows drive-letter roots work
|
|
58
|
+
* identically to POSIX `~/.claude/...`.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
62
|
+
import { dirname } from "node:path";
|
|
63
|
+
|
|
64
|
+
import semver from "semver";
|
|
65
|
+
|
|
66
|
+
import { globalNpmVersionCheckPath } from "./paths.mjs";
|
|
67
|
+
|
|
68
|
+
/** Endpoint that resolves npm's `dist-tags.latest` for `skillrepo`. */
|
|
69
|
+
export const NPM_LATEST_URL = "https://registry.npmjs.org/skillrepo/latest";
|
|
70
|
+
|
|
71
|
+
/** Hard cap on the fetch. Network calls slower than this are treated as a no-result. */
|
|
72
|
+
export const FETCH_TIMEOUT_MS = 2000;
|
|
73
|
+
|
|
74
|
+
/** Cache TTL on a successful fetch (24 hours). */
|
|
75
|
+
export const POSITIVE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
76
|
+
|
|
77
|
+
/** Cache TTL on a failed fetch (1 hour) — fast retry without hammering. */
|
|
78
|
+
export const NEGATIVE_TTL_MS = 60 * 60 * 1000;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Cache file location — delegated to `paths.mjs` so every CLI-written
|
|
82
|
+
* path lives in a single registry. Tests inject via `sandbox-home.mjs`
|
|
83
|
+
* (which sets both HOME and USERPROFILE so `homedir()` reads sandbox
|
|
84
|
+
* locations on POSIX AND Windows), not by mocking this function.
|
|
85
|
+
*
|
|
86
|
+
* Kept as a re-export rather than a direct import in callers so the
|
|
87
|
+
* test boundary stays consistent with the module's other public API.
|
|
88
|
+
*/
|
|
89
|
+
export function cachePath() {
|
|
90
|
+
return globalNpmVersionCheckPath();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Current cache schema. Bump only when fields change incompatibly. */
|
|
94
|
+
export const CACHE_SCHEMA_VERSION = 1;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Try to read and parse the cache file. Returns null on any failure
|
|
98
|
+
* (missing file, malformed JSON, wrong schemaVersion, missing required
|
|
99
|
+
* field). The caller treats null as "cache miss" and proceeds to fetch.
|
|
100
|
+
*
|
|
101
|
+
* This intentionally does NOT differentiate between "no cache yet" and
|
|
102
|
+
* "cache exists but is unusable" — both lead to the same recovery path.
|
|
103
|
+
*/
|
|
104
|
+
function readCache() {
|
|
105
|
+
let raw;
|
|
106
|
+
try {
|
|
107
|
+
raw = readFileSync(cachePath(), "utf-8");
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(raw);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
120
|
+
if (parsed.schemaVersion !== CACHE_SCHEMA_VERSION) return null;
|
|
121
|
+
if (typeof parsed.checkedAt !== "string") return null;
|
|
122
|
+
if (typeof parsed.currentCliVersion !== "string") return null;
|
|
123
|
+
if (typeof parsed.fetchOk !== "boolean") return null;
|
|
124
|
+
// latestPublishedVersion may be null when fetchOk: false; both shapes
|
|
125
|
+
// are valid cache entries.
|
|
126
|
+
if (
|
|
127
|
+
parsed.latestPublishedVersion !== null &&
|
|
128
|
+
typeof parsed.latestPublishedVersion !== "string"
|
|
129
|
+
) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return parsed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Persist the cache. Best-effort: read-only filesystems (Docker layers,
|
|
138
|
+
* some CI snapshots) cause writeFileSync to throw EROFS/EACCES, which
|
|
139
|
+
* we swallow. The user still sees the nudge on this run (if applicable);
|
|
140
|
+
* they just won't get a cached answer next time.
|
|
141
|
+
*/
|
|
142
|
+
function writeCache(entry, io) {
|
|
143
|
+
const target = cachePath();
|
|
144
|
+
const dir = dirname(target);
|
|
145
|
+
const writeFn = io.writeFile;
|
|
146
|
+
const mkdirFn = io.mkdir;
|
|
147
|
+
const existsFn = io.exists;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
if (!existsFn(dir)) {
|
|
151
|
+
mkdirFn(dir, { recursive: true });
|
|
152
|
+
}
|
|
153
|
+
writeFn(target, JSON.stringify(entry, null, 2) + "\n", "utf-8");
|
|
154
|
+
} catch {
|
|
155
|
+
// Read-only FS or permission denied — silently give up. The user's
|
|
156
|
+
// primary command output already showed; we will not interrupt it
|
|
157
|
+
// with a cache-write warning. Next invocation will re-fetch.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Decide whether a cache entry is still fresh given the CURRENT live
|
|
163
|
+
* version. Two gates must pass:
|
|
164
|
+
*
|
|
165
|
+
* 1. The cache must have been written by the same CLI version that's
|
|
166
|
+
* running now. If the user upgraded between calls, the cached
|
|
167
|
+
* `latestPublishedVersion` may now be the version they're running
|
|
168
|
+
* — discarding the entry forces a re-check so we don't nudge the
|
|
169
|
+
* user to upgrade to the same version they just installed.
|
|
170
|
+
* 2. `checkedAt` must be within the appropriate TTL — positive for
|
|
171
|
+
* successful fetches, negative for failed ones.
|
|
172
|
+
*
|
|
173
|
+
* Returns true when the cache is reusable; false means re-fetch.
|
|
174
|
+
*/
|
|
175
|
+
function isCacheFresh(entry, currentVersion, now) {
|
|
176
|
+
if (entry.currentCliVersion !== currentVersion) return false;
|
|
177
|
+
const checkedAtMs = Date.parse(entry.checkedAt);
|
|
178
|
+
if (!Number.isFinite(checkedAtMs)) return false;
|
|
179
|
+
const ageMs = now - checkedAtMs;
|
|
180
|
+
const ttl = entry.fetchOk ? POSITIVE_TTL_MS : NEGATIVE_TTL_MS;
|
|
181
|
+
return ageMs < ttl && ageMs >= 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Fetch `/skillrepo/latest` from the npm registry with a 2-second hard
|
|
186
|
+
* timeout. Returns the parsed `version` string on success, null on any
|
|
187
|
+
* failure (network, non-2xx, parse, abort).
|
|
188
|
+
*
|
|
189
|
+
* The `/latest` shortcut returns the version manifest for `dist-tags.latest`
|
|
190
|
+
* — about 1.5 KB on the wire. The User-Agent identifies the CLI so npm's
|
|
191
|
+
* abuse tooling can rate-limit us specifically if we ever misbehave.
|
|
192
|
+
*
|
|
193
|
+
* Accept header note: the issue spec called for
|
|
194
|
+
* `application/vnd.npm.install-v1+json`, but the npm registry returns
|
|
195
|
+
* HTTP 406 for that media type on the `/latest` endpoint — the install
|
|
196
|
+
* manifest type is only valid against the FULL packument
|
|
197
|
+
* (`/skillrepo`), which is ~10x larger. `application/json` is the
|
|
198
|
+
* documented accept for the `/latest` shortcut and is what `npm view`
|
|
199
|
+
* itself uses under the hood. Verified via curl on 2026-05-19. The
|
|
200
|
+
* issue body should be updated to reflect this; tracked in the PR.
|
|
201
|
+
*/
|
|
202
|
+
async function fetchLatestVersion(currentVersion, fetchImpl) {
|
|
203
|
+
const controller = new AbortController();
|
|
204
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
205
|
+
try {
|
|
206
|
+
const res = await fetchImpl(NPM_LATEST_URL, {
|
|
207
|
+
method: "GET",
|
|
208
|
+
headers: {
|
|
209
|
+
Accept: "application/json",
|
|
210
|
+
"User-Agent": `skillrepo-cli/${currentVersion} (+https://skillrepo.dev)`,
|
|
211
|
+
},
|
|
212
|
+
signal: controller.signal,
|
|
213
|
+
});
|
|
214
|
+
if (!res || !res.ok) return null;
|
|
215
|
+
let body;
|
|
216
|
+
try {
|
|
217
|
+
body = await res.json();
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
if (!body || typeof body !== "object") return null;
|
|
222
|
+
if (typeof body.version !== "string" || body.version.length === 0) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
// Validate semver shape upfront — a malformed version from a
|
|
226
|
+
// compromised mirror should not surface to the user as a nudge.
|
|
227
|
+
if (!semver.valid(body.version)) return null;
|
|
228
|
+
return body.version;
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
} finally {
|
|
232
|
+
clearTimeout(timeoutId);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Style helpers — duplicated from bin/skillrepo.mjs to keep this module
|
|
238
|
+
* standalone (a CLI nudge module that pulls in the dispatcher's helpers
|
|
239
|
+
* would create a cycle). `bold`/`dim` mirror the existing http.mjs:248
|
|
240
|
+
* pattern.
|
|
241
|
+
*/
|
|
242
|
+
function dim(s) {
|
|
243
|
+
return process.stderr.isTTY && !process.env.NO_COLOR ? `\x1b[2m${s}\x1b[0m` : s;
|
|
244
|
+
}
|
|
245
|
+
function bold(s) {
|
|
246
|
+
return process.stderr.isTTY && !process.env.NO_COLOR ? `\x1b[1m${s}\x1b[0m` : s;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Render the nudge to stderr. Two lines, indented to match the
|
|
251
|
+
* dispatcher's error/help shape. Never throws — if stderr is closed
|
|
252
|
+
* (e.g. parent pipe shut down) the write call's exception is swallowed
|
|
253
|
+
* at the call site so a failing nudge can't break the user's flow.
|
|
254
|
+
*/
|
|
255
|
+
function emitNudge(currentVersion, latestVersion, stderrWrite) {
|
|
256
|
+
const line1 = dim(
|
|
257
|
+
` A newer ${bold("skillrepo")} is available: ${currentVersion} → ${latestVersion}`,
|
|
258
|
+
);
|
|
259
|
+
const line2 = dim(` Upgrade: ${bold("npm install -g skillrepo@latest")}`);
|
|
260
|
+
try {
|
|
261
|
+
stderrWrite(`\n${line1}\n${line2}\n`);
|
|
262
|
+
} catch {
|
|
263
|
+
// Closed stream. Nothing to do.
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Are the kill switches set? Centralized so the bin layer and any
|
|
269
|
+
* test fixtures share the same view of "should we even bother."
|
|
270
|
+
*/
|
|
271
|
+
export function updateCheckDisabledByEnv() {
|
|
272
|
+
const explicit = process.env.SKILLREPO_NO_UPDATE_CHECK;
|
|
273
|
+
if (explicit && explicit !== "0" && explicit.toLowerCase() !== "false") {
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
if (process.env.CI === "true") return true;
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Main entry point.
|
|
282
|
+
*
|
|
283
|
+
* @param {object} params
|
|
284
|
+
* @param {string} params.currentVersion - The CLI's own version
|
|
285
|
+
* (`getCliVersion()` from the dispatcher).
|
|
286
|
+
* @param {object} [params.io] - I/O dependency injection. Production
|
|
287
|
+
* callers pass nothing and get Node built-ins; tests inject mocks.
|
|
288
|
+
* Shape:
|
|
289
|
+
* - `fetch`: fetch impl (default `globalThis.fetch`)
|
|
290
|
+
* - `now`: () => number, ms since epoch (default `Date.now`)
|
|
291
|
+
* - `writeFile`/`mkdir`/`exists`: fs primitives for the cache write
|
|
292
|
+
* path. Reads use real `fs` directly (no injection seam) — tests
|
|
293
|
+
* redirect the read path via `sandbox-home.mjs` instead.
|
|
294
|
+
* (defaults from `node:fs`)
|
|
295
|
+
* - `stderrWrite`: (s) => void (default `process.stderr.write`)
|
|
296
|
+
*
|
|
297
|
+
* @returns {Promise<{ nudged: boolean, reason?: string }>} Resolves
|
|
298
|
+
* with a tiny status object — tests assert on it directly. Production
|
|
299
|
+
* callers ignore the return value.
|
|
300
|
+
*/
|
|
301
|
+
export async function checkForCliUpdate({ currentVersion, io = {} } = {}) {
|
|
302
|
+
if (updateCheckDisabledByEnv()) {
|
|
303
|
+
return { nudged: false, reason: "disabled" };
|
|
304
|
+
}
|
|
305
|
+
if (typeof currentVersion !== "string" || !semver.valid(currentVersion)) {
|
|
306
|
+
// Defensive: a malformed live version is a programming error
|
|
307
|
+
// upstream. Don't try to compare against random strings.
|
|
308
|
+
return { nudged: false, reason: "invalid-current-version" };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const resolvedIo = {
|
|
312
|
+
fetch: io.fetch ?? globalThis.fetch,
|
|
313
|
+
now: io.now ?? Date.now,
|
|
314
|
+
writeFile: io.writeFile ?? writeFileSync,
|
|
315
|
+
mkdir: io.mkdir ?? mkdirSync,
|
|
316
|
+
exists: io.exists ?? existsSync,
|
|
317
|
+
stderrWrite: io.stderrWrite ?? ((s) => process.stderr.write(s)),
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
if (typeof resolvedIo.fetch !== "function") {
|
|
321
|
+
// Node < 18 has no global fetch. The CLI's minimum is 18 (README),
|
|
322
|
+
// so this should never fire — but a missing fetch isn't a user-
|
|
323
|
+
// visible failure either.
|
|
324
|
+
return { nudged: false, reason: "no-fetch" };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const now = resolvedIo.now();
|
|
328
|
+
const cache = readCache();
|
|
329
|
+
let latestVersion = null;
|
|
330
|
+
|
|
331
|
+
if (cache && isCacheFresh(cache, currentVersion, now)) {
|
|
332
|
+
// Fresh cache. Reuse its latestPublishedVersion (which may be null
|
|
333
|
+
// for a negative cache hit — in which case we don't nudge).
|
|
334
|
+
latestVersion = cache.latestPublishedVersion;
|
|
335
|
+
} else {
|
|
336
|
+
// Cache miss / stale / version mismatch. Try the network. The
|
|
337
|
+
// fetch has its own 2-second AbortController; the dispatcher's
|
|
338
|
+
// `Promise.race` with `setTimeout(0)` means we return synchronously
|
|
339
|
+
// either way — but the fetch keeps running in the background so
|
|
340
|
+
// the cache lands and the NEXT invocation can use it.
|
|
341
|
+
latestVersion = await fetchLatestVersion(currentVersion, resolvedIo.fetch);
|
|
342
|
+
const entry = {
|
|
343
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
344
|
+
checkedAt: new Date(now).toISOString(),
|
|
345
|
+
currentCliVersion: currentVersion,
|
|
346
|
+
latestPublishedVersion: latestVersion,
|
|
347
|
+
fetchOk: latestVersion !== null,
|
|
348
|
+
};
|
|
349
|
+
writeCache(entry, resolvedIo);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!latestVersion) {
|
|
353
|
+
return { nudged: false, reason: "no-result" };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Compare against the LIVE current version, not the cached
|
|
357
|
+
// currentCliVersion. The cached value was only used to gate freshness;
|
|
358
|
+
// the user is running THIS binary now, and that's what they need a
|
|
359
|
+
// nudge relative to.
|
|
360
|
+
if (!semver.gt(latestVersion, currentVersion)) {
|
|
361
|
+
return { nudged: false, reason: "up-to-date" };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
emitNudge(currentVersion, latestVersion, resolvedIo.stderrWrite);
|
|
365
|
+
return { nudged: true };
|
|
366
|
+
}
|
package/src/lib/paths.mjs
CHANGED
|
@@ -28,6 +28,16 @@ export const vscodeMcpJson = () => join(cwd(), ".vscode", "mcp.json");
|
|
|
28
28
|
export const globalConfigPath = () => join(homedir(), ".claude", "skillrepo", "config.json");
|
|
29
29
|
export const globalLastSyncPath = () => join(homedir(), ".claude", "skillrepo", ".last-sync");
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* npm-registry self-staleness check cache (#1554). Separate file from
|
|
33
|
+
* `.last-sync` — different concern (CLI's own version vs. library
|
|
34
|
+
* content state) and a schema bump in one must not gate the other.
|
|
35
|
+
* Lives under the same `~/.claude/skillrepo/` directory so cleanup
|
|
36
|
+
* stays simple (rm -rf the parent removes both).
|
|
37
|
+
*/
|
|
38
|
+
export const globalNpmVersionCheckPath = () =>
|
|
39
|
+
join(homedir(), ".claude", "skillrepo", ".npm-version-check");
|
|
40
|
+
|
|
31
41
|
// ── Skill placement targets ────────────────────────────────────────────
|
|
32
42
|
//
|
|
33
43
|
// Per-vendor placement decisions live in `agent-registry.mjs`. This
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk on-disk skill placements and compute SHAs (#1555).
|
|
3
|
+
*
|
|
4
|
+
* Read-side counterpart of `file-write.mjs`: given a set of detected
|
|
5
|
+
* vendor keys, enumerate the skill directories each vendor's
|
|
6
|
+
* placement target contains and compute the same SHA pair that
|
|
7
|
+
* `runSync` persists in `.last-sync` v2. The output feeds `drift.mjs`
|
|
8
|
+
* for per-skill state classification.
|
|
9
|
+
*
|
|
10
|
+
* Design notes
|
|
11
|
+
* ------------
|
|
12
|
+
*
|
|
13
|
+
* - **One walk per unique target.** Cohort vendors (cursor, windsurf,
|
|
14
|
+
* gemini, codex, cline, copilot) all share `agentsProject`. Walking
|
|
15
|
+
* the parent dir N times — once per detected cohort vendor — would
|
|
16
|
+
* read every file N times. Instead, we walk each unique target
|
|
17
|
+
* ONCE and expand the result to one entry per (vendor, scope,
|
|
18
|
+
* skill) combination.
|
|
19
|
+
*
|
|
20
|
+
* - **POSIX path normalization for hashing.** `filesSha256` is the
|
|
21
|
+
* SHA-256 of a sorted `<path>|<sha>` projection. The path component
|
|
22
|
+
* MUST be forward-slash-separated to match what the server-side
|
|
23
|
+
* hashing produces (and what `crypto-shas.mjs` documents). Disk
|
|
24
|
+
* paths on Windows have backslashes; we normalize at the
|
|
25
|
+
* computation boundary, not in the data shape.
|
|
26
|
+
*
|
|
27
|
+
* - **Project scope only in v1.** The spec defers `--global` to a
|
|
28
|
+
* future flag. This module walks each detected vendor's
|
|
29
|
+
* `projectTarget` exclusively. When the global flag lands, add a
|
|
30
|
+
* `scopes` parameter that defaults to `["project"]`.
|
|
31
|
+
*
|
|
32
|
+
* - **Errors degrade gracefully.** A skill directory we can't read
|
|
33
|
+
* (permission error, broken symlink, partial file) produces an
|
|
34
|
+
* entry with `present: true` and `skillMdSha256: null` /
|
|
35
|
+
* `filesSha256: null`. The drift classifier treats null SHAs as
|
|
36
|
+
* `edited` — conservative, surfaces the issue to the user without
|
|
37
|
+
* crashing the command.
|
|
38
|
+
*
|
|
39
|
+
* - **`.tmp/` and `.old/` are skipped.** These are crashed-write
|
|
40
|
+
* artifacts handled by `cleanupOrphans` in `file-write.mjs`. They
|
|
41
|
+
* are not skill directories.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync, readdirSync, readFileSync, lstatSync } from "node:fs";
|
|
45
|
+
import { join, relative, sep } from "node:path";
|
|
46
|
+
|
|
47
|
+
import { computeSkillShas } from "./crypto-shas.mjs";
|
|
48
|
+
import { resolvePlacementRoot } from "./file-write.mjs";
|
|
49
|
+
import { getAgentByKey } from "./agent-registry.mjs";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {Object} LocalPlacement
|
|
53
|
+
* @property {string} vendorKey - Canonical agent key from agent-registry.
|
|
54
|
+
* @property {"project"} scope - Always "project" in v1.
|
|
55
|
+
* @property {string} target - PlacementTarget enum value.
|
|
56
|
+
* @property {string} skillName - Directory name on disk.
|
|
57
|
+
* @property {boolean} present - Always true for entries returned here.
|
|
58
|
+
* @property {string | null} skillMdSha256
|
|
59
|
+
* @property {string | null} filesSha256
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Walk every detected vendor's project placement and return a Map
|
|
64
|
+
* keyed by `"<vendorKey>::project::<skillName>"` for O(1) lookup
|
|
65
|
+
* from `list.mjs`.
|
|
66
|
+
*
|
|
67
|
+
* Each unique placement TARGET is walked exactly once. Detected
|
|
68
|
+
* vendors that map to the same target share the walk's results.
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} detectedVendorKeys
|
|
71
|
+
* @returns {Map<string, LocalPlacement>}
|
|
72
|
+
*/
|
|
73
|
+
export function walkDetectedPlacements(detectedVendorKeys) {
|
|
74
|
+
if (!Array.isArray(detectedVendorKeys) || detectedVendorKeys.length === 0) {
|
|
75
|
+
return new Map();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Group detected vendors by their projectTarget. A vendor with a
|
|
79
|
+
// null projectTarget (no documented project-scope placement) is
|
|
80
|
+
// dropped from this map — there's nothing to walk.
|
|
81
|
+
/** @type {Map<string, string[]>} */
|
|
82
|
+
const targetToVendors = new Map();
|
|
83
|
+
for (const key of detectedVendorKeys) {
|
|
84
|
+
const entry = getAgentByKey(key);
|
|
85
|
+
if (!entry || !entry.projectTarget) continue;
|
|
86
|
+
const target = entry.projectTarget;
|
|
87
|
+
if (!targetToVendors.has(target)) targetToVendors.set(target, []);
|
|
88
|
+
targetToVendors.get(target).push(key);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @type {Map<string, LocalPlacement>} */
|
|
92
|
+
const result = new Map();
|
|
93
|
+
for (const [target, vendorKeys] of targetToVendors) {
|
|
94
|
+
const skillsAtTarget = walkPlacementTarget(target);
|
|
95
|
+
for (const skill of skillsAtTarget) {
|
|
96
|
+
for (const vendorKey of vendorKeys) {
|
|
97
|
+
const key = `${vendorKey}::project::${skill.skillName}`;
|
|
98
|
+
result.set(key, {
|
|
99
|
+
vendorKey,
|
|
100
|
+
scope: "project",
|
|
101
|
+
target,
|
|
102
|
+
skillName: skill.skillName,
|
|
103
|
+
present: true,
|
|
104
|
+
skillMdSha256: skill.skillMdSha256,
|
|
105
|
+
filesSha256: skill.filesSha256,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Walk a single placement target's PARENT directory and return one
|
|
115
|
+
* entry per skill subdirectory found. Exposed for tests and for
|
|
116
|
+
* callers that need a single-target view.
|
|
117
|
+
*
|
|
118
|
+
* Output shape: `{ skillName, skillMdSha256, filesSha256 }[]`. The
|
|
119
|
+
* vendor/scope expansion happens in `walkDetectedPlacements`.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} target - PlacementTarget enum value.
|
|
122
|
+
* @returns {{ skillName: string, skillMdSha256: string | null, filesSha256: string | null }[]}
|
|
123
|
+
*/
|
|
124
|
+
export function walkPlacementTarget(target) {
|
|
125
|
+
let parentDir;
|
|
126
|
+
try {
|
|
127
|
+
parentDir = resolvePlacementRoot(target);
|
|
128
|
+
} catch {
|
|
129
|
+
// Unknown target — surface as no placements rather than throwing.
|
|
130
|
+
// Defensive: detect-agents + agent-registry should never produce
|
|
131
|
+
// an unknown target, but if they ever do we don't want `list` to
|
|
132
|
+
// crash.
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!existsSync(parentDir)) return [];
|
|
137
|
+
|
|
138
|
+
/** @type {string[]} */
|
|
139
|
+
let entries;
|
|
140
|
+
try {
|
|
141
|
+
entries = readdirSync(parentDir);
|
|
142
|
+
} catch {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @type {{ skillName: string, skillMdSha256: string | null, filesSha256: string | null }[]} */
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
// Skip crashed-write artifacts handled by cleanupOrphans.
|
|
150
|
+
if (entry.endsWith(".tmp") || entry.endsWith(".old")) continue;
|
|
151
|
+
|
|
152
|
+
const skillDir = join(parentDir, entry);
|
|
153
|
+
let stat;
|
|
154
|
+
try {
|
|
155
|
+
// lstatSync (not statSync) so a symlink-to-directory at the
|
|
156
|
+
// placements root is rejected here, not recursed into. A
|
|
157
|
+
// circular symlink would otherwise crash `list` with a stack
|
|
158
|
+
// overflow from infinite recursion in walkSkillDirRecursive.
|
|
159
|
+
stat = lstatSync(skillDir);
|
|
160
|
+
} catch {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (stat.isSymbolicLink()) continue;
|
|
164
|
+
if (!stat.isDirectory()) continue;
|
|
165
|
+
|
|
166
|
+
const shas = computeShasFromDir(skillDir);
|
|
167
|
+
out.push({
|
|
168
|
+
skillName: entry,
|
|
169
|
+
skillMdSha256: shas.skillMdSha256,
|
|
170
|
+
filesSha256: shas.filesSha256,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read every file in `skillDir` and compute the same SHA pair that
|
|
178
|
+
* `runSync` persists. On ANY file-read failure (permission, broken
|
|
179
|
+
* symlink, truncated file), returns null SHAs — the caller treats
|
|
180
|
+
* this as `edited` (conservative). Returning null is intentional:
|
|
181
|
+
* we cannot honestly claim `current` on data we couldn't read.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} skillDir
|
|
184
|
+
* @returns {{ skillMdSha256: string | null, filesSha256: string | null }}
|
|
185
|
+
*/
|
|
186
|
+
function computeShasFromDir(skillDir) {
|
|
187
|
+
/** @type {{ path: string, content: string }[]} */
|
|
188
|
+
const files = [];
|
|
189
|
+
let readError = false;
|
|
190
|
+
walkSkillDirRecursive(skillDir, skillDir, files, (err) => {
|
|
191
|
+
if (err) readError = true;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (readError || files.length === 0) {
|
|
195
|
+
return { skillMdSha256: null, filesSha256: null };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
return computeSkillShas(files);
|
|
200
|
+
} catch {
|
|
201
|
+
// computeSkillShas only throws on bad input shape. We constructed
|
|
202
|
+
// the input ourselves, so a throw indicates a programming error
|
|
203
|
+
// we can't recover from — but treat as null SHAs rather than
|
|
204
|
+
// crashing the user's `list` command.
|
|
205
|
+
return { skillMdSha256: null, filesSha256: null };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Recursive directory walk that populates a flat `{path, content}[]`
|
|
211
|
+
* array, with paths POSIX-normalized relative to `skillDir`. Errors
|
|
212
|
+
* on individual entries are reported through the `onError` callback
|
|
213
|
+
* (the caller flips a `readError` boolean to discard the partial
|
|
214
|
+
* accum and return null SHAs) but the walk CONTINUES with the
|
|
215
|
+
* remaining siblings.
|
|
216
|
+
*
|
|
217
|
+
* The continue-not-return choice matters: a `readdirSync` listing of
|
|
218
|
+
* [unreadable, readable1, readable2] would previously bail on entry
|
|
219
|
+
* 0 and never see entries 1 and 2. Setting `readError` once and
|
|
220
|
+
* still walking the rest gives the caller correct behavior (null
|
|
221
|
+
* SHAs from the gate at `computeShasFromDir`) and produces a
|
|
222
|
+
* deterministic outcome that doesn't depend on `readdirSync`
|
|
223
|
+
* ordering. The accum is partially populated when `readError` is
|
|
224
|
+
* true but the gate in `computeShasFromDir` discards it.
|
|
225
|
+
*
|
|
226
|
+
* Depth is not capped — agentskills.io spec mandates files at most
|
|
227
|
+
* one level deep from SKILL.md (`scripts/foo.sh`, `references/bar.md`)
|
|
228
|
+
* but the CLI doesn't reject a deeper tree. If the user has hand-
|
|
229
|
+
* authored a deeper structure, we hash it faithfully.
|
|
230
|
+
*/
|
|
231
|
+
function walkSkillDirRecursive(rootDir, currentDir, accum, onError) {
|
|
232
|
+
let entries;
|
|
233
|
+
try {
|
|
234
|
+
entries = readdirSync(currentDir);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
// Can't list this directory at all — flag the error and bail
|
|
237
|
+
// out of this level (no entries to walk). The outer caller
|
|
238
|
+
// continues with whatever siblings the parent dir contains.
|
|
239
|
+
onError(err);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
const fullPath = join(currentDir, entry);
|
|
245
|
+
let stat;
|
|
246
|
+
try {
|
|
247
|
+
// lstatSync (not statSync) is load-bearing here. statSync
|
|
248
|
+
// follows symlinks, which means:
|
|
249
|
+
// 1. A symlink-to-directory passes isDirectory() and
|
|
250
|
+
// walkSkillDirRecursive recurses into it. A circular
|
|
251
|
+
// symlink (e.g. `references/loop -> .`) infinite-loops
|
|
252
|
+
// and crashes the process with a stack overflow.
|
|
253
|
+
// 2. A symlink-to-file passes isFile() and readFileSync
|
|
254
|
+
// reads the target — potentially OUTSIDE the skill dir.
|
|
255
|
+
// Not a privilege escalation (we only read what the user
|
|
256
|
+
// can read), but a principle-of-least-surprise violation.
|
|
257
|
+
// The agentskills.io spec does not define symlinks as valid
|
|
258
|
+
// skill content. Skip them.
|
|
259
|
+
stat = lstatSync(fullPath);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
onError(err);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (stat.isSymbolicLink()) continue;
|
|
265
|
+
if (stat.isDirectory()) {
|
|
266
|
+
walkSkillDirRecursive(rootDir, fullPath, accum, onError);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (!stat.isFile()) continue; // skip sockets / pipes / etc.
|
|
270
|
+
|
|
271
|
+
let content;
|
|
272
|
+
try {
|
|
273
|
+
content = readFileSync(fullPath, "utf8");
|
|
274
|
+
} catch (err) {
|
|
275
|
+
onError(err);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
// POSIX-normalize the path so SHA computation matches what the
|
|
279
|
+
// server produces. On POSIX, `sep` is "/" and this is a no-op;
|
|
280
|
+
// on Windows, `sep` is "\\" and we convert.
|
|
281
|
+
const posixRelPath =
|
|
282
|
+
sep === "/" ? relative(rootDir, fullPath) : relative(rootDir, fullPath).split(sep).join("/");
|
|
283
|
+
accum.push({ path: posixRelPath, content });
|
|
284
|
+
}
|
|
285
|
+
}
|