cognitive-modules-cli 2.2.7 → 2.2.8

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,723 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { createGzip } from 'node:zlib';
6
+ import { finished } from 'node:stream/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import yaml from 'js-yaml';
9
+ import { extractTarGzFile } from './tar.js';
10
+ function isHttpUrl(maybeUrl) {
11
+ try {
12
+ const u = new URL(maybeUrl);
13
+ return u.protocol === 'http:' || u.protocol === 'https:';
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ function tarballFileName(tarballRef) {
20
+ if (isHttpUrl(tarballRef)) {
21
+ // Ignore query/hash and use the URL pathname for a stable filename.
22
+ const u = new URL(tarballRef);
23
+ return path.basename(u.pathname);
24
+ }
25
+ return path.basename(tarballRef);
26
+ }
27
+ async function fetchTextWithLimit(url, maxBytes, timeoutMs) {
28
+ const controller = new AbortController();
29
+ const t = setTimeout(() => controller.abort(), timeoutMs);
30
+ try {
31
+ const res = await fetch(url, { signal: controller.signal });
32
+ if (!res.ok) {
33
+ throw new Error(`Failed to fetch: ${res.status} ${res.statusText}`);
34
+ }
35
+ const contentLength = res.headers?.get('content-length');
36
+ if (contentLength) {
37
+ const n = Number(contentLength);
38
+ if (!Number.isNaN(n) && n > maxBytes) {
39
+ throw new Error(`Remote payload too large: ${n} bytes (max ${maxBytes})`);
40
+ }
41
+ }
42
+ if (res.body && typeof res.body.getReader === 'function') {
43
+ const reader = res.body.getReader();
44
+ const decoder = new TextDecoder('utf-8');
45
+ let total = 0;
46
+ let buf = '';
47
+ try {
48
+ while (true) {
49
+ const { done, value } = await reader.read();
50
+ if (done)
51
+ break;
52
+ if (value) {
53
+ total += value.byteLength;
54
+ if (total > maxBytes) {
55
+ controller.abort();
56
+ throw new Error(`Remote payload too large: ${total} bytes (max ${maxBytes})`);
57
+ }
58
+ buf += decoder.decode(value, { stream: true });
59
+ }
60
+ }
61
+ buf += decoder.decode();
62
+ }
63
+ finally {
64
+ reader.releaseLock();
65
+ }
66
+ return buf;
67
+ }
68
+ const text = await res.text();
69
+ const byteLen = Buffer.byteLength(text, 'utf-8');
70
+ if (byteLen > maxBytes) {
71
+ throw new Error(`Remote payload too large: ${byteLen} bytes (max ${maxBytes})`);
72
+ }
73
+ return text;
74
+ }
75
+ catch (e) {
76
+ if (e instanceof Error && e.name === 'AbortError') {
77
+ throw new Error(`Fetch timed out after ${timeoutMs}ms`);
78
+ }
79
+ throw e;
80
+ }
81
+ finally {
82
+ clearTimeout(t);
83
+ }
84
+ }
85
+ async function downloadToFileWithSha256(url, outPath, maxBytes, timeoutMs) {
86
+ const controller = new AbortController();
87
+ const t = setTimeout(() => controller.abort(), timeoutMs);
88
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
89
+ try {
90
+ const res = await fetch(url, { signal: controller.signal });
91
+ if (!res.ok) {
92
+ throw new Error(`Failed to fetch tarball: ${res.status} ${res.statusText}`);
93
+ }
94
+ const contentLength = res.headers?.get('content-length');
95
+ if (contentLength) {
96
+ const n = Number(contentLength);
97
+ if (!Number.isNaN(n) && n > maxBytes) {
98
+ throw new Error(`Tarball too large: ${n} bytes (max ${maxBytes})`);
99
+ }
100
+ }
101
+ if (!res.body) {
102
+ throw new Error('Tarball fetch returned no body');
103
+ }
104
+ const h = createHash('sha256');
105
+ const ws = createWriteStream(outPath, { flags: 'w', mode: 0o644 });
106
+ let total = 0;
107
+ const writeChunk = async (chunk) => {
108
+ if (!chunk.length)
109
+ return;
110
+ const ok = ws.write(chunk);
111
+ if (ok)
112
+ return;
113
+ await new Promise((resolveDrain, rejectDrain) => {
114
+ const onDrain = () => {
115
+ cleanup();
116
+ resolveDrain();
117
+ };
118
+ const onError = (err) => {
119
+ cleanup();
120
+ rejectDrain(err);
121
+ };
122
+ const cleanup = () => {
123
+ ws.off('drain', onDrain);
124
+ ws.off('error', onError);
125
+ };
126
+ ws.once('drain', onDrain);
127
+ ws.once('error', onError);
128
+ });
129
+ };
130
+ try {
131
+ // Prefer the Web ReadableStream reader API when available.
132
+ const body = res.body;
133
+ if (body && typeof body.getReader === 'function') {
134
+ const reader = body.getReader();
135
+ try {
136
+ while (true) {
137
+ const { done, value } = await reader.read();
138
+ if (done)
139
+ break;
140
+ if (!value)
141
+ continue;
142
+ const buf = Buffer.from(value);
143
+ total += buf.length;
144
+ if (total > maxBytes) {
145
+ controller.abort();
146
+ throw new Error(`Tarball too large: ${total} bytes (max ${maxBytes})`);
147
+ }
148
+ h.update(buf);
149
+ await writeChunk(buf);
150
+ }
151
+ }
152
+ finally {
153
+ reader.releaseLock?.();
154
+ }
155
+ }
156
+ else {
157
+ // Fallback: async iteration (Node fetch supports this for ReadableStream in newer runtimes).
158
+ const stream = res.body;
159
+ for await (const chunk of stream) {
160
+ const buf = Buffer.from(chunk);
161
+ total += buf.length;
162
+ if (total > maxBytes) {
163
+ controller.abort();
164
+ throw new Error(`Tarball too large: ${total} bytes (max ${maxBytes})`);
165
+ }
166
+ h.update(buf);
167
+ await writeChunk(buf);
168
+ }
169
+ }
170
+ ws.end();
171
+ await finished(ws);
172
+ }
173
+ catch (e) {
174
+ try {
175
+ ws.destroy();
176
+ }
177
+ catch {
178
+ // ignore
179
+ }
180
+ throw e;
181
+ }
182
+ return { sha256: h.digest('hex'), sizeBytes: total };
183
+ }
184
+ catch (e) {
185
+ if (e instanceof Error && e.name === 'AbortError') {
186
+ throw new Error(`Tarball fetch timed out after ${timeoutMs}ms`);
187
+ }
188
+ throw e;
189
+ }
190
+ finally {
191
+ clearTimeout(t);
192
+ }
193
+ }
194
+ function nowIsoUtc() {
195
+ const d = new Date();
196
+ const iso = d.toISOString();
197
+ // keep seconds precision like the python tool (best effort)
198
+ return iso.replace(/\.\d{3}Z$/, 'Z');
199
+ }
200
+ function jsonStringifyAscii(obj) {
201
+ const s = JSON.stringify(obj, null, 2);
202
+ // Escape non-ASCII to keep registry ASCII-only for portability and stable diffs.
203
+ return s.replace(/[^\x00-\x7F]/g, (ch) => {
204
+ const cp = ch.codePointAt(0) ?? 0;
205
+ if (cp <= 0xffff) {
206
+ return `\\u${cp.toString(16).padStart(4, '0')}`;
207
+ }
208
+ // surrogate pair
209
+ const hi = Math.floor((cp - 0x10000) / 0x400) + 0xd800;
210
+ const lo = ((cp - 0x10000) % 0x400) + 0xdc00;
211
+ return `\\u${hi.toString(16).padStart(4, '0')}\\u${lo.toString(16).padStart(4, '0')}`;
212
+ }) + '\n';
213
+ }
214
+ async function sha256File(filePath) {
215
+ const h = createHash('sha256');
216
+ await new Promise((resolve, reject) => {
217
+ const rs = createReadStream(filePath);
218
+ rs.on('data', (chunk) => h.update(chunk));
219
+ rs.on('error', reject);
220
+ rs.on('end', resolve);
221
+ });
222
+ return h.digest('hex');
223
+ }
224
+ async function listFiles(moduleDir) {
225
+ const out = [];
226
+ async function walk(relDir) {
227
+ const abs = path.join(moduleDir, relDir);
228
+ const entries = await fs.readdir(abs, { withFileTypes: true });
229
+ for (const ent of entries) {
230
+ if (ent.name === '.DS_Store')
231
+ continue;
232
+ const rel = relDir ? path.posix.join(relDir.replace(/\\/g, '/'), ent.name) : ent.name;
233
+ const absPath = path.join(moduleDir, rel);
234
+ const st = await fs.lstat(absPath);
235
+ if (st.isSymbolicLink()) {
236
+ throw new Error(`Refusing to package symlink: ${absPath}`);
237
+ }
238
+ if (ent.isDirectory()) {
239
+ await walk(rel);
240
+ }
241
+ else if (ent.isFile()) {
242
+ out.push(rel);
243
+ }
244
+ }
245
+ }
246
+ await walk('');
247
+ out.sort((a, b) => a.localeCompare(b));
248
+ return out;
249
+ }
250
+ function writeOctal(buf, offset, length, value) {
251
+ const oct = value.toString(8);
252
+ const str = oct.padStart(length - 1, '0') + '\0';
253
+ buf.write(str, offset, length, 'ascii');
254
+ }
255
+ function pad512(n) {
256
+ return Math.ceil(n / 512) * 512;
257
+ }
258
+ function tarChecksum(header) {
259
+ for (let i = 148; i < 156; i++)
260
+ header[i] = 0x20; // spaces
261
+ let sum = 0;
262
+ for (let i = 0; i < 512; i++)
263
+ sum += header[i];
264
+ return sum;
265
+ }
266
+ function makeTarHeader(opts) {
267
+ const h = Buffer.alloc(512, 0);
268
+ h.write(opts.name, 0, 100, 'utf-8');
269
+ writeOctal(h, 100, 8, opts.mode ?? 0o644);
270
+ writeOctal(h, 108, 8, 0);
271
+ writeOctal(h, 116, 8, 0);
272
+ writeOctal(h, 124, 12, opts.size);
273
+ writeOctal(h, 136, 12, 0); // mtime=0 for deterministic builds
274
+ h.write(opts.typeflag, 156, 1, 'ascii');
275
+ h.write('ustar\0', 257, 6, 'ascii');
276
+ h.write('00', 263, 2, 'ascii');
277
+ h.write('root', 265, 32, 'ascii');
278
+ h.write('root', 297, 32, 'ascii');
279
+ const sum = tarChecksum(h);
280
+ const sumStr = sum.toString(8).padStart(6, '0');
281
+ h.write(sumStr, 148, 6, 'ascii');
282
+ h[154] = 0; // NUL
283
+ h[155] = 0x20; // space
284
+ return h;
285
+ }
286
+ function paxRecord(key, value) {
287
+ // PAX: "<len> <key>=<value>\n" where len counts the entire record.
288
+ const base = `${key}=${value}\n`;
289
+ // Compute len iteratively because len digits affect total length.
290
+ let len = base.length + String(base.length).length + 1; // rough
291
+ while (true) {
292
+ const rec = `${len} ${base}`;
293
+ if (rec.length === len)
294
+ return rec;
295
+ len = rec.length;
296
+ }
297
+ }
298
+ async function writeToStream(ws, chunk) {
299
+ if (!chunk.length)
300
+ return;
301
+ const ok = ws.write(chunk);
302
+ if (ok)
303
+ return;
304
+ await new Promise((resolve) => ws.once('drain', resolve));
305
+ }
306
+ async function writeFileIntoStream(ws, filePath) {
307
+ const st = await fs.stat(filePath);
308
+ await new Promise((resolve, reject) => {
309
+ const rs = createReadStream(filePath);
310
+ rs.on('error', reject);
311
+ rs.on('end', resolve);
312
+ rs.on('data', async (chunk) => {
313
+ rs.pause();
314
+ try {
315
+ await writeToStream(ws, Buffer.from(chunk));
316
+ rs.resume();
317
+ }
318
+ catch (e) {
319
+ reject(e);
320
+ }
321
+ });
322
+ });
323
+ return st.size;
324
+ }
325
+ async function readModuleMeta(moduleYamlPath) {
326
+ const content = await fs.readFile(moduleYamlPath, 'utf-8');
327
+ const loaded = yaml.load(content);
328
+ const manifest = loaded && typeof loaded === 'object' ? loaded : {};
329
+ const name = String(manifest.name ?? '').trim();
330
+ const version = String(manifest.version ?? '').trim();
331
+ const tier = String(manifest.tier ?? '').trim();
332
+ const responsibility = String(manifest.responsibility ?? '').trim();
333
+ const missing = [];
334
+ if (!name)
335
+ missing.push('name');
336
+ if (!version)
337
+ missing.push('version');
338
+ if (!tier)
339
+ missing.push('tier');
340
+ if (!responsibility)
341
+ missing.push('responsibility');
342
+ if (missing.length) {
343
+ throw new Error(`module.yaml missing required keys [${missing.join(', ')}]: ${moduleYamlPath}`);
344
+ }
345
+ return { name, version, tier, responsibility };
346
+ }
347
+ async function buildTarball(moduleDir, outPath, moduleName) {
348
+ const relFiles = await listFiles(moduleDir);
349
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
350
+ const rawOut = createWriteStream(outPath, { flags: 'w', mode: 0o644 });
351
+ // Node supports `mtime` for deterministic gzip headers, but TS typings may lag.
352
+ const gz = createGzip({ mtime: 0 });
353
+ gz.pipe(rawOut);
354
+ let paxIndex = 0;
355
+ try {
356
+ for (const rel of relFiles) {
357
+ const abs = path.join(moduleDir, rel);
358
+ const fullPath = `${moduleName}/${rel}`.replace(/\\/g, '/');
359
+ // Add PAX header when path is too long for ustar name/prefix.
360
+ // This keeps extraction compatible with our Node extractor (supports PAX).
361
+ const needsPax = Buffer.byteLength(fullPath, 'utf-8') > 255;
362
+ const canUstar = Buffer.byteLength(fullPath, 'utf-8') <= 255;
363
+ if (needsPax || !canUstar) {
364
+ const payload = Buffer.from(paxRecord('path', fullPath), 'utf-8');
365
+ const paxName = `pax-${String(paxIndex++).padStart(6, '0')}`;
366
+ const paxHeader = makeTarHeader({ name: paxName, size: payload.length, typeflag: 'x', mode: 0o644 });
367
+ await writeToStream(gz, paxHeader);
368
+ await writeToStream(gz, payload);
369
+ const pad = pad512(payload.length) - payload.length;
370
+ if (pad)
371
+ await writeToStream(gz, Buffer.alloc(pad, 0));
372
+ const st = await fs.stat(abs);
373
+ const placeholder = `${moduleName}/${path.basename(rel).slice(0, 80)}`.replace(/\\/g, '/');
374
+ const fileHeader = makeTarHeader({ name: placeholder, size: st.size, typeflag: '0', mode: 0o644 });
375
+ await writeToStream(gz, fileHeader);
376
+ await writeFileIntoStream(gz, abs);
377
+ const padFile = pad512(st.size) - st.size;
378
+ if (padFile)
379
+ await writeToStream(gz, Buffer.alloc(padFile, 0));
380
+ continue;
381
+ }
382
+ // Try to fit into ustar prefix/name when possible, else fall back to PAX.
383
+ const nameBytes = Buffer.byteLength(fullPath, 'utf-8');
384
+ if (nameBytes <= 100) {
385
+ const st = await fs.stat(abs);
386
+ const fileHeader = makeTarHeader({ name: fullPath, size: st.size, typeflag: '0', mode: 0o644 });
387
+ await writeToStream(gz, fileHeader);
388
+ await writeFileIntoStream(gz, abs);
389
+ const padFile = pad512(st.size) - st.size;
390
+ if (padFile)
391
+ await writeToStream(gz, Buffer.alloc(padFile, 0));
392
+ continue;
393
+ }
394
+ // Use PAX for paths that don't fit the 100-byte name field.
395
+ const payload = Buffer.from(paxRecord('path', fullPath), 'utf-8');
396
+ const paxName = `pax-${String(paxIndex++).padStart(6, '0')}`;
397
+ const paxHeader = makeTarHeader({ name: paxName, size: payload.length, typeflag: 'x', mode: 0o644 });
398
+ await writeToStream(gz, paxHeader);
399
+ await writeToStream(gz, payload);
400
+ const pad = pad512(payload.length) - payload.length;
401
+ if (pad)
402
+ await writeToStream(gz, Buffer.alloc(pad, 0));
403
+ const st = await fs.stat(abs);
404
+ const placeholder = `${moduleName}/${path.basename(rel).slice(0, 80)}`.replace(/\\/g, '/');
405
+ const fileHeader = makeTarHeader({ name: placeholder, size: st.size, typeflag: '0', mode: 0o644 });
406
+ await writeToStream(gz, fileHeader);
407
+ await writeFileIntoStream(gz, abs);
408
+ const padFile = pad512(st.size) - st.size;
409
+ if (padFile)
410
+ await writeToStream(gz, Buffer.alloc(padFile, 0));
411
+ }
412
+ // EOF blocks.
413
+ await writeToStream(gz, Buffer.alloc(1024, 0));
414
+ gz.end();
415
+ await finished(rawOut);
416
+ }
417
+ finally {
418
+ try {
419
+ gz.destroy();
420
+ }
421
+ catch {
422
+ // ignore
423
+ }
424
+ try {
425
+ rawOut.destroy();
426
+ }
427
+ catch {
428
+ // ignore
429
+ }
430
+ }
431
+ }
432
+ async function loadV1Registry(v1RegistryPath) {
433
+ const raw = await fs.readFile(v1RegistryPath, 'utf-8');
434
+ const parsed = JSON.parse(raw);
435
+ return parsed;
436
+ }
437
+ function deriveTarballBaseUrl(opts) {
438
+ if (opts.tarballBaseUrl)
439
+ return String(opts.tarballBaseUrl);
440
+ const tag = (opts.tag ?? '').trim();
441
+ if (!tag)
442
+ return null;
443
+ const repo = String(opts.repository).replace(/\/+$/, '');
444
+ return `${repo}/releases/download/${tag}`;
445
+ }
446
+ export async function buildRegistryAssets(opts) {
447
+ const v1 = await loadV1Registry(opts.v1RegistryPath);
448
+ const v1Modules = (v1.modules ?? {});
449
+ const only = new Set((opts.only ?? []).map((s) => s.trim()).filter(Boolean));
450
+ const updated = (opts.timestamp ?? '').trim() || nowIsoUtc();
451
+ const tarballBaseUrl = deriveTarballBaseUrl(opts);
452
+ const modulesDir = path.resolve(opts.modulesDir);
453
+ const outDir = path.resolve(opts.outDir);
454
+ const registryOut = path.resolve(opts.registryOut);
455
+ const moduleYamlPaths = [];
456
+ const entries = await fs.readdir(modulesDir, { withFileTypes: true });
457
+ for (const ent of entries) {
458
+ if (!ent.isDirectory())
459
+ continue;
460
+ const yamlPath = path.join(modulesDir, ent.name, 'module.yaml');
461
+ try {
462
+ await fs.access(yamlPath);
463
+ moduleYamlPaths.push(yamlPath);
464
+ }
465
+ catch {
466
+ // skip
467
+ }
468
+ }
469
+ moduleYamlPaths.sort((a, b) => a.localeCompare(b));
470
+ const built = [];
471
+ const registryModules = {};
472
+ for (const moduleYamlPath of moduleYamlPaths) {
473
+ const moduleDir = path.dirname(moduleYamlPath);
474
+ const meta = await readModuleMeta(moduleYamlPath);
475
+ if (only.size && !only.has(meta.name))
476
+ continue;
477
+ const v1Info = v1Modules[meta.name] ?? {};
478
+ const description = String(v1Info.description ?? meta.responsibility);
479
+ const author = String(v1Info.author ?? 'unknown');
480
+ const keywords = Array.isArray(v1Info.tags) ? v1Info.tags : [];
481
+ const tarName = `${meta.name}-${meta.version}.tar.gz`;
482
+ const tarPath = path.join(outDir, tarName);
483
+ await buildTarball(moduleDir, tarPath, meta.name);
484
+ const digest = await sha256File(tarPath);
485
+ const sizeBytes = (await fs.stat(tarPath)).size;
486
+ const tarballUrl = tarballBaseUrl ? `${tarballBaseUrl}/${tarName}` : tarName;
487
+ registryModules[meta.name] = {
488
+ $schema: 'https://cognitive-modules.dev/schema/registry-entry-v1.json',
489
+ identity: {
490
+ name: meta.name,
491
+ namespace: opts.namespace,
492
+ version: meta.version,
493
+ spec_version: '2.2',
494
+ },
495
+ metadata: {
496
+ description,
497
+ description_zh: description,
498
+ author,
499
+ tier: meta.tier,
500
+ license: opts.license,
501
+ repository: opts.repository,
502
+ homepage: opts.homepage,
503
+ keywords,
504
+ },
505
+ dependencies: {
506
+ runtime_min: opts.runtimeMin,
507
+ modules: [],
508
+ },
509
+ distribution: {
510
+ tarball: tarballUrl,
511
+ checksum: `sha256:${digest}`,
512
+ size_bytes: sizeBytes,
513
+ files: await listFiles(moduleDir),
514
+ },
515
+ timestamps: {
516
+ created_at: updated,
517
+ updated_at: updated,
518
+ deprecated_at: null,
519
+ },
520
+ };
521
+ built.push({ name: meta.name, version: meta.version, file: tarName, sha256: digest, size_bytes: sizeBytes });
522
+ }
523
+ const registry = {
524
+ $schema: 'https://cognitive-modules.dev/schema/registry-v2.json',
525
+ version: '2.0.0',
526
+ updated,
527
+ modules: registryModules,
528
+ categories: v1.categories ?? {},
529
+ featured: Object.keys(registryModules),
530
+ stats: {
531
+ total_modules: Object.keys(registryModules).length,
532
+ total_downloads: 0,
533
+ last_updated: updated,
534
+ },
535
+ };
536
+ await fs.mkdir(path.dirname(registryOut), { recursive: true });
537
+ await fs.writeFile(registryOut, jsonStringifyAscii(registry), 'utf-8');
538
+ return {
539
+ registryOut,
540
+ outDir,
541
+ updated,
542
+ modules: built,
543
+ };
544
+ }
545
+ function isValidModuleDir(dirPath) {
546
+ return fs
547
+ .access(path.join(dirPath, 'module.yaml'))
548
+ .then(() => true)
549
+ .catch(() => fs.access(path.join(dirPath, 'MODULE.md')).then(() => true).catch(() => fs.access(path.join(dirPath, 'module.md')).then(() => true).catch(() => false)));
550
+ }
551
+ export async function verifyRegistryAssets(opts) {
552
+ const maxIndexBytes = opts.maxIndexBytes ?? 2 * 1024 * 1024; // 2MB
553
+ const fetchTimeoutMs = opts.fetchTimeoutMs ?? 15_000; // 15s
554
+ const maxTarballBytes = opts.maxTarballBytes ?? 25 * 1024 * 1024; // 25MB
555
+ const wantRemote = Boolean(opts.remote) || isHttpUrl(opts.registryIndexPath);
556
+ let registryRaw;
557
+ if (wantRemote) {
558
+ if (!isHttpUrl(opts.registryIndexPath)) {
559
+ throw new Error(`--remote requires an http(s) registry index URL, got: ${opts.registryIndexPath}`);
560
+ }
561
+ registryRaw = await fetchTextWithLimit(opts.registryIndexPath, maxIndexBytes, fetchTimeoutMs);
562
+ }
563
+ else {
564
+ registryRaw = await fs.readFile(opts.registryIndexPath, 'utf-8');
565
+ }
566
+ const registry = JSON.parse(registryRaw);
567
+ const modules = (registry.modules ?? {});
568
+ const failures = [];
569
+ let checked = 0;
570
+ let passed = 0;
571
+ const tmpAssetsRoot = wantRemote && !opts.assetsDir ? await fs.mkdtemp(path.join(tmpdir(), 'cog-reg-assets-')) : null;
572
+ const assetsDir = opts.assetsDir ?? tmpAssetsRoot ?? '';
573
+ if (!wantRemote && !assetsDir) {
574
+ throw new Error('Local verify requires --assets-dir (directory containing tarballs)');
575
+ }
576
+ const entries = Object.entries(modules);
577
+ const desiredConcurrency = opts.concurrency ?? (wantRemote ? 4 : 1);
578
+ const concurrency = Math.max(1, Math.min(8, Math.floor(desiredConcurrency)));
579
+ const localTarPath = async (moduleName, tarballRef) => {
580
+ const fileName = tarballFileName(tarballRef);
581
+ // Avoid collisions when we download into a temp dir (remote verify).
582
+ if (wantRemote && tmpAssetsRoot && !opts.assetsDir) {
583
+ const p = path.join(assetsDir, moduleName, fileName);
584
+ await fs.mkdir(path.dirname(p), { recursive: true });
585
+ return p;
586
+ }
587
+ return path.join(assetsDir, fileName);
588
+ };
589
+ const verifyOne = async (moduleName, entry) => {
590
+ checked += 1;
591
+ let tarPathForCleanup = null;
592
+ try {
593
+ const dist = entry.distribution ?? {};
594
+ const tarballUrl = String(dist.tarball ?? '');
595
+ const checksum = String(dist.checksum ?? '');
596
+ const sizeBytes = Number(dist.size_bytes ?? NaN);
597
+ const expectedFiles = Array.isArray(dist.files) ? dist.files.map(String) : [];
598
+ if (!tarballUrl)
599
+ throw new Error('Missing distribution.tarball');
600
+ const tarPath = await localTarPath(moduleName, tarballUrl);
601
+ tarPathForCleanup = tarPath;
602
+ if (wantRemote) {
603
+ if (!isHttpUrl(tarballUrl)) {
604
+ throw new Error(`Remote verify requires http(s) tarball URL, got: ${tarballUrl}`);
605
+ }
606
+ const downloaded = await downloadToFileWithSha256(tarballUrl, tarPath, maxTarballBytes, fetchTimeoutMs);
607
+ if (Number.isFinite(sizeBytes) && downloaded.sizeBytes !== sizeBytes) {
608
+ throw new Error(`Size mismatch: expected ${sizeBytes}, got ${downloaded.sizeBytes}`);
609
+ }
610
+ const m = checksum.match(/^sha256:([a-f0-9]{64})$/);
611
+ if (!m)
612
+ throw new Error(`Unsupported checksum format: ${checksum}`);
613
+ const expectedSha = m[1];
614
+ if (downloaded.sha256 !== expectedSha) {
615
+ throw new Error(`Checksum mismatch: expected ${expectedSha}, got ${downloaded.sha256}`);
616
+ }
617
+ }
618
+ const st = await fs.stat(tarPath);
619
+ if (!Number.isFinite(sizeBytes))
620
+ throw new Error('Missing distribution.size_bytes');
621
+ if (st.size !== sizeBytes)
622
+ throw new Error(`Size mismatch: expected ${sizeBytes}, got ${st.size}`);
623
+ const m = checksum.match(/^sha256:([a-f0-9]{64})$/);
624
+ if (!m)
625
+ throw new Error(`Unsupported checksum format: ${checksum}`);
626
+ const expectedSha = m[1];
627
+ const actualSha = await sha256File(tarPath);
628
+ if (actualSha !== expectedSha)
629
+ throw new Error(`Checksum mismatch: expected ${expectedSha}, got ${actualSha}`);
630
+ // Extract and validate contents (layout + file list).
631
+ const tmp = await fs.mkdtemp(path.join(tmpdir(), 'cog-reg-verify-'));
632
+ try {
633
+ const extractedRoot = path.join(tmp, 'pkg');
634
+ await fs.mkdir(extractedRoot, { recursive: true });
635
+ await extractTarGzFile(tarPath, extractedRoot, {
636
+ maxFiles: 5_000,
637
+ maxTotalBytes: 50 * 1024 * 1024,
638
+ maxSingleFileBytes: 20 * 1024 * 1024,
639
+ maxTarBytes: 100 * 1024 * 1024,
640
+ });
641
+ const rootNames = (await fs.readdir(extractedRoot)).filter((e) => e !== '__MACOSX' && e !== '.DS_Store');
642
+ const rootPaths = rootNames.map((e) => path.join(extractedRoot, e));
643
+ const stats = await Promise.all(rootPaths.map(async (p) => ({ p, st: await fs.stat(p) })));
644
+ const rootDirs = stats.filter((x) => x.st.isDirectory()).map((x) => x.p);
645
+ const rootFiles = stats.filter((x) => !x.st.isDirectory()).map((x) => x.p);
646
+ if (rootDirs.length !== 1 || rootFiles.length > 0) {
647
+ throw new Error('Tarball must contain exactly one root directory and no other top-level entries');
648
+ }
649
+ const moduleDir = rootDirs[0];
650
+ if (!(await isValidModuleDir(moduleDir))) {
651
+ throw new Error('Root directory is not a valid module');
652
+ }
653
+ if (expectedFiles.length > 0) {
654
+ const actualFiles = await listFiles(moduleDir);
655
+ const exp = expectedFiles.slice().sort();
656
+ const act = actualFiles.slice().sort();
657
+ if (exp.length !== act.length) {
658
+ throw new Error(`File list mismatch: expected ${exp.length} files, got ${act.length}`);
659
+ }
660
+ for (let i = 0; i < exp.length; i++) {
661
+ if (exp[i] !== act[i])
662
+ throw new Error(`File list mismatch at ${i}: expected ${exp[i]}, got ${act[i]}`);
663
+ }
664
+ }
665
+ // Basic identity check: module.yaml version matches registry identity.version
666
+ try {
667
+ const y = await readModuleMeta(path.join(moduleDir, 'module.yaml'));
668
+ const identityVersion = String(entry.identity?.version ?? '').trim();
669
+ if (identityVersion && y.version !== identityVersion) {
670
+ throw new Error(`module.yaml version mismatch: registry=${identityVersion}, module.yaml=${y.version}`);
671
+ }
672
+ const identityName = String(entry.identity?.name ?? '').trim();
673
+ if (identityName && y.name !== identityName) {
674
+ throw new Error(`module.yaml name mismatch: registry=${identityName}, module.yaml=${y.name}`);
675
+ }
676
+ }
677
+ catch {
678
+ // ignore if module.yaml is missing or malformed; module validity already checked
679
+ }
680
+ }
681
+ finally {
682
+ await fs.rm(tmp, { recursive: true, force: true });
683
+ }
684
+ passed += 1;
685
+ }
686
+ catch (e) {
687
+ failures.push({ module: moduleName, reason: e instanceof Error ? e.message : String(e) });
688
+ }
689
+ finally {
690
+ // If we downloaded tarballs into a temp dir, keep disk usage bounded.
691
+ if (wantRemote && tmpAssetsRoot && tarPathForCleanup) {
692
+ try {
693
+ await fs.rm(tarPathForCleanup, { force: true });
694
+ }
695
+ catch {
696
+ // ignore
697
+ }
698
+ }
699
+ }
700
+ };
701
+ let cursor = 0;
702
+ const workers = Array.from({ length: concurrency }, async () => {
703
+ while (true) {
704
+ const i = cursor;
705
+ cursor += 1;
706
+ if (i >= entries.length)
707
+ break;
708
+ const [name, entry] = entries[i];
709
+ await verifyOne(name, entry);
710
+ }
711
+ });
712
+ await Promise.all(workers);
713
+ if (tmpAssetsRoot) {
714
+ await fs.rm(tmpAssetsRoot, { recursive: true, force: true });
715
+ }
716
+ return {
717
+ ok: failures.length === 0,
718
+ checked,
719
+ passed,
720
+ failed: failures.length,
721
+ failures,
722
+ };
723
+ }