Something went wrong. Try again.
An AT Protocol Personal Data Server written in JavaScript pdsjs.dev
pds atproto
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365#!/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();
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: NAME, 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}`);