#!/usr/bin/env node // @pdsjs/readonly/build-car - Build CAR files for read-only PDS // Creates a signed repository CAR file from records import fs from 'node:fs'; import { buildMst } from '@pdsjs/core'; import { bytesToHex, generateKeyPair, hexToBytes, importPrivateKey, sign, } from '@pdsjs/core/crypto'; import { buildCarFile, CID, cborEncodeDagCbor, cidToBytes, cidToString, createCid, createTid, } from '@pdsjs/core/repo'; /** * @typedef {Object} RecordInput * @property {string} collection - Collection NSID (e.g., "app.bsky.feed.post") * @property {string} rkey - Record key * @property {object} record - Record data */ /** * Build a CAR file from records * @param {Object} options * @param {string} options.did - Repository DID * @param {Uint8Array} options.privateKey - 32-byte P-256 private key * @param {RecordInput[]} options.records - Records to include * @param {string} [options.prevCommitCid] - Previous commit CID (for chaining) * @returns {Promise<{carBytes: Uint8Array, commitCid: string, rev: string}>} */ export async function buildRepositoryCar({ did, privateKey, records, prevCommitCid, }) { /** @type {Array<{cid: string, data: Uint8Array}>} */ const blocks = []; // Encode records and build MST entries /** @type {Array<{key: string, cid: string}>} */ const mstEntries = []; for (const { collection, rkey, record } of records) { const recordBytes = cborEncodeDagCbor(record); const recordCid = cidToString(await createCid(recordBytes)); blocks.push({ cid: recordCid, data: recordBytes }); mstEntries.push({ key: `${collection}/${rkey}`, cid: recordCid }); } // Sort entries for MST (required for deterministic tree structure) mstEntries.sort((a, b) => a.key.localeCompare(b.key)); // Build MST const mstRoot = await buildMst(mstEntries, async (cid, data) => { blocks.push({ cid, data }); }); // Create revision ID const rev = createTid(); // Build unsigned commit const commit = { did, version: 3, rev, prev: prevCommitCid ? new CID(cidToBytes(prevCommitCid)) : null, data: mstRoot ? new CID(cidToBytes(mstRoot)) : null, }; // Sign commit const signingKey = await importPrivateKey(privateKey); const commitBytes = cborEncodeDagCbor(commit); const sig = await sign(signingKey, commitBytes); const signedCommit = { ...commit, sig }; // Encode signed commit const signedCommitBytes = cborEncodeDagCbor(signedCommit); const commitCid = cidToString(await createCid(signedCommitBytes)); blocks.push({ cid: commitCid, data: signedCommitBytes }); // Build CAR file const carBytes = buildCarFile(commitCid, blocks); return { carBytes, commitCid, rev }; } /** * Parse command line arguments * @returns {{ * output: string, * did: string, * privateKey: Uint8Array | null, * generateKey: boolean, * collection: string | null, * rkey: string | null, * record: object | null, * recordsFile: string | null, * help: boolean * }} */ function parseArgs() { const args = process.argv.slice(2); let output = ''; let did = ''; /** @type {Uint8Array | null} */ let privateKey = null; let generateKey = false; /** @type {string | null} */ let collection = null; /** @type {string | null} */ let rkey = null; /** @type {object | null} */ let record = null; /** @type {string | null} */ let recordsFile = null; let help = false; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--output' || arg === '-o') { output = args[++i]; } else if (arg === '--did' || arg === '-d') { did = args[++i]; } else if (arg === '--key' || arg === '-k') { const keyArg = args[++i]; if (keyArg) { // Support hex-encoded key or file path if (keyArg.length === 64 && /^[0-9a-fA-F]+$/.test(keyArg)) { privateKey = hexToBytes(keyArg); } else if (fs.existsSync(keyArg)) { const keyData = fs.readFileSync(keyArg); // Support raw binary or hex file if (keyData.length === 32) { privateKey = new Uint8Array(keyData); } else { const hex = keyData.toString('utf-8').trim(); privateKey = hexToBytes(hex); } } else { console.error(`Error: Key file not found or invalid hex: ${keyArg}`); process.exit(1); } } } else if (arg === '--generate-key' || arg === '-g') { generateKey = true; } else if (arg === '--collection' || arg === '-c') { collection = args[++i]; } else if (arg === '--rkey' || arg === '-r') { rkey = args[++i]; } else if (arg === '--record' || arg === '-R') { const recordArg = args[++i]; if (recordArg) { try { record = JSON.parse(recordArg); } catch (err) { console.error(`Error: Invalid JSON for record: ${err}`); process.exit(1); } } } else if (arg === '--record-file' || arg === '-f') { const filePath = args[++i]; if (filePath && fs.existsSync(filePath)) { try { record = JSON.parse(fs.readFileSync(filePath, 'utf-8')); } catch (err) { console.error(`Error: Failed to parse record file: ${err}`); process.exit(1); } } else { console.error(`Error: Record file not found: ${filePath}`); process.exit(1); } } else if (arg === '--records' || arg === '-F') { recordsFile = args[++i]; } else if (arg === '--help' || arg === '-h') { help = true; } else if (arg.startsWith('-')) { console.error(`Unknown option: ${arg}`); process.exit(1); } } return { output, did, privateKey, generateKey, collection, rkey, record, recordsFile, help, }; } function printHelp() { console.log(` build-car - Build a CAR file for read-only PDS Usage: build-car [options] Options: --output, -o Output CAR file path (required) --did, -d Repository DID (required) --key, -k Private key (hex string or file path) --generate-key, -g Generate a new key pair and print to stdout --collection, -c Collection NSID for single record mode --rkey, -r Record key for single record mode --record, -R Record JSON for single record mode --record-file, -f Path to JSON file containing record --records, -F Path to JSON file containing array of records --help, -h Show this help message Single Record Mode: build-car -o repo.car -d did:plc:abc123 -k \\ -c app.bsky.feed.post -r 3abc123 -R '{"text":"Hello"}' Multiple Records Mode (JSON file format): [ {"collection": "app.bsky.feed.post", "rkey": "3abc", "record": {"text": "Post 1"}}, {"collection": "app.bsky.feed.post", "rkey": "3def", "record": {"text": "Post 2"}} ] build-car -o repo.car -d did:plc:abc123 -k -F records.json Key Generation: build-car --generate-key # Outputs: privateKey= publicKey= Examples: # Generate a new key build-car -g > keys.txt # Create a CAR with a single post build-car -o my-repo.car -d did:plc:abc123 -k private.key \\ -c app.bsky.feed.post -r 3abc123def \\ -R '{"$type":"app.bsky.feed.post","text":"Hello World!","createdAt":"2024-01-01T00:00:00Z"}' # Create a CAR from a records file build-car -o my-repo.car -d did:plc:abc123 -k -F records.json `); } async function main() { const { output, did, privateKey, generateKey, collection, rkey, record, recordsFile, help, } = parseArgs(); if (help) { printHelp(); process.exit(0); } // Handle key generation if (generateKey) { const { privateKey: privKey, publicKey: pubKey } = await generateKeyPair(); console.log(`privateKey=${bytesToHex(privKey)}`); console.log(`publicKey=${bytesToHex(pubKey)}`); console.log(`# Save the privateKey securely. Use it with --key option.`); process.exit(0); } // Validate required arguments if (!output) { console.error('Error: --output is required'); printHelp(); process.exit(1); } if (!did) { console.error('Error: --did is required'); printHelp(); process.exit(1); } if (!privateKey) { console.error( 'Error: --key is required (or use --generate-key to create one)', ); printHelp(); process.exit(1); } // Build records array /** @type {RecordInput[]} */ let records = []; if (recordsFile) { // Multiple records from file if (!fs.existsSync(recordsFile)) { console.error(`Error: Records file not found: ${recordsFile}`); process.exit(1); } try { const data = JSON.parse(fs.readFileSync(recordsFile, 'utf-8')); if (!Array.isArray(data)) { console.error('Error: Records file must contain an array'); process.exit(1); } records = data; } catch (err) { console.error(`Error: Failed to parse records file: ${err}`); process.exit(1); } } else if (collection && rkey && record) { // Single record mode records = [{ collection, rkey, record }]; } else { console.error( 'Error: Either provide --collection, --rkey, and --record for single record,', ); console.error(' or --records for multiple records from a file.'); printHelp(); process.exit(1); } if (records.length === 0) { console.error('Error: No records provided'); process.exit(1); } // Validate records for (const r of records) { if (!r.collection || !r.rkey || !r.record) { console.error( 'Error: Each record must have collection, rkey, and record fields', ); process.exit(1); } } console.log(`Building CAR file for ${did}...`); console.log(`Records: ${records.length}`); try { const { carBytes, commitCid, rev } = await buildRepositoryCar({ did, privateKey, records, }); fs.writeFileSync(output, carBytes); console.log(`\nCAR file written: ${output}`); console.log(`Commit CID: ${commitCid}`); console.log(`Revision: ${rev}`); console.log(`Size: ${carBytes.length} bytes`); console.log(`\nRecords included:`); for (const r of records) { console.log(` ${r.collection}/${r.rkey}`); } } catch (err) { console.error(`Error building CAR: ${err}`); process.exit(1); } } // Only run CLI if this is the main module const isMainModule = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('/build-car.js') || process.argv[1]?.endsWith('/build-car'); if (isMainModule) { main().catch((err) => { console.error('Fatal error:', err); process.exit(1); }); }