#!/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);
});