#!/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 / [...] 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} */ async function loadSecrets(path, env, onNotice) { const grants = parseSecretsFile(await readFile(path, 'utf8')); /** @type {Map>} */ 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); });