~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
337 lines · 10.3 KB Raw History
#!/usr/bin/env node
/**
 * Local-dev seed for the checks pages: a few more commits on the demo
 * repository, and the check records a runner would have published about them.
 * The account runs as its own runner, which is what the config record's
 * `runner` field names. A reader resolves that DID and reads its checks, so
 * one account playing both parts exercises the same path as two.
 *
 * The runs are shaped to cover what the pages have to render: several
 * workflows on one commit, a workflow re-run, a failure beside a success, and
 * a run still going.
 *
 * Run `node scripts/dev-git-demo.mjs` first; this pushes onto the repository
 * that leaves behind. See `just dev-git`, which runs all three.
 *
 * Usage: node scripts/dev-git-checks.mjs [repo-name]
 * Env:   PDS_DEV_URL (default http://localhost:2471)
 *        PDS_DEV_PASSWORD (default test-password)
 *        PDS_DEV_PLC_URL (default http://localhost:2582)
 *        PDS_DEV_WORK_DIR (default .dev-pds/work)
 */
import { spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { resolve } from 'node:path';

const BASE = process.env.PDS_DEV_URL || 'http://localhost:2471';
const PASSWORD = process.env.PDS_DEV_PASSWORD || 'test-password';
const PLC_URL = process.env.PDS_DEV_PLC_URL || 'http://localhost:2582';
const WORK = resolve(process.env.PDS_DEV_WORK_DIR || '.dev-pds/work');
const NAME = process.argv[2] || 'demo-app';

const CHECK_COLLECTION = 'dev.pdsjs.git.check';
const LATEST_CHECK_COLLECTION = 'dev.pdsjs.git.latestCheck';
const REF = 'refs/heads/main';

/** @param {string} command @param {string[]} args @param {object} [options] */
function run(command, args, options = {}) {
  const result = spawnSync(command, args, {
    cwd: `${WORK}/${NAME}`,
    stdio: ['ignore', 'pipe', 'pipe'],
    encoding: 'utf8',
    ...options,
    env: { ...process.env, ...(options.env ?? {}) },
  });
  if (result.status !== 0) {
    throw new Error(
      `${command} ${args.join(' ')} failed:\n${result.stderr || result.stdout}`,
    );
  }
  return result.stdout?.trim() ?? '';
}

const did = (await (await fetch(`${BASE}/.well-known/atproto-did`)).text())
  .trim()
  .replace(/^"|"$/g, '');

const session = await (
  await fetch(`${BASE}/xrpc/com.atproto.server.createSession`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ identifier: did, password: PASSWORD }),
  })
).json();
const AUTH = { Authorization: `Bearer ${session.accessJwt}` };

/** @param {string} path @param {unknown} body */
async function call(path, body) {
  const res = await fetch(`${BASE}/xrpc/${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...AUTH },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${path} answered ${res.status}`);
  return res.json();
}

// ---- 1. a history worth listing ------------------------------------------

const COMMITS = [
  ['README.md', '\nBuilt by its own runner.\n', 'document the runner'],
  ['index.js', 'export const version = () => "1.0.1";\n', 'add version()'],
  [
    'index.js',
    'export const shout = (who) => `HELLO, ${who}`;\n',
    'add shout()',
  ],
];
for (const [file, body, message] of COMMITS) {
  appendFileSync(`${WORK}/${NAME}/${file}`, body);
  run('git', ['add', '-A']);
  run('git', [
    '-c',
    'user.email=alice@localhost',
    '-c',
    'user.name=Alice',
    'commit',
    '-qm',
    message,
  ]);
}
run('git', ['push', '-q', `atproto://${did}/${NAME}`, 'main'], {
  env: {
    PATH: `${WORK}/bin:${process.env.PATH}`,
    ATPROTO_GIT_IDENTIFIER: did,
    ATPROTO_GIT_PASSWORD: PASSWORD,
    ATPROTO_GIT_PLC_URL: PLC_URL,
    ATPROTO_GIT_SERVICE: BASE,
  },
});

// Newest first, which is the order the runs below are written in.
const shas = run('git', ['log', '-4', '--format=%H']).split('\n');
console.log(`Pushed ${COMMITS.length} more commits to ${NAME}`);

// ---- 2. what the runner would have published ------------------------------

// The key is minted, so the listing is what joins the name to the record.
const repoRecord = (
  await (
    await fetch(
      `${BASE}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=dev.pdsjs.git.repo&limit=100`,
    )
  ).json()
).records.find((row) => row.value.name === NAME);
if (!repoRecord) throw new Error(`no repository named ${NAME} to check`);
const repoKey = repoRecord.uri.split('/').pop();
const subject = { uri: repoRecord.uri, cid: repoRecord.cid };

/** A step's line in the log, the way a shell runner writes one. */
const logFor = (steps) =>
  steps
    .map(({ name, exitCode }) =>
      exitCode === 0
        ? `$ ${name}\nran ${name} in the checkout\n${name}: ok\n`
        : `$ ${name}\nran ${name} in the checkout\n${name}: FAILED\nexit status ${exitCode}\n`,
    )
    .join('\n');

/** @param {string} text */
async function uploadLog(text) {
  const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, {
    method: 'POST',
    headers: { 'Content-Type': 'text/plain', ...AUTH },
    body: text,
  });
  if (!res.ok) throw new Error(`uploadBlob answered ${res.status}`);
  return (await res.json()).blob;
}

const MINUTE = 60_000;
let clock = Date.now() - 90 * MINUTE;

/**
 * One run, written the way the runner writes one.
 * @param {{sha: string, workflow: string, steps: {name: string, exitCode: number, durationMs: number}[], running?: boolean}} spec
 */
async function publish({ sha, workflow, steps, running }) {
  const startedAt = new Date(clock).toISOString();
  const took = steps.reduce((total, step) => total + step.durationMs, 0);
  clock += took + MINUTE;
  const failed = steps.find((step) => step.exitCode !== 0);
  const status = running ? 'running' : failed ? 'failure' : 'success';
  const record = {
    $type: CHECK_COLLECTION,
    subject,
    ref: REF,
    sha,
    workflow,
    status,
    steps,
    startedAt,
    ...(running
      ? {}
      : {
          finishedAt: new Date(Date.parse(startedAt) + took).toISOString(),
          logs: await uploadLog(logFor(steps)),
        }),
  };
  const { uri, cid } = await call('com.atproto.repo.createRecord', {
    repo: did,
    collection: CHECK_COLLECTION,
    record,
  });
  return { workflow, subject, check: { uri, cid }, sha, status, startedAt };
}

const step = (name, exitCode, durationMs) => ({ name, exitCode, durationMs });
const install = step('pnpm install --frozen-lockfile', 0, 21_400);

// Oldest commit first, so the runs read in the order they happened.
const [head, second, third, first] = shas;
const written = [];
for (const spec of [
  { sha: first, workflow: 'ci', steps: [install, step('npm test', 0, 46_800)] },
  { sha: third, workflow: 'ci', steps: [install, step('npm test', 0, 51_200)] },
  {
    sha: third,
    workflow: 'lint',
    steps: [install, step('biome check .', 0, 3_900)],
  },
  {
    sha: second,
    workflow: 'ci',
    steps: [install, step('npm test', 1, 38_500)],
  },
  // The same workflow again, which is what a re-run leaves behind.
  {
    sha: second,
    workflow: 'ci',
    steps: [install, step('npm test', 0, 44_100)],
  },
  {
    sha: second,
    workflow: 'lint',
    steps: [install, step('biome check .', 0, 4_200)],
  },
  {
    sha: second,
    workflow: 'publish',
    steps: [install, step('npm publish', 0, 12_600)],
  },
  {
    sha: head,
    workflow: 'lint',
    steps: [install, step('biome check .', 0, 4_050)],
  },
  {
    sha: head,
    workflow: 'ci',
    steps: [
      install,
      step('npm test', 1, 62_300),
      step('npm run typecheck', 0, 9_100),
    ],
  },
  {
    sha: head,
    workflow: 'publish',
    steps: [install, step('npm publish', 0, 0)],
    running: true,
  },
]) {
  written.push(await publish(spec));
}
console.log(`Published ${written.length} check records as ${did}`);

// ---- 3. the record a reader asks for by name ------------------------------

/** The newest entry per workflow, which is what this record holds. */
const latest = new Map();
for (const entry of written) latest.set(entry.workflow, entry);

const RKEY_SAFE = /[^A-Za-z0-9._~:-]/g;
await call('com.atproto.repo.putRecord', {
  repo: did,
  collection: LATEST_CHECK_COLLECTION,
  rkey: `${did}:${repoKey}:${REF}`.replace(RKEY_SAFE, '_'),
  record: {
    $type: LATEST_CHECK_COLLECTION,
    ref: REF,
    checks: [...latest.values()],
  },
});

// ---- 4. the repository names its runner -----------------------------------

const config = await (
  await fetch(
    `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=dev.pdsjs.git.config&rkey=${repoKey}`,
  )
).json();
await call('com.atproto.repo.putRecord', {
  repo: did,
  collection: 'dev.pdsjs.git.config',
  rkey: repoKey,
  record: { ...config.value, runner: did },
});
console.log(`${NAME} names ${did} as its runner`);

// ---- 5. a palette the account publishes ----------------------------------

// Proves the theme record path: the page ships four palettes and reads any
// the account publishes beside them.
await call('com.atproto.repo.putRecord', {
  repo: did,
  collection: 'dev.pdsjs.app.theme',
  rkey: 'ember',
  record: {
    $type: 'dev.pdsjs.app.theme',
    name: 'Ember',
    scheme: 'dark',
    colors: {
      background: '#160f0c',
      card: '#1d1512',
      muted: '#1d1512',
      accent: '#2a1e19',
      border: '#33241d',
      input: '#4a3529',
      foreground: '#f4e7de',
      'muted-foreground': '#b39b8c',
      faint: '#7d6357',
      key: '#e8873c',
      ring: '#e8873c',
      destructive: '#e2564a',
      success: '#8fb15c',
      'diff-add': '#1d2416',
      'diff-del': '#2a1614',
      'code-keyword': '#e8873c',
      'code-string': '#8fb15c',
      'code-number': '#e0b64a',
      'code-title': '#f4e7de',
      'code-comment': '#7d6357',
    },
    createdAt: new Date().toISOString(),
  },
});
console.log(`${did} publishes the Ember palette`);

// ---- 6. an identity the account claims -----------------------------------

// The commits above are co-authored, and the tool that co-wrote them holds no
// atproto account. The account says what to call it, which is what lets a
// facepile name it. A real one would give it a face too, as an avatar blob.
await call('com.atproto.repo.putRecord', {
  repo: did,
  collection: 'dev.pdsjs.git.identity',
  rkey: 'self',
  record: {
    $type: 'dev.pdsjs.git.identity',
    idents: [
      { email: 'alice@localhost', did },
      { email: 'noreply@anthropic.com', label: 'Claude' },
    ],
    createdAt: new Date().toISOString(),
  },
});
console.log('Claimed the identities behind the commits');