import { readFile, mkdir, writeFile, rm, readdir, stat } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts'; import { normalizeConfig } from '../../../server/config/schema.js'; import { collectOne, selectReadonlyOperations, validateInstanceOrigin } from './collector.ts'; const here = path.dirname(fileURLToPath(import.meta.url)); const pkg = path.resolve(here, '..'); const repo = path.resolve(pkg, '../..'); export function loadInstances(config: any) { if (!config || !Array.isArray(config.instances) || config.instances.length !== 2) throw new Error('config must contain exactly two instances'); for (const raw of config.instances) { if (!raw || typeof raw !== 'object' || typeof raw.url !== 'string') throw new Error('invalid collector instance configuration'); validateInstanceOrigin(raw.url); if (raw.password || raw.auth?.password || !['none', undefined].includes(raw.auth?.mode)) throw new Error('collector instances must be password-free'); } const normalized = normalizeConfig(config, { HOST: '127.0.0.1', PORT: '8788' }); if (normalized.instances.length !== 2) throw new Error('config must contain exactly two instances'); return normalized.instances.map((raw: any, i: number) => { if (raw.auth.mode !== 'none' || raw.auth.password) throw new Error(`instance-${i + 1} must be password-free`); return { origin: validateInstanceOrigin(raw.url), alias: `instance-${i + 1}` }; }); } async function mapLimit(xs: T[], limit: number, fn: (x: T) => Promise) { const out: R[] = []; let n = 0; async function worker() { while (n < xs.length) { const i = n++; out[i] = await fn(xs[i]); } } await Promise.all(Array.from({ length: Math.min(limit, xs.length) }, worker)); return out; } async function allJson(dir: string): Promise { const out: string[] = []; for (const e of await readdir(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) out.push(...(await allJson(p))); else if (e.name.endsWith('.json')) out.push(p); } return out; } function safeName(s: string) { return s.replace(/[^a-zA-Z0-9_-]/g, '-'); } export async function main(argv = process.argv.slice(2)) { if (argv.length !== 1 || !['--dry-run', '--capture'].includes(argv[0])) throw new Error('usage: collect-readonly-fixtures.ts --dry-run|--capture'); const config = JSON.parse(await readFile(path.join(repo, 'config.json'), 'utf8')); const instances = loadInstances(config); const { selected, denied } = selectReadonlyOperations(upstream58e2204Operations); if (argv[0] === '--dry-run') { for (const op of selected) console.log(`registry ${op.operationId}`); console.log(`selected=${selected.length} denied=${denied.length}`); return; } const shapePath = path.join(pkg, 'src/response-shapes-58e2204.json'); // Separately reviewed: capture attests this baseline but never creates it. const shapeBytes = await readFile(shapePath); const root = path.join(pkg, 'src/simadmin'); await rm(root, { recursive: true, force: true }); await rm(path.join(pkg, 'src/manifest.json'), { force: true }); await mkdir(root, { recursive: true }); const jobs = instances.flatMap((instance) => selected.map((op) => ({ instance, op }))); const fixtures = await mapLimit(jobs, 2, async ({ instance, op }) => { const f = await collectOne(instance, op); console.log(`${instance.alias} ${op.operationId} ${f.statusCategory}`); return { f, domain: op.upstreamDomain }; }); for (const { f, domain } of fixtures) { const dir = path.join(root, safeName(domain)); await mkdir(dir, { recursive: true }); await writeFile( path.join(dir, `${f.sourceInstanceAlias}--${f.operationId}.json`), JSON.stringify(f, null, 2) + '\n', { flag: 'wx' }, ); } const files = []; const coverage: Record = {}; for (const full of await allJson(root)) { const text = await readFile(full); const rel = path.relative(pkg, full); const domain = path.basename(path.dirname(full)); coverage[domain] = (coverage[domain] || 0) + 1; files.push({ path: rel, size: (await stat(full)).size, sha256: createHash('sha256').update(text).digest('hex'), }); } files.sort((a, b) => a.path.localeCompare(b.path)); const shapeBaseline = { path: 'src/response-shapes-58e2204.json', size: shapeBytes.length, sha256: createHash('sha256').update(shapeBytes).digest('hex'), }; await writeFile( path.join(pkg, 'src/manifest.json'), JSON.stringify( { schemaVersion: 1, upstreamBaseline: '58e2204', realFixtureCount: files.length, syntheticFixtureCount: 0, domainCoverage: Object.fromEntries(Object.entries(coverage).sort()), shapeBaseline, files, }, null, 2, ) + '\n', ); } if (process.argv[1] === fileURLToPath(import.meta.url)) main().catch(() => { console.error('collector failed'); process.exitCode = 1; });