chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The workflows use shared steps in place of their pasted blocks
.knotless/workflows/check.yml+2−4
| @@ -16,9 +16,7 @@ on: | ||
pull: {base: [refs/heads/main, refs/heads/feature/*]} | ||
cache: [target, /usr/local/cargo/registry, /usr/local/rustup] | ||
steps: | ||
- name: fmt | ||
run: rustup component add rustfmt && cargo fmt --all --check | ||
- name: clippy | ||
run: rustup component add clippy && cargo clippy --workspace --all-targets --locked -- -D warnings | ||
# cargo fmt --check, then clippy over the workspace, warnings denied. | ||
- uses: chadtmiller.com/steps/rust-lint@1 | ||
.knotless/workflows/check.ymlline 20This | ||
- name: changes | ||
run: harness/changes.sh | ||
.knotless/workflows/docs.yml+7−9
| @@ -13,17 +13,15 @@ on: | ||
push: {refs: [refs/heads/main]} | ||
pull: | ||
base: [refs/heads/main, refs/heads/feature/*] | ||
paths: [docs/**, README.md, AGENTS.md, deploy/README.md, harness/docs.mjs, docs-site/**, lexicons/**, web/src/tokens.css, web/package-lock.json, .knotless/workflows/docs.yml] | ||
paths: [docs/**, README.md, AGENTS.md, deploy/README.md, harness/docs.mjs, docs-site/**, lexicons/**, web/src/tokens.css, web/package-lock.json, .knotless/workflows/docs.yml, .knotless/uses.lock] | ||
cache: [web/node_modules, docs-site/node_modules] | ||
steps: | ||
- name: packages | ||
run: | | ||
for dir in web docs-site; do | ||
lock=$(sha256sum $dir/package-lock.json) | ||
[ "$(cat $dir/node_modules/.lock 2>/dev/null)" = "$lock" ] && continue | ||
(cd $dir && npm ci --no-audit --no-fund) | ||
echo "$lock" > $dir/node_modules/.lock | ||
done | ||
- uses: chadtmiller.com/steps/npm-ci@1 | ||
name: web | ||
with: {dir: web} | ||
- uses: chadtmiller.com/steps/npm-ci@1 | ||
name: docs-site | ||
with: {dir: docs-site} | ||
- name: docs | ||
run: node harness/docs.mjs | ||
- name: site | ||
.knotless/workflows/e2e.yml+9−14
| @@ -21,7 +21,7 @@ on: | ||
push: {refs: [refs/heads/main]} | ||
pull: | ||
base: [refs/heads/main, refs/heads/feature/*] | ||
paths: [web/**, lexicons/**, crates/knotless/**, crates/knotless-serve/**, crates/knotless-mcp/**, Cargo.toml, Cargo.lock, harness/e2e/**, harness/shots/world.mjs, harness/shots/fixture/**, harness/pds.mjs, harness/tls-proxy.mjs, harness/package.json, harness/package-lock.json, .knotless/workflows/e2e.yml] | ||
paths: [web/**, lexicons/**, crates/knotless/**, crates/knotless-serve/**, crates/knotless-mcp/**, Cargo.toml, Cargo.lock, harness/e2e/**, harness/shots/world.mjs, harness/shots/fixture/**, harness/pds.mjs, harness/tls-proxy.mjs, harness/package.json, harness/package-lock.json, .knotless/workflows/e2e.yml, .knotless/uses.lock] | ||
cache: [target, /usr/local/cargo/registry, /usr/local/rustup, /opt/node, /opt/browsers, /opt/chromium-debs, /opt/atproto, web/node_modules, harness/node_modules] | ||
env: | ||
PLAYWRIGHT_BROWSERS_PATH: /opt/browsers | ||
| @@ -30,19 +30,14 @@ env: | ||
ATPROTO_COMMIT: 11c5d98cd50261c5e1201fedc5c6c05a7b8d1ccf | ||
ALPHA_BUILD: "2" | ||
steps: | ||
- name: node | ||
run: | | ||
[ -x /opt/node/bin/node ] || curl -fsSL https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.xz | tar -xJ -C /opt/node --strip-components 1 | ||
/opt/node/bin/node --version | ||
- name: packages | ||
run: | | ||
export PATH=/opt/node/bin:$PATH | ||
for dir in web harness; do | ||
lock=$(sha256sum $dir/package-lock.json) | ||
[ "$(cat $dir/node_modules/.lock 2>/dev/null)" = "$lock" ] && continue | ||
(cd $dir && npm ci --no-audit --no-fund) | ||
echo "$lock" > $dir/node_modules/.lock | ||
done | ||
- uses: chadtmiller.com/steps/node@1 | ||
with: {version: 24.22.1} | ||
- uses: chadtmiller.com/steps/npm-ci@1 | ||
name: web | ||
with: {dir: web} | ||
- uses: chadtmiller.com/steps/npm-ci@1 | ||
name: harness | ||
with: {dir: harness} | ||
- name: chromium | ||
run: | | ||
export PATH=/opt/node/bin:$PATH | ||
.knotless/workflows/publish-lexicons.yml+28−34
| @@ -1,11 +1,12 @@ | ||
# Publishes the lexicons as atproto resolves them, once main's checks | ||
# pass at a commit that changes them (deploy/publish-lexicons.mjs): the | ||
# `_lexicon.<authority>` TXT records in the knotless.dev zone, naming | ||
# lexicons.knotless.dev, and a com.atproto.lexicon.schema record there | ||
# for each lexicon. What is new or changed is written, and what main no longer has | ||
# is deleted, schemas and the records of authorities none uses now. Each | ||
# run makes the whole set match main, so a run cut off by a newer one | ||
# leaves nothing the next does not finish. | ||
# pass at a commit that changes them, with the shared publish-lexicons | ||
# step (chadtmiller.com/steps): the `_lexicon.<authority>` TXT records in | ||
# the knotless.dev zone, naming lexicons.knotless.dev, and a | ||
# com.atproto.lexicon.schema record there for each lexicon. What is new or | ||
# changed is written, and what main no longer has is deleted, schemas and | ||
# the records of authorities none uses now. Each run makes the whole set | ||
# match main, so a run cut off by a newer one leaves nothing the next does | ||
# not finish. | ||
# | ||
# lexicons.knotless.dev is an account of its own, on a pds.js worker | ||
# (knotless-lexicons-pds), so that what CI holds writes lexicons and | ||
| @@ -14,39 +15,32 @@ | ||
# resolvers see it, and changes only with CLOUDFLARE_API_TOKEN, a token | ||
# with Zone:DNS:Edit on knotless.dev that the host's address may use: it | ||
# is needed only when an authority is added or dropped, and is not | ||
# granted now, so such a run fails, saying which record to make. | ||
# Asked for on any other branch, | ||
# `knotless rerun <repo> --ref refs/heads/<branch> --workflow | ||
# publish-lexicons`, it says what it would change and changes nothing. | ||
# granted now, so such a run writes the schemas, then fails, saying which | ||
# record to make. | ||
# | ||
# Asked for on any other branch, `knotless rerun <repo> --ref | ||
# refs/heads/<branch> --workflow publish-lexicons`, it plans and skips the | ||
# wait for checks. It is given no secrets there, so its write passes only | ||
# when there is nothing to change, and otherwise fails having written | ||
# nothing. | ||
on: | ||
push: {refs: [refs/heads/main], paths: [lexicons/**, deploy/publish-lexicons.mjs]} | ||
push: {refs: [refs/heads/main], paths: [lexicons/**, .knotless/workflows/publish-lexicons.yml]} | ||
asked: {refs: [refs/heads/*]} | ||
image: node:24.22.1 | ||
secrets: [LEX_PASSWORD, CLOUDFLARE_API_TOKEN] | ||
env: | ||
REPO: knotless://chadtmiller.com/knotless | ||
KNOTLESS_APPVIEW: https://knotless.dev | ||
LEX_DID: did:plc:yqsg7znbbhfmj3a2nrpnaerk | ||
LEX_ZONE: knotless.dev | ||
# Cloudflare refuses a token limited to an address over IPv6. | ||
NODE_OPTIONS: --dns-result-order=ipv4first | ||
steps: | ||
- name: plan | ||
run: node deploy/publish-lexicons.mjs | ||
- name: checks pass | ||
timeout: 30 | ||
run: | | ||
[ "$KNOTLESS_RUN_REF" = refs/heads/main ] || exit 0 | ||
curl -fsSL https://knotless.dev/install.sh | sh | ||
end=$(($(date +%s) + 1500)) | ||
for wf in check test web lexicons shots docs e2e; do | ||
left=$((end - $(date +%s))) | ||
[ "$left" -gt 0 ] || left=1 | ||
out=$(~/.local/bin/knotless checks "$REPO" --ref "$KNOTLESS_RUN_REF" --commit "$KNOTLESS_RUN_SHA" --workflow "$wf" --wait "$left") | ||
echo "$out" | ||
case "$out" in success*) ;; *) echo "$wf has not passed: not publishing"; exit 1 ;; esac | ||
done | ||
- name: publish | ||
run: | | ||
[ "$KNOTLESS_RUN_REF" = refs/heads/main ] || exit 0 | ||
node deploy/publish-lexicons.mjs --write | ||
# What a write would change, with no secrets. | ||
- uses: chadtmiller.com/steps/publish-lexicons@1 | ||
name: plan | ||
with: {did: "did:plc:yqsg7znbbhfmj3a2nrpnaerk", zone: knotless.dev} | ||
- uses: chadtmiller.com/steps/wait-for-checks@1 | ||
name: checks pass | ||
with: {workflows: check test web lexicons shots docs e2e, only-on: refs/heads/main} | ||
- uses: chadtmiller.com/steps/publish-lexicons@1 | ||
name: publish | ||
with: {did: "did:plc:yqsg7znbbhfmj3a2nrpnaerk", zone: knotless.dev, write: "true"} | ||
secrets: [LEX_PASSWORD, CLOUDFLARE_API_TOKEN] | ||
.knotless/workflows/release.yml+6−12
| @@ -36,18 +36,12 @@ steps: | ||
elif [ "$KNOTLESS_RUN_REF" != refs/heads/main ]; then | ||
echo "on $KNOTLESS_RUN_REF: building $version, publishing nothing" | ||
fi | ||
- name: checks pass | ||
timeout: 30 | ||
run: | | ||
[ ! -e ~/.done ] && [ "$KNOTLESS_RUN_REF" = refs/heads/main ] || exit 0 | ||
end=$(($(date +%s) + 1500)) | ||
for wf in check test web lexicons shots docs e2e; do | ||
left=$((end - $(date +%s))) | ||
[ "$left" -gt 0 ] || left=1 | ||
out=$(knotless checks "$REPO" --ref "$KNOTLESS_RUN_REF" --commit "$KNOTLESS_RUN_SHA" --workflow "$wf" --wait "$left") | ||
echo "$out" | ||
case "$out" in success*) ;; *) echo "$wf has not passed: not publishing"; exit 1 ;; esac | ||
done | ||
# All seven, over 25 minutes, with the knotless just built. On main | ||
# only, and even when the version is published already, as the step | ||
# cannot see ~/.done: it then waits for checks it does not need. | ||
- uses: chadtmiller.com/steps/wait-for-checks@1 | ||
name: checks pass | ||
with: {workflows: check test web lexicons shots docs e2e, only-on: refs/heads/main} | ||
- name: tools | ||
run: | | ||
[ ! -e ~/.done ] || exit 0 | ||
.knotless/workflows/scenarios.yml+5−11
| @@ -28,22 +28,16 @@ env: | ||
ATPROTO_COMMIT: 11c5d98cd50261c5e1201fedc5c6c05a7b8d1ccf | ||
ALPHA_BUILD: "2" | ||
steps: | ||
- name: node | ||
run: | | ||
[ -x /opt/node/bin/node ] || curl -fsSL https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.xz | tar -xJ -C /opt/node --strip-components 1 | ||
/opt/node/bin/node --version | ||
- uses: chadtmiller.com/steps/node@1 | ||
with: {version: 24.22.1} | ||
- name: claude | ||
run: | | ||
export PATH=/opt/node/bin:$PATH | ||
[ -x /opt/node/bin/claude ] || npm install -g --no-audit --no-fund @anthropic-ai/claude-code | ||
claude --version | ||
- name: harness | ||
run: | | ||
cd harness | ||
lock=$(sha256sum package-lock.json) | ||
[ "$(cat node_modules/.lock 2>/dev/null)" = "$lock" ] && exit 0 | ||
PATH=/opt/node/bin:$PATH npm ci --omit=dev --no-audit --no-fund | ||
echo "$lock" > node_modules/.lock | ||
- uses: chadtmiller.com/steps/npm-ci@1 | ||
name: harness | ||
with: {dir: harness, omit: dev} | ||
- name: spaces alpha | ||
run: | | ||
case " $TRACKS " in *" private "*) ;; *) exit 0 ;; esac | ||
.knotless/workflows/test.yml+9−18
| @@ -27,25 +27,16 @@ env: | ||
ATPROTO_COMMIT: 11c5d98cd50261c5e1201fedc5c6c05a7b8d1ccf | ||
# Bumped when the steps that build the alpha change, to build it again. | ||
ALPHA_BUILD: "2" | ||
LLVM_COV_VERSION: 0.9.1 | ||
steps: | ||
- name: node | ||
run: | | ||
[ -x /opt/node/bin/node ] || curl -fsSL https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.xz | tar -xJ -C /opt/node --strip-components 1 | ||
/opt/node/bin/node --version | ||
- name: harness | ||
run: | | ||
cd harness | ||
lock=$(sha256sum package-lock.json) | ||
[ "$(cat node_modules/.lock 2>/dev/null)" = "$lock" ] && exit 0 | ||
PATH=/opt/node/bin:$PATH npm ci --omit=dev --no-audit --no-fund | ||
echo "$lock" > node_modules/.lock | ||
- name: tools | ||
run: | | ||
rustup component add llvm-tools-preview | ||
[ "$(cat /opt/tools/.llvm-cov 2>/dev/null)" = "$LLVM_COV_VERSION" ] && exit 0 | ||
curl -fsSL "https://github.com/taiki-e/cargo-llvm-cov/releases/download/v$LLVM_COV_VERSION/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C /opt/tools | ||
echo "$LLVM_COV_VERSION" > /opt/tools/.llvm-cov | ||
# Node into /opt/node, the harness's packages when their lockfile | ||
# changes, and cargo-llvm-cov into /opt/tools, each kept in the cache. | ||
- uses: chadtmiller.com/steps/node@1 | ||
with: {version: 24.22.1} | ||
- uses: chadtmiller.com/steps/npm-ci@1 | ||
name: harness | ||
with: {dir: harness, omit: dev} | ||
- uses: chadtmiller.com/steps/cargo-llvm-cov@1 | ||
with: {version: 0.9.1} | ||
- name: spaces alpha | ||
run: | | ||
[ "$(cat /opt/atproto/.built 2>/dev/null)" = "$ATPROTO_COMMIT $ALPHA_BUILD" ] && exit 0 | ||
.knotless/workflows/web.yml+4−8
| @@ -12,16 +12,12 @@ on: | ||
push: {refs: [refs/heads/main]} | ||
pull: | ||
base: [refs/heads/main, refs/heads/feature/*] | ||
paths: [web/**, lexicons/**, harness/layers.mjs, harness/contrast.mjs, .knotless/workflows/web.yml] | ||
paths: [web/**, lexicons/**, harness/layers.mjs, harness/contrast.mjs, .knotless/workflows/web.yml, .knotless/uses.lock] | ||
cache: [web/node_modules] | ||
steps: | ||
- name: packages | ||
run: | | ||
cd web | ||
lock=$(sha256sum package-lock.json) | ||
[ "$(cat node_modules/.lock 2>/dev/null)" = "$lock" ] && exit 0 | ||
npm ci --no-audit --no-fund | ||
echo "$lock" > node_modules/.lock | ||
- uses: chadtmiller.com/steps/npm-ci@1 | ||
name: packages | ||
with: {dir: web} | ||
- name: layers | ||
run: node harness/layers.mjs | ||
- name: contrast | ||
.knotless/uses.locknew+25−0
| @@ -0,0 +1,25 @@ | ||
# Written by `knotless uses update`. Commit it: runs read it. | ||
chadtmiller.com/steps/cargo-llvm-cov@1: | ||
version: 1.0.0 | ||
commit: 686cdbb38978730bb1ba5d1c999967e48497ce15 | ||
release: at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.release/3mza7iq7tundv.tbnmqntp7ufhx7yd6i3rdqewys.sjjb7q6l3fsl3spvqsuzdoe73w | ||
chadtmiller.com/steps/node@1: | ||
version: 1.0.0 | ||
commit: 686cdbb38978730bb1ba5d1c999967e48497ce15 | ||
release: at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.release/3mza7iq7tundv.qespbvwvof6xwjdaedpzq3koia.sjjb7q6l3fsl3spvqsuzdoe73w | ||
chadtmiller.com/steps/npm-ci@1: | ||
version: 1.0.0 | ||
commit: 686cdbb38978730bb1ba5d1c999967e48497ce15 | ||
release: at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.release/3mza7iq7tundv.dzyz2ceqihhxzgk2f2637ygpfw.sjjb7q6l3fsl3spvqsuzdoe73w | ||
chadtmiller.com/steps/publish-lexicons@1: | ||
version: 1.0.0 | ||
commit: cc436177fab7461a33e84ec8876a2d66656a5c09 | ||
release: at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.release/3mza7iq7tundv.ez5tgppxbpaxxrztjj3cnlyyhc.sjjb7q6l3fsl3spvqsuzdoe73w | ||
chadtmiller.com/steps/rust-lint@1: | ||
version: 1.0.0 | ||
commit: 686cdbb38978730bb1ba5d1c999967e48497ce15 | ||
release: at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.release/3mza7iq7tundv.zvxvlxa6fe7s4pnwsnmgmm63aj.sjjb7q6l3fsl3spvqsuzdoe73w | ||
chadtmiller.com/steps/wait-for-checks@1: | ||
version: 1.0.0 | ||
commit: 686cdbb38978730bb1ba5d1c999967e48497ce15 | ||
release: at://did:plc:bcgltzqazw5tb6k2g3ttenbj/dev.knotless.release/3mza7iq7tundv.nglt7rswdf5dzx2tmob2gquhpu.sjjb7q6l3fsl3spvqsuzdoe73w | ||
deploy/publish-lexicons.mjsdeleted+0−180
| @@ -1,180 +0,0 @@ | ||
// Publishes the lexicons under lexicons/ as atproto resolves them: a TXT | ||
// record `_lexicon.<authority>` naming the account, for each authority | ||
// (`_lexicon.knotless.dev` for `dev.knotless.*`), and in that account a | ||
// com.atproto.lexicon.schema record for each lexicon, keyed by its NSID. | ||
// It adds and updates what is new or changed, and deletes what the | ||
// repository no longer has: a schema of its namespaces, and a `_lexicon` | ||
// record naming the account for an authority none of them uses now. The | ||
// rest of the account and the zone it leaves alone. | ||
// | ||
// node deploy/publish-lexicons.mjs # says what it would do | ||
// node deploy/publish-lexicons.mjs --write | ||
// | ||
// LEX_DID is the account; LEX_PASSWORD, an app password of its, writes the | ||
// schemas, and CLOUDFLARE_API_TOKEN, with Zone:DNS:Edit on LEX_ZONE, the | ||
// DNS. Without the token it reads the DNS as resolvers see it, and fails | ||
// to write while a record is missing or names another account. | ||
import { readdir, readFile } from "node:fs/promises"; | ||
import { join } from "node:path"; | ||
| ||
const COLLECTION = "com.atproto.lexicon.schema"; | ||
const ROOT = new URL("../lexicons", import.meta.url).pathname; | ||
const write = process.argv.includes("--write"); | ||
const { LEX_DID: did, LEX_ZONE: zone = "knotless.dev", LEX_PASSWORD, CLOUDFLARE_API_TOKEN: cfToken } = process.env; | ||
if (!did) throw new Error("LEX_DID names the account to publish into"); | ||
| ||
async function* files(dir) { | ||
for (const e of await readdir(dir, { withFileTypes: true })) { | ||
const p = join(dir, e.name); | ||
if (e.isDirectory()) yield* files(p); | ||
else if (e.name.endsWith(".json")) yield p; | ||
} | ||
} | ||
| ||
const shipped = new Map(); | ||
for await (const p of files(ROOT)) { | ||
const doc = JSON.parse(await readFile(p, "utf8")); | ||
shipped.set(doc.id, doc); | ||
} | ||
| ||
async function json(url, init = {}) { | ||
const res = await fetch(url, init); | ||
if (!res.ok) throw new Error(`${init.method ?? "GET"} ${url.split("?")[0]}: ${res.status} ${await res.text()}`); | ||
return res.json(); | ||
} | ||
| ||
// DNS. An NSID's authority is all but its name, reversed into a domain: | ||
// each has a `_lexicon` record of its own, since resolution never walks up. | ||
const authority = (nsid) => nsid.split(".").slice(0, -1).reverse().join("."); | ||
const domains = [...new Set([...shipped.keys()].map(authority))].sort(); | ||
const outside = domains.filter((d) => d !== zone && !d.endsWith(`.${zone}`)); | ||
if (outside.length) throw new Error(`authorities outside ${zone}: ${outside.join(", ")}`); | ||
const wanted = `did=${did}`; | ||
| ||
// The zone's `_lexicon` TXT records, from Cloudflare with a token, else as | ||
// a resolver sees them now, over DNS-over-HTTPS rather than through what | ||
// this machine has cached. | ||
const cf = (path, init = {}) => | ||
json(`https://api.cloudflare.com/client/v4/${path}`, { | ||
...init, | ||
headers: { authorization: `Bearer ${cfToken}`, "content-type": "application/json" }, | ||
}); | ||
let zoneId; | ||
const records = []; // { name, content, id? } | ||
if (cfToken) { | ||
zoneId = (await cf(`zones?${new URLSearchParams({ name: zone })}`)).result[0]?.id; | ||
if (!zoneId) throw new Error(`the token sees no zone ${zone}`); | ||
for (let page = 1; ; page++) { | ||
const { result, result_info } = await cf(`zones/${zoneId}/dns_records?type=TXT&per_page=500&page=${page}`); | ||
for (const r of result) | ||
if (r.name.startsWith("_lexicon.")) records.push({ id: r.id, name: r.name, content: r.content.replace(/^"|"$/g, "") }); | ||
if (page >= result_info.total_pages) break; | ||
} | ||
} else { | ||
for (const domain of domains) { | ||
const name = `_lexicon.${domain}`; | ||
const { Answer = [] } = await json(`https://cloudflare-dns.com/dns-query?${new URLSearchParams({ name, type: "TXT" })}`, { | ||
headers: { accept: "application/dns-json" }, | ||
}); | ||
// Each string of a record quoted, and a long one split into several. | ||
for (const a of Answer.filter((a) => a.type === 16)) | ||
records.push({ name, content: [...a.data.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]).join("") }); | ||
} | ||
} | ||
const dnsAdd = []; | ||
const dnsFix = []; | ||
for (const domain of domains) { | ||
const name = `_lexicon.${domain}`; | ||
const mine = records.filter((r) => r.name === name && r.content.startsWith("did=")); | ||
if (mine.some((r) => r.content === wanted)) continue; | ||
if (mine.length) dnsFix.push(...mine); | ||
else dnsAdd.push(name); | ||
} | ||
// Only records naming this account: another's `_lexicon` in the zone is | ||
// some other publisher's. | ||
const dnsGone = records.filter((r) => r.content === wanted && !domains.includes(r.name.slice("_lexicon.".length))); | ||
for (const name of dnsAdd) console.log(` ${name} TXT "${wanted}" new`); | ||
for (const r of dnsFix) console.log(` ${r.name} TXT "${wanted}" updated, from "${r.content}"`); | ||
for (const r of dnsGone) console.log(` ${r.name} TXT removed`); | ||
| ||
// Schemas. What the account holds now of the namespaces published from | ||
// here: the first two segments of every NSID shipped (`dev.knotless`). | ||
const { service = [] } = await json( | ||
did.startsWith("did:web:") ? `https://${did.slice(8)}/.well-known/did.json` : `https://plc.directory/${did}`, | ||
); | ||
const pds = service.find((s) => s.id === "#atproto_pds" || s.id === `${did}#atproto_pds`)?.serviceEndpoint; | ||
if (!pds) throw new Error(`${did} names no PDS`); | ||
const xrpc = (method, { query, body, token } = {}) => | ||
json(`${pds}/xrpc/${method}${query ? `?${new URLSearchParams(query)}` : ""}`, { | ||
method: body ? "POST" : "GET", | ||
headers: { | ||
...(token && { authorization: `Bearer ${token}` }), | ||
...(body && { "content-type": "application/json" }), | ||
}, | ||
body: body && JSON.stringify(body), | ||
}); | ||
| ||
const roots = new Set([...shipped.keys()].map((id) => id.split(".").slice(0, 2).join("."))); | ||
const ours = (nsid) => [...roots].some((r) => nsid.startsWith(`${r}.`)); | ||
const held = new Map(); | ||
let cursor; | ||
do { | ||
const page = await xrpc("com.atproto.repo.listRecords", { | ||
query: { repo: did, collection: COLLECTION, limit: "100", ...(cursor && { cursor }) }, | ||
}); | ||
for (const r of page.records) { | ||
const rkey = r.uri.split("/").pop(); | ||
if (ours(rkey)) held.set(rkey, r.value); | ||
} | ||
cursor = page.records.length ? page.cursor : undefined; | ||
} while (cursor); | ||
| ||
// JSON with its keys sorted, to compare a record with its file whatever | ||
// order the PDS hands its keys back in. | ||
const canon = (v) => | ||
Array.isArray(v) | ||
? v.map(canon) | ||
: v && typeof v === "object" | ||
? Object.fromEntries( | ||
Object.keys(v) | ||
.filter((k) => k !== "$type" || v.$type !== COLLECTION) | ||
.sort() | ||
.map((k) => [k, canon(v[k])]), | ||
) | ||
: v; | ||
const same = (a, b) => JSON.stringify(canon(a)) === JSON.stringify(canon(b)); | ||
const put = [...shipped.keys()].filter((id) => !held.has(id) || !same(held.get(id), shipped.get(id))).sort(); | ||
const gone = [...held.keys()].filter((id) => !shipped.has(id)).sort(); | ||
for (const id of put) console.log(` ${id} ${held.has(id) ? "updated" : "new"}`); | ||
for (const id of gone) console.log(` ${id} removed`); | ||
| ||
const dnsChanges = dnsAdd.length + dnsFix.length + dnsGone.length; | ||
console.log( | ||
`DNS: ${dnsChanges} to change. Schemas in ${did} at ${pds}: ` + | ||
`${put.length} to put, ${gone.length} to delete, ${shipped.size - put.length} unchanged.`, | ||
); | ||
if (!write || (!dnsChanges && !put.length && !gone.length)) process.exit(0); | ||
if (dnsChanges && !cfToken) throw new Error("CLOUDFLARE_API_TOKEN is needed to change the DNS"); | ||
if ((put.length || gone.length) && !LEX_PASSWORD) throw new Error("LEX_PASSWORD is needed to write the schemas"); | ||
| ||
// The DNS that names the account first, then the schemas, then what no | ||
// longer resolves: a resolver never meets a record pointing at nothing new. | ||
const txt = (name) => ({ type: "TXT", name, content: `"${wanted}"`, ttl: 1, comment: "lexicon authority" }); | ||
for (const name of dnsAdd) await cf(`zones/${zoneId}/dns_records`, { method: "POST", body: JSON.stringify(txt(name)) }); | ||
for (const r of dnsFix) await cf(`zones/${zoneId}/dns_records/${r.id}`, { method: "PUT", body: JSON.stringify(txt(r.name)) }); | ||
| ||
if (put.length || gone.length) { | ||
const session = await xrpc("com.atproto.server.createSession", { body: { identifier: did, password: LEX_PASSWORD } }); | ||
if (session.did !== did) throw new Error(`signed in as ${session.did}, not ${did}`); | ||
const token = session.accessJwt; | ||
for (const id of put) | ||
await xrpc("com.atproto.repo.putRecord", { | ||
token, | ||
body: { repo: did, collection: COLLECTION, rkey: id, record: { $type: COLLECTION, ...shipped.get(id) } }, | ||
}); | ||
for (const id of gone) | ||
await xrpc("com.atproto.repo.deleteRecord", { token, body: { repo: did, collection: COLLECTION, rkey: id } }); | ||
} | ||
| ||
for (const r of dnsGone) await cf(`zones/${zoneId}/dns_records/${r.id}`, { method: "DELETE" }); | ||
console.log("published"); | ||