chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

main
368 lines · 15.7 KB Raw History
// The world the screenshots show: a reference PDS holding the same
// repositories, pulls and issues every time, so each page renders to the
// same pixels. Commits carry fixed dates, so their hashes are fixed too, and
// every record's createdAt is rewritten to a fixed time, in the order the
// records were made. The index a knotless-serve built while it was seeded
// is kept in `dir`, with its mirrors, for the next server to start from.
//
//   node harness/shots/world.mjs    seed one and keep it up, for
//                                   `just shots` runs to reuse, until
//                                   ctrl-c; written to target/shots/world.json

import { spawn, execFileSync } from 'node:child_process'
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { createInterface } from 'node:readline'
import { fileURLToPath } from 'node:url'

const here = dirname(fileURLToPath(import.meta.url))
export const root = resolve(here, '../..')
const fixture = join(here, 'fixture')
const bin = join(root, 'target/debug')
// Where the world answers, and the pages behind it on the next two ports:
// SHOTS_PORT moves them, for a second checkout's shots beside a first's.
export const port = Number(process.env.SHOTS_PORT ?? 2593)
export const base = `http://127.0.0.1:${port}`
export const saved = join(root, 'target/shots/world.json')

const people = {
  alice: { name: 'Alice Moreau', email: 'alice@example.com' },
  bob: { name: 'Bob Tanaka', email: 'bob@example.com' },
}

export const until = async (what, check) => {
  for (let i = 0; i < 300; i++) {
    try {
      if (await check()) return
    } catch {}
    await new Promise((r) => setTimeout(r, 100))
  }
  throw new Error(`timed out waiting for ${what}`)
}

export const page = async (path) => {
  const res = await fetch(base + path)
  return res.ok ? res.text() : ''
}

/// The environment `who` runs the CLI, git or the server in.
const environment = (net, dir, who, when) => {
  const clean = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('KNOTLESS_')))
  const person = people[who] ?? { name: who, email: `${who}@example.com` }
  return {
    ...clean,
    PATH: `${bin}:${process.env.PATH}`,
    KNOTLESS_HANDLE_RESOLVER: net.pds,
    KNOTLESS_PLC_URL: net.plc,
    KNOTLESS_COMPACT: 'off',
    KNOTLESS_APPVIEW: base,
    KNOTLESS_CONFIG_DIR: join(dir, who, 'config'),
    KNOTLESS_CACHE_DIR: join(dir, who, 'cache'),
    GIT_CONFIG_GLOBAL: '/dev/null',
    GIT_CONFIG_NOSYSTEM: '1',
    GIT_AUTHOR_NAME: person.name,
    GIT_AUTHOR_EMAIL: person.email,
    GIT_COMMITTER_NAME: person.name,
    GIT_COMMITTER_EMAIL: person.email,
    GIT_AUTHOR_DATE: when ?? '2026-10-01T09:00:00Z',
    GIT_COMMITTER_DATE: when ?? '2026-10-01T09:00:00Z',
  }
}

/// The pages, from web/build, answering on `at` and reading from `data`.
export const pages = (at, data) =>
  spawn('node', [join(root, 'web/build')], {
    env: {
      ...process.env,
      HOST: '127.0.0.1',
      PORT: String(at),
      KNOTLESS_SERVE: data,
      HOST_HEADER: 'x-forwarded-host',
      PROTOCOL_HEADER: 'x-forwarded-proto',
    },
    stdio: 'inherit',
  })

/// knotless-serve from this build, over the world's index and mirrors, on
/// `at`: in front of the pages, unless `alone`.
export const serve = async (world, { at = port, alone = false } = {}) => {
  const drawn = alone ? null : pages(port + 1, base)
  const server = spawn(
    join(bin, 'knotless-serve'),
    [
      '--listen', `127.0.0.1:${at}`,
      '--index', join(world.dir, 'index.db'),
      '--firehose', world.net.pds.replace('http', 'ws'),
      ...(alone ? [] : ['--web', `http://127.0.0.1:${port + 1}`]),
    ],
    { env: environment(world.net, world.dir, 'server'), stdio: 'inherit' },
  )
  const kill = () => {
    server.kill()
    drawn?.kill()
  }
  const up = alone ? `http://127.0.0.1:${at}/healthz` : base
  try {
    await until('knotless-serve and the pages', async () => (await fetch(up)).ok)
  } catch (e) {
    kill()
    throw e
  }
  return { kill }
}

/// A new world: its PDS, a server, and the seeded data. Returns them with
/// the pages to shoot.
export async function create() {
  const dir = mkdtempSync(join(tmpdir(), 'knotless-shots-'))
  const children = []
  const stop = () => {
    for (const child of children.reverse()) child.kill()
    rmSync(dir, { recursive: true, force: true })
  }
  try {
    const pds = spawn('node', ['harness/pds.mjs', '--accounts', 'alice,bob'], {
      cwd: root,
      stdio: ['pipe', 'pipe', 'inherit'],
    })
    children.push(pds)
    const [line] = await createInterface({ input: pds.stdout })[Symbol.asyncIterator]().next().then((r) => [r.value])
    const net = JSON.parse(line)
    const account = Object.fromEntries(net.accounts.map((a) => [a.handle.split('.')[0], a]))

    const work = dir
    const env = (who, when) => environment(net, dir, who, when)
    const run = (who, cmd, args, opts = {}) =>
      execFileSync(cmd, args, { env: env(who, opts.when), cwd: opts.cwd ?? work, input: opts.input ?? '' })
        .toString()
        .trim()
    const knotless = (who, ...args) => run(who, join(bin, 'knotless'), args)
    const git = (who, dir, when, ...args) => run(who, 'git', args, { cwd: dir, when })
    /// Copies files from a fixture step into `dir` and commits them.
    const commit = (who, dir, when, message, files) => {
      for (const [from, to] of Object.entries(files)) {
        mkdirSync(dirname(join(dir, to)), { recursive: true })
        cpSync(join(fixture, from), join(dir, to))
      }
      git(who, dir, when, 'add', '-A')
      git(who, dir, when, 'commit', '-q', '-m', message)
    }

    children.push(await serve({ net, dir }))

    for (const who of ['alice', 'bob']) {
      run(who, join(bin, 'knotless'), ['login', account[who].handle, '--password-stdin'], { input: account[who].password })
    }
    const tally = 'knotless://alice.test/tally'
    knotless('alice', 'create', tally, '--description', 'Counts lines, words and bytes, with output that lines up')
    const a = join(work, 'alice/tally')
    mkdirSync(a, { recursive: true })
    git('alice', a, null, 'init', '-q', '-b', 'main')
    commit('alice', a, '2026-10-01T09:12:00Z', 'Start tally', {
      'start/Cargo.toml': 'Cargo.toml',
      'start/.gitignore': '.gitignore',
      'start/README.md': 'README.md',
      'start/main.rs': 'src/main.rs',
    })
    commit('alice', a, '2026-10-02T14:40:00Z', 'Count words, lines and bytes', {
      'count/lib.rs': 'src/lib.rs',
      'count/main.rs': 'src/main.rs',
    })
    commit(
      'alice',
      a,
      '2026-10-03T08:05:00Z',
      'Read from stdin when no file is given\n\nSo tally works at the end of a pipe, as wc does. The name shown\nfor stdin is "-".',
      { 'stdin/main.rs': 'src/main.rs' },
    )
    commit('alice', a, '2026-10-05T17:21:00Z', 'Add a logo', { 'logo/logo.svg': 'assets/logo.svg' })
    commit('alice', a, '2026-10-06T10:02:00Z', 'Document usage and output', {
      'docs/README.md': 'README.md',
      'docs/design.md': 'docs/design.md',
    })
    git('alice', a, null, 'mv', 'docs/design.md', 'docs/DESIGN.md')
    commit('alice', a, '2026-10-08T11:47:00Z', 'Split parsing into its own module', {
      'parse/parse.rs': 'src/parse.rs',
      'parse/lib.rs': 'src/lib.rs',
    })
    const parse = git('alice', a, null, 'rev-parse', 'HEAD')
    git('alice', a, null, 'push', '-q', tally, 'main')

    const notes = 'knotless://alice.test/notes'
    knotless('alice', 'create', notes, '--description', 'Scratch notes 📝')
    const n = join(work, 'alice/notes')
    mkdirSync(n, { recursive: true })
    git('alice', n, null, 'init', '-q', '-b', 'main')
    commit('alice', n, '2026-09-20T19:30:00Z', 'Begin', { 'notes/README.md': 'README.md' })
    git('alice', n, null, 'push', '-q', notes, 'main')
    knotless('alice', 'create', 'knotless://alice.test/scratch')

    const fork = knotless('bob', 'fork', tally)
    const b = join(work, 'bob/tally')
    git('bob', work, null, 'clone', '-q', fork, b)
    git('bob', b, null, 'checkout', '-q', '-b', 'empty-input')
    commit('bob', b, '2026-10-09T09:31:00Z', 'Count no lines in empty input', { 'fix/parse.rs': 'src/parse.rs' })
    git('bob', b, null, 'push', '-q', 'origin', 'empty-input')
    git('bob', b, null, 'checkout', '-q', '-b', 'json', 'main')
    commit('bob', b, '2026-10-10T16:12:00Z', 'Print counts as JSON with --json', { 'json/main.rs': 'src/main.rs' })
    git('bob', b, null, 'push', '-q', 'origin', 'json')

    const pull = knotless(
      'bob', 'pull', tally, 'main', fork, 'empty-input', 'Count no lines in empty input',
      '-m', '`tally` printed one line for an empty file. `lines` now yields nothing when the text is empty, and a test covers it.',
    )
    knotless('alice', 'comment', tally, pull, 'Could `lines` check for emptiness once, rather than in the filter?', '--changes')
    knotless('bob', 'comment', tally, pull, 'It only runs per line, so the cost is one comparison each. Happy to change it if you prefer.')
    knotless('alice', 'comment', tally, pull, 'Fair enough. Thanks!', '--approve')
    // Merging and closing read the item from the index, which follows the
    // PDS a moment behind.
    await until('the pull', async () => (await page('/alice.test/tally/pulls')).includes('Count no lines in empty input'))
    knotless('alice', 'merge', tally, pull)
    const json = knotless(
      'bob', 'pull', tally, 'main', fork, 'json', 'Print counts as JSON with --json',
      '-m', 'For scripts. The object has `lines`, `words`, `bytes` and `name`.',
    )
    const utf8 = knotless(
      'bob', 'issue', tally, 'Fails on files that are not UTF-8',
      '-m', 'Running `tally image.png` panics:\n\n```\nthread \'main\' panicked at src/main.rs:8:56:\nstream did not contain valid UTF-8\n```\n\nwc counts bytes for any file.',
    )
    knotless('alice', 'comment', tally, utf8, 'Confirmed. Reading bytes and counting words lossily would fix it.')
    const typo = knotless('bob', 'issue', tally, 'Typo in the README ✏️')
    await until('the issue', async () => (await page('/alice.test/tally/issues')).includes('Typo in the README'))
    knotless('alice', 'close', tally, typo)

    // Every record a fixed createdAt, keeping their order.
    const records = []
    for (const who of ['alice', 'bob']) {
      const session = await fetch(`${net.pds}/xrpc/com.atproto.server.createSession`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ identifier: account[who].handle, password: account[who].password }),
      }).then((r) => r.json())
      const did = account[who].did
      if (who === 'alice') {
        const res = await fetch(`${net.pds}/xrpc/com.atproto.repo.putRecord`, {
          method: 'POST',
          headers: { 'content-type': 'application/json', authorization: `Bearer ${session.accessJwt}` },
          body: JSON.stringify({
            repo: did,
            collection: 'app.bsky.actor.profile',
            rkey: 'self',
            record: {
              $type: 'app.bsky.actor.profile',
              displayName: people.alice.name,
              description: 'Small tools for text. Writes tally.',
            },
          }),
        })
        if (!res.ok) throw new Error(`profile: ${await res.text()}`)
      }
      const { collections } = await fetch(`${net.pds}/xrpc/com.atproto.repo.describeRepo?repo=${did}`).then((r) => r.json())
      for (const collection of collections) {
        let cursor = ''
        do {
          const listed = await fetch(
            `${net.pds}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=${collection}&limit=100${cursor}`,
          ).then((r) => r.json())
          for (const r of listed.records) {
            if (typeof r.value.createdAt === 'string') records.push({ ...r, did, collection, jwt: session.accessJwt })
          }
          cursor = listed.cursor ? `&cursor=${listed.cursor}` : ''
        } while (cursor)
      }
    }
    records.sort((x, y) => (x.value.createdAt < y.value.createdAt ? -1 : x.value.createdAt > y.value.createdAt ? 1 : 0))
    const start = Date.parse('2026-10-09T10:00:00Z')
    const cids = new Map()
    // Strong references to a record whose CID changed are pointed at the new one.
    const repoint = (v) => {
      if (Array.isArray(v)) return v.map(repoint)
      if (v && typeof v === 'object') {
        const out = Object.fromEntries(Object.entries(v).map(([k, x]) => [k, repoint(x)]))
        if (typeof out.uri === 'string' && typeof out.cid === 'string' && cids.has(out.uri)) out.cid = cids.get(out.uri)
        return out
      }
      return v
    }
    for (const [i, r] of records.entries()) {
      const value = repoint({ ...r.value, createdAt: new Date(start + i * 37 * 60_000).toISOString() })
      const res = await fetch(`${net.pds}/xrpc/com.atproto.repo.putRecord`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', authorization: `Bearer ${r.jwt}` },
        body: JSON.stringify({ repo: r.did, collection: r.collection, rkey: r.uri.split('/').pop(), record: value }),
      })
      if (!res.ok) throw new Error(`putRecord ${r.uri}: ${await res.text()}`)
      cids.set(r.uri, (await res.json()).cid)
    }

    const item = (kind, uri) => {
      const [, , did, , rkey] = uri.split('/')
      return `/alice.test/tally/${kind}/${did}/${rkey}`
    }
    const views = {
      home: '/',
      search: '/?q=t',
      start: '/start',
      profile: '/alice.test',
      'profile-fork': '/bob.test',
      repository: '/alice.test/tally',
      directory: '/alice.test/tally/tree/main/src',
      file: '/alice.test/tally/blob/main/src/lib.rs',
      markdown: '/alice.test/tally/blob/main/README.md',
      image: '/alice.test/tally/blob/main/assets/logo.svg',
      commits: '/alice.test/tally/commits/main',
      commit: `/alice.test/tally/commit/${parse}`,
      pulls: '/alice.test/tally/pulls',
      'pulls-closed': '/alice.test/tally/pulls?state=closed',
      pull: item('pull', pull),
      'pull-open': item('pull', json),
      issues: '/alice.test/tally/issues',
      'issues-closed': '/alice.test/tally/issues?state=closed',
      issue: item('issue', utf8),
      'issue-closed': item('issue', typo),
      fork: '/bob.test/tally',
      empty: '/alice.test/scratch',
      missing: '/alice.test/tally/blob/main/nowhere',
    }
    // The pages whose social cards are shot, by the names of the shots.
    const cards = {
      home: '/',
      repository: '/alice.test/tally',
      notes: '/alice.test/notes',
      pull: views.pull,
      'pull-open': views['pull-open'],
      issue: views.issue,
      'issue-closed': item('issue', typo),
    }
    // The index has caught up when no page shows today's date, every
    // record now being dated before it, and the merge shows.
    const today = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
    await until('the index', async () => {
      const pages = await Promise.all(Object.values(views).map(page))
      const all = pages.join('')
      return !all.includes(today) && all.includes('Merged as') && all.includes('Forked from') && all.includes('1 Closed')
    })
    return { net, dir, views, cards, pds, server: children[1], stop }
  } catch (e) {
    stop()
    throw e
  }
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const world = await create()
  world.server.kill()
  mkdirSync(dirname(saved), { recursive: true })
  writeFileSync(saved, JSON.stringify({ net: world.net, dir: world.dir, views: world.views, cards: world.cards }, null, 2))
  console.log(`seeded; kept up for \`just shots\` until ctrl-c (${saved})`)
  const end = () => {
    rmSync(saved, { force: true })
    world.stop()
    process.exit(0)
  }
  process.on('SIGINT', end)
  process.on('SIGTERM', end)
  process.stdin.on('end', end)
  process.stdin.resume()
}