#!/usr/bin/env node /** * List holepunchto/bare-* repos, resolve npm latest via registry API, * merge scripts/bare-catalog-overrides.json, write docs/bare-holepunch-catalog.json * * node scripts/gen-bare-holepunch-catalog.mjs * HOLEPUNCH_MIRROR=/path/to/repos node scripts/gen-bare-holepunch-catalog.mjs * node scripts/gen-bare-holepunch-catalog.mjs --check */ import { readFile, writeFile, readdir, stat, mkdir } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import process from 'node:process' const __dirname = dirname(fileURLToPath(import.meta.url)) const root = join(__dirname, '..') const outPath = join(root, 'docs', 'bare-holepunch-catalog.json') const overridesPath = join(__dirname, 'bare-catalog-overrides.json') export function manifestCtxKeyForPackage(name) { const legacy = { b4a: 'b4a', 'compact-encoding': 'compactEncoding', 'safety-catch': 'safetyCatch', 'hypercore-id-encoding': 'hypercoreIdEncoding', protomux: 'protomux', 'bare-url': 'bareUrl', 'bare-path': 'barePath', 'bare-encoding': 'bareEncoding', 'bare-events': 'bareEvents', 'bare-fetch': 'fetch', 'bare-readline': 'bareReadline', 'bare-crypto': 'bareCrypto' } if (legacy[name]) return legacy[name] if (name.startsWith('bare-')) { const parts = name.slice('bare-'.length).split('-') const pascal = parts .map((p) => (p.length ? p[0].toUpperCase() + p.slice(1) : '')) .join('') return 'bare' + pascal } return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) } async function loadOverrides() { const raw = await readFile(overridesPath, 'utf8') return JSON.parse(raw) } async function fetchGithubBareRepos() { const all = [] let url = 'https://api.github.com/orgs/holepunchto/repos?per_page=100' for (let i = 0; i < 15 && url; i++) { const r = await fetch(url, { headers: { 'User-Agent': 'bare-operating-system-catalog' } }) if (!r.ok) throw new Error(`GitHub API ${r.status}: ${await r.text()}`) const page = await r.json() if (!Array.isArray(page)) throw new Error('GitHub: expected array') all.push(...page) const link = r.headers.get('link') url = null if (link) { const m = link.match(/<([^>]+)>;\s*rel="next"/) if (m) url = m[1] } } return all.filter((x) => x.name?.startsWith('bare-')) } async function fetchMirrorBareRepos(mirrorRoot) { const names = [] for (const ent of await readdir(mirrorRoot, { withFileTypes: true })) { if (ent.isDirectory() && ent.name.startsWith('bare-')) names.push(ent.name) } return names.sort().map((name) => ({ name, html_url: `https://github.com/holepunchto/${name}`, description: '' })) } async function npmLatestVersion(packageName) { const r = await fetch( `https://registry.npmjs.org/${encodeURIComponent(packageName)}`, { headers: { Accept: 'application/json' } } ) if (r.status === 404) return null if (!r.ok) throw new Error(`npm ${packageName}: ${r.status}`) const j = await r.json() const v = j['dist-tags']?.latest return typeof v === 'string' ? v : null } async function batchNpmVersions(names, concurrency = 16) { const m = new Map() let i = 0 async function worker() { while (i < names.length) { const idx = i++ const n = names[idx] try { m.set(n, await npmLatestVersion(n)) } catch { m.set(n, null) } } } await Promise.all(Array.from({ length: concurrency }, () => worker())) return m } async function main() { const check = process.argv.includes('--check') const mirror = process.env.HOLEPUNCH_MIRROR const overrides = await loadOverrides() const unpublished = new Set(overrides.unpublishedRepos || []) const excluded = new Map() for (const e of overrides.excludedFromBooter || []) { if (typeof e === 'string') excluded.set(e, 'excluded_from_booter') else if (e && typeof e.repo === 'string') excluded.set(e.repo, e.reason || 'excluded_from_booter') } let ghRepos if (mirror) { const st = await stat(mirror).catch(() => null) if (!st?.isDirectory()) { throw new Error(`HOLEPUNCH_MIRROR is not a directory: ${mirror}`) } ghRepos = await fetchMirrorBareRepos(mirror) } else { ghRepos = await fetchGithubBareRepos() } ghRepos.sort((a, b) => a.name.localeCompare(b.name)) const names = ghRepos.map((r) => r.name) const npmMap = await batchNpmVersions( names.filter((n) => !unpublished.has(n)), 16 ) const entries = [] for (const r of ghRepos) { const name = r.name const npmVersion = unpublished.has(name) ? null : npmMap.get(name) ?? null const npmPublished = Boolean(npmVersion) let exclusion = unpublished.has(name) ? 'unpublished' : null if (excluded.has(name)) exclusion = 'excluded_from_booter' const includedInBooter = npmPublished && !unpublished.has(name) && !excluded.has(name) entries.push({ repo: name, githubUrl: r.html_url, description: r.description || '', npmName: npmPublished ? name : null, npmPublished, npmVersion, includedInBooter, exclusion, exclusionDetail: excluded.get(name) || null, manifestCtxKey: includedInBooter ? manifestCtxKeyForPackage(name) : null }) } for (const x of overrides.additionalCatalogEntries || []) { if (!x || typeof x.repo !== 'string') continue const name = x.repo.trim() if (!name || entries.some((e) => e.repo === name)) continue const npmHint = typeof x.npmHint === 'string' ? x.npmHint.trim() : name let npmVersion = null try { npmVersion = await npmLatestVersion(npmHint) } catch { npmVersion = null } entries.push({ repo: name, githubUrl: x.githubUrl || `https://github.com/holepunchto/${name}`, description: x.description || '', npmName: npmVersion ? npmHint : null, npmPublished: Boolean(npmVersion), npmVersion, includedInBooter: false, exclusion: 'p2p_aux_manual', exclusionDetail: 'Listed for P2P stack integration research; not stock ctx.bare keys.', manifestCtxKey: null }) } const out = { schemaVersion: 1, generatedAt: new Date().toISOString(), source: mirror ? { mode: 'local-mirror', mirrorRoot: mirror } : { mode: 'github-api', githubOrg: 'holepunchto' }, npmRegistry: 'https://registry.npmjs.org', entries } const json = JSON.stringify(out, null, 2) + '\n' if (check) { let existingRaw = '' try { existingRaw = await readFile(outPath, 'utf8') } catch { console.error('[gen-bare-holepunch-catalog] --check: missing', outPath) process.exit(1) } let existing try { existing = JSON.parse(existingRaw) } catch { console.error('[gen-bare-holepunch-catalog] --check: invalid JSON on disk') process.exit(1) } const stable = (o) => ({ schemaVersion: o.schemaVersion, source: o.source, entries: o.entries }) const a = JSON.stringify(stable(out)) const b = JSON.stringify(stable(existing)) if (a !== b) { console.error( '[gen-bare-holepunch-catalog] --check: drift; run gen:bare-catalog to refresh' ) process.exit(1) } console.log('[gen-bare-holepunch-catalog] --check ok') return } await mkdir(dirname(outPath), { recursive: true }) await writeFile(outPath, json, 'utf8') console.log( 'Wrote', outPath, 'entries=', entries.length, 'npmPublished=', entries.filter((e) => e.npmPublished).length, 'includedInBooter=', entries.filter((e) => e.includedInBooter).length ) } if ( process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href ) { main().catch((e) => { console.error(e) process.exit(1) }) }