@blamejs/exceptd-skills 0.11.13 → 0.11.15

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.
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * lib/refresh-network.js
6
+ *
7
+ * `exceptd refresh --network` — fetch the latest signed catalog snapshot
8
+ * from the maintainer's npm-published tarball, verify every skill
9
+ * signature against the Ed25519 public key already shipped in the
10
+ * operator's local install, and swap in the fresh `data/`, `manifest.json`,
11
+ * and `manifest-snapshot.json`.
12
+ *
13
+ * Trust boundary:
14
+ * - Trust anchor = the `keys/public.pem` already present in the local
15
+ * install. Operators rotated the key by running `npm install -g` at
16
+ * some earlier point; nothing in --network changes that root.
17
+ * - Tarball authenticity = each shipped `skills/<name>/SKILL.md` (or
18
+ * equivalent payload) has an Ed25519 signature in manifest.json that
19
+ * resolves against the local public key. ANY signature mismatch aborts
20
+ * the swap; the local install is untouched.
21
+ *
22
+ * Why this exists:
23
+ * `npm update -g` already pulls the same signed artifact (npm provenance
24
+ * + OIDC). --network is for operators who want only the data slice
25
+ * without re-resolving CLI/lib code, OR who are on a constrained host
26
+ * where the global npm install requires sudo and they're running a
27
+ * user-local copy they can write to.
28
+ *
29
+ * Requires write access to the install directory. Fails fast with a
30
+ * clear message + the `npm update` fallback when the install is not
31
+ * writable (typical for system-global installs).
32
+ *
33
+ * Zero npm deps. Node 24 stdlib only.
34
+ */
35
+
36
+ const fs = require("fs");
37
+ const path = require("path");
38
+ const https = require("https");
39
+ const crypto = require("crypto");
40
+ const zlib = require("zlib");
41
+ const os = require("os");
42
+
43
+ const ROOT = path.resolve(__dirname, "..");
44
+ const PKG_NAME = "@blamejs/exceptd-skills";
45
+ const REQUEST_TIMEOUT_MS = 15000;
46
+
47
+ function parseArgs(argv) {
48
+ const out = { force: false, dryRun: false, timeoutMs: REQUEST_TIMEOUT_MS, json: false };
49
+ for (let i = 2; i < argv.length; i++) {
50
+ const a = argv[i];
51
+ if (a === "--force") out.force = true;
52
+ else if (a === "--dry-run") out.dryRun = true;
53
+ else if (a === "--json") out.json = true;
54
+ else if (a === "--timeout") out.timeoutMs = parseInt(argv[++i], 10) || REQUEST_TIMEOUT_MS;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function emit(obj, json) {
60
+ if (json) process.stdout.write(JSON.stringify(obj) + "\n");
61
+ else if (obj.ok) process.stdout.write(`[refresh-network] ${obj.message || JSON.stringify(obj)}\n`);
62
+ else process.stderr.write(`[refresh-network] FAIL: ${obj.error || JSON.stringify(obj)}\n`);
63
+ }
64
+
65
+ function progress(line, json) {
66
+ // Progress messages always go to stderr so --json consumers see only the
67
+ // final JSON result on stdout. Plain mode also routes progress through
68
+ // stderr (so `exceptd refresh --network > catalog.log` doesn't mix logs
69
+ // with structured output).
70
+ process.stderr.write(`[refresh-network] ${line}\n`);
71
+ }
72
+
73
+ function getJson(url, timeoutMs) {
74
+ return new Promise((resolve, reject) => {
75
+ const u = new URL(url);
76
+ const req = https.get({
77
+ host: u.host, path: u.pathname + u.search,
78
+ headers: { "Accept": "application/json", "User-Agent": "exceptd/refresh-network" },
79
+ timeout: timeoutMs,
80
+ }, (res) => {
81
+ if (res.statusCode !== 200) {
82
+ res.resume();
83
+ return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
84
+ }
85
+ const chunks = [];
86
+ res.on("data", (c) => chunks.push(c));
87
+ res.on("end", () => {
88
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
89
+ catch (e) { reject(new Error(`parse: ${e.message}`)); }
90
+ });
91
+ });
92
+ req.on("timeout", () => req.destroy(new Error("timeout")));
93
+ req.on("error", reject);
94
+ });
95
+ }
96
+
97
+ function getBuffer(url, timeoutMs) {
98
+ return new Promise((resolve, reject) => {
99
+ const u = new URL(url);
100
+ const req = https.get({
101
+ host: u.host, path: u.pathname + u.search,
102
+ headers: { "User-Agent": "exceptd/refresh-network" },
103
+ timeout: timeoutMs,
104
+ }, (res) => {
105
+ if (res.statusCode !== 200) {
106
+ res.resume();
107
+ return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
108
+ }
109
+ const chunks = [];
110
+ res.on("data", (c) => chunks.push(c));
111
+ res.on("end", () => resolve(Buffer.concat(chunks)));
112
+ });
113
+ req.on("timeout", () => req.destroy(new Error("timeout")));
114
+ req.on("error", reject);
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Parse a tar buffer (uncompressed) and return [{ name, body }] entries.
120
+ * Tiny implementation — supports the GNU ustar variant npm produces.
121
+ * Skips PaxHeader entries; treats long-link entries (type L) by stitching
122
+ * the next entry's name from the long-link body.
123
+ */
124
+ function parseTar(buf) {
125
+ const entries = [];
126
+ let offset = 0;
127
+ let pendingLongName = null;
128
+ while (offset + 512 <= buf.length) {
129
+ const block = buf.subarray(offset, offset + 512);
130
+ // empty block = end-of-archive marker
131
+ if (block.every((b) => b === 0)) break;
132
+ let name = block.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
133
+ const sizeStr = block.subarray(124, 136).toString("utf8").replace(/\0.*$|\s+$/g, "").trim();
134
+ const size = parseInt(sizeStr, 8) || 0;
135
+ const type = String.fromCharCode(block[156] || 0);
136
+ const prefix = block.subarray(345, 500).toString("utf8").replace(/\0.*$/, "");
137
+ if (prefix) name = prefix + "/" + name;
138
+ if (pendingLongName) { name = pendingLongName; pendingLongName = null; }
139
+ const dataStart = offset + 512;
140
+ const dataEnd = dataStart + size;
141
+ if (type === "L") {
142
+ pendingLongName = buf.subarray(dataStart, dataEnd).toString("utf8").replace(/\0.*$/, "");
143
+ } else if (type === "0" || type === "" || type === "\0") {
144
+ entries.push({ name, body: buf.subarray(dataStart, dataEnd) });
145
+ }
146
+ // round up to 512
147
+ offset = dataStart + Math.ceil(size / 512) * 512;
148
+ }
149
+ return entries;
150
+ }
151
+
152
+ function fingerprintPublicKey(pemText) {
153
+ try {
154
+ const ko = crypto.createPublicKey(pemText);
155
+ const der = ko.export({ type: "spki", format: "der" });
156
+ return crypto.createHash("sha256").update(der).digest("base64");
157
+ } catch { return null; }
158
+ }
159
+
160
+ function verifyDetached(publicKeyObj, payload, sigB64) {
161
+ try {
162
+ return crypto.verify(null, payload, publicKeyObj, Buffer.from(sigB64, "base64"));
163
+ } catch { return false; }
164
+ }
165
+
166
+ async function main() {
167
+ const opts = parseArgs(process.argv);
168
+ const localPkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
169
+ const localVersion = localPkg.version;
170
+
171
+ progress(`local v${localVersion} — querying npm registry...`, opts.json);
172
+
173
+ let meta;
174
+ try {
175
+ if (process.env.EXCEPTD_REGISTRY_FIXTURE) {
176
+ // Honor the same fixture mechanism as upstream-check so test runners
177
+ // can exercise the offline / reachable / unreachable branches without
178
+ // touching the network. Fixture shape: a JSON file matching the
179
+ // /<pkg>/latest registry response (must include version + dist.tarball
180
+ // + dist.shasum, or just version to exercise the early-return path).
181
+ meta = JSON.parse(fs.readFileSync(process.env.EXCEPTD_REGISTRY_FIXTURE, "utf8"));
182
+ } else {
183
+ meta = await getJson(`https://registry.npmjs.org/${encodeURIComponent(PKG_NAME).replace("%40", "@").replace("%2F", "/")}/latest`, opts.timeoutMs);
184
+ }
185
+ } catch (e) {
186
+ emit({ ok: false, error: `registry unreachable: ${e.message}`, hint: "Network required. Air-gap workflow: run `exceptd refresh --prefetch` on a connected host, then `exceptd refresh --from-cache --apply` offline. Or set EXCEPTD_REGISTRY_FIXTURE for offline testing." }, opts.json);
187
+ process.exitCode = 2; return;
188
+ }
189
+
190
+ const latestVersion = meta.version;
191
+ const tarballUrl = meta.dist && meta.dist.tarball;
192
+ const tarballShasum = meta.dist && meta.dist.shasum;
193
+ if (!tarballUrl) {
194
+ emit({ ok: false, error: "registry metadata missing dist.tarball" }, opts.json);
195
+ process.exitCode = 2; return;
196
+ }
197
+
198
+ if (latestVersion === localVersion && !opts.force) {
199
+ emit({ ok: true, message: `already at latest v${localVersion} — nothing to do. Pass --force to re-pull anyway.`, local_version: localVersion, latest_version: latestVersion, skipped: true }, opts.json);
200
+ return;
201
+ }
202
+
203
+ // Writable-install check. Global installs typically live in a
204
+ // root-owned dir; refusing to fail-then-leave-partial is the safer
205
+ // contract.
206
+ const writeProbe = path.join(ROOT, `.write-probe-${process.pid}`);
207
+ try {
208
+ fs.writeFileSync(writeProbe, "");
209
+ fs.unlinkSync(writeProbe);
210
+ } catch (e) {
211
+ emit({
212
+ ok: false,
213
+ error: `install directory not writable: ${ROOT}`,
214
+ hint: `Global installs typically require elevated permissions. Either: (a) run \`npm update -g @blamejs/exceptd-skills\` (recommended — same trust anchor, full package update), or (b) install locally with \`npm install @blamejs/exceptd-skills\` in a user-writable directory and retry --network there.`,
215
+ }, opts.json);
216
+ process.exitCode = 3; return;
217
+ }
218
+
219
+ progress(`fetching ${tarballUrl} (${tarballShasum?.slice(0, 12) || "no shasum"})...`, opts.json);
220
+
221
+ let tgzBuf;
222
+ try {
223
+ tgzBuf = await getBuffer(tarballUrl, opts.timeoutMs);
224
+ } catch (e) {
225
+ emit({ ok: false, error: `tarball fetch failed: ${e.message}` }, opts.json);
226
+ process.exitCode = 2; return;
227
+ }
228
+
229
+ // Verify shasum (registry-provided integrity).
230
+ if (tarballShasum) {
231
+ const actual = crypto.createHash("sha1").update(tgzBuf).digest("hex");
232
+ if (actual !== tarballShasum) {
233
+ emit({ ok: false, error: `tarball shasum mismatch: expected ${tarballShasum}, got ${actual}` }, opts.json);
234
+ process.exitCode = 4; return;
235
+ }
236
+ }
237
+
238
+ // Extract.
239
+ let tarBuf;
240
+ try { tarBuf = zlib.gunzipSync(tgzBuf); }
241
+ catch (e) { emit({ ok: false, error: `gunzip: ${e.message}` }, opts.json); process.exitCode = 4; return; }
242
+
243
+ const entries = parseTar(tarBuf);
244
+ // npm tarballs prefix every entry with "package/"
245
+ const stripPkg = (n) => n.startsWith("package/") ? n.slice("package/".length) : n;
246
+
247
+ const tarballManifestEntry = entries.find((e) => stripPkg(e.name) === "manifest.json");
248
+ const tarballPubKeyEntry = entries.find((e) => stripPkg(e.name) === "keys/public.pem");
249
+ if (!tarballManifestEntry) {
250
+ emit({ ok: false, error: "tarball missing manifest.json" }, opts.json);
251
+ process.exitCode = 4; return;
252
+ }
253
+
254
+ // Trust anchor = LOCAL public.pem. Compare it to the tarball's public.pem
255
+ // by fingerprint; if they differ, refuse the swap (key rotation requires
256
+ // a full `npm update -g` so the operator has explicit visibility).
257
+ const localPubKeyPath = path.join(ROOT, "keys", "public.pem");
258
+ let localPubKeyText, tarballPubKeyText;
259
+ try { localPubKeyText = fs.readFileSync(localPubKeyPath, "utf8"); }
260
+ catch (e) { emit({ ok: false, error: `local keys/public.pem unreadable: ${e.message}` }, opts.json); process.exitCode = 4; return; }
261
+ if (tarballPubKeyEntry) tarballPubKeyText = tarballPubKeyEntry.body.toString("utf8");
262
+
263
+ const localFp = fingerprintPublicKey(localPubKeyText);
264
+ const tarballFp = tarballPubKeyText ? fingerprintPublicKey(tarballPubKeyText) : null;
265
+ if (tarballFp && tarballFp !== localFp) {
266
+ emit({
267
+ ok: false,
268
+ error: `public key fingerprint mismatch: local=${localFp} tarball=${tarballFp}`,
269
+ hint: `The maintainer rotated the Ed25519 signing key in v${latestVersion}. Key rotations require an explicit \`npm update -g @blamejs/exceptd-skills\` so you can audit the trust transition. Refusing to swap on --network.`,
270
+ }, opts.json);
271
+ process.exitCode = 5; return;
272
+ }
273
+
274
+ // Verify every signed entry in the tarball manifest using the local key.
275
+ let tarballManifest;
276
+ try { tarballManifest = JSON.parse(tarballManifestEntry.body.toString("utf8")); }
277
+ catch (e) { emit({ ok: false, error: `tarball manifest.json parse: ${e.message}` }, opts.json); process.exitCode = 4; return; }
278
+
279
+ const localKeyObj = crypto.createPublicKey(localPubKeyText);
280
+ const skills = Array.isArray(tarballManifest.skills) ? tarballManifest.skills : [];
281
+ const failures = [];
282
+ let verifiedCount = 0;
283
+ for (const sk of skills) {
284
+ if (!sk || !sk.id || !sk.signature) continue;
285
+ // Find the skill payload entry. manifest convention: skills/<id>/SKILL.md
286
+ const payloadName = `skills/${sk.id}/SKILL.md`;
287
+ const payloadEntry = entries.find((e) => stripPkg(e.name) === payloadName);
288
+ if (!payloadEntry) { failures.push({ id: sk.id, reason: "payload not in tarball" }); continue; }
289
+ const ok = verifyDetached(localKeyObj, payloadEntry.body, sk.signature);
290
+ if (ok) verifiedCount++;
291
+ else failures.push({ id: sk.id, reason: "signature did not verify against local public key" });
292
+ }
293
+
294
+ if (failures.length > 0) {
295
+ emit({
296
+ ok: false,
297
+ error: `${failures.length}/${skills.length} skill signature(s) failed verification — refusing to swap`,
298
+ failures: failures.slice(0, 10),
299
+ verified: verifiedCount,
300
+ total: skills.length,
301
+ hint: "Refusing to install unverified content. Run `npm update -g @blamejs/exceptd-skills` for the full provenance-verified path, or report this tarball at https://github.com/blamejs/exceptd-skills/issues.",
302
+ }, opts.json);
303
+ process.exitCode = 5; return;
304
+ }
305
+
306
+ if (opts.dryRun) {
307
+ emit({
308
+ ok: true,
309
+ dry_run: true,
310
+ local_version: localVersion,
311
+ latest_version: latestVersion,
312
+ verified_skills: verifiedCount,
313
+ total_skills: skills.length,
314
+ message: `--dry-run: would swap data/ + manifest.json from v${latestVersion} (${verifiedCount}/${skills.length} signatures verified). No files changed.`,
315
+ }, opts.json);
316
+ return;
317
+ }
318
+
319
+ // Atomic swap: stage to a tmp dir under the install, then rename.
320
+ const stageDir = fs.mkdtempSync(path.join(ROOT, ".refresh-network-"));
321
+ let written = 0;
322
+ try {
323
+ for (const entry of entries) {
324
+ const rel = stripPkg(entry.name);
325
+ // Scope: only data/, skills/, manifest.json, manifest-snapshot.json.
326
+ // Everything else (bin/, lib/, package.json, etc.) is left alone —
327
+ // --network is a DATA refresh, not a code refresh.
328
+ if (!(rel === "manifest.json" || rel === "manifest-snapshot.json" ||
329
+ rel.startsWith("data/") || rel.startsWith("skills/"))) continue;
330
+ const dst = path.join(stageDir, rel);
331
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
332
+ fs.writeFileSync(dst, entry.body);
333
+ written++;
334
+ }
335
+
336
+ // Replace targets.
337
+ const replaceList = ["data", "skills", "manifest.json", "manifest-snapshot.json"];
338
+ const backupDir = path.join(ROOT, `.refresh-network-backup-${Date.now()}`);
339
+ fs.mkdirSync(backupDir);
340
+ for (const target of replaceList) {
341
+ const src = path.join(stageDir, target);
342
+ if (!fs.existsSync(src)) continue;
343
+ const dst = path.join(ROOT, target);
344
+ if (fs.existsSync(dst)) {
345
+ fs.renameSync(dst, path.join(backupDir, target));
346
+ }
347
+ fs.renameSync(src, dst);
348
+ }
349
+ fs.rmSync(stageDir, { recursive: true, force: true });
350
+ // Best-effort cleanup of backup dir — keep on disk for one cycle so
351
+ // operators can manually roll back if something feels off.
352
+ emit({
353
+ ok: true,
354
+ local_version: localVersion,
355
+ latest_version: latestVersion,
356
+ verified_skills: verifiedCount,
357
+ total_skills: skills.length,
358
+ files_written: written,
359
+ backup_dir: path.relative(ROOT, backupDir),
360
+ message: `refreshed catalog from v${localVersion} → v${latestVersion} (${verifiedCount}/${skills.length} signatures verified). Backup at ${path.relative(ROOT, backupDir)} — safe to remove after verifying the new run.`,
361
+ }, opts.json);
362
+ } catch (e) {
363
+ fs.rmSync(stageDir, { recursive: true, force: true });
364
+ emit({ ok: false, error: `swap failed mid-rename: ${e.message}`, hint: "If files are missing, restore from the backup dir at the install root, or reinstall with `npm install -g @blamejs/exceptd-skills`." }, opts.json);
365
+ process.exitCode = 4;
366
+ }
367
+ }
368
+
369
+ if (require.main === module) {
370
+ main().catch((err) => {
371
+ process.stderr.write(`refresh-network: fatal: ${err && err.message || err}\n`);
372
+ process.exit(2);
373
+ });
374
+ }
375
+
376
+ module.exports = { parseTar, fingerprintPublicKey };
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * lib/upstream-check-cli.js
6
+ *
7
+ * Small wrapper that calls lib/upstream-check.fetchLatestPublished() and
8
+ * emits the freshness report as JSON to stdout. Used internally by:
9
+ * - `exceptd doctor --registry-check`
10
+ * - `exceptd run --upstream-check`
11
+ * - `exceptd refresh --network`
12
+ *
13
+ * Runs in a child process so the parent verb stays synchronous and the
14
+ * network timeout is bounded by the spawnSync timeout.
15
+ *
16
+ * Output: one JSON line to stdout. Exits 0 even when the registry is
17
+ * unreachable (offline ≠ error — the freshness signal degrades gracefully).
18
+ *
19
+ * Flags:
20
+ * --timeout <ms> override the default 5000 ms network timeout
21
+ * --raw emit raw registry response instead of freshness report
22
+ */
23
+
24
+ const path = require("path");
25
+ const fs = require("fs");
26
+
27
+ const ROOT = path.resolve(__dirname, "..");
28
+ const { fetchLatestPublished, buildFreshnessReport } = require("./upstream-check.js");
29
+
30
+ function parseArgs(argv) {
31
+ const out = { timeoutMs: 5000, raw: false };
32
+ for (let i = 2; i < argv.length; i++) {
33
+ const a = argv[i];
34
+ if (a === "--timeout") { out.timeoutMs = parseInt(argv[++i], 10) || 5000; }
35
+ else if (a.startsWith("--timeout=")) { out.timeoutMs = parseInt(a.slice("--timeout=".length), 10) || 5000; }
36
+ else if (a === "--raw") out.raw = true;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function readPkgVersion() {
42
+ try {
43
+ return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
44
+ } catch { return "0.0.0"; }
45
+ }
46
+
47
+ function readManifest() {
48
+ try {
49
+ return JSON.parse(fs.readFileSync(path.join(ROOT, "manifest.json"), "utf8"));
50
+ } catch { return null; }
51
+ }
52
+
53
+ (async () => {
54
+ const opts = parseArgs(process.argv);
55
+ const registry = await fetchLatestPublished({ timeoutMs: opts.timeoutMs });
56
+ if (opts.raw) {
57
+ process.stdout.write(JSON.stringify(registry) + "\n");
58
+ return;
59
+ }
60
+ const report = buildFreshnessReport({
61
+ localVersion: readPkgVersion(),
62
+ registry,
63
+ localManifest: readManifest(),
64
+ });
65
+ process.stdout.write(JSON.stringify(report) + "\n");
66
+ })();
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * lib/upstream-check.js
5
+ *
6
+ * Shared helper used by `doctor --registry-check`, `run --upstream-check`,
7
+ * and `refresh --network`. Queries the npm registry for the package's
8
+ * latest published version + publish timestamp. Operator opts in — never
9
+ * fired automatically on every CLI invocation.
10
+ *
11
+ * Trust model: the registry call is a freshness signal, not a trust
12
+ * anchor. The Ed25519-signed skill catalog shipped in the operator's
13
+ * installed package remains the source of truth. This helper only
14
+ * reports "you're N days behind" — does not auto-update anything.
15
+ *
16
+ * Zero npm deps. Node 24 stdlib only.
17
+ */
18
+
19
+ const https = require("https");
20
+
21
+ const REGISTRY_HOST = "registry.npmjs.org";
22
+ const PKG_NAME = "@blamejs/exceptd-skills";
23
+ const REQUEST_TIMEOUT_MS = 5000;
24
+
25
+ /**
26
+ * Fetch the latest version + publish time from the npm registry.
27
+ *
28
+ * Returns:
29
+ * { ok: true, version: "0.11.14", published_at: ISO_STRING, source: "npm-registry" }
30
+ * { ok: false, error: "timeout" | "offline" | "parse" | string, source: "offline" }
31
+ *
32
+ * Honors EXCEPTD_REGISTRY_FIXTURE env var for offline testing — value is
33
+ * a path to a JSON file with { version, time: { <ver>: ISO } } shape.
34
+ */
35
+ async function fetchLatestPublished({ timeoutMs = REQUEST_TIMEOUT_MS, pkgName = PKG_NAME } = {}) {
36
+ if (process.env.EXCEPTD_REGISTRY_FIXTURE) {
37
+ try {
38
+ const fs = require("fs");
39
+ const fixture = JSON.parse(fs.readFileSync(process.env.EXCEPTD_REGISTRY_FIXTURE, "utf8"));
40
+ const version = fixture["dist-tags"]?.latest || fixture.version;
41
+ const published = fixture.time?.[version] || fixture.time?.modified;
42
+ return { ok: true, version, published_at: published, source: "fixture" };
43
+ } catch (e) {
44
+ return { ok: false, error: `fixture: ${e.message}`, source: "offline" };
45
+ }
46
+ }
47
+
48
+ return new Promise((resolve) => {
49
+ const path = `/${encodeURIComponent(pkgName).replace("%40", "@").replace("%2F", "/")}`;
50
+ const req = https.get({
51
+ host: REGISTRY_HOST,
52
+ path,
53
+ headers: {
54
+ "Accept": "application/vnd.npm.install-v1+json, application/json",
55
+ "User-Agent": "exceptd/upstream-check"
56
+ },
57
+ timeout: timeoutMs,
58
+ }, (res) => {
59
+ if (res.statusCode !== 200) {
60
+ res.resume();
61
+ return resolve({ ok: false, error: `registry returned HTTP ${res.statusCode}`, source: "offline" });
62
+ }
63
+ const chunks = [];
64
+ res.on("data", (c) => chunks.push(c));
65
+ res.on("end", () => {
66
+ try {
67
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
68
+ const version = body["dist-tags"]?.latest;
69
+ const published = body.time?.[version] || body.time?.modified || null;
70
+ if (!version) {
71
+ return resolve({ ok: false, error: "registry response missing dist-tags.latest", source: "offline" });
72
+ }
73
+ resolve({ ok: true, version, published_at: published, source: "npm-registry" });
74
+ } catch (e) {
75
+ resolve({ ok: false, error: `parse: ${e.message}`, source: "offline" });
76
+ }
77
+ });
78
+ });
79
+ req.on("timeout", () => { req.destroy(new Error("timeout")); });
80
+ req.on("error", (e) => resolve({ ok: false, error: e.message, source: "offline" }));
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Semver compare (returns -1, 0, 1). Accepts canonical N.N.N strings only;
86
+ * pre-release tags are ignored (rare on this package, and operators behind
87
+ * a pre-release would explicitly opt in).
88
+ */
89
+ function semverCmp(a, b) {
90
+ const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
91
+ const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
92
+ for (let i = 0; i < 3; i++) {
93
+ const da = pa[i] || 0, db = pb[i] || 0;
94
+ if (da !== db) return da < db ? -1 : 1;
95
+ }
96
+ return 0;
97
+ }
98
+
99
+ /**
100
+ * Build the operator-facing freshness report. Pure function — takes the
101
+ * registry response and the local version/manifest, returns the report.
102
+ */
103
+ function buildFreshnessReport({ localVersion, registry, localManifest }) {
104
+ if (!registry || !registry.ok) {
105
+ return {
106
+ ok: false,
107
+ source: "offline",
108
+ error: registry?.error || "registry unreachable",
109
+ local_version: localVersion,
110
+ hint: "Network unreachable. Skipping upstream-check. This is a freshness signal only; the local catalog remains the source of truth.",
111
+ };
112
+ }
113
+ const cmp = semverCmp(localVersion, registry.version);
114
+ const daysBehind = registry.published_at
115
+ ? Math.max(0, Math.floor((Date.now() - new Date(registry.published_at).getTime()) / (24 * 3600 * 1000)))
116
+ : null;
117
+ // Manifest's last_threat_review (per-skill) — surface the most stale.
118
+ let oldestReview = null;
119
+ if (localManifest && Array.isArray(localManifest.skills)) {
120
+ for (const s of localManifest.skills) {
121
+ if (s.last_threat_review && (!oldestReview || s.last_threat_review < oldestReview)) {
122
+ oldestReview = s.last_threat_review;
123
+ }
124
+ }
125
+ }
126
+ return {
127
+ ok: true,
128
+ source: registry.source,
129
+ local_version: localVersion,
130
+ latest_version: registry.version,
131
+ latest_published_at: registry.published_at,
132
+ days_since_latest_publish: daysBehind,
133
+ behind: cmp < 0,
134
+ same: cmp === 0,
135
+ ahead: cmp > 0,
136
+ oldest_skill_last_threat_review: oldestReview,
137
+ hint: cmp < 0
138
+ ? `Local install is behind. Run \`npm update -g @blamejs/exceptd-skills\` to consume v${registry.version} (published ${registry.published_at}). Or \`exceptd refresh --network\` to pull just the catalog without changing the CLI/lib code.`
139
+ : cmp === 0
140
+ ? `Local install matches the latest published version.`
141
+ : `Local install is AHEAD of the published registry version (development build?).`
142
+ };
143
+ }
144
+
145
+ module.exports = { fetchLatestPublished, semverCmp, buildFreshnessReport };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "Auto-generated by scripts/refresh-manifest-snapshot.js — do not hand-edit. Public skill surface used by check-manifest-snapshot.js to detect breaking removals.",
3
- "_generated_at": "2026-05-13T01:02:03.236Z",
3
+ "_generated_at": "2026-05-13T02:18:38.639Z",
4
4
  "atlas_version": "5.1.0",
5
5
  "skill_count": 38,
6
6
  "skills": [