#!/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': `\n\n \n \n ${NAME}\n \n \n \n` + repeat((n) => `
Panel ${n}
\n`, 60) + ' \n\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} */ ({}), }, ); } 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}`);