/** * `corpora build` on the command line. * * The private key is read here and passed straight to the builder. It is never printed, never * echoed back in an error, and never written to the output. */ import { readFile } from 'node:fs/promises' import { parseArgs } from 'node:util' import { message } from '../message.js' import { build } from './index.js' import { isDevelopmentDid } from './diddoc.js' import { dnsCheckMode } from './dns.js' import { BuildError } from './errors.js' const USAGE = `corpora build — sign a lexicon directory into dist/ Usage: corpora build --lexicons --out --did [options] Options: --lexicons directory of lexicon JSON files, read recursively (required) --out output directory (default: dist) --did did:web: or did:plc: (required) --key-file secp256k1 private key in hex; or set CORPORA_SIGNING_KEY --service service endpoint to publish (default: the did:web origin) --rev commit rev: a TID, or an ISO 8601 instant to derive one from (default: derived from the MST root, so builds are reproducible) --against compare against a deployed authority instead of --out --dns-check authority-domain TXT checks (default: strict) strict every record must exist and name --did cutover every record must exist; naming another DID is a warning, for building a new authority before the records are flipped to it off no lookups at all --no-dns-check alias for --dns-check off --force write even when nothing changed --quiet print nothing on success ` /** * @param {string[]} argv * @returns {Promise} */ export async function run(argv) { const { values } = parseArgs({ args: argv, options: { lexicons: { type: 'string' }, out: { type: 'string', default: 'dist' }, did: { type: 'string' }, 'key-file': { type: 'string' }, service: { type: 'string' }, rev: { type: 'string' }, against: { type: 'string' }, 'dns-check': { type: 'string' }, 'no-dns-check': { type: 'boolean', default: false }, force: { type: 'boolean', default: false }, quiet: { type: 'boolean', default: false }, help: { type: 'boolean', default: false }, }, }) if (values.help) { process.stdout.write(USAGE) return 0 } try { if (!values.lexicons) throw new BuildError('--lexicons is required') if (!values.did) throw new BuildError('--did is required') if (values['no-dns-check'] && values['dns-check'] && values['dns-check'] !== 'off') { throw new BuildError( `--no-dns-check is an alias for --dns-check off, which contradicts --dns-check ${values['dns-check']}`, ) } const report = await build({ lexicons: values.lexicons, out: values.out, did: values.did, key: await readKey(values['key-file']), service: values.service, rev: values.rev, against: values.against, dnsCheck: values['no-dns-check'] ? 'off' : dnsCheckMode(values['dns-check']), force: values.force, }) for (const warning of report.warnings) process.stderr.write(`warning: ${warning}\n`) if (!values.quiet) process.stdout.write(summarize(report, values.out)) return 0 } catch (err) { if (!(err instanceof BuildError)) throw err process.stderr.write(`corpora build: ${err.message}\n`) return 1 } } /** * @param {string | undefined} keyFile * @returns {Promise} */ async function readKey(keyFile) { if (keyFile) { try { return (await readFile(keyFile, 'utf8')).trim() } catch (err) { throw new BuildError(`cannot read --key-file ${keyFile}: ${message(err)}`) } } const fromEnv = process.env.CORPORA_SIGNING_KEY?.trim() if (fromEnv) return fromEnv throw new BuildError('no signing key: pass --key-file or set CORPORA_SIGNING_KEY (`corpora keygen` makes one)') } /** * @param {import('./index.js').BuildReport} report * @param {string} out */ function summarize(report, out) { const lines = [ `did ${report.did}`, `service ${report.service}${isDevelopmentDid(report.did) ? ' (development only, not resolvable elsewhere)' : ''}`, `signing key ${report.signingKey}`, `rev ${report.rev}`, `commit ${report.commitCid}`, `mst root ${report.mstRoot}`, '', ] // `report.files` and `report.nsids` are built from the same `sources` array by the same // build, and `planDist` emits a `records/` and a `proofs/` entry for every NSID in it. Every // path looked up below therefore exists; indexing states that, where a `find` would invite a // fallback for a report this build cannot produce. /** @type {Record} */ const size = {} for (const file of report.files) size[file.path] = file.bytes const nsidWidth = Math.max(...report.nsids.map((n) => n.length)) for (const nsid of report.nsids) { const record = size[`records/${nsid}.json`] const proof = size[`proofs/${nsid}.car`] lines.push(`${nsid.padEnd(nsidWidth)} ${bytes(record)} record ${bytes(proof)} proof`) } lines.push('') const domains = Object.entries(report.authorityDomains) const domainWidth = Math.max(...domains.map(([d]) => d.length)) for (const [domain, nsids] of domains) { lines.push(`${domain.padEnd(domainWidth)} ${nsids.length} lexicon${nsids.length === 1 ? '' : 's'}`) } lines.push('') lines.push( report.written ? `wrote ${report.files.length} files to ${out}, ${bytes(report.totalBytes)} of served artifacts` : `unchanged: ${report.unchanged}\nnothing written; pass --force to rebuild anyway`, ) return `${lines.join('\n')}\n` } /** @param {number} n */ function bytes(n) { return `${n.toLocaleString('en-US')} B` }