#!/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();
// 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}`);