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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
377 lines · 12.5 KB Raw History
#!/usr/bin/env node
/**
 * Local-dev seed for the repository browser: a git repository pushed over the
 * remote helper, the same project published to the account's npm registry, an
 * image pushed to its OCI registry, and the config record that links all
 * three. `@pdsjs/git-ui` reads that link, so this is what gives its packages
 * page something to show.
 *
 * The account must already exist; run `node scripts/dev-seed.mjs` first. See
 * `just dev-git`, which runs both and then starts the UI.
 *
 * Usage: node scripts/dev-git-demo.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 { createHash } from 'node:crypto';
import {
  appendFileSync,
  mkdirSync,
  rmSync,
  symlinkSync,
  writeFileSync,
} from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

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 ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const HOST = new URL(BASE).host;

/** @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}`,
    );
  }
  // A caller that silences the child gets no captured output.
  return result.stdout?.trim() ?? '';
}

const did = (await (await fetch(`${BASE}/.well-known/atproto-did`)).text())
  .trim()
  .replace(/^"|"$/g, '');
if (!did.startsWith('did:')) {
  throw new Error(`${BASE} serves no account yet; run scripts/dev-seed.mjs`);
}

// ---- 1. a repository, pushed the way a person pushes one ------------------

rmSync(WORK, { recursive: true, force: true });
mkdirSync(`${WORK}/${NAME}`, { recursive: true });

// git finds the helper by name on PATH, so the checkout gets a bin directory
// holding this workspace's copy.
mkdirSync(`${WORK}/bin`, { recursive: true });
symlinkSync(
  `${ROOT}/packages/git/src/cli.js`,
  `${WORK}/bin/git-remote-atproto`,
);

const files = {
  'README.md': `# ${NAME}\n\nA small package published to its own atproto account.\n`,
  // biome-ignore lint/suspicious/noTemplateCurlyInString: the placeholder
  // belongs to the demo package's own source, not to this string.
  'index.js': 'export const greet = (who) => `hello, ${who}`;\n',
  'package.json': `${JSON.stringify(
    {
      name: NAME,
      version: '1.0.0',
      description: 'A small package published to its own atproto account',
      main: 'index.js',
      license: 'MIT',
    },
    null,
    2,
  )}\n`,
  '.npmrc': `registry=${BASE}/npm/\n//${HOST}/npm/:_authToken=${PASSWORD}\n`,
  LICENSE: `MIT License\n\nCopyright (c) ${new Date().getFullYear()}\n`,

  // Nested directories, so the file pane has somewhere to walk into, and a
  // file long enough to scroll past a window.
  'src/index.js':
    "export { parse } from './lib/parse.js';\n" +
    "export { format } from './lib/format.js';\n" +
    "export { run } from './commands/run.js';\n",
  'src/lib/parse.js': lines(
    'parse',
    'Read one line of the record stream into a value.',
    140,
  ),
  'src/lib/format.js': lines(
    'format',
    'Write a value back out in the shape the stream expects.',
    90,
  ),
  'src/lib/errors.js': lines('errors', 'The failures this package names.', 40),
  'src/commands/run.js': lines(
    'run',
    'The command the CLI runs by default.',
    70,
  ),
  'src/commands/list.js': lines('list', 'List what the account holds.', 55),
  'test/parse.test.js': lines('parseTest', 'What parse promises.', 60),
  'docs/guide.md':
    `# Guide\n\nHow to use ${NAME}.\n\n` +
    Array.from(
      { length: 24 },
      (_, index) =>
        `## Step ${index + 1}\n\nRun the command, read what it prints, and move on.\n`,
    ).join('\n'),

  // A spread of languages, so the gauge on the repository page has a mix to
  // draw rather than one colour. The sizes decide the shares, and the tail
  // past the sixth largest is what the gauge adds together as Other.
  'web/app.css': repeat(
    (n) =>
      `.panel-${n} {\n  border: 1px solid var(--border);\n  padding: ${n % 8}px;\n}\n`,
    120,
  ),
  'web/index.html':
    `<!doctype html>\n<html lang="en">\n  <head>\n    <meta charset="utf-8" />\n    <title>${NAME}</title>\n    <link rel="stylesheet" href="app.css" />\n  </head>\n  <body>\n` +
    repeat((n) => `    <section id="panel-${n}">Panel ${n}</section>\n`, 60) +
    '  </body>\n</html>\n',
  'tools/report.py':
    '"""Summarise a run of the toolkit."""\n\n\ndef summarise(rows):\n' +
    repeat((n) => `    total_${n} = sum(row[${n % 5}] for row in rows)\n`, 70) +
    '    return rows\n',
  'tools/checksum.go':
    'package tools\n\nimport "crypto/sha256"\n\n// Sum hashes each chunk in turn.\nfunc Sum(chunks [][]byte) []byte {\n\th := sha256.New()\n' +
    repeat((n) => `\th.Write(chunks[${n % 9}])\n`, 45) +
    '\treturn h.Sum(nil)\n}\n',
  'scripts/release.sh':
    '#!/usr/bin/env bash\nset -euo pipefail\n\n' +
    repeat((n) => `echo "step ${n}: checking the tree"\n`, 30),
  Makefile: `build:\n\tnode src/index.js\n\ntest:\n\tnode --test\n`,
  Dockerfile: `FROM node:22-alpine\nWORKDIR /app\nCOPY . .\nCMD ["node", "src/index.js"]\n`,
};

/**
 * `count` lines from one template, for the generated files above.
 * @param {(n: number) => string} line
 * @param {number} count
 */
function repeat(line, count) {
  return Array.from({ length: count }, (_, index) => line(index + 1)).join('');
}

/**
 * A source file of about `count` lines. Real length is what makes a pane
 * worth scrolling, and generated length is what keeps this script short.
 * @param {string} name
 * @param {string} summary
 * @param {number} count
 */
function lines(name, summary, count) {
  const body = Array.from({ length: count }, (_, index) => {
    const step = index + 1;
    if (step % 12 === 0) return `\n  // step ${step}: nothing to do here yet`;
    return `  const step${step} = value[${step % 7}] ?? fallback(${step});`;
  }).join('\n');
  return `/** ${summary} */\nexport function ${name}(value, fallback) {\n${body}\n  return value;\n}\n`;
}

for (const [path, body] of Object.entries(files)) {
  const full = `${WORK}/${NAME}/${path}`;
  mkdirSync(dirname(full), { recursive: true });
  writeFileSync(full, body);
}

run('git', ['init', '-q', '-b', 'main']);

/**
 * One commit, dated. git takes the date from the environment, which is what
 * lets this build a history with a shape rather than a stack of commits all
 * made in the same second.
 * @param {string} message
 * @param {Date} [when]
 */
function commit(message, when) {
  const at = when ? when.toISOString() : undefined;
  run('git', ['add', '-A']);
  run(
    'git',
    [
      '-c',
      'user.email=alice@localhost',
      '-c',
      'user.name=Alice',
      'commit',
      '-qm',
      message,
    ],
    {
      env: at
        ? { GIT_AUTHOR_DATE: at, GIT_COMMITTER_DATE: at }
        : /** @type {Record<string, string>} */ ({}),
    },
  );
}

commit('the first commit', new Date(Date.now() - 154 * 86_400_000));

// A run of dated commits so the repository has a history to draw: the
// activity gauge on the repository page reads the last few months, and a
// repository built in one second has nothing to show it.
const DAY_MS = 86_400_000;
const BACKDATED = [
  [147, 'add a changelog'],
  [140, 'note the license'],
  [126, 'describe the layout'],
  [119, 'tidy the readme'],
  [98, 'add a usage example'],
  [91, 'fix a typo in the example'],
  [84, 'link the registry'],
  [56, 'document the helper'],
  [49, 'mention the runner'],
  [21, 'refresh the readme'],
  [14, 'add a contributing note'],
];
for (const [daysAgo, message] of BACKDATED) {
  appendFileSync(`${WORK}/${NAME}/README.md`, `\n${message}.\n`);
  commit(message, new Date(Date.now() - daysAgo * DAY_MS));
}
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,
  },
});
console.log(`Pushed ${NAME}`);

// ---- 2. two npm versions, published with the stock client -----------------

run('npm', ['publish', '--silent']);
run('npm', ['version', 'patch', '--no-git-tag-version'], { stdio: 'ignore' });
run('npm', ['publish', '--silent']);
console.log(`Published ${NAME}@1.0.0 and ${NAME}@1.0.1 to ${BASE}/npm/`);

// ---- 3. an image, pushed the way docker pushes one ------------------------

const AUTH = `Basic ${Buffer.from(`${did}:${PASSWORD}`).toString('base64')}`;
const digestOf = (/** @type {Buffer} */ bytes) =>
  `sha256:${createHash('sha256').update(bytes).digest('hex')}`;

/** One blob, through the upload session: POST, PATCH, PUT. */
async function pushBlob(/** @type {Buffer} */ bytes) {
  const started = await fetch(`${BASE}/v2/${NAME}/blobs/uploads/`, {
    method: 'POST',
    headers: { Authorization: AUTH },
  });
  const patched = await fetch(`${BASE}${started.headers.get('location')}`, {
    method: 'PATCH',
    headers: { Authorization: AUTH },
    body: bytes,
  });
  const digest = digestOf(bytes);
  const put = await fetch(
    `${BASE}${patched.headers.get('location')}?digest=${digest}`,
    { method: 'PUT', headers: { Authorization: AUTH } },
  );
  if (put.status !== 201) {
    throw new Error(`blob upload answered ${put.status}`);
  }
  return { digest, size: bytes.length };
}

const config = Buffer.from(
  JSON.stringify({
    architecture: 'arm64',
    os: 'linux',
    rootfs: { type: 'layers', diff_ids: [] },
  }),
);
const layer = Buffer.from(`${NAME} layer bytes\n`.repeat(64));
const configBlob = await pushBlob(config);
const layerBlob = await pushBlob(layer);

const manifest = Buffer.from(
  JSON.stringify({
    schemaVersion: 2,
    mediaType: 'application/vnd.oci.image.manifest.v1+json',
    config: {
      mediaType: 'application/vnd.oci.image.config.v1+json',
      digest: configBlob.digest,
      size: configBlob.size,
    },
    layers: [
      {
        mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip',
        digest: layerBlob.digest,
        size: layerBlob.size,
      },
    ],
  }),
);
for (const tag of ['latest', 'v1.0.1']) {
  const put = await fetch(`${BASE}/v2/${NAME}/manifests/${tag}`, {
    method: 'PUT',
    headers: {
      Authorization: AUTH,
      'Content-Type': 'application/vnd.oci.image.manifest.v1+json',
    },
    body: manifest,
  });
  if (put.status !== 201) {
    throw new Error(`manifest ${tag} answered ${put.status}`);
  }
}
console.log(`Pushed ${HOST}/${NAME}:latest and :v1.0.1`);

// ---- 4. the link between them --------------------------------------------

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();

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

const written = await fetch(`${BASE}/xrpc/com.atproto.repo.putRecord`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${session.accessJwt}`,
  },
  body: JSON.stringify({
    repo: did,
    collection: 'dev.pdsjs.git.config',
    rkey: repoKey,
    record: {
      $type: 'dev.pdsjs.git.config',
      name: NAME,
      packages: [
        { registry: 'npm', name: NAME },
        { registry: 'oci', name: NAME },
      ],
      createdAt: new Date().toISOString(),
    },
  }),
});
if (!written.ok) {
  throw new Error(`linking failed: ${written.status} ${await written.text()}`);
}
console.log(`Linked both packages to ${NAME}`);