#!/usr/bin/env node // @pdsjs/sites - the pdsjs-site deploy CLI // // The only file in this package that touches the filesystem; everything // decision-shaped lives in deploy.js where it is unit-testable. A deploy is // stock atproto traffic — createSession, uploadBlob per changed file, one // putRecord — so the server needs nothing beyond an ordinary PDS. import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative, sep } from 'node:path'; import { checkUploadedRef, planDeploy } from './deploy.js'; import { planInstall } from './install.js'; import { SITE_COLLECTION } from './lexicons.js'; function usage() { console.error(`Usage: pdsjs-site deploy --site --pds --handle [--fallback ] [--cache-control "[glob:]value"]... pdsjs-site install --pds --handle [--site ] [--set key=value]... [--dev-pds ] Deploys a directory as a static site: files become blobs, the manifest becomes the dev.pdsjs.site.deploy/ record. The app password is read from the PDS_APP_PASSWORD environment variable, never from arguments. install copies an app published as a dev.pdsjs.app.manifest record onto your own PDS: the site files come over as blobs (verified by CID), the app's query definitions are written, and a dev.pdsjs.app.install record pins the manifest version. --set chooses declared settings; --dev-pds overrides the developer PDS lookup for local testing. --cache-control sets the Cache-Control header files are served with. Without a glob it applies to every file; with one ("assets/**:...") it applies to the matched paths, and the last matching flag wins. Content is addressed by hash, so fingerprinted assets can safely take "public, max-age=31536000, immutable". Files no flag matches keep the value already on the record.`); process.exit(2); } /** * Walk a directory into forward-slash relative paths, skipping dotfiles. * @param {string} root * @returns {Array<{path: string, bytes: Uint8Array}>} */ function walk(root) { /** @type {Array<{path: string, bytes: Uint8Array}>} */ const files = []; /** @param {string} dir */ const visit = (dir) => { for (const name of readdirSync(dir)) { if (name.startsWith('.')) continue; const full = join(dir, name); const stat = statSync(full); if (stat.isDirectory()) visit(full); else if (stat.isFile()) { files.push({ path: relative(root, full).split(sep).join('/'), bytes: new Uint8Array(readFileSync(full)), }); } } }; visit(root); return files; } /** * @param {string} base * @param {string} path * @param {RequestInit & {expectOk?: boolean}} [init] */ async function xrpc(base, path, init = {}) { const response = await fetch(`${base.replace(/\/$/, '')}/xrpc/${path}`, init); const body = await response.json().catch(() => ({})); if (init.expectOk !== false && !response.ok) { throw new Error( `${path} failed (${response.status}): ${body.message || body.error || 'unknown error'}`, ); } return { status: response.status, body }; } /** * Resolve an at:// URI's authority to a DID, via the public resolver when it * is a handle. * @param {string} authority * @returns {Promise} */ async function resolveDid(authority) { if (authority.startsWith('did:')) return authority; const response = await fetch( `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(authority)}`, ); if (!response.ok) throw new Error(`Could not resolve handle ${authority}.`); return (await response.json()).did; } /** * The PDS endpoint from a DID's document. * @param {string} did * @returns {Promise} */ async function pdsEndpoint(did) { const response = await fetch(`https://plc.directory/${did}`); if (!response.ok) throw new Error(`Could not resolve ${did}.`); const doc = await response.json(); const service = (doc.service || []).find( (/** @type {{id?: string, type?: string}} */ s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer', ); if (!service?.serviceEndpoint) { throw new Error(`${did} names no PDS in its document.`); } return service.serviceEndpoint; } /** @param {string[]} args */ async function install(args) { /** @type {Record} */ const flags = {}; /** @type {Record} */ const overrides = {}; /** @type {string[]} */ const positional = []; for (let i = 1; i < args.length; i++) { if (args[i] === '--set') { const raw = args[++i] || ''; const eq = raw.indexOf('='); if (eq === -1) usage(); const value = raw.slice(eq + 1); overrides[raw.slice(0, eq)] = value === 'true' ? true : value === 'false' ? false : value; } else if (args[i].startsWith('--')) flags[args[i].slice(2)] = args[++i] || ''; else positional.push(args[i]); } const uri = positional[0]; const { pds, handle } = flags; if (!uri || !pds || !handle) usage(); const match = uri.match(/^at:\/\/([^/]+)\/dev\.pdsjs\.app\.manifest\/(.+)$/); if (!match) { console.error( 'Give the manifest as at:///dev.pdsjs.app.manifest/', ); process.exit(2); } const password = process.env.PDS_APP_PASSWORD; if (!password) { console.error('Set PDS_APP_PASSWORD to an app password for your account.'); process.exit(2); } const devDid = await resolveDid(match[1]); const devPds = flags['dev-pds'] || (await pdsEndpoint(devDid)); console.error(`developer: ${devDid} at ${devPds}`); /** @param {string} path */ const devGet = async (path) => { const response = await fetch(`${devPds.replace(/\/$/, '')}/xrpc/${path}`); if (!response.ok) { throw new Error(`${path.split('?')[0]} failed (${response.status})`); } return response; }; const manifest = await ( await devGet( `com.atproto.repo.getRecord?repo=${encodeURIComponent(devDid)}&collection=dev.pdsjs.app.manifest&rkey=${encodeURIComponent(match[2])}`, ) ).json(); const siteRef = manifest.value?.site; if (!siteRef?.uri) throw new Error('The manifest names no site.'); const siteMatch = siteRef.uri.match(/\/([^/]+)$/); const site = await ( await devGet( `com.atproto.repo.getRecord?repo=${encodeURIComponent(devDid)}&collection=${SITE_COLLECTION}&rkey=${encodeURIComponent(siteMatch[1])}`, ) ).json(); const session = await xrpc(pds, 'com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: handle, password }), }); const { accessJwt, did } = session.body; const auth = { Authorization: `Bearer ${accessJwt}` }; const installs = await xrpc( pds, `com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=dev.pdsjs.app.install&limit=100`, { expectOk: false }, ); const existing = (installs.body.records || []).find( ( /** @type {{uri: string, value?: {manifest?: {uri?: string}, config?: Record}}} */ r, ) => r.value?.manifest?.uri === manifest.uri, ); const plan = planInstall({ manifest, site, localName: flags.site, overrides, existingConfig: existing?.value?.config, }); console.error( `installing "${manifest.value.name}" as site "${plan.localName}": ${plan.copies.length} files, ${plan.queryDefs.length} queries`, ); for (const copy of plan.copies) { const bytes = new Uint8Array( await ( await devGet( `com.atproto.sync.getBlob?did=${encodeURIComponent(devDid)}&cid=${copy.cid}`, ) ).arrayBuffer(), ); const uploaded = await xrpc(pds, 'com.atproto.repo.uploadBlob', { method: 'POST', headers: { ...auth, 'Content-Type': copy.contentType }, body: /** @type {BodyInit} */ (bytes), }); const gotCid = uploaded.body.blob?.ref?.$link; // Content addressing is the integrity check: identical bytes must // produce the CID the developer's record named. if (gotCid !== copy.cid) { throw new Error( `Copy of ${copy.path} came back as ${gotCid}, expected ${copy.cid}. Not installing.`, ); } console.error(`copied ${copy.path} (${bytes.length} bytes)`); } if (plan.icon) { const bytes = new Uint8Array( await ( await devGet( `com.atproto.sync.getBlob?did=${encodeURIComponent(devDid)}&cid=${plan.icon.cid}`, ) ).arrayBuffer(), ); const uploaded = await xrpc(pds, 'com.atproto.repo.uploadBlob', { method: 'POST', headers: { ...auth, 'Content-Type': plan.icon.contentType }, body: /** @type {BodyInit} */ (bytes), }); if (uploaded.body.blob?.ref?.$link !== plan.icon.cid) { throw new Error('The icon copy did not verify. Not installing.'); } console.error(`copied icon (${bytes.length} bytes)`); } await xrpc(pds, 'com.atproto.repo.putRecord', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: did, collection: SITE_COLLECTION, rkey: plan.localName, record: plan.siteRecord, }), }); for (const def of plan.queryDefs) { await xrpc(pds, 'com.atproto.repo.putRecord', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: did, collection: 'dev.pdsjs.query.def', rkey: def.rkey, record: def.record, }), }); } // One install record per manifest: a reinstall updates it in place. if (existing) { await xrpc(pds, 'com.atproto.repo.putRecord', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: did, collection: 'dev.pdsjs.app.install', rkey: existing.uri.split('/').pop(), record: plan.installRecord, }), }); } else { await xrpc(pds, 'com.atproto.repo.createRecord', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: did, collection: 'dev.pdsjs.app.install', record: plan.installRecord, }), }); } console.error( `Installed "${manifest.value.name}". The site serves as "${plan.localName}" on your site domain.`, ); } async function main() { const args = process.argv.slice(2); if (args[0] === 'install') return install(args); if (args[0] !== 'deploy') usage(); /** @type {Record} */ const flags = {}; /** @type {import('./deploy.js').CacheRule[]} */ const cacheRules = []; /** @type {string[]} */ const positional = []; for (let i = 1; i < args.length; i++) { if (args[i] === '--cache-control') { // A colon separates an optional glob from the value; Cache-Control // directives never contain one, so a colon always means a glob. const raw = args[++i] || ''; const colon = raw.indexOf(':'); cacheRules.push( colon === -1 ? { value: raw.trim() } : { glob: raw.slice(0, colon).trim(), value: raw.slice(colon + 1).trim(), }, ); } else if (args[i].startsWith('--')) flags[args[i].slice(2)] = args[++i] || ''; else positional.push(args[i]); } const dir = positional[0]; const { site, pds, handle, fallback } = flags; if (!dir || !site || !pds || !handle) usage(); if (cacheRules.some((rule) => !rule.value)) { console.error('Each --cache-control needs a header value.'); process.exit(2); } const password = process.env.PDS_APP_PASSWORD; if (!password) { console.error('Set PDS_APP_PASSWORD to an app password for the account.'); process.exit(2); } const files = walk(dir); if (files.length === 0) { console.error(`No files under ${dir}.`); process.exit(1); } const session = await xrpc(pds, 'com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: handle, password }), }); const { accessJwt, did } = session.body; const auth = { Authorization: `Bearer ${accessJwt}` }; const existing = await xrpc( pds, `com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${SITE_COLLECTION}&rkey=${encodeURIComponent(site)}`, { expectOk: false }, ); const plan = await planDeploy({ site, files, existing: existing.status === 200 ? existing.body : null, fallback, cacheRules, }); for (const upload of plan.uploads) { const response = await xrpc(pds, 'com.atproto.repo.uploadBlob', { method: 'POST', headers: { ...auth, 'Content-Type': upload.contentType }, body: /** @type {BodyInit} */ (upload.bytes), }); const ref = checkUploadedRef(upload, response.body.blob); const entry = /** @type {Array<{path: string, blob: unknown}>} */ ( plan.record.files ).find((file) => file.path === upload.path); if (entry) entry.blob = ref; console.error(`uploaded ${upload.path} (${upload.bytes.length} bytes)`); } await xrpc(pds, 'com.atproto.repo.putRecord', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: did, collection: SITE_COLLECTION, rkey: site, record: plan.record, }), }); console.error( `Deployed ${site}: ${plan.uploads.length} uploaded, ${plan.reused.length} unchanged, ${plan.removed.length} removed.`, ); console.error( 'Removed files orphan their blobs; the server reaps them within a day.', ); } main().catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); });