/**
* `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`
}