chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
#!/usr/bin/env node
/**
* The daemon's command line. Resolves the repositories to watch, signs the
* runner in, and hands the watch loop a shell executor.
*/
import { readFile } from 'node:fs/promises';
import { resolveRepoLocation, XrpcClient } from '@pdsjs/git';
import { watch } from './daemon.js';
import { createShellExecutor } from './executor.js';
import { createPublisher, createUnfinishedRefsReader } from './publish.js';
import { readRepoRecord } from './request.js';
import { createRunner } from './runner.js';
import { grantedSecrets, parseSecretsFile } from './secrets.js';
import { parseSpec, repoKey } from './spec.js';
import { createStateStore, statePath } from './state.js';
const USAGE = `pdsjs-git-ci watch <handle-or-did>/<repo> [...]
Runs .pdsjs/ci.json for every push to the named repositories and publishes
the result as a dev.pdsjs.git.check record.
Environment:
PDSJS_CI_IDENTIFIER Runner account handle or DID (required)
PDSJS_CI_PASSWORD App password for that account (required)
PDSJS_CI_REPOS Repositories to watch, when none are given as
arguments. Separated by spaces or commas.
PDSJS_CI_SECRETS Per-repository secrets file path
PDSJS_CI_SERVICE Runner's PDS base URL, skips identity resolution
PDSJS_CI_STATE State file path
PDSJS_CI_STEP_TIMEOUT Per-step timeout in milliseconds
`;
/**
* Load the secrets file and key its grants by the record key of the
* repository each names. The file is written by a person, so it names a
* repository the way a watch spec does, by handle and name; a run names one
* by its record key, which no rename moves.
*
* A grant naming a repository the account does not hold is reported here.
* The alternative is a build that quietly runs without its secrets.
* @param {string} path
* @param {NodeJS.ProcessEnv} env
* @param {(message: string) => void} onNotice
* @returns {Promise<import('./secrets.js').SecretsResolver>}
*/
async function loadSecrets(path, env, onNotice) {
const grants = parseSecretsFile(await readFile(path, 'utf8'));
/** @type {Map<string, Record<string, string>>} */
const byKey = new Map();
for (const [spec, values] of Object.entries(grants)) {
const { authority, repoName } = parseSpec(spec);
const { did, service } = await resolveRepoLocation(authority, env);
try {
const record = await readRepoRecord({ service, did, repoName });
byKey.set(repoKey(did, record.rkey), values);
} catch {
onNotice(`secrets: ${spec} names no repository this account holds`);
}
}
return (did, repoRkey, names) =>
grantedSecrets(byKey.get(repoKey(did, repoRkey)), names);
}
async function main() {
const env = process.env;
const [command, ...args] = process.argv.slice(2);
// A container image names no repositories in its command, so the
// environment carries them instead.
const specs =
args.length > 0
? args
: (env.PDSJS_CI_REPOS ?? '').split(/[\s,]+/).filter(Boolean);
if (command !== 'watch' || specs.length === 0) {
process.stderr.write(USAGE);
process.exit(command === '--help' ? 0 : 129);
}
const identifier = env.PDSJS_CI_IDENTIFIER;
const password = env.PDSJS_CI_PASSWORD;
if (!identifier || !password) {
throw new Error(
'PDSJS_CI_IDENTIFIER and PDSJS_CI_PASSWORD must both be set',
);
}
const repos = await Promise.all(
specs.map(async (spec) => {
const { authority, repoName } = parseSpec(spec);
const { did, service } = await resolveRepoLocation(authority, env);
return { did, repoName, service };
}),
);
const runnerService =
env.PDSJS_CI_SERVICE ?? (await resolveRepoLocation(identifier, {})).service;
const client = new XrpcClient(runnerService);
const { did } = await client.createSession(identifier, password);
const onNotice = (/** @type {string} */ message) => {
process.stdout.write(`${new Date().toISOString()} ${message}\n`);
};
const timeout = env.PDSJS_CI_STEP_TIMEOUT;
const runCheck = createRunner({
executor: createShellExecutor(
timeout ? { timeoutMs: Number(timeout) } : {},
),
publisher: createPublisher({ client, did }),
secrets: env.PDSJS_CI_SECRETS
? await loadSecrets(env.PDSJS_CI_SECRETS, env, onNotice)
: undefined,
onNotice,
});
const controller = new AbortController();
process.on('SIGINT', () => controller.abort());
process.on('SIGTERM', () => controller.abort());
onNotice(`publishing checks as ${identifier} (${did})`);
await watch({
repos,
runCheck,
unfinishedRefs: createUnfinishedRefsReader({ client, did }),
state: createStateStore(statePath(env), onNotice),
signal: controller.signal,
onNotice,
});
}
main().catch((err) => {
process.stderr.write(
`fatal: ${err instanceof Error ? err.message : String(err)}\n`,
);
process.exit(1);
});