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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
531 lines · 16.3 KB Raw History
#!/usr/bin/env node
/**
 * git remote helper entry point. Git invokes this as
 * `git-remote-atproto <remote-name> <url>` for atproto:// remotes and
 * speaks the helper protocol over stdin/stdout.
 *
 * Invoked by hand, it also manages OAuth sessions:
 *
 *   git-remote-atproto login <handle-or-did> [--service <url>] [--scope <scopes>]
 *   git-remote-atproto logout <handle-or-did>
 *   git-remote-atproto pr create [--remote <name>] [--title <title>] [--body <body>] [--draft]
 *   git-remote-atproto pr update [--remote <name>] [--title <title>] [--body <body>] [--draft]
 *   git-remote-atproto pr merge [--remote <name>] [--mode merge|squash] [--subject <title>] [--body <body>]
 */

import { spawn } from 'node:child_process';
import { git } from './git.js';
import { AtprotoRemoteHelper } from './helper.js';
import { resolveRepoLocation } from './identity.js';
import { GIT_PULL_COLLECTION, GIT_REPO_COLLECTION } from './lexicon.js';
import { oauthLogin } from './oauth.js';
import { pullRkey } from './pull-requests.js';
import { repoNameOf, rkeyOf } from './record.js';
import {
  deleteSession,
  findSessionByDid,
  saveSession,
} from './session-store.js';
import { parseAtprotoUrl } from './url.js';
import { XrpcClient } from './xrpc.js';

const USAGE = `usage: git-remote-atproto <remote-name> atproto://<handle-or-did>/<repo>
       git-remote-atproto login <handle-or-did> [--service <url>] [--scope <scopes>]
       git-remote-atproto logout <handle-or-did>
       git-remote-atproto pr create [--remote <name>] [--title <title>] [--body <body>] [--draft]
       git-remote-atproto pr update [--remote <name>] [--title <title>] [--body <body>] [--draft]
       git-remote-atproto pr merge [--remote <name>] [--mode merge|squash] [--subject <title>] [--body <body>]
`;

/** @param {string} url */
function openBrowser(url) {
  const [cmd, args] =
    process.platform === 'darwin'
      ? ['open', [url]]
      : process.platform === 'win32'
        ? ['cmd', ['/c', 'start', '', url]]
        : ['xdg-open', [url]];
  // The URL is already printed, so a machine with no opener still works: the
  // user follows it by hand.
  const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
  child.on('error', () => {});
  child.unref();
}

/**
 * @param {string[]} args - argv after the subcommand
 * @returns {Promise<void>}
 */
async function login(args) {
  /** @type {string|undefined} */
  let identifier;
  /** @type {string|undefined} */
  let service;
  let scope = 'atproto';
  for (let i = 0; i < args.length; i++) {
    if (args[i] === '--service') service = args[++i];
    else if (args[i] === '--scope') scope = args[++i];
    else if (!identifier) identifier = args[i];
    else throw new Error(USAGE);
  }
  if (!identifier) throw new Error(USAGE);

  const location = await resolveRepoLocation(identifier, {
    ...process.env,
    ...(service ? { ATPROTO_GIT_SERVICE: service } : {}),
  });
  const session = await oauthLogin({
    service: location.service,
    scope,
    identifier,
    openBrowser,
    log: (text) => process.stderr.write(text),
  });
  if (session.did !== location.did) {
    process.stderr.write(
      `warning: logged in as ${session.did}, but ${identifier} resolves to ${location.did}\n`,
    );
  }
  session.identifier = identifier;
  saveSession(process.env, identifier, session);
  process.stderr.write(
    `Logged in as ${session.did}\n  scope: ${session.scope}\n`,
  );
}

/**
 * @param {string[]} args
 * @returns {{remote: string|undefined, title: string|undefined, note: string|undefined, draft: boolean}}
 */
function parsePullOptions(args) {
  /** @type {string|undefined} */
  let remote;
  /** @type {string|undefined} */
  let title;
  /** @type {string|undefined} */
  let note;
  let draft = false;
  for (let i = 0; i < args.length; i++) {
    const option = args[i];
    if (option === '--draft') {
      draft = true;
      continue;
    }
    if (
      option !== '--remote' &&
      option !== '--title' &&
      option !== '--body' &&
      option !== '--note'
    ) {
      throw new Error(USAGE);
    }
    const value = args[++i];
    if (!value || value.startsWith('--')) throw new Error(USAGE);
    if (option === '--remote') remote = value;
    else if (option === '--title') title = value;
    else note = value;
  }
  if ([remote, title, note].some((value) => value === '')) {
    throw new Error(USAGE);
  }
  return { remote, title, note, draft };
}

/**
 * @param {string[]} args
 * @param {string} what
 * @returns {string}
 */
function gitValue(args, what) {
  const result = git(args);
  if (result.status !== 0) {
    throw new Error(`git ${what} failed: ${result.stderr.trim()}`);
  }
  return result.stdout.trim();
}

/**
 * @param {string[]} args
 * @returns {string}
 */
function gitOptional(args) {
  const result = git(args);
  return result.status === 0 ? result.stdout.trim() : '';
}

/**
 * @param {string} uri
 * @returns {{did: string, repo: string}}
 */
function parseRepoUri(uri) {
  const match = /^at:\/\/([^/]+)\/dev\.pdsjs\.git\.repo\/([^/]+)$/.exec(uri);
  if (!match) throw new Error(`invalid upstream repository URI "${uri}"`);
  return { did: match[1], repo: match[2] };
}

/**
 * The shared setup of a pull command: the branch at HEAD, the selected remote
 * and the account it names, and an authenticated XRPC client for that account.
 * The subject is the repository the work is offered to: the copy's own record,
 * or the upstream the record names when the copy is a fork.
 * @param {{remote?: string|undefined}} options
 * @returns {Promise<{
 *   branch: string,
 *   sha: string,
 *   remote: string,
 *   location: {did: string, service: string},
 *   client: XrpcClient,
 *   subject: {uri: string, cid: string},
 *   subjectName: string,
 *   subjectKey: string,
 *   workKey: string,
 *   repoName: string,
 *   authorHandle: string,
 * }>}
 */
async function pullContext(options) {
  const branch = gitValue(
    ['symbolic-ref', '--quiet', '--short', 'HEAD'],
    'read branch',
  );
  const sha = gitValue(['rev-parse', 'HEAD'], 'read HEAD');
  const configuredRemote = gitOptional([
    'config',
    '--get',
    `branch.${branch}.remote`,
  ]);
  const remote = options.remote || configuredRemote || 'origin';
  const remoteUrl = gitValue(
    ['remote', 'get-url', '--push', remote],
    `read ${remote} URL`,
  );
  const parsedRemote = parseAtprotoUrl(remoteUrl);

  const location = await resolveRepoLocation(
    parsedRemote.authority,
    process.env,
  );
  const session = findSessionByDid(process.env, location.did);
  if (!session) {
    throw new Error(
      `no OAuth session for ${location.did}; run git-remote-atproto login first`,
    );
  }
  const client = new XrpcClient(location.service);
  await client.setOauthSession(session, (updated) =>
    saveSession(process.env, session.identifier || location.did, updated),
  );
  // The copy holding the branch is this account's own record, under this
  // account's own key, so the name on the remote finds it. A pull request
  // names a public record; a private repository has none, and its branches
  // are read through the space instead.
  const work = (
    await client.listRecords(location.did, GIT_REPO_COLLECTION)
  ).find(
    (row) => repoNameOf(row.value, rkeyOf(row.uri)) === parsedRemote.repoName,
  );
  if (!work) {
    throw new Error(
      `no git repo record "${parsedRemote.repoName}" on ${parsedRemote.authority}`,
    );
  }

  let subject = { uri: work.uri, cid: work.cid };
  let subjectName = parsedRemote.repoName;
  const workValue = /** @type {{upstream?: unknown}} */ (work.value || {});
  if (typeof workValue.upstream === 'string') {
    const upstream = parseRepoUri(workValue.upstream);
    const upstreamLocation = await resolveRepoLocation(
      upstream.did,
      process.env,
    );
    const upstreamClient = new XrpcClient(upstreamLocation.service);
    const canonical = await upstreamClient.getRecord(
      upstream.did,
      GIT_REPO_COLLECTION,
      upstream.repo,
    );
    if (!canonical) {
      throw new Error(`no upstream git repo record at ${workValue.upstream}`);
    }
    subject = { uri: canonical.uri, cid: canonical.cid };
    subjectName = repoNameOf(canonical.value, upstream.repo);
  }

  return {
    branch,
    sha,
    remote,
    location,
    client,
    subject,
    subjectName,
    // What the pull request record is keyed by, and what it names as the
    // copy holding the branch.
    subjectKey: rkeyOf(subject.uri),
    workKey: rkeyOf(work.uri),
    repoName: parsedRemote.repoName,
    authorHandle: session.identifier || location.did,
  };
}

/**
 * The pull record that names one branch: the canonical repository it is
 * offered to, the author's own ref and tip, and the words the author sets.
 * The same shape the UI writes, so create and update make identical records.
 * @param {Object} about
 * @param {{uri: string, cid: string}} about.subject - the canonical repository
 * @param {string} about.repo - repository name, the rkey of the author's copy
 * @param {string} about.ref - full ref name of the branch
 * @param {string} about.sha
 * @param {string} [about.title]
 * @param {string} [about.status]
 * @param {string} [about.note]
 * @returns {Record<string, unknown>}
 */
function buildPullRecord(about) {
  return {
    $type: GIT_PULL_COLLECTION,
    subject: about.subject,
    repo: about.repo,
    ref: about.ref,
    sha: about.sha,
    ...(about.title ? { title: about.title } : {}),
    ...(about.status ? { status: about.status } : {}),
    ...(about.note ? { note: about.note } : {}),
    createdAt: new Date().toISOString(),
  };
}

/**
 * Create the current branch's pull request: one record at the branch's key,
 * which `pr update` rewrites in place. Nothing may hold the key yet, so a
 * second `pr create` refuses rather than clobbering the words already there.
 * @param {string[]} args
 * @returns {Promise<void>}
 */
async function createPull(args) {
  const options = parsePullOptions(args);
  const { branch, sha, location, client, subject, subjectKey, workKey } =
    await pullContext(options);
  const ref = `refs/heads/${branch}`;
  const record = buildPullRecord({
    subject,
    repo: workKey,
    ref,
    sha,
    title: options.title,
    status: options.draft ? 'draft' : '',
    note: options.note,
  });
  const created = await client.putRecord(
    location.did,
    GIT_PULL_COLLECTION,
    pullRkey(subjectKey, ref),
    record,
    null,
  );
  process.stdout.write(`${created.uri}\n`);
}

/**
 * Update the current branch's pull request: rewrite the record at the
 * branch's key with the newest words and the tip at HEAD, keeping what a
 * flag did not set. The write compares against the version just read, so a
 * concurrent edit surfaces instead of being silently overwritten.
 * @param {string[]} args
 * @returns {Promise<void>}
 */
async function updatePull(args) {
  const options = parsePullOptions(args);
  if (!options.title && !options.note && !options.draft) {
    throw new Error(
      'pr update needs at least one of --title, --body or --draft; nothing to change',
    );
  }
  const { branch, sha, location, client, subject, subjectKey, workKey } =
    await pullContext(options);
  const ref = `refs/heads/${branch}`;
  const rkey = pullRkey(subjectKey, ref);
  const existing = await client.getRecord(
    location.did,
    GIT_PULL_COLLECTION,
    rkey,
  );
  if (!existing) {
    throw new Error(
      `no pull request for ${branch}; run git-remote-atproto pr create first`,
    );
  }
  const held = /** @type {Record<string, unknown>} */ (existing.value);
  const record = buildPullRecord({
    subject,
    repo: workKey,
    ref,
    sha,
    title: options.title ?? /** @type {string|undefined} */ (held.title),
    status: options.draft
      ? 'draft'
      : /** @type {string|undefined} */ (held.status),
    note: options.note ?? /** @type {string|undefined} */ (held.note),
  });
  const updated = await client.putRecord(
    location.did,
    GIT_PULL_COLLECTION,
    rkey,
    record,
    existing.cid,
  );
  process.stdout.write(`${updated.uri}\n`);
}

/**
 * @param {string[]} args
 * @returns {{remote: string|undefined, mode: 'merge'|'squash', subject: string|undefined, body: string|undefined}}
 */
function parseMergeOptions(args) {
  /** @type {string|undefined} */
  let remote;
  /** @type {string|undefined} */
  let subject;
  /** @type {string|undefined} */
  let body;
  /** @type {'merge'|'squash'} */
  let mode = 'merge';
  for (let i = 0; i < args.length; i++) {
    const option = args[i];
    if (option === '--mode') {
      const value = args[++i];
      if (value !== 'merge' && value !== 'squash') throw new Error(USAGE);
      mode = value;
      continue;
    }
    if (
      option !== '--remote' &&
      option !== '--subject' &&
      option !== '--body'
    ) {
      throw new Error(USAGE);
    }
    const value = args[++i];
    if (!value || value.startsWith('--')) throw new Error(USAGE);
    if (option === '--remote') remote = value;
    else if (option === '--subject') subject = value;
    else body = value;
  }
  return { remote, mode, subject, body };
}

/**
 * The account the work is offered to, as the canonical record names it. A
 * self-merge names the branch's own copy, which is the same account.
 * @param {{uri: string}} subject
 * @returns {{did: string}}
 */
function ownerOf(subject) {
  const match = /^at:\/\/([^/]+)\/dev\.pdsjs\.git\.repo\/([^/]+)$/.exec(
    subject.uri,
  );
  if (!match) throw new Error(`invalid repository record ${subject.uri}`);
  return { did: match[1] };
}

/**
 * Merge the current branch's pull request onto the repository's canonical
 * branch. Merging is the repository owner's own act, so the owner's session
 * is required: for a fork that is a session for the upstream account, not the
 * branch author's. A self-merge reuses the session the branch already reads
 * with.
 * @param {string[]} args
 * @returns {Promise<void>}
 */
async function mergePull(args) {
  const options = parseMergeOptions(args);
  const { branch, location, client, subject, subjectName, authorHandle } =
    await pullContext(options);
  const owner = ownerOf(subject);

  let ownerClient = client;
  if (owner.did !== location.did) {
    const ownerLocation = await resolveRepoLocation(owner.did, process.env);
    const session = findSessionByDid(process.env, owner.did);
    if (!session) {
      throw new Error(
        `only the repository's owner can merge; run git-remote-atproto login for ${owner.did}`,
      );
    }
    ownerClient = new XrpcClient(ownerLocation.service);
    await ownerClient.setOauthSession(session, (updated) =>
      saveSession(process.env, session.identifier || owner.did, updated),
    );
  }

  const ref = `refs/heads/${branch}`;
  const result = await ownerClient.mergePull({
    name: subjectName,
    did: location.did,
    ref,
    mode: options.mode,
    ...(authorHandle ? { handle: authorHandle } : {}),
    // A squash is the change, so its subject is the change's own subject when
    // the caller does not name one.
    ...(options.mode === 'squash'
      ? {
          subject:
            options.subject ??
            gitValue(['log', '-1', '--format=%s'], 'read subject'),
        }
      : {}),
    ...(options.body ? { body: options.body } : {}),
  });
  process.stdout.write(`${result.sha}\n`);
}

async function main() {
  const command = process.argv[2];
  if (command === 'login') {
    await login(process.argv.slice(3));
    return;
  }
  if (command === 'logout') {
    const identifier = process.argv[3];
    if (!identifier) throw new Error(USAGE);
    deleteSession(process.env, identifier);
    process.stderr.write(`Logged out ${identifier}\n`);
    return;
  }
  if (command === 'pr') {
    const action = process.argv[3];
    if (action === 'create') {
      await createPull(process.argv.slice(4));
      return;
    }
    if (action === 'update') {
      await updatePull(process.argv.slice(4));
      return;
    }
    if (action === 'merge') {
      await mergePull(process.argv.slice(4));
      return;
    }
    throw new Error(USAGE);
  }
  const url = process.argv[3] ?? process.argv[2];
  if (!url) {
    process.stderr.write(USAGE);
    process.exit(129);
  }
  const helper = new AtprotoRemoteHelper({
    url,
    input: process.stdin,
    write: (text) => process.stdout.write(text),
    log: (text) => process.stderr.write(text),
  });
  await helper.run();
}

main().catch((err) => {
  const message = err instanceof Error ? err.message : String(err);
  process.stderr.write(`fatal: ${message}\n`);
  process.exit(128);
});