chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
#!/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);
});