Move the Hub channel table, organization tags and device capability definitions into the workspace packages so the control plane and the console validate the same contract, and refresh the frozen upstream evidence for the new paths.
129 lines
5.1 KiB
TypeScript
129 lines
5.1 KiB
TypeScript
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<T, R>(xs: T[], limit: number, fn: (x: T) => Promise<R>) {
|
|
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<string[]> {
|
|
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<string, number> = {};
|
|
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;
|
|
});
|