skillrepo 4.3.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 +160 -3
- package/bin/skillrepo.mjs +45 -0
- package/package.json +3 -2
- package/src/commands/init.mjs +60 -2
- package/src/commands/list.mjs +328 -56
- package/src/lib/config.mjs +6 -0
- 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/lib/telemetry.mjs +201 -0
- package/src/test/commands/init.test.mjs +85 -0
- package/src/test/commands/list.test.mjs +510 -2
- package/src/test/lib/config.test.mjs +33 -0
- 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
- package/src/test/lib/telemetry.test.mjs +289 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-skill drift classification (#1555 — `list` local-awareness).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions for deciding "is this skill current, stale, missing,
|
|
5
|
+
* or edited?" given the three input axes:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Library state** — version from `GET /api/v1/library`.
|
|
8
|
+
* 2. **Last-sync baseline** — version + content SHAs persisted by
|
|
9
|
+
* `runSync` to `~/.claude/skillrepo/.last-sync` (v2 schema).
|
|
10
|
+
* 3. **On-disk presence** — { present, skillMdSha256, filesSha256 }
|
|
11
|
+
* from walking the vendor's placement directory.
|
|
12
|
+
*
|
|
13
|
+
* No I/O, no clock, no env reads. Same input → same output. This is
|
|
14
|
+
* the layer that gets exhaustive unit tests; the file-system walker
|
|
15
|
+
* and the `list` renderer compose this with their own concerns.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import semver from "semver";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Per-vendor placement state. The four values cover the
|
|
22
|
+
* "where is this skill, and is it the right version" question. A
|
|
23
|
+
* fifth state, `extra` (on disk but not in the library), lives in
|
|
24
|
+
* the renderer because it depends on the library shape, not just
|
|
25
|
+
* the per-skill comparison here.
|
|
26
|
+
*/
|
|
27
|
+
export const SKILL_STATE = Object.freeze({
|
|
28
|
+
CURRENT: "current",
|
|
29
|
+
STALE: "stale",
|
|
30
|
+
MISSING: "missing",
|
|
31
|
+
EDITED: "edited",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compute the placement-level state for a single (skill, vendor) pair.
|
|
36
|
+
*
|
|
37
|
+
* Decision order — first match wins:
|
|
38
|
+
*
|
|
39
|
+
* 1. **No on-disk presence** → `missing`. The vendor's placement
|
|
40
|
+
* directory does not contain this skill.
|
|
41
|
+
* 2. **No last-sync baseline** → `missing`. We can't verify
|
|
42
|
+
* anything without the SHA from the prior sync. Even if the
|
|
43
|
+
* directory exists, treating it as `current` would lie about
|
|
44
|
+
* drift we cannot detect. The user's resolution is the same as
|
|
45
|
+
* true `missing`: run `skillrepo update` to establish a
|
|
46
|
+
* baseline. This matches the spec's "fresh install → missing
|
|
47
|
+
* everywhere" behavior.
|
|
48
|
+
* 3. **Library version newer than synced version** → `stale`. The
|
|
49
|
+
* registry has moved on; the user is behind.
|
|
50
|
+
* 4. **Either SHA differs from baseline** → `edited`. Same
|
|
51
|
+
* version, content drifted. The user (or an agent) touched the
|
|
52
|
+
* files on disk after the last sync.
|
|
53
|
+
* 5. **Otherwise** → `current`.
|
|
54
|
+
*
|
|
55
|
+
* Semver comparison falls back to strict string equality when either
|
|
56
|
+
* value is not a valid semver. The fallback's effect: any non-semver
|
|
57
|
+
* mismatch is treated as `stale` (the user is "behind" some
|
|
58
|
+
* unparseable label). Pre-existing test fixtures (`"1.0.0"`,
|
|
59
|
+
* `"2.0.1"`) all parse; this branch exists for forward-compat with
|
|
60
|
+
* versioning schemes the registry may adopt later.
|
|
61
|
+
*
|
|
62
|
+
* @param {Object} input
|
|
63
|
+
* @param {string | null} input.libraryVersion
|
|
64
|
+
* The current version per the library response, or null if the
|
|
65
|
+
* library entry has no version yet (shouldn't happen in production
|
|
66
|
+
* but the type allows it).
|
|
67
|
+
* @param {{ version: string, skillMdSha256: string, filesSha256: string } | null} input.lastSyncEntry
|
|
68
|
+
* Persisted state from the last successful sync, or null if no
|
|
69
|
+
* baseline exists for this skill.
|
|
70
|
+
* @param {{ present: boolean, skillMdSha256: string | null, filesSha256: string | null } | null} input.localPlacement
|
|
71
|
+
* Disk presence at one vendor's placement target. `present: false`
|
|
72
|
+
* means the directory is absent; non-null SHAs imply `present:
|
|
73
|
+
* true`.
|
|
74
|
+
* @returns {"current" | "stale" | "missing" | "edited"}
|
|
75
|
+
*/
|
|
76
|
+
export function computeSkillState({ libraryVersion, lastSyncEntry, localPlacement }) {
|
|
77
|
+
if (!localPlacement || !localPlacement.present) {
|
|
78
|
+
return SKILL_STATE.MISSING;
|
|
79
|
+
}
|
|
80
|
+
if (!lastSyncEntry) {
|
|
81
|
+
// On-disk but no baseline → cannot verify. Spec choice: report
|
|
82
|
+
// missing rather than fabricating a "current" verdict. See the
|
|
83
|
+
// docstring's decision-order comment.
|
|
84
|
+
return SKILL_STATE.MISSING;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (libraryVersionIsNewer(libraryVersion, lastSyncEntry.version)) {
|
|
88
|
+
return SKILL_STATE.STALE;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const skillMdMatches =
|
|
92
|
+
localPlacement.skillMdSha256 !== null &&
|
|
93
|
+
localPlacement.skillMdSha256 === lastSyncEntry.skillMdSha256;
|
|
94
|
+
const filesMatch =
|
|
95
|
+
localPlacement.filesSha256 !== null &&
|
|
96
|
+
localPlacement.filesSha256 === lastSyncEntry.filesSha256;
|
|
97
|
+
|
|
98
|
+
if (skillMdMatches && filesMatch) return SKILL_STATE.CURRENT;
|
|
99
|
+
return SKILL_STATE.EDITED;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reduce per-vendor states for a single skill to a single
|
|
104
|
+
* worst-state-wins value for the `Local` column in `list`'s table
|
|
105
|
+
* and the top-level `state` in `--json`.
|
|
106
|
+
*
|
|
107
|
+
* Precedence (high → low severity):
|
|
108
|
+
* missing > edited > stale > current
|
|
109
|
+
*
|
|
110
|
+
* Reasoning: the user's action depends on the worst state.
|
|
111
|
+
* - `missing` requires `skillrepo update` to populate the placement.
|
|
112
|
+
* - `edited` requires user judgement (keep edits? overwrite?).
|
|
113
|
+
* - `stale` requires `skillrepo update` to fetch the new version.
|
|
114
|
+
* - `current` requires nothing.
|
|
115
|
+
*
|
|
116
|
+
* Edited ranks above stale because the user is more likely to lose
|
|
117
|
+
* work on `edited` (overwrite by sync). Missing ranks above edited
|
|
118
|
+
* because the missing placement is the most immediately broken.
|
|
119
|
+
*
|
|
120
|
+
* An empty input array returns `missing` — there are no detected
|
|
121
|
+
* vendors to check against, so we can't claim anything is current.
|
|
122
|
+
* Callers that have a true "no vendors detected" case usually
|
|
123
|
+
* short-circuit before getting here, but the safe fallback is the
|
|
124
|
+
* conservative one.
|
|
125
|
+
*
|
|
126
|
+
* @param {ReadonlyArray<"current" | "stale" | "missing" | "edited">} perVendorStates
|
|
127
|
+
* @returns {"current" | "stale" | "missing" | "edited"}
|
|
128
|
+
*/
|
|
129
|
+
export function rollupState(perVendorStates) {
|
|
130
|
+
if (!Array.isArray(perVendorStates) || perVendorStates.length === 0) {
|
|
131
|
+
return SKILL_STATE.MISSING;
|
|
132
|
+
}
|
|
133
|
+
const order = [
|
|
134
|
+
SKILL_STATE.MISSING,
|
|
135
|
+
SKILL_STATE.EDITED,
|
|
136
|
+
SKILL_STATE.STALE,
|
|
137
|
+
SKILL_STATE.CURRENT,
|
|
138
|
+
];
|
|
139
|
+
for (const s of order) {
|
|
140
|
+
if (perVendorStates.includes(s)) return s;
|
|
141
|
+
}
|
|
142
|
+
// Defensive fallback for an array containing an unknown enum value.
|
|
143
|
+
// Treating it as the worst case (missing) is the conservative
|
|
144
|
+
// choice — it tells the user "something's off, run update" rather
|
|
145
|
+
// than silently rendering as current.
|
|
146
|
+
return SKILL_STATE.MISSING;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Strict semver `>` with a string-fallback escape hatch.
|
|
151
|
+
*
|
|
152
|
+
* `semver.gt` throws on invalid versions; we wrap with `semver.valid`
|
|
153
|
+
* to avoid the throw. When EITHER side is not valid semver, fall
|
|
154
|
+
* back to a plain string inequality — any difference is treated as
|
|
155
|
+
* the library being "ahead," which classifies the skill as `stale`.
|
|
156
|
+
* This is the right safety direction: false-stale tells the user to
|
|
157
|
+
* run `update`, which is harmless; false-current would mask drift.
|
|
158
|
+
*
|
|
159
|
+
* @param {string | null} libraryVersion
|
|
160
|
+
* @param {string | null} syncedVersion
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
function libraryVersionIsNewer(libraryVersion, syncedVersion) {
|
|
164
|
+
if (typeof libraryVersion !== "string" || libraryVersion === "") return false;
|
|
165
|
+
if (typeof syncedVersion !== "string" || syncedVersion === "") {
|
|
166
|
+
// No baseline version → we can't decide ordering. Caller treats
|
|
167
|
+
// this as "version comparison inconclusive" and continues to
|
|
168
|
+
// the SHA check.
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
if (semver.valid(libraryVersion) && semver.valid(syncedVersion)) {
|
|
172
|
+
return semver.gt(libraryVersion, syncedVersion);
|
|
173
|
+
}
|
|
174
|
+
return libraryVersion !== syncedVersion;
|
|
175
|
+
}
|
package/src/lib/file-write.mjs
CHANGED
|
@@ -227,10 +227,25 @@ export function describePlacementTarget(target) {
|
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Resolve the parent directory for a `PlacementTarget` — the dir
|
|
232
|
+
* that contains all skill subdirectories for this target. Exposed so
|
|
233
|
+
* read-side consumers (`placement-walk.mjs` for `list`'s drift
|
|
234
|
+
* detection in #1555) can enumerate skill placements without
|
|
235
|
+
* duplicating the target→root mapping that already lives here.
|
|
236
|
+
*
|
|
237
|
+
* @param {PlacementTarget} target
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
export function resolvePlacementRoot(target) {
|
|
241
|
+
return placementRootFn(target)();
|
|
242
|
+
}
|
|
243
|
+
|
|
230
244
|
/**
|
|
231
245
|
* Map a `PlacementTarget` to its parent root resolver. Used by
|
|
232
246
|
* `cleanupOrphans` to know which directories to scan for `.tmp`/`.old`
|
|
233
|
-
* siblings
|
|
247
|
+
* siblings, and by `resolvePlacementRoot` above for read-side
|
|
248
|
+
* consumers.
|
|
234
249
|
*
|
|
235
250
|
* @param {PlacementTarget} target
|
|
236
251
|
* @returns {() => string}
|
|
@@ -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
|