@blamejs/exceptd-skills 0.11.12 → 0.11.14
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/CHANGELOG.md +57 -0
- package/bin/exceptd.js +231 -41
- package/data/_indexes/_meta.json +2 -2
- package/keys/public.pem +1 -1
- package/lib/refresh-external.js +47 -12
- package/lib/refresh-network.js +376 -0
- package/lib/upstream-check-cli.js +66 -0
- package/lib/upstream-check.js +145 -0
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -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 };
|
package/manifest-snapshot.json
CHANGED
|
@@ -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-
|
|
3
|
+
"_generated_at": "2026-05-13T01:22:11.901Z",
|
|
4
4
|
"atlas_version": "5.1.0",
|
|
5
5
|
"skill_count": 38,
|
|
6
6
|
"skills": [
|