diff --git a/bun.lock b/bun.lock index 0667eb2..7a75ef4 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "tangle", "dependencies": { + "@atproto/lexicon": "^0.6.2", "commander": "^12.1.0", }, "devDependencies": { @@ -14,6 +15,16 @@ }, }, "packages": { + "@atproto/common-web": ["@atproto/common-web@0.4.21", "", { "dependencies": { "@atproto/lex-data": "^0.0.15", "@atproto/lex-json": "^0.0.16", "@atproto/syntax": "^0.5.4", "zod": "^3.23.8" } }, "sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw=="], + + "@atproto/lex-data": ["@atproto/lex-data@0.0.15", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw=="], + + "@atproto/lex-json": ["@atproto/lex-json@0.0.16", "", { "dependencies": { "@atproto/lex-data": "^0.0.15", "tslib": "^2.8.1" } }, "sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg=="], + + "@atproto/lexicon": ["@atproto/lexicon@0.6.2", "", { "dependencies": { "@atproto/common-web": "^0.4.18", "@atproto/syntax": "^0.5.0", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw=="], + + "@atproto/syntax": ["@atproto/syntax@0.5.4", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], @@ -22,8 +33,20 @@ "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], + + "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], } } diff --git a/package.json b/package.json index 66eac0a..258c947 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ }, "scripts": { "dev": "bun run src/index.ts", - "build": "bun build --compile --minify --sourcemap --target=bun src/index.ts --outfile dist/tangle", + "vendor": "bun run scripts/vendor-lexicons.ts", + "build": "bun run vendor && bun build --compile --minify --sourcemap --target=bun src/index.ts --outfile dist/tangle", "build:all": "bun run build:darwin-arm64 && bun run build:darwin-x64 && bun run build:linux-x64 && bun run build:linux-arm64", "build:darwin-arm64": "bun build --compile --minify --target=bun-darwin-arm64 src/index.ts --outfile dist/tangle-darwin-arm64", "build:darwin-x64": "bun build --compile --minify --target=bun-darwin-x64 src/index.ts --outfile dist/tangle-darwin-x64", @@ -19,6 +20,7 @@ "typescript": "^5.5.0" }, "dependencies": { + "@atproto/lexicon": "^0.6.2", "commander": "^12.1.0" } } diff --git a/scripts/vendor-lexicons.ts b/scripts/vendor-lexicons.ts new file mode 100644 index 0000000..b5eb8d1 --- /dev/null +++ b/scripts/vendor-lexicons.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env bun +// Vendors lexicon JSONs into src/lexicons/data/ as .json, so the +// runtime loader can find them by NSID without walking nested directories. +// +// Re-run whenever tangled-core lexicons or the upstream atproto lexicons +// change. The set of atproto NSIDs we vendor is explicit below — add to it +// when the CLI starts calling a new com.atproto.* endpoint. +import { readdir, readFile, writeFile, rm, mkdir } from "fs/promises"; +import { join, relative } from "path"; + +const HERE = new URL(".", import.meta.url).pathname; +const ROOT = join(HERE, ".."); +const TANGLED_SRC = join(ROOT, "..", "tangled-core", "lexicons"); +const OUT = join(ROOT, "src", "lexicons", "data"); + +const ATPROTO_NSIDS = [ + "com.atproto.server.createSession", + "com.atproto.server.refreshSession", + "com.atproto.server.getServiceAuth", + "com.atproto.repo.putRecord", + "com.atproto.repo.getRecord", + "com.atproto.repo.listRecords", + "com.atproto.repo.deleteRecord", + "com.atproto.repo.describeRepo", +]; + +async function walkJson(dir: string): Promise { + const out: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) out.push(...(await walkJson(p))); + else if (entry.isFile() && entry.name.endsWith(".json")) out.push(p); + } + return out; +} + +async function main() { + await rm(OUT, { recursive: true, force: true }); + await mkdir(OUT, { recursive: true }); + + let tangledCount = 0; + for (const file of await walkJson(TANGLED_SRC)) { + const lex = JSON.parse(await readFile(file, "utf8")); + if (typeof lex.id !== "string") { + console.warn(`skip (no id): ${relative(TANGLED_SRC, file)}`); + continue; + } + await writeFile(join(OUT, `${lex.id}.json`), JSON.stringify(lex) + "\n"); + tangledCount++; + } + + let atprotoCount = 0; + for (const nsid of ATPROTO_NSIDS) { + const path = nsid.replace(/\./g, "/") + ".json"; + const url = `https://raw.githubusercontent.com/bluesky-social/atproto/main/lexicons/${path}`; + const resp = await fetch(url); + if (!resp.ok) throw new Error(`fetch ${url}: ${resp.status}`); + const lex = (await resp.json()) as { id?: string }; + if (lex.id !== nsid) throw new Error(`${url}: id mismatch (${lex.id} != ${nsid})`); + await writeFile(join(OUT, `${nsid}.json`), JSON.stringify(lex) + "\n"); + atprotoCount++; + } + + // Emit an index.ts that imports each vendored JSON explicitly, so + // `bun build --compile` embeds them into the final binary. + const files = (await readdir(OUT)).filter((f) => f.endsWith(".json")).sort(); + const lines: string[] = [ + "// Auto-generated by scripts/vendor-lexicons.ts — do not edit by hand.", + "// Regenerate with: bun run vendor", + "", + ]; + const nsids: string[] = []; + for (let i = 0; i < files.length; i++) { + const f = files[i]!; + const nsid = f.replace(/\.json$/, ""); + nsids.push(nsid); + lines.push(`import lex${i} from "./data/${f}" with { type: "json" };`); + } + lines.push(""); + lines.push("export const VENDORED_LEXICONS: { id: string; doc: unknown }[] = ["); + for (let i = 0; i < nsids.length; i++) { + lines.push(` { id: ${JSON.stringify(nsids[i])}, doc: lex${i} },`); + } + lines.push("];"); + lines.push(""); + await writeFile(join(ROOT, "src", "lexicons", "generated.ts"), lines.join("\n")); + + console.log( + `vendored ${tangledCount} tangled + ${atprotoCount} atproto lexicons → ${relative(ROOT, OUT)}`, + ); +} + +await main(); diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 12ebe5f..3a0a57e 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -13,6 +13,8 @@ import { resolveIdentity } from "../resolver.ts"; import { createSession, PdsClient, XrpcError } from "../pds.ts"; import { DEFAULT_KNOT } from "../constants.ts"; import { knotDid } from "../serviceXrpc.ts"; +import { AuthError, NotFoundError, ValidationError } from "../errors.ts"; +import { globalOpts, jsonOut, textOut } from "../output.ts"; export function authCommand(): Command { const cmd = new Command("auth").description("manage tangled accounts"); @@ -22,22 +24,17 @@ export function authCommand(): Command { .description("log in with a handle and app password") .argument("") .option("--password ", "app password (omit to be prompted; not recommended on shared shells)") - .action(async (handle: string, opts: { password?: string }) => { + .action(async (handle: string, opts: { password?: string }, cmd: Command) => { const identity = await resolveIdentity(handle); - const password = opts.password ?? (await promptPassword(`App password for ${identity.handle ?? handle}: `)); - if (!password) { - process.stderr.write("error: empty password\n"); - process.exit(1); - } + const password = + opts.password ?? (await promptPassword(`App password for ${identity.handle ?? handle}: `)); + if (!password) throw new ValidationError("empty password"); let session; try { session = await createSession(identity.pds, identity.did, password); } catch (e) { - if (e instanceof XrpcError) { - process.stderr.write(`login failed: ${e.message}\n`); - process.exit(1); - } + if (e instanceof XrpcError) throw new AuthError(`login failed: ${e.message}`); throw e; } @@ -56,60 +53,72 @@ export function authCommand(): Command { await probeClient.getServiceAuth(knotDid(DEFAULT_KNOT), "sh.tangled.repo.create"); } catch (e) { if (e instanceof XrpcError) { - process.stderr.write( - `login succeeded but service-auth probe failed: ${e.message}\n` + - `(your app password may not have permission to call com.atproto.server.getServiceAuth — ` + - `try a fresh app password from your PDS)\n`, + throw new AuthError( + `login succeeded but service-auth probe failed: ${e.message}. ` + + `Your app password may not have permission to call com.atproto.server.getServiceAuth — ` + + `try a fresh app password from your PDS.`, ); - process.exit(1); } throw e; } await setPassword(account.did, password); await upsertAccount(account); - process.stdout.write(`logged in as @${account.handle} (${account.did})\n`); + + const { json } = globalOpts(cmd); + if (json) { + jsonOut({ handle: account.handle, did: account.did, pds: account.pds }); + } else { + textOut(`logged in as @${account.handle} (${account.did})`); + } }); cmd .command("logout") .description("remove an account") .argument("[handle]", "handle to remove (default: current)") - .action(async (handle?: string) => { + .action(async (handle: string | undefined, _opts: unknown, cmd: Command) => { const cfg = await loadConfig(); let did: string | null = null; if (handle) { const a = await findAccountByHandle(handle); - if (!a) { - process.stderr.write(`no account found for ${handle}\n`); - process.exit(1); - } + if (!a) throw new NotFoundError(`no account found for ${handle}`); did = a.did; } else { did = cfg.current; } - if (!did) { - process.stderr.write("no account to log out of\n"); - process.exit(1); - } + if (!did) throw new NotFoundError("no account to log out of"); await deletePassword(did); await removeAccount(did); - process.stdout.write(`logged out ${did}\n`); + if (globalOpts(cmd).json) jsonOut({ did, loggedOut: true }); + else textOut(`logged out ${did}`); }); cmd .command("list") .description("list configured accounts") - .action(async () => { + .action(async (_opts: unknown, cmd: Command) => { const cfg = await loadConfig(); const accounts = Object.values(cfg.accounts); + if (globalOpts(cmd).json) { + jsonOut({ + current: cfg.current, + accounts: accounts.map((a) => ({ + handle: a.handle, + did: a.did, + pds: a.pds, + current: a.did === cfg.current, + })), + }); + return; + } if (accounts.length === 0) { - process.stdout.write("no accounts configured\n"); + textOut("no accounts configured"); return; } for (const a of accounts) { const marker = a.did === cfg.current ? "*" : " "; - process.stdout.write(`${marker} @${a.handle} ${a.did}\n`); + textOut(`${marker} @${a.handle} ${a.did}`); } }); @@ -117,43 +126,51 @@ export function authCommand(): Command { .command("switch") .description("set the current account") .argument("") - .action(async (handle: string) => { + .action(async (handle: string, _opts: unknown, cmd: Command) => { const a = await findAccountByHandle(handle); - if (!a) { - process.stderr.write(`no account found for ${handle}\n`); - process.exit(1); - } + if (!a) throw new NotFoundError(`no account found for ${handle}`); await setCurrent(a.did); - process.stdout.write(`switched to @${a.handle}\n`); + if (globalOpts(cmd).json) jsonOut({ handle: a.handle, did: a.did }); + else textOut(`switched to @${a.handle}`); }); cmd .command("status") - .description("show the current account and verify the session") - .action(async () => { + .description("show the current account and verify the session (alias for `whoami --probe`)") + .action(async (_opts: unknown, cmd: Command) => { const cfg = await loadConfig(); - if (!cfg.current) { - process.stdout.write("not logged in\n"); - return; - } - const a = cfg.accounts[cfg.current]; + const a = cfg.current ? cfg.accounts[cfg.current] : undefined; if (!a) { - process.stdout.write("config inconsistent: current account not found\n"); + if (globalOpts(cmd).json) jsonOut({ loggedIn: false }); + else textOut("not logged in"); return; } const pw = await getPassword(a.did); - process.stdout.write(`current: @${a.handle} (${a.did})\n`); - process.stdout.write(`pds: ${a.pds}\n`); - process.stdout.write(`keychain has password: ${pw ? "yes" : "no"}\n`); - // Cheap session probe — getServiceAuth is the real workhorse. + let probeOk = false; + let probeError: string | null = null; try { const c = new PdsClient(a); await c.getServiceAuth(knotDid(DEFAULT_KNOT), "sh.tangled.repo.create"); - process.stdout.write("service-auth probe: ok\n"); + probeOk = true; await saveConfig(cfg); } catch (e) { - process.stdout.write(`service-auth probe: failed (${(e as Error).message})\n`); + probeError = (e as Error).message; + } + if (globalOpts(cmd).json) { + jsonOut({ + loggedIn: true, + handle: a.handle, + did: a.did, + pds: a.pds, + hasKeychainPassword: pw !== null, + probe: { ok: probeOk, error: probeError }, + }); + return; } + textOut(`current: @${a.handle} (${a.did})`); + textOut(`pds: ${a.pds}`); + textOut(`keychain has password: ${pw ? "yes" : "no"}`); + textOut(`service-auth probe: ${probeOk ? "ok" : `failed (${probeError})`}`); }); return cmd; diff --git a/src/commands/batch.ts b/src/commands/batch.ts new file mode 100644 index 0000000..c5f0e8a --- /dev/null +++ b/src/commands/batch.ts @@ -0,0 +1,125 @@ +import { Command } from "commander"; +import { spawn } from "bun"; + +interface BatchCmd { + cmd?: string | string[]; + args?: string[]; + id?: string; +} + +interface BatchResult { + id?: string; + cmd: string[]; + exitCode: number; + stdout: string; + stderr: string; + durationMs: number; +} + +export function batchCommand(): Command { + return new Command("batch") + .description( + "read NDJSON commands from stdin, execute sequentially with one auth round-trip, emit NDJSON results", + ) + .option("--continue-on-error", "keep going after a non-zero exit code", false) + .option("--parallel ", "max commands to run concurrently", "1") + .option("--json", "ignored (always emits NDJSON)", false) + .action(async (opts: { continueOnError?: boolean; parallel: string }) => { + const concurrency = Math.max(1, Number(opts.parallel) || 1); + + // Rebuild the command prefix so each child invocation lands back in us. + // In a Bun-compiled binary argv[1] is the /$bunfs/... virtual path and + // process.execPath is the real on-disk binary. In `bun run foo.ts` + // mode argv[0] is the bun binary and argv[1] is the entry file. + const isCompiled = (process.argv[1] ?? "").startsWith("/$bunfs/"); + const prefix: string[] = isCompiled + ? [process.execPath] + : [process.argv[0]!, process.argv[1]!]; + + const cmds = await readNdjsonFromStdin(); + const results: BatchResult[] = []; + let failFast = false; + + const run = async (entry: BatchCmd, idx: number): Promise => { + const args = extractArgs(entry); + if (args.length === 0) { + return { + id: entry.id ?? String(idx), + cmd: [], + exitCode: 1, + stdout: "", + stderr: "empty command", + durationMs: 0, + }; + } + const fullCmd = [...prefix, ...args]; + const start = performance.now(); + const proc = spawn({ + cmd: fullCmd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { + id: entry.id ?? String(idx), + cmd: args, + exitCode, + stdout, + stderr, + durationMs: Math.round(performance.now() - start), + }; + }; + + let i = 0; + while (i < cmds.length && !failFast) { + const slice = cmds.slice(i, i + concurrency); + const batch = await Promise.all(slice.map((e, k) => run(e, i + k))); + for (const r of batch) { + results.push(r); + process.stdout.write(JSON.stringify(r) + "\n"); + if (r.exitCode !== 0 && !opts.continueOnError) failFast = true; + } + i += concurrency; + } + + const failed = results.filter((r) => r.exitCode !== 0).length; + process.stderr.write( + `batch: ${results.length - failed}/${results.length} ok${failed ? ` (${failed} failed)` : ""}\n`, + ); + if (failed > 0) process.exit(1); + }); +} + +function extractArgs(entry: BatchCmd): string[] { + if (Array.isArray(entry.cmd)) return [...entry.cmd, ...(entry.args ?? [])]; + if (typeof entry.cmd === "string") { + // Minimal shell-like split: doesn't handle quoting. Users with quoting + // needs should use the array form. + const split = entry.cmd.split(/\s+/).filter((x) => x.length > 0); + return [...split, ...(entry.args ?? [])]; + } + if (entry.args && entry.args.length > 0) return entry.args; + return []; +} + +async function readNdjsonFromStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin as AsyncIterable) chunks.push(chunk); + const text = Buffer.concat(chunks).toString("utf8"); + const out: BatchCmd[] = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed) as BatchCmd; + out.push(parsed); + } catch { + out.push({ cmd: trimmed }); + } + } + return out; +} diff --git a/src/commands/collab.ts b/src/commands/collab.ts index de78040..0a98e26 100644 --- a/src/commands/collab.ts +++ b/src/commands/collab.ts @@ -1,10 +1,12 @@ import { Command } from "commander"; import { requireCurrentAccount } from "../config.ts"; -import { PdsClient } from "../pds.ts"; +import { PdsClient, XrpcError } from "../pds.ts"; import { parseRepoRef, resolveRepo } from "../repoRef.ts"; import { resolveIdentity } from "../resolver.ts"; import { NSID } from "../constants.ts"; import { tid } from "../tid.ts"; +import { globalOpts, jsonOut, textOut } from "../output.ts"; +import { NotFoundError, ValidationError } from "../errors.ts"; export function collabCommand(): Command { const cmd = new Command("collab").description("manage repository collaborators"); @@ -14,7 +16,8 @@ export function collabCommand(): Command { .description("add a collaborator to a repository") .argument("", "handle/name") .argument("", "handle or DID of the user to add") - .action(async (repo: string, collaborator: string) => { + .action(async (repo: string, collaborator: string, _opts: unknown, cmd: Command) => { + const { json } = globalOpts(cmd); const account = await requireCurrentAccount(); const pds = new PdsClient(account); const ref = parseRepoRef(repo); @@ -40,9 +43,147 @@ export function collabCommand(): Command { record, }); - process.stdout.write(`added @${target.handle ?? collaborator} to ${ref.handle}/${ref.name}\n`); - process.stdout.write(`record: ${result.uri}\n`); + if (json) { + jsonOut({ + repo: resolved.atUri, + collaborator: { handle: target.handle, did: target.did }, + record: { uri: result.uri, cid: result.cid }, + }); + } else { + textOut(`added @${target.handle ?? collaborator} to ${ref.handle}/${ref.name}`); + textOut(`record: ${result.uri}`); + } }); + cmd + .command("list") + .description( + "list collaborators for a repo (scans the owner's PDS; only shows owner-issued invites)", + ) + .argument("", "handle/name") + .action(async (repo: string, _opts: unknown, cmd: Command) => { + const { json } = globalOpts(cmd); + const ref = parseRepoRef(repo); + const resolved = await resolveRepo(ref); + + const out: Array<{ handle: string | null; did: string; uri: string; cid: string; createdAt: unknown }> = []; + let cursor: string | undefined; + while (true) { + const qs = new URLSearchParams({ + repo: resolved.ownerDid, + collection: NSID.collaborator, + limit: "100", + }); + if (cursor) qs.set("cursor", cursor); + const r = await fetch(`${resolved.ownerPds}/xrpc/com.atproto.repo.listRecords?${qs}`); + if (!r.ok) throw new XrpcError(r.status, "XrpcError", `listRecords failed (${r.status})`); + const data = (await r.json()) as { + cursor?: string; + records: { uri: string; cid: string; value: Record }[]; + }; + for (const rec of data.records) { + if (rec.value.repo !== resolved.atUri) continue; + const subjectDid = String(rec.value.subject); + let handle: string | null = null; + try { + const ident = await resolveIdentity(subjectDid); + handle = ident.handle; + } catch { + // best-effort handle resolution; leave null on failure + } + out.push({ + handle, + did: subjectDid, + uri: rec.uri, + cid: rec.cid, + createdAt: rec.value.createdAt, + }); + } + if (!data.cursor || data.records.length === 0) break; + cursor = data.cursor; + } + + if (json) return jsonOut({ repo: resolved.atUri, collaborators: out }); + if (out.length === 0) { + textOut("(no collaborators)"); + return; + } + for (const c of out) { + textOut(`${c.handle ? "@" + c.handle : "(no handle)"} ${c.did} ${c.uri}`); + } + }); + + cmd + .command("remove") + .description("remove a collaborator (deletes the inviter's record)") + .argument("", "handle/name") + .argument("", "handle or DID") + .option("--dry-run", "print what would be deleted", false) + .action( + async ( + repo: string, + collaborator: string, + opts: { dryRun?: boolean }, + cmd: Command, + ) => { + const { json } = globalOpts(cmd); + const account = await requireCurrentAccount(); + const pds = new PdsClient(account); + const ref = parseRepoRef(repo); + const resolved = await resolveRepo(ref); + const target = await resolveIdentity(collaborator); + + // Scan the *current user's* PDS for a matching collaborator record — + // only the inviter can rescind an invite they issued. + let match: { uri: string; cid: string } | null = null; + let cursor: string | undefined; + while (true) { + const qs = new URLSearchParams({ + repo: account.did, + collection: NSID.collaborator, + limit: "100", + }); + if (cursor) qs.set("cursor", cursor); + const r = await fetch(`${account.pds}/xrpc/com.atproto.repo.listRecords?${qs}`); + if (!r.ok) throw new XrpcError(r.status, "XrpcError", `listRecords failed (${r.status})`); + const data = (await r.json()) as { + cursor?: string; + records: { uri: string; cid: string; value: Record }[]; + }; + for (const rec of data.records) { + if (rec.value.repo === resolved.atUri && rec.value.subject === target.did) { + match = { uri: rec.uri, cid: rec.cid }; + break; + } + } + if (match || !data.cursor || data.records.length === 0) break; + cursor = data.cursor; + } + + if (!match) { + throw new NotFoundError( + `no collaborator invite from you for ${target.handle ?? target.did} on ${ref.handle}/${ref.name}`, + ); + } + + if (opts.dryRun) { + if (json) jsonOut({ dryRun: true, wouldDelete: match.uri }); + else textOut(`would delete ${match.uri}`); + return; + } + + const rkey = match.uri.split("/").pop(); + if (!rkey) throw new ValidationError(`invalid record uri: ${match.uri}`); + + await pds.procedure("com.atproto.repo.deleteRecord", { + repo: account.did, + collection: NSID.collaborator, + rkey, + }); + if (json) jsonOut({ removed: match.uri, collaborator: { handle: target.handle, did: target.did } }); + else textOut(`removed @${target.handle ?? target.did} from ${ref.handle}/${ref.name}`); + }, + ); + return cmd; } diff --git a/src/commands/identity.ts b/src/commands/identity.ts new file mode 100644 index 0000000..d038a70 --- /dev/null +++ b/src/commands/identity.ts @@ -0,0 +1,105 @@ +import { Command } from "commander"; +import { getCurrentAccount } from "../config.ts"; +import { PdsClient } from "../pds.ts"; +import { resolveIdentity } from "../resolver.ts"; +import { parseRepoRef, resolveRepo } from "../repoRef.ts"; +import { knotDid } from "../serviceXrpc.ts"; +import { DEFAULT_KNOT } from "../constants.ts"; +import { AuthError, NotFoundError, ValidationError } from "../errors.ts"; +import { globalOpts, jsonOut, textOut } from "../output.ts"; + +export function whoamiCommand(): Command { + return new Command("whoami") + .description("print info about the current account and probe the session") + .option("--probe", "also verify the session can mint service-auth JWTs", false) + .action(async (opts: { probe?: boolean }, cmd: Command) => { + const { json } = globalOpts(cmd); + const a = await getCurrentAccount(); + if (!a) throw new AuthError("not logged in — run `tangle auth login `"); + + let probeOk: boolean | null = null; + let probeError: string | null = null; + if (opts.probe) { + try { + const c = new PdsClient(a); + await c.getServiceAuth(knotDid(DEFAULT_KNOT), "sh.tangled.repo.create"); + probeOk = true; + } catch (e) { + probeOk = false; + probeError = (e as Error).message; + } + } + + if (json) { + jsonOut({ + handle: a.handle, + did: a.did, + pds: a.pds, + probe: opts.probe ? { ok: probeOk, error: probeError } : undefined, + }); + } else { + textOut(`handle: @${a.handle}`); + textOut(`did: ${a.did}`); + textOut(`pds: ${a.pds}`); + if (opts.probe) textOut(`probe: ${probeOk ? "ok" : `failed — ${probeError}`}`); + } + }); +} + +export function resolveCommand(): Command { + return new Command("resolve") + .description("resolve a handle, DID, or handle/repo reference") + .argument("", "handle (e.g. alice.bsky.social), DID, or handle/repo") + .action(async (target: string, _opts: unknown, cmd: Command) => { + const { json } = globalOpts(cmd); + + if (target.includes("/") && !target.startsWith("did:")) { + const ref = parseRepoRef(target); + const repo = await resolveRepo(ref); + const payload = { + kind: "repo" as const, + handle: repo.ownerHandle, + did: repo.ownerDid, + pds: repo.ownerPds, + repo: { + name: (repo.record as { name?: string }).name, + knot: repo.knot, + spindle: repo.spindle, + repoDid: repo.repoDid, + atUri: repo.atUri, + cid: repo.recordCid, + }, + }; + if (json) return jsonOut(payload); + textOut(`kind: repo`); + textOut(`handle: ${payload.handle}`); + textOut(`did: ${payload.did}`); + textOut(`pds: ${payload.pds}`); + textOut(`at-uri: ${payload.repo.atUri}`); + textOut(`knot: ${payload.repo.knot}`); + if (payload.repo.spindle) textOut(`spindle: ${payload.repo.spindle}`); + if (payload.repo.repoDid) textOut(`repoDid: ${payload.repo.repoDid}`); + return; + } + + try { + const ident = await resolveIdentity(target); + const payload = { + kind: "identity" as const, + handle: ident.handle, + did: ident.did, + pds: ident.pds, + }; + if (json) return jsonOut(payload); + textOut(`kind: identity`); + textOut(`handle: ${payload.handle ?? "(none in did doc)"}`); + textOut(`did: ${payload.did}`); + textOut(`pds: ${payload.pds}`); + } catch (e) { + const msg = (e as Error).message; + if (/could not resolve/i.test(msg)) throw new NotFoundError(msg); + if (/unsupported DID/i.test(msg)) throw new ValidationError(msg); + throw e; + } + }); +} diff --git a/src/commands/lexicon.ts b/src/commands/lexicon.ts new file mode 100644 index 0000000..7e255f1 --- /dev/null +++ b/src/commands/lexicon.ts @@ -0,0 +1,32 @@ +import { Command } from "commander"; +import { getLexiconDoc, listNsids } from "../lexicons/index.ts"; + +export function lexiconCommand(): Command { + const cmd = new Command("lexicon").description("inspect vendored lexicons"); + + cmd + .command("list") + .description("list all bundled lexicon NSIDs") + .option("--filter ", "only show NSIDs with this prefix") + .action((opts: { filter?: string }) => { + for (const id of listNsids()) { + if (opts.filter && !id.startsWith(opts.filter)) continue; + process.stdout.write(id + "\n"); + } + }); + + cmd + .command("show") + .description("print the bundled lexicon document for an NSID") + .argument("") + .action((nsid: string) => { + const doc = getLexiconDoc(nsid); + if (!doc) { + process.stderr.write(`unknown lexicon: ${nsid}\n`); + process.exit(3); + } + process.stdout.write(JSON.stringify(doc, null, 2) + "\n"); + }); + + return cmd; +} diff --git a/src/commands/pipeline.ts b/src/commands/pipeline.ts index 0a37a86..9569948 100644 --- a/src/commands/pipeline.ts +++ b/src/commands/pipeline.ts @@ -1,6 +1,11 @@ import { Command } from "commander"; import { parseRepoRef, resolveRepo, type ResolvedRepo } from "../repoRef.ts"; import { DEFAULT_SPINDLE } from "../constants.ts"; +import { globalOpts, jsonOut, textOut } from "../output.ts"; +import { requireCurrentAccount } from "../config.ts"; +import { PdsClient } from "../pds.ts"; +import { callServiceXrpc, spindleDid } from "../serviceXrpc.ts"; +import { TimeoutError } from "../errors.ts"; interface StreamEvent { rkey: string; @@ -49,7 +54,8 @@ export function pipelineCommand(): Command { "look back this many hours of spindle history (default: 168 = 7d)", "168", ) - .action(async (repo: string, opts: { limit: string; since: string }) => { + .action(async (repo: string, opts: { limit: string; since: string }, cmd: Command) => { + const { json } = globalOpts(cmd); const ref = parseRepoRef(repo); const resolved = await resolveRepo(ref); @@ -58,25 +64,28 @@ export function pipelineCommand(): Command { const sinceHours = Math.max(1, Number(opts.since) || 168); const sinceCursor = msToNanoCursor(Date.now() - sinceHours * 3600 * 1000); - process.stdout.write( - `scanning knot ${resolved.knot} for pipelines (last ${sinceHours}h)...\n`, - ); + if (!json) textOut(`scanning knot ${resolved.knot} for pipelines (last ${sinceHours}h)...`); const knotEvents = await collectStreamBackfill(`wss://${resolved.knot}/events`, sinceCursor); const matchingPipelines = filterPipelinesForRepo(knotEvents, resolved); if (matchingPipelines.size === 0) { - process.stdout.write(`no pipelines found for ${ref.handle}/${ref.name} in last ${sinceHours}h\n`); + if (json) jsonOut({ pipelines: [], sinceHours, knot: resolved.knot, spindle }); + else textOut(`no pipelines found for ${ref.handle}/${ref.name} in last ${sinceHours}h`); return; } - process.stdout.write( - `found ${matchingPipelines.size} pipeline(s); fetching status from spindle ${spindle}...\n`, - ); + if (!json) { + textOut( + `found ${matchingPipelines.size} pipeline(s); fetching status from spindle ${spindle}...`, + ); + } const spindleEvents = await collectStreamBackfill(`wss://${spindle}/events`, sinceCursor); attachStatuses(matchingPipelines, spindleEvents); const pipelines = [...matchingPipelines.values()].sort((a, b) => b.earliest - a.earliest); - printPipelines(pipelines.slice(0, limit)); + const slice = pipelines.slice(0, limit); + if (json) jsonOut({ pipelines: slice.map(pipelineToJson), knot: resolved.knot, spindle }); + else printPipelines(slice); }); cmd @@ -133,9 +142,239 @@ export function pipelineCommand(): Command { }, ); + cmd + .command("wait") + .description( + "wait until a pipeline reaches a terminal status. Exits 0 on success, 1 on failed/cancelled, 6 on timeout.", + ) + .argument("", "handle/name") + .argument("", "pipeline rkey") + .option("--workflow ", "wait only for this workflow (default: all workflows)") + .option("--timeout ", "give up after this many seconds", "1800") + .action( + async ( + repoArg: string, + pipelineRkey: string, + opts: { workflow?: string; timeout: string }, + cmd: Command, + ) => { + const { json } = globalOpts(cmd); + const ref = parseRepoRef(repoArg); + const r = await resolveRepo(ref); + const spindle = r.spindle || DEFAULT_SPINDLE; + const timeoutMs = Math.max(10, Number(opts.timeout) || 1800) * 1000; + + // Discover workflow names for this pipeline from the knot's /events. + const since = msToNanoCursor(Date.now() - 24 * 3600 * 1000); + const knotEvents = await collectStreamBackfill(`wss://${r.knot}/events`, since); + const discovered = new Set(); + for (const ev of knotEvents) { + if (ev.rkey === pipelineRkey && ev.nsid === "sh.tangled.pipeline") { + const rec = ev.event as PipelineRecord; + for (const w of rec.workflows ?? []) discovered.add(w.name); + } + } + if (discovered.size === 0) { + if (!json) { + textOut( + `warning: could not find pipeline ${pipelineRkey} on ${r.knot} in last 24h. ` + + `Will watch for status events but may wait until timeout if the pipeline doesn't exist.`, + ); + } + } + const watching = opts.workflow ? new Set([opts.workflow]) : discovered; + + // Get any status events already on the spindle. + const spindleBack = await collectStreamBackfill(`wss://${spindle}/events`, since); + const latestByWorkflow = new Map(); + const applyStatus = (ev: StreamEvent) => { + if (ev.nsid !== "sh.tangled.pipeline.status") return; + const rec = ev.event as StatusRecord; + const rkey = (rec.pipeline ?? "").split("/").pop() ?? ""; + if (rkey !== pipelineRkey) return; + const wf = rec.workflow ?? "?"; + if (watching.size > 0 && !watching.has(wf)) return; + const prev = latestByWorkflow.get(wf); + if (!prev || (rec.createdAt ?? "") > prev.createdAt) { + latestByWorkflow.set(wf, { + status: rec.status ?? "?", + createdAt: rec.createdAt ?? "", + error: rec.error, + }); + } + }; + for (const ev of spindleBack) applyStatus(ev); + + const terminal = new Set(["success", "failed", "cancelled", "timeout"]); + const allTerminal = () => { + if (watching.size === 0) return latestByWorkflow.size > 0; + for (const w of watching) { + const s = latestByWorkflow.get(w); + if (!s || !terminal.has(s.status)) return false; + } + return true; + }; + + const summarize = (): { ok: boolean; workflows: Record } => { + const workflows: Record = {}; + let ok = true; + for (const [wf, s] of latestByWorkflow) { + workflows[wf] = s; + if (s.status !== "success") ok = false; + } + if (watching.size > 0) { + for (const w of watching) { + if (!latestByWorkflow.has(w)) { + ok = false; + workflows[w] = { status: "missing" }; + } + } + } + return { ok, workflows }; + }; + + if (allTerminal()) { + const summary = summarize(); + if (json) jsonOut({ pipeline: pipelineRkey, terminal: true, ...summary }); + else { + textOut(`pipeline ${pipelineRkey}:`); + for (const [wf, s] of Object.entries(summary.workflows)) { + textOut(` ${wf} ${(s as { status: string }).status}`); + } + } + process.exit(summary.ok ? 0 : 1); + } + + // Live-watch the spindle for new status events. + const startAt = Date.now(); + let lastCursor = msToNanoCursor(Date.now() - 60 * 1000); + while (Date.now() - startAt < timeoutMs) { + const batch = await singleBatchUrl(`wss://${spindle}/events?cursor=${lastCursor}`); + for (const ev of batch) { + applyStatus(ev); + if (!json) { + const rec = ev.event as StatusRecord; + const rkey = (rec.pipeline ?? "").split("/").pop() ?? ""; + if (rkey === pipelineRkey) { + const wf = rec.workflow ?? "?"; + if (watching.size === 0 || watching.has(wf)) { + textOut(`${new Date().toISOString().slice(11, 19)} ${wf} ${rec.status}`); + } + } + } + } + if (batch.length > 0) { + lastCursor = String(batch[batch.length - 1]!.created); + } + if (allTerminal()) break; + await new Promise((r) => setTimeout(r, 1500)); + } + + if (!allTerminal()) { + const summary = summarize(); + if (json) jsonOut({ pipeline: pipelineRkey, terminal: false, timedOut: true, ...summary }); + else textOut(`timed out after ${Math.round(timeoutMs / 1000)}s`); + throw new TimeoutError(`timeout waiting for pipeline ${pipelineRkey}`); + } + + const summary = summarize(); + if (json) jsonOut({ pipeline: pipelineRkey, terminal: true, ...summary }); + else { + textOut(`pipeline ${pipelineRkey}:`); + for (const [wf, s] of Object.entries(summary.workflows)) { + textOut(` ${wf} ${(s as { status: string }).status}`); + } + } + process.exit(summary.ok ? 0 : 1); + }, + ); + + cmd + .command("cancel") + .description("cancel a running pipeline workflow") + .argument("", "handle/name") + .argument("", "pipeline rkey") + .requiredOption("--workflow ", "workflow to cancel") + .action( + async ( + repoArg: string, + pipelineRkey: string, + opts: { workflow: string }, + cmd: Command, + ) => { + const { json } = globalOpts(cmd); + const account = await requireCurrentAccount(); + const pds = new PdsClient(account); + const ref = parseRepoRef(repoArg); + const r = await resolveRepo(ref); + const spindle = r.spindle || DEFAULT_SPINDLE; + const pipelineUri = `at://did:web:${r.knot}/sh.tangled.pipeline/${pipelineRkey}`; + + await callServiceXrpc({ + pds, + serviceHost: spindle, + serviceDid: spindleDid(spindle), + nsid: "sh.tangled.pipeline.cancelPipeline", + method: "POST", + body: { + repo: r.atUri, + pipeline: pipelineUri, + workflow: opts.workflow, + }, + }); + + if (json) jsonOut({ cancelled: pipelineUri, workflow: opts.workflow }); + else textOut(`cancelled ${pipelineRkey} workflow ${opts.workflow}`); + }, + ); + return cmd; } +// Exposed helper for `pipeline wait` so we can reconnect per poll tick. +async function singleBatchUrl(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const events: StreamEvent[] = []; + let lastAt = Date.now(); + let opened = false; + const timer = setInterval(() => { + if (opened && Date.now() - lastAt >= 600) { + clearInterval(timer); + ws.close(); + resolve(events); + } + }, 200); + const cap = setTimeout(() => { + clearInterval(timer); + ws.close(); + resolve(events); + }, 5000); + ws.addEventListener("open", () => { + opened = true; + lastAt = Date.now(); + }); + ws.addEventListener("message", (e) => { + lastAt = Date.now(); + try { + events.push(JSON.parse(e.data as string)); + } catch { + // ignore + } + }); + ws.addEventListener("error", (e) => { + clearInterval(timer); + clearTimeout(cap); + reject(new Error(`ws error: ${(e as ErrorEvent).message ?? "unknown"}`)); + }); + ws.addEventListener("close", () => { + clearInterval(timer); + clearTimeout(cap); + resolve(events); + }); + }); +} + function msToNanoCursor(ms: number): string { // Number can't precisely represent nanoseconds-since-epoch (~1.7e18 > 2^53). // Append "000000" to ms to get ns as a decimal string. @@ -267,6 +506,22 @@ const STATUS_GLYPH: Record = { pending: "○", }; +function pipelineToJson(p: PipelineSummary): Record { + return { + rkey: p.rkey, + knot: p.knot, + earliestNs: p.earliest, + earliestAt: new Date(p.earliest / 1_000_000).toISOString(), + trigger: p.trigger, + workflows: [...p.workflows.entries()].map(([name, w]) => ({ + name, + status: w.status, + createdAt: w.createdAt, + error: w.error ?? null, + })), + }; +} + function printPipelines(pipelines: PipelineSummary[]): void { for (const p of pipelines) { const trigger = p.trigger?.kind ?? "?"; diff --git a/src/commands/record.ts b/src/commands/record.ts new file mode 100644 index 0000000..0a36ccf --- /dev/null +++ b/src/commands/record.ts @@ -0,0 +1,229 @@ +import { Command } from "commander"; +import { requireCurrentAccount } from "../config.ts"; +import { PdsClient, XrpcError } from "../pds.ts"; +import { resolveIdentity } from "../resolver.ts"; +import { validateRecord, getLexiconDoc } from "../lexicons/index.ts"; +import { globalOpts, jsonOut, textOut } from "../output.ts"; +import { NotFoundError, ValidationError } from "../errors.ts"; + +interface ParsedAtUri { + did: string; + collection: string; + rkey?: string; +} + +function parseAtUri(uri: string): ParsedAtUri { + const m = uri.match(/^at:\/\/([^/]+)\/([^/]+)(?:\/(.+))?$/); + if (!m) throw new ValidationError(`not an at-uri: ${uri}`); + return { did: m[1]!, collection: m[2]!, rkey: m[3] }; +} + +export function recordCommand(): Command { + const cmd = new Command("record").description("raw atproto record I/O (validated against vendored lexicons)"); + + cmd + .command("get") + .description("fetch a record by at-uri") + .argument("", "at:////") + .action(async (uri: string, _opts: unknown, cmd: Command) => { + const { json } = globalOpts(cmd); + const parsed = parseAtUri(uri); + if (!parsed.rkey) throw new ValidationError("at-uri must include an rkey for `get`"); + const ident = await resolveIdentity(parsed.did); + + const resp = await fetch( + `${ident.pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(parsed.did)}&collection=${encodeURIComponent(parsed.collection)}&rkey=${encodeURIComponent(parsed.rkey)}`, + ); + if (resp.status === 404) throw new NotFoundError(`record not found: ${uri}`); + if (!resp.ok) throw new XrpcError(resp.status, "XrpcError", `getRecord failed (${resp.status})`); + const body = await resp.json(); + if (json) jsonOut(body); + else textOut(JSON.stringify(body, null, 2)); + }); + + cmd + .command("list") + .description("list records in a collection (paginates via --cursor)") + .argument("", "handle or DID") + .argument("", "NSID (e.g. sh.tangled.repo)") + .option("--limit ", "records per page (max 100)", "50") + .option("--cursor ") + .option("--all", "follow pagination and emit all records as NDJSON", false) + .action( + async ( + owner: string, + collection: string, + opts: { limit: string; cursor?: string; all?: boolean }, + cmd: Command, + ) => { + const { json } = globalOpts(cmd); + const ident = await resolveIdentity(owner); + const limit = String(Math.min(100, Math.max(1, Number(opts.limit) || 50))); + + if (opts.all) { + let cursor = opts.cursor; + while (true) { + const qs = new URLSearchParams({ repo: ident.did, collection, limit }); + if (cursor) qs.set("cursor", cursor); + const resp = await fetch(`${ident.pds}/xrpc/com.atproto.repo.listRecords?${qs}`); + if (!resp.ok) throw new XrpcError(resp.status, "XrpcError", `listRecords failed (${resp.status})`); + const data = (await resp.json()) as { + cursor?: string; + records: { uri: string; cid: string; value: unknown }[]; + }; + for (const r of data.records) jsonOut(r); + if (!data.cursor || data.records.length === 0) break; + cursor = data.cursor; + } + return; + } + + const qs = new URLSearchParams({ repo: ident.did, collection, limit }); + if (opts.cursor) qs.set("cursor", opts.cursor); + const resp = await fetch(`${ident.pds}/xrpc/com.atproto.repo.listRecords?${qs}`); + if (!resp.ok) throw new XrpcError(resp.status, "XrpcError", `listRecords failed (${resp.status})`); + const body = await resp.json(); + if (json) jsonOut(body); + else textOut(JSON.stringify(body, null, 2)); + }, + ); + + cmd + .command("put") + .description("create or update a record (validates against lexicon before sending)") + .argument("", "at:////") + .option("--record ", "record body as JSON (or pass via --stdin)") + .option("--stdin", "read record body from stdin", false) + .option("--if-match ", "optimistic concurrency: only write if current CID matches") + .option("--skip-validate", "skip local lexicon validation", false) + .option("--dry-run", "validate and print what would be sent, but do not write", false) + .action( + async ( + uri: string, + opts: { + record?: string; + stdin?: boolean; + ifMatch?: string; + skipValidate?: boolean; + dryRun?: boolean; + }, + cmd: Command, + ) => { + const { json } = globalOpts(cmd); + const parsed = parseAtUri(uri); + if (!parsed.rkey) throw new ValidationError("at-uri must include an rkey for `put`"); + + const body = await readRecordBody(opts); + if (!body.$type) body.$type = parsed.collection; + if (body.$type !== parsed.collection) { + throw new ValidationError( + `record $type "${body.$type}" doesn't match collection "${parsed.collection}" in at-uri`, + ); + } + + if (!opts.skipValidate) { + if (!getLexiconDoc(parsed.collection)) { + throw new ValidationError( + `no vendored lexicon for ${parsed.collection}; pass --skip-validate to write anyway`, + ); + } + const res = validateRecord(parsed.collection, body); + if (!res.ok) throw new ValidationError(`lexicon validation failed: ${res.message}`); + } + + if (opts.dryRun) { + const preview = { + target: uri, + record: body, + ifMatch: opts.ifMatch ?? null, + validated: !opts.skipValidate, + }; + if (json) jsonOut({ dryRun: true, ...preview }); + else textOut(JSON.stringify(preview, null, 2)); + return; + } + + const account = await requireCurrentAccount(); + if (account.did !== parsed.did) { + throw new ValidationError( + `current account (${account.did}) doesn't own this record (${parsed.did}); switch accounts first`, + ); + } + const pds = new PdsClient(account); + const result = await pds.putRecord({ + repo: parsed.did, + collection: parsed.collection, + rkey: parsed.rkey, + record: body, + swapRecord: opts.ifMatch, + }); + if (json) jsonOut(result); + else textOut(`wrote ${result.uri} (cid ${result.cid})`); + }, + ); + + cmd + .command("delete") + .description("delete a record by at-uri") + .argument("") + .option("--if-match ", "optimistic concurrency") + .option("--dry-run", "print what would be deleted, but do not delete", false) + .action( + async (uri: string, opts: { ifMatch?: string; dryRun?: boolean }, cmd: Command) => { + const { json } = globalOpts(cmd); + const parsed = parseAtUri(uri); + if (!parsed.rkey) throw new ValidationError("at-uri must include an rkey for `delete`"); + + if (opts.dryRun) { + const preview = { target: uri, ifMatch: opts.ifMatch ?? null }; + if (json) jsonOut({ dryRun: true, ...preview }); + else textOut(`would delete ${uri}`); + return; + } + + const account = await requireCurrentAccount(); + if (account.did !== parsed.did) { + throw new ValidationError( + `current account (${account.did}) doesn't own this record (${parsed.did})`, + ); + } + const pds = new PdsClient(account); + await pds.procedure("com.atproto.repo.deleteRecord", { + repo: parsed.did, + collection: parsed.collection, + rkey: parsed.rkey, + ...(opts.ifMatch ? { swapRecord: opts.ifMatch } : {}), + }); + if (json) jsonOut({ deleted: uri }); + else textOut(`deleted ${uri}`); + }, + ); + + return cmd; +} + +async function readRecordBody(opts: { record?: string; stdin?: boolean }): Promise> { + let raw: string; + if (opts.stdin) { + raw = await readStdin(); + } else if (opts.record !== undefined) { + raw = opts.record; + } else { + throw new ValidationError("pass --record or --stdin"); + } + try { + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("must be a JSON object"); + } + return parsed as Record; + } catch (e) { + throw new ValidationError(`invalid record JSON: ${(e as Error).message}`); + } +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin as AsyncIterable) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/src/commands/ref.ts b/src/commands/ref.ts new file mode 100644 index 0000000..acd5d98 --- /dev/null +++ b/src/commands/ref.ts @@ -0,0 +1,222 @@ +import { Command } from "commander"; +import { parseRepoRef, resolveRepo, type ResolvedRepo } from "../repoRef.ts"; +import { globalOpts, jsonOut, textOut } from "../output.ts"; +import { NotFoundError } from "../errors.ts"; + +interface BranchRow { + name: string; + sha: string; + isDefault?: boolean; +} + +async function fetchKnotQuery( + host: string, + nsid: string, + params: Record, +): Promise { + const qs = new URLSearchParams(params).toString(); + const resp = await fetch(`https://${host}/xrpc/${nsid}?${qs}`); + if (!resp.ok) { + const body = (await resp.json().catch(() => ({}))) as { message?: string }; + throw new Error(`${nsid} failed (${resp.status}): ${body.message ?? "unknown"}`); + } + return resp.json(); +} + +async function listBranches(r: ResolvedRepo, repoName: string): Promise { + const out: BranchRow[] = []; + let cursor: string | undefined; + while (true) { + const params: Record = { + repo: `${r.ownerDid}/${repoName}`, + limit: "100", + }; + if (cursor) params.cursor = cursor; + const data = (await fetchKnotQuery(r.knot, "sh.tangled.repo.branches", params)) as { + branches: Array<{ reference: { name: string; hash: string }; is_default?: boolean }>; + cursor?: string; + }; + for (const b of data.branches ?? []) { + out.push({ + name: b.reference.name, + sha: b.reference.hash, + isDefault: b.is_default, + }); + } + if (!data.cursor || !data.branches || data.branches.length === 0) break; + cursor = data.cursor; + } + return out; +} + +async function listTags(r: ResolvedRepo, repoName: string): Promise { + const out: BranchRow[] = []; + let cursor: string | undefined; + while (true) { + const params: Record = { + repo: `${r.ownerDid}/${repoName}`, + limit: "100", + }; + if (cursor) params.cursor = cursor; + const data = (await fetchKnotQuery(r.knot, "sh.tangled.repo.tags", params)) as { + tags?: Array<{ reference?: { name: string; hash: string } }>; + cursor?: string; + }; + for (const t of data.tags ?? []) { + if (t.reference) out.push({ name: t.reference.name, sha: t.reference.hash }); + } + if (!data.cursor || !data.tags || data.tags.length === 0) break; + cursor = data.cursor; + } + return out; +} + +export function refCommand(): Command { + const cmd = new Command("ref").description("inspect branches and tags of a repository"); + + cmd + .command("list") + .description("list all branches and tags") + .argument("", "handle/name") + .option("--branches-only", "only list branches", false) + .option("--tags-only", "only list tags", false) + .action( + async ( + repoArg: string, + opts: { branchesOnly?: boolean; tagsOnly?: boolean }, + cmd: Command, + ) => { + const { json } = globalOpts(cmd); + const ref = parseRepoRef(repoArg); + const r = await resolveRepo(ref); + + const wantBranches = !opts.tagsOnly; + const wantTags = !opts.branchesOnly; + + const [branches, tags] = await Promise.all([ + wantBranches ? listBranches(r, ref.name) : Promise.resolve([]), + wantTags ? listTags(r, ref.name) : Promise.resolve([]), + ]); + + if (json) return jsonOut({ repo: r.atUri, knot: r.knot, branches, tags }); + + if (wantBranches) { + textOut(`branches (${branches.length}):`); + for (const b of branches) { + textOut(` ${b.sha.slice(0, 7)} ${b.name}${b.isDefault ? " (default)" : ""}`); + } + } + if (wantTags) { + textOut(`tags (${tags.length}):`); + for (const t of tags) textOut(` ${t.sha.slice(0, 7)} ${t.name}`); + } + }, + ); + + cmd + .command("get") + .description("resolve a branch, tag, or short SHA to a full commit SHA") + .argument("", "handle/name") + .argument("", "branch, tag, or commit-ish") + .action(async (repoArg: string, refName: string, _opts: unknown, cmd: Command) => { + const { json } = globalOpts(cmd); + const parsed = parseRepoRef(repoArg); + const r = await resolveRepo(parsed); + + const [branches, tags] = await Promise.all([ + listBranches(r, parsed.name), + listTags(r, parsed.name), + ]); + + let match: { kind: "branch" | "tag"; name: string; sha: string } | null = null; + for (const b of branches) { + if (b.name === refName) { + match = { kind: "branch", name: b.name, sha: b.sha }; + break; + } + } + if (!match) { + for (const t of tags) { + if (t.name === refName) { + match = { kind: "tag", name: t.name, sha: t.sha }; + break; + } + } + } + if (!match) { + // fall back to short-SHA match + for (const b of [...branches, ...tags]) { + if (b.sha.startsWith(refName)) { + match = { kind: "branch", name: b.name, sha: b.sha }; + break; + } + } + } + if (!match) throw new NotFoundError(`ref not found: ${refName}`); + + if (json) jsonOut({ ...match }); + else textOut(`${match.sha} ${match.kind} ${match.name}`); + }); + + return cmd; +} + +export function fileCommand(): Command { + const cmd = new Command("file").description("read files from a repository"); + + cmd + .command("get") + .description("read a file from a repository at a given ref") + .argument("", "handle/name") + .argument("", "branch, tag, or commit SHA") + .argument("", "path to the file within the repo") + .option("--raw", "print raw file content (binary-safe) to stdout", false) + .action( + async ( + repoArg: string, + refName: string, + path: string, + opts: { raw?: boolean }, + cmd: Command, + ) => { + const { json } = globalOpts(cmd); + const parsed = parseRepoRef(repoArg); + const r = await resolveRepo(parsed); + const data = (await fetchKnotQuery(r.knot, "sh.tangled.repo.blob", { + repo: `${r.ownerDid}/${parsed.name}`, + ref: refName, + path, + })) as { + ref: string; + path: string; + content?: string; + encoding?: string; + size?: number; + isBinary?: boolean; + mimeType?: string; + lastCommit?: unknown; + }; + + if (opts.raw) { + if (data.encoding === "base64" && typeof data.content === "string") { + process.stdout.write(Buffer.from(data.content, "base64")); + } else if (typeof data.content === "string") { + process.stdout.write(data.content); + } + return; + } + + if (json) return jsonOut(data); + textOut(`# ${r.ownerHandle}/${parsed.name} @ ${refName}`); + textOut(`# path: ${data.path}`); + textOut( + `# size: ${data.size ?? "?"} ${data.mimeType ?? ""} ${data.isBinary ? "(binary)" : ""}`, + ); + textOut(""); + if (data.encoding === "base64") textOut("(binary content — pass --raw to print)"); + else if (typeof data.content === "string") process.stdout.write(data.content); + }, + ); + + return cmd; +} diff --git a/src/commands/repo.ts b/src/commands/repo.ts index 2bae5c9..cc24cf5 100644 --- a/src/commands/repo.ts +++ b/src/commands/repo.ts @@ -1,10 +1,13 @@ import { Command } from "commander"; import { loadConfig, rememberKnot, requireCurrentAccount } from "../config.ts"; -import { PdsClient } from "../pds.ts"; +import { PdsClient, XrpcError } from "../pds.ts"; import { callServiceXrpc, knotDid } from "../serviceXrpc.ts"; -import { DEFAULT_KNOT, NSID } from "../constants.ts"; +import { APPVIEW_HOST, DEFAULT_KNOT, NSID } from "../constants.ts"; import { tid } from "../tid.ts"; import { pickFromList, prompt } from "../prompt.ts"; +import { globalOpts, jsonOut, textOut } from "../output.ts"; +import { parseRepoRef, resolveRepo } from "../repoRef.ts"; +import { resolveIdentity } from "../resolver.ts"; export function repoCommand(): Command { const cmd = new Command("repo").description("manage tangled repositories"); @@ -21,14 +24,16 @@ export function repoCommand(): Command { async ( name: string, opts: { knot?: string; description?: string; source?: string; defaultBranch: string }, + cmd: Command, ) => { + const { json } = globalOpts(cmd); const account = await requireCurrentAccount(); const knot = opts.knot ?? (await pickKnot()); const pds = new PdsClient(account); const rkey = tid(); - process.stdout.write(`creating ${name} on ${knot}...\n`); + if (!json) textOut(`creating ${name} on ${knot}...`); const createResp = await callServiceXrpc<{ repoDid?: string }>({ pds, serviceHost: knot, @@ -84,12 +89,455 @@ export function repoCommand(): Command { await rememberKnot(knot); - const url = `https://tangled.sh/@${account.handle}/${name}`; - process.stdout.write(`created ${url}\n`); - if (repoDid) process.stdout.write(`repo did: ${repoDid}\n`); + const url = `https://tangled.org/@${account.handle}/${name}`; + if (json) { + jsonOut({ + name, + knot, + handle: account.handle, + ownerDid: account.did, + repoDid: repoDid ?? null, + rkey, + atUri: `at://${account.did}/${NSID.repo}/${rkey}`, + url, + }); + } else { + textOut(`created ${url}`); + if (repoDid) textOut(`repo did: ${repoDid}`); + } + }, + ); + + cmd + .command("fork") + .description("fork a repository (source can be a tangled handle/name or any git URL)") + .argument("", "handle/name on tangled, or a full git URL (e.g. https://github.com/foo/bar)") + .option("--name ", "name for the fork (default: source name)") + .option("--knot ", "knot to host the fork on (default: interactive picker)") + .option("--description ", "short description") + .option("--default-branch ", "default branch of the fork", "main") + .action( + async ( + source: string, + opts: { name?: string; knot?: string; description?: string; defaultBranch: string }, + cmd: Command, + ) => { + const { json } = globalOpts(cmd); + const account = await requireCurrentAccount(); + const pds = new PdsClient(account); + + // Figure out source URL + a default name. + let sourceUrl: string; + let defaultName: string; + if (source.includes("://")) { + sourceUrl = source; + const trailing = source.replace(/\/$/, "").split("/").pop() ?? "fork"; + defaultName = trailing.replace(/\.git$/, ""); + } else { + const ref = parseRepoRef(source); + const resolved = await resolveRepo(ref); + sourceUrl = `${APPVIEW_HOST}/@${resolved.ownerHandle}/${ref.name}`; + defaultName = ref.name; + } + + const name = opts.name ?? defaultName; + const knot = opts.knot ?? (await pickKnot()); + const rkey = tid(); + + if (!json) textOut(`forking ${source} → ${name} on ${knot}...`); + const createResp = await callServiceXrpc<{ repoDid?: string }>({ + pds, + serviceHost: knot, + serviceDid: knotDid(knot), + nsid: NSID.repoCreate, + method: "POST", + body: { rkey, name, defaultBranch: opts.defaultBranch, source: sourceUrl }, + }); + const repoDid = createResp.repoDid; + + const record: Record = { + $type: NSID.repo, + name, + knot, + source: sourceUrl, + createdAt: new Date().toISOString(), + }; + if (opts.description) record.description = opts.description; + if (repoDid) record.repoDid = repoDid; + + try { + await pds.putRecord({ + repo: account.did, + collection: NSID.repo, + rkey, + record, + }); + } catch (e) { + if (!json) textOut(`PDS write failed; rolling back on knot: ${(e as Error).message}`); + try { + await callServiceXrpc({ + pds, + serviceHost: knot, + serviceDid: knotDid(knot), + nsid: NSID.repoDelete, + method: "POST", + body: { did: account.did, name, rkey }, + }); + } catch { + // best-effort + } + throw e; + } + + await rememberKnot(knot); + const url = `${APPVIEW_HOST}/@${account.handle}/${name}`; + if (json) { + jsonOut({ + name, + knot, + source: sourceUrl, + handle: account.handle, + ownerDid: account.did, + repoDid: repoDid ?? null, + rkey, + atUri: `at://${account.did}/${NSID.repo}/${rkey}`, + url, + }); + } else { + textOut(`forked → ${url}`); + if (repoDid) textOut(`repo did: ${repoDid}`); + } + }, + ); + + cmd + .command("fork-sync") + .description("sync a forked repository with its upstream source") + .argument("", "handle/name of the fork") + .option("--branch ", "branch to sync", "main") + .action(async (repoArg: string, opts: { branch: string }, cmd: Command) => { + const { json } = globalOpts(cmd); + const account = await requireCurrentAccount(); + const pds = new PdsClient(account); + const ref = parseRepoRef(repoArg); + const r = await resolveRepo(ref); + const source = (r.record as { source?: string }).source; + if (!source) { + throw new Error( + `${repoArg} has no 'source' field on its sh.tangled.repo record; not a fork (or fork relationship not recorded)`, + ); + } + const result = await callServiceXrpc>({ + pds, + serviceHost: r.knot, + serviceDid: knotDid(r.knot), + nsid: "sh.tangled.repo.forkSync", + method: "POST", + body: { + did: r.ownerDid, + source, + name: ref.name, + branch: opts.branch, + }, + }); + if (json) jsonOut({ synced: r.atUri, branch: opts.branch, source, result }); + else textOut(`synced ${ref.handle}/${ref.name}@${opts.branch} from ${source}`); + }); + + cmd + .command("fork-status") + .description("check a fork's status vs. upstream (requires the hidden-ref tangled uses)") + .argument("", "handle/name of the fork") + .option("--branch ", "branch to check", "main") + .requiredOption("--hidden-ref ", "hidden ref tangled uses to track upstream (e.g. refs/tangled/upstream)") + .action( + async (repoArg: string, opts: { branch: string; hiddenRef: string }, cmd: Command) => { + const { json } = globalOpts(cmd); + const account = await requireCurrentAccount(); + const pds = new PdsClient(account); + const ref = parseRepoRef(repoArg); + const r = await resolveRepo(ref); + const source = (r.record as { source?: string }).source; + if (!source) { + throw new Error(`${repoArg} has no 'source' field; not a fork`); + } + const result = await callServiceXrpc<{ status: number }>({ + pds, + serviceHost: r.knot, + serviceDid: knotDid(r.knot), + nsid: "sh.tangled.repo.forkStatus", + method: "POST", + body: { + did: r.ownerDid, + name: ref.name, + source, + branch: opts.branch, + hiddenRef: opts.hiddenRef, + }, + }); + const statusNames = ["UpToDate", "FastForwardable", "Conflict", "MissingBranch"]; + const name = statusNames[result.status] ?? "Unknown"; + if (json) jsonOut({ repo: r.atUri, branch: opts.branch, source, status: result.status, statusName: name }); + else textOut(`${ref.handle}/${ref.name}@${opts.branch}: ${name} (${result.status})`); }, ); + cmd + .command("show") + .description("show full details of a repository") + .argument("", "handle/name") + .action(async (repoArg: string, _opts: unknown, cmd: Command) => { + const { json } = globalOpts(cmd); + const ref = parseRepoRef(repoArg); + const r = await resolveRepo(ref); + const record = r.record as Record; + const payload = { + handle: r.ownerHandle, + ownerDid: r.ownerDid, + pds: r.ownerPds, + repoDid: r.repoDid, + rkey: r.rkey, + atUri: r.atUri, + cid: r.recordCid, + knot: r.knot, + spindle: r.spindle, + name: record.name, + description: record.description ?? null, + topics: record.topics ?? [], + source: record.source ?? null, + website: record.website ?? null, + createdAt: record.createdAt, + url: `${APPVIEW_HOST}/@${r.ownerHandle}/${record.name}`, + }; + if (json) return jsonOut(payload); + textOut(`${payload.handle}/${payload.name}`); + textOut(` url: ${payload.url}`); + textOut(` at-uri: ${payload.atUri}`); + textOut(` cid: ${payload.cid}`); + textOut(` knot: ${payload.knot}`); + if (payload.spindle) textOut(` spindle: ${payload.spindle}`); + if (payload.repoDid) textOut(` repoDid: ${payload.repoDid}`); + if (payload.description) textOut(` desc: ${payload.description}`); + if (Array.isArray(payload.topics) && payload.topics.length > 0) { + textOut(` topics: ${payload.topics.join(", ")}`); + } + if (payload.source) textOut(` source: ${payload.source}`); + textOut(` created: ${payload.createdAt}`); + }); + + cmd + .command("list") + .description("list repositories for a user") + .argument("[owner]", "handle or DID (default: current account)") + .option("--limit ", "max records per page", "100") + .option("--all", "follow pagination (emits NDJSON when --json)", false) + .action( + async ( + ownerArg: string | undefined, + opts: { limit: string; all?: boolean }, + cmd: Command, + ) => { + const { json } = globalOpts(cmd); + let ownerDid: string; + let ownerHandle: string | null; + let ownerPds: string; + if (ownerArg) { + const ident = await resolveIdentity(ownerArg); + ownerDid = ident.did; + ownerHandle = ident.handle; + ownerPds = ident.pds; + } else { + const current = await requireCurrentAccount(); + ownerDid = current.did; + ownerHandle = current.handle; + ownerPds = current.pds; + } + const limit = String(Math.min(100, Math.max(1, Number(opts.limit) || 100))); + + async function* pages(): AsyncGenerator<{ uri: string; cid: string; value: Record }> { + let cursor: string | undefined; + while (true) { + const qs = new URLSearchParams({ + repo: ownerDid, + collection: NSID.repo, + limit, + }); + if (cursor) qs.set("cursor", cursor); + const resp = await fetch(`${ownerPds}/xrpc/com.atproto.repo.listRecords?${qs}`); + if (!resp.ok) { + throw new XrpcError(resp.status, "XrpcError", `listRecords failed (${resp.status})`); + } + const data = (await resp.json()) as { + cursor?: string; + records: { uri: string; cid: string; value: Record }[]; + }; + for (const r of data.records) yield r; + if (!opts.all || !data.cursor || data.records.length === 0) break; + cursor = data.cursor; + } + } + + const items: Record[] = []; + for await (const r of pages()) { + const row = { + name: r.value.name, + knot: r.value.knot, + spindle: r.value.spindle ?? null, + description: r.value.description ?? null, + rkey: r.uri.split("/").pop(), + atUri: r.uri, + cid: r.cid, + repoDid: r.value.repoDid ?? null, + handle: ownerHandle, + ownerDid, + url: `${APPVIEW_HOST}/@${ownerHandle}/${r.value.name}`, + createdAt: r.value.createdAt, + }; + if (json && opts.all) { + jsonOut(row); + } else { + items.push(row); + } + } + + if (!json) { + if (items.length === 0) textOut("(no repos)"); + for (const r of items) { + textOut(`${r.handle}/${r.name} ${r.knot}${r.description ? " — " + r.description : ""}`); + } + return; + } + if (!opts.all) jsonOut({ owner: ownerHandle, did: ownerDid, repos: items }); + }, + ); + + cmd + .command("edit") + .description("partially update the sh.tangled.repo record (CID-swapped)") + .argument("", "handle/name") + .option("--description ") + .option("--spindle ", "set CI spindle (pass empty string to clear)") + .option("--source ", "set source/upstream URL (pass empty string to clear)") + .option("--website ") + .option("--add-topic ", "add a topic (repeatable)", (v, p: string[]) => [...p, v], [] as string[]) + .option( + "--remove-topic ", + "remove a topic (repeatable)", + (v, p: string[]) => [...p, v], + [] as string[], + ) + .option("--dry-run", "print the patched record without writing", false) + .action( + async ( + repoArg: string, + opts: { + description?: string; + spindle?: string; + source?: string; + website?: string; + addTopic: string[]; + removeTopic: string[]; + dryRun?: boolean; + }, + cmd: Command, + ) => { + const { json } = globalOpts(cmd); + const account = await requireCurrentAccount(); + const pds = new PdsClient(account); + const parsed = parseRepoRef(repoArg); + const r = await resolveRepo(parsed); + + if (account.did !== r.ownerDid) { + throw new Error( + `current account (${account.did}) doesn't own ${repoArg}; switch accounts first`, + ); + } + + const next = { ...(r.record as Record) }; + if (opts.description !== undefined) { + if (opts.description === "") delete next.description; + else next.description = opts.description; + } + if (opts.spindle !== undefined) { + if (opts.spindle === "") delete next.spindle; + else next.spindle = opts.spindle; + } + if (opts.source !== undefined) { + if (opts.source === "") delete next.source; + else next.source = opts.source; + } + if (opts.website !== undefined) { + if (opts.website === "") delete next.website; + else next.website = opts.website; + } + const currentTopics = new Set(Array.isArray(next.topics) ? (next.topics as string[]) : []); + for (const t of opts.addTopic) currentTopics.add(t); + for (const t of opts.removeTopic) currentTopics.delete(t); + if (currentTopics.size > 0 || opts.addTopic.length || opts.removeTopic.length) { + next.topics = [...currentTopics]; + if ((next.topics as string[]).length === 0) delete next.topics; + } + + if (opts.dryRun) { + const preview = { target: r.atUri, swapCid: r.recordCid, record: next }; + if (json) jsonOut({ dryRun: true, ...preview }); + else textOut(JSON.stringify(preview, null, 2)); + return; + } + + const result = await pds.putRecord({ + repo: account.did, + collection: NSID.repo, + rkey: r.rkey, + record: next, + swapRecord: r.recordCid, + }); + if (json) jsonOut({ updated: result.uri, cid: result.cid }); + else textOut(`updated ${result.uri} (cid ${result.cid})`); + }, + ); + + cmd + .command("delete") + .description("delete a repository (knot storage + PDS record)") + .argument("", "handle/name") + .option("--confirm", "required: confirm you intend to destroy the repo", false) + .action(async (repoArg: string, opts: { confirm?: boolean }, cmd: Command) => { + const { json } = globalOpts(cmd); + if (!opts.confirm) { + throw new Error("refusing to delete without --confirm"); + } + const account = await requireCurrentAccount(); + const pds = new PdsClient(account); + const parsed = parseRepoRef(repoArg); + const r = await resolveRepo(parsed); + if (account.did !== r.ownerDid) { + throw new Error( + `current account (${account.did}) doesn't own ${repoArg}; switch accounts first`, + ); + } + + // Call knot delete first (free storage + PLC deactivate for did:plc repos). + await callServiceXrpc({ + pds, + serviceHost: r.knot, + serviceDid: knotDid(r.knot), + nsid: NSID.repoDelete, + method: "POST", + body: { did: account.did, name: parsed.name, rkey: r.rkey }, + }); + + // Then delete the PDS record. + await pds.procedure("com.atproto.repo.deleteRecord", { + repo: account.did, + collection: NSID.repo, + rkey: r.rkey, + }); + + if (json) jsonOut({ deleted: r.atUri, knot: r.knot }); + else textOut(`deleted ${r.atUri}`); + }); + return cmd; } diff --git a/src/commands/secret.ts b/src/commands/secret.ts new file mode 100644 index 0000000..0a93912 --- /dev/null +++ b/src/commands/secret.ts @@ -0,0 +1,122 @@ +import { Command } from "commander"; +import { requireCurrentAccount } from "../config.ts"; +import { PdsClient } from "../pds.ts"; +import { callServiceXrpc, spindleDid } from "../serviceXrpc.ts"; +import { DEFAULT_SPINDLE } from "../constants.ts"; +import { parseRepoRef, resolveRepo } from "../repoRef.ts"; +import { globalOpts, jsonOut, textOut } from "../output.ts"; +import { ValidationError } from "../errors.ts"; + +function spindleFor(r: { spindle: string | null }): string { + return r.spindle || DEFAULT_SPINDLE; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin as AsyncIterable) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8").replace(/\n$/, ""); +} + +export function secretCommand(): Command { + const cmd = new Command("secret").description("manage CI secrets (stored on the spindle)"); + + cmd + .command("list") + .description("list secret names for a repo") + .argument("", "handle/name") + .action(async (repoArg: string, _opts: unknown, cmd: Command) => { + const { json } = globalOpts(cmd); + const account = await requireCurrentAccount(); + const pds = new PdsClient(account); + const ref = parseRepoRef(repoArg); + const r = await resolveRepo(ref); + const spindle = spindleFor(r); + + const result = await callServiceXrpc<{ secrets: Array<{ key: string; createdAt: string; createdBy: string; repo: string }> }>({ + pds, + serviceHost: spindle, + serviceDid: spindleDid(spindle), + nsid: "sh.tangled.repo.listSecrets", + method: "GET", + query: { repo: r.atUri }, + }); + + if (json) return jsonOut({ repo: r.atUri, spindle, secrets: result.secrets }); + if ((result.secrets ?? []).length === 0) { + textOut("(no secrets)"); + return; + } + for (const s of result.secrets) { + textOut(`${s.key} (added ${s.createdAt.slice(0, 10)} by ${s.createdBy})`); + } + }); + + cmd + .command("set") + .description("add or overwrite a secret (value via --value or stdin)") + .argument("", "handle/name") + .argument("") + .option("--value ", "value (prefer --stdin for shell-history safety)") + .option("--stdin", "read value from stdin", false) + .action( + async ( + repoArg: string, + key: string, + opts: { value?: string; stdin?: boolean }, + cmd: Command, + ) => { + const { json } = globalOpts(cmd); + let value: string; + if (opts.stdin) value = await readStdin(); + else if (opts.value !== undefined) value = opts.value; + else throw new ValidationError("pass --value or --stdin"); + if (!value) throw new ValidationError("secret value must not be empty"); + + const account = await requireCurrentAccount(); + const pds = new PdsClient(account); + const ref = parseRepoRef(repoArg); + const r = await resolveRepo(ref); + const spindle = spindleFor(r); + + await callServiceXrpc({ + pds, + serviceHost: spindle, + serviceDid: spindleDid(spindle), + nsid: "sh.tangled.repo.addSecret", + method: "POST", + body: { repo: r.atUri, key, value }, + }); + + if (json) jsonOut({ set: key, repo: r.atUri, spindle }); + else textOut(`set ${key} on ${ref.handle}/${ref.name}`); + }, + ); + + cmd + .command("remove") + .description("remove a secret") + .argument("", "handle/name") + .argument("") + .action(async (repoArg: string, key: string, _opts: unknown, cmd: Command) => { + const { json } = globalOpts(cmd); + const account = await requireCurrentAccount(); + const pds = new PdsClient(account); + const ref = parseRepoRef(repoArg); + const r = await resolveRepo(ref); + const spindle = spindleFor(r); + + await callServiceXrpc({ + pds, + serviceHost: spindle, + serviceDid: spindleDid(spindle), + nsid: "sh.tangled.repo.removeSecret", + method: "POST", + body: { repo: r.atUri, key }, + }); + + if (json) jsonOut({ removed: key, repo: r.atUri, spindle }); + else textOut(`removed ${key} from ${ref.handle}/${ref.name}`); + }); + + return cmd; +} diff --git a/src/commands/xrpc.ts b/src/commands/xrpc.ts new file mode 100644 index 0000000..42563fe --- /dev/null +++ b/src/commands/xrpc.ts @@ -0,0 +1,184 @@ +import { Command } from "commander"; +import { requireCurrentAccount } from "../config.ts"; +import { PdsClient, XrpcError } from "../pds.ts"; +import { + getLexiconDoc, + validateXrpcInput, + validateXrpcOutput, + validateXrpcParams, +} from "../lexicons/index.ts"; +import { globalOpts, jsonOut, textOut } from "../output.ts"; +import { ValidationError } from "../errors.ts"; + +interface LexiconMain { + lexicon?: number; + id?: string; + defs?: Record; +} +interface LexiconDef { + type: string; + parameters?: { properties?: Record; required?: string[] }; + input?: { encoding?: string; schema?: unknown }; + output?: { encoding?: string; schema?: unknown }; +} + +export function xrpcCommand(): Command { + return new Command("xrpc") + .description("call an XRPC endpoint (validates input/output against vendored lexicon)") + .argument("", "host (e.g. knot1.tangled.sh) — no scheme") + .argument("", "NSID of the endpoint") + .option("--method ", "HTTP method (default inferred from lexicon)") + .option("--body ", "JSON body for procedures (or use --stdin)") + .option("--stdin", "read JSON body from stdin", false) + .option( + "--param ", + "query parameter (repeatable)", + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .option("--as-service", "auth with a service-auth JWT from the current account", false) + .option("--scheme ", "scheme to use", "https") + .option("--skip-validate", "skip lexicon validation of input/output", false) + .option("--dry-run", "print the request and exit without sending", false) + .action( + async ( + host: string, + nsid: string, + opts: { + method?: string; + body?: string; + stdin?: boolean; + param: string[]; + asService?: boolean; + scheme: string; + skipValidate?: boolean; + dryRun?: boolean; + }, + cmd: Command, + ) => { + const { json } = globalOpts(cmd); + const lexDoc = getLexiconDoc(nsid) as LexiconMain | null; + + const def = lexDoc?.defs?.main; + if (!opts.skipValidate && !def) { + throw new ValidationError( + `no vendored lexicon for ${nsid}; pass --skip-validate to call anyway`, + ); + } + const endpointType = def?.type ?? null; + const method = + opts.method ?? + (endpointType === "query" ? "GET" : endpointType === "procedure" ? "POST" : "POST"); + + // Parse query params + const params: Record = {}; + for (const kv of opts.param) { + const eq = kv.indexOf("="); + if (eq < 0) throw new ValidationError(`--param must be key=value, got: ${kv}`); + params[kv.slice(0, eq)] = kv.slice(eq + 1); + } + + // Validate params against lexicon (assertValidXrpcParams coerces + // strings to their typed values; we treat our string-keyed map as + // the best-effort input to that validator). + if (!opts.skipValidate && def?.parameters) { + const res = validateXrpcParams(nsid, params); + if (!res.ok) throw new ValidationError(`param validation failed: ${res.message}`); + } + + // Parse body (procedures only) + let body: unknown = undefined; + if (method !== "GET") { + if (opts.stdin) body = JSON.parse(await readStdin()); + else if (opts.body !== undefined) body = JSON.parse(opts.body); + } + + // Validate input body + if (!opts.skipValidate && body !== undefined && def?.input?.schema) { + const res = validateXrpcInput(nsid, body); + if (!res.ok) throw new ValidationError(`input validation failed: ${res.message}`); + } + + const qs = new URLSearchParams(params).toString(); + const url = `${opts.scheme}://${host}/xrpc/${nsid}${qs ? `?${qs}` : ""}`; + + if (opts.dryRun) { + const preview = { + url, + method, + body: body ?? null, + endpointType, + validated: !opts.skipValidate, + }; + if (json) jsonOut({ dryRun: true, ...preview }); + else textOut(JSON.stringify(preview, null, 2)); + return; + } + + const headers: Record = {}; + if (opts.asService) { + const account = await requireCurrentAccount(); + const pds = new PdsClient(account); + // Service-auth JWT is bound to the host's DID. Default assumption: + // host is a did:web (knot/spindle convention). Users can override + // by passing --skip-validate and adding Authorization themselves + // via a future flag if needed. + const aud = `did:web:${host}`; + const { token } = await pds.getServiceAuth(aud, nsid); + headers.authorization = `Bearer ${token}`; + } + if (body !== undefined) { + headers["content-type"] = "application/json"; + } + + const resp = await fetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await resp.text(); + let parsed: unknown = text; + try { + parsed = JSON.parse(text); + } catch { + // non-JSON response; keep as text + } + + if (!resp.ok) { + const msg = + (parsed as { message?: string })?.message ?? + (parsed as { error?: string })?.error ?? + `HTTP ${resp.status}`; + throw new XrpcError( + resp.status, + (parsed as { error?: string })?.error, + `${nsid} failed: ${msg}`, + ); + } + + // Validate output (warn, don't fail — the server spoke, after all) + if ( + !opts.skipValidate && + def?.output?.schema && + typeof parsed === "object" && + parsed !== null + ) { + const res = validateXrpcOutput(nsid, parsed); + if (!res.ok && !json) { + process.stderr.write( + `warning: output did not validate against lexicon: ${res.message}\n`, + ); + } + } + + if (json || typeof parsed === "object") jsonOut(parsed); + else textOut(text); + }, + ); +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin as AsyncIterable) chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..2d52251 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,53 @@ +export const EXIT = { + OK: 0, + GENERIC: 1, + AUTH: 2, + NOT_FOUND: 3, + CONFLICT: 4, + PERMISSION: 5, + TIMEOUT: 6, +} as const; + +export type ExitCode = (typeof EXIT)[keyof typeof EXIT]; + +export class TangleError extends Error { + constructor( + message: string, + public readonly code: ExitCode, + public readonly errorName: string, + ) { + super(message); + this.name = "TangleError"; + } +} + +export class AuthError extends TangleError { + constructor(message: string) { + super(message, EXIT.AUTH, "AuthError"); + } +} +export class NotFoundError extends TangleError { + constructor(message: string) { + super(message, EXIT.NOT_FOUND, "NotFoundError"); + } +} +export class ConflictError extends TangleError { + constructor(message: string) { + super(message, EXIT.CONFLICT, "ConflictError"); + } +} +export class PermissionError extends TangleError { + constructor(message: string) { + super(message, EXIT.PERMISSION, "PermissionError"); + } +} +export class TimeoutError extends TangleError { + constructor(message: string) { + super(message, EXIT.TIMEOUT, "TimeoutError"); + } +} +export class ValidationError extends TangleError { + constructor(message: string) { + super(message, EXIT.GENERIC, "ValidationError"); + } +} diff --git a/src/index.ts b/src/index.ts index 42a0994..4739385 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,27 +5,71 @@ import { repoCommand } from "./commands/repo.ts"; import { cloneCommand } from "./commands/clone.ts"; import { collabCommand } from "./commands/collab.ts"; import { pipelineCommand } from "./commands/pipeline.ts"; +import { lexiconCommand } from "./commands/lexicon.ts"; +import { whoamiCommand, resolveCommand } from "./commands/identity.ts"; +import { recordCommand } from "./commands/record.ts"; +import { xrpcCommand } from "./commands/xrpc.ts"; +import { refCommand, fileCommand } from "./commands/ref.ts"; +import { secretCommand } from "./commands/secret.ts"; +import { batchCommand } from "./commands/batch.ts"; import { XrpcError } from "./pds.ts"; +import { EXIT, TangleError } from "./errors.ts"; const program = new Command(); program .name("tangle") .description("CLI for tangled — atproto-native git collaboration") - .version("0.1.0"); + .version("0.1.0") + .option("--json", "emit machine-readable JSON output (command-specific shape)"); program.addCommand(authCommand()); program.addCommand(repoCommand()); program.addCommand(cloneCommand()); program.addCommand(collabCommand()); program.addCommand(pipelineCommand()); +program.addCommand(lexiconCommand()); +program.addCommand(whoamiCommand()); +program.addCommand(resolveCommand()); +program.addCommand(recordCommand()); +program.addCommand(xrpcCommand()); +program.addCommand(refCommand()); +program.addCommand(fileCommand()); +program.addCommand(secretCommand()); +program.addCommand(batchCommand()); try { await program.parseAsync(process.argv); } catch (e) { + emitError(e); + process.exit(exitCodeFor(e)); +} + +function exitCodeFor(e: unknown): number { + if (e instanceof TangleError) return e.code; + if (e instanceof XrpcError) { + if (e.status === 401 || e.status === 403) return EXIT.AUTH; + if (e.status === 404) return EXIT.NOT_FOUND; + if (e.status === 409) return EXIT.CONFLICT; + return EXIT.GENERIC; + } + return EXIT.GENERIC; +} + +function emitError(e: unknown): void { + const globalJson = (program.opts() as { json?: boolean }).json === true; + const { name, message } = errorShape(e); + if (globalJson) { + process.stderr.write(JSON.stringify({ error: name, message }) + "\n"); + } else { + process.stderr.write(`error: ${message}\n`); + } +} + +function errorShape(e: unknown): { name: string; message: string } { + if (e instanceof TangleError) return { name: e.errorName, message: e.message }; if (e instanceof XrpcError) { - process.stderr.write(`error: ${e.message}\n`); - process.exit(1); + return { name: e.errorName ?? "XrpcError", message: e.message }; } - process.stderr.write(`error: ${(e as Error).message}\n`); - process.exit(1); + if (e instanceof Error) return { name: e.name, message: e.message }; + return { name: "Error", message: String(e) }; } diff --git a/src/lexicons/data/com.atproto.repo.deleteRecord.json b/src/lexicons/data/com.atproto.repo.deleteRecord.json new file mode 100644 index 0000000..1c17c3b --- /dev/null +++ b/src/lexicons/data/com.atproto.repo.deleteRecord.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"com.atproto.repo.deleteRecord","defs":{"main":{"type":"procedure","description":"Delete a repository record, or ensure it doesn't exist. Requires auth, implemented by PDS.","input":{"encoding":"application/json","schema":{"type":"object","required":["repo","collection","rkey"],"properties":{"repo":{"type":"string","format":"at-identifier","description":"The handle or DID of the repo (aka, current account)."},"collection":{"type":"string","format":"nsid","description":"The NSID of the record collection."},"rkey":{"type":"string","format":"record-key","description":"The Record Key."},"swapRecord":{"type":"string","format":"cid","description":"Compare and swap with the previous record by CID."},"swapCommit":{"type":"string","format":"cid","description":"Compare and swap with the previous commit by CID."}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"commit":{"type":"ref","ref":"com.atproto.repo.defs#commitMeta"}}}},"errors":[{"name":"InvalidSwap"}]}}} diff --git a/src/lexicons/data/com.atproto.repo.describeRepo.json b/src/lexicons/data/com.atproto.repo.describeRepo.json new file mode 100644 index 0000000..bbe4de2 --- /dev/null +++ b/src/lexicons/data/com.atproto.repo.describeRepo.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"com.atproto.repo.describeRepo","defs":{"main":{"type":"query","description":"Get information about an account and repository, including the list of collections. Does not require auth.","parameters":{"type":"params","required":["repo"],"properties":{"repo":{"type":"string","format":"at-identifier","description":"The handle or DID of the repo."}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["handle","did","didDoc","collections","handleIsCorrect"],"properties":{"handle":{"type":"string","format":"handle"},"did":{"type":"string","format":"did"},"didDoc":{"type":"unknown","description":"The complete DID document for this account."},"collections":{"type":"array","description":"List of all the collections (NSIDs) for which this repo contains at least one record.","items":{"type":"string","format":"nsid"}},"handleIsCorrect":{"type":"boolean","description":"Indicates if handle is currently valid (resolves bi-directionally)"}}}}}}} diff --git a/src/lexicons/data/com.atproto.repo.getRecord.json b/src/lexicons/data/com.atproto.repo.getRecord.json new file mode 100644 index 0000000..1cf95da --- /dev/null +++ b/src/lexicons/data/com.atproto.repo.getRecord.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"com.atproto.repo.getRecord","defs":{"main":{"type":"query","description":"Get a single record from a repository. Does not require auth.","parameters":{"type":"params","required":["repo","collection","rkey"],"properties":{"repo":{"type":"string","format":"at-identifier","description":"The handle or DID of the repo."},"collection":{"type":"string","format":"nsid","description":"The NSID of the record collection."},"rkey":{"type":"string","description":"The Record Key.","format":"record-key"},"cid":{"type":"string","format":"cid","description":"The CID of the version of the record. If not specified, then return the most recent version."}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["uri","value"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"value":{"type":"unknown"}}}},"errors":[{"name":"RecordNotFound"}]}}} diff --git a/src/lexicons/data/com.atproto.repo.listRecords.json b/src/lexicons/data/com.atproto.repo.listRecords.json new file mode 100644 index 0000000..204d89d --- /dev/null +++ b/src/lexicons/data/com.atproto.repo.listRecords.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"com.atproto.repo.listRecords","defs":{"main":{"type":"query","description":"List a range of records in a repository, matching a specific collection. Does not require auth.","parameters":{"type":"params","required":["repo","collection"],"properties":{"repo":{"type":"string","format":"at-identifier","description":"The handle or DID of the repo."},"collection":{"type":"string","format":"nsid","description":"The NSID of the record type."},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"The number of records to return."},"cursor":{"type":"string"},"reverse":{"type":"boolean","description":"Flag to reverse the order of the returned records."}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["records"],"properties":{"cursor":{"type":"string"},"records":{"type":"array","items":{"type":"ref","ref":"#record"}}}}}},"record":{"type":"object","required":["uri","cid","value"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"value":{"type":"unknown"}}}}} diff --git a/src/lexicons/data/com.atproto.repo.putRecord.json b/src/lexicons/data/com.atproto.repo.putRecord.json new file mode 100644 index 0000000..b385987 --- /dev/null +++ b/src/lexicons/data/com.atproto.repo.putRecord.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"com.atproto.repo.putRecord","defs":{"main":{"type":"procedure","description":"Write a repository record, creating or updating it as needed. Requires auth, implemented by PDS.","input":{"encoding":"application/json","schema":{"type":"object","required":["repo","collection","rkey","record"],"nullable":["swapRecord"],"properties":{"repo":{"type":"string","format":"at-identifier","description":"The handle or DID of the repo (aka, current account)."},"collection":{"type":"string","format":"nsid","description":"The NSID of the record collection."},"rkey":{"type":"string","format":"record-key","description":"The Record Key.","maxLength":512},"validate":{"type":"boolean","description":"Can be set to 'false' to skip Lexicon schema validation of record data, 'true' to require it, or leave unset to validate only for known Lexicons."},"record":{"type":"unknown","description":"The record to write."},"swapRecord":{"type":"string","format":"cid","description":"Compare and swap with the previous record by CID. WARNING: nullable and optional field; may cause problems with golang implementation"},"swapCommit":{"type":"string","format":"cid","description":"Compare and swap with the previous commit by CID."}}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["uri","cid"],"properties":{"uri":{"type":"string","format":"at-uri"},"cid":{"type":"string","format":"cid"},"commit":{"type":"ref","ref":"com.atproto.repo.defs#commitMeta"},"validationStatus":{"type":"string","knownValues":["valid","unknown"]}}}},"errors":[{"name":"InvalidSwap"}]}}} diff --git a/src/lexicons/data/com.atproto.server.createSession.json b/src/lexicons/data/com.atproto.server.createSession.json new file mode 100644 index 0000000..8590ab8 --- /dev/null +++ b/src/lexicons/data/com.atproto.server.createSession.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"com.atproto.server.createSession","defs":{"main":{"type":"procedure","description":"Create an authentication session.","input":{"encoding":"application/json","schema":{"type":"object","required":["identifier","password"],"properties":{"identifier":{"type":"string","description":"Handle or other identifier supported by the server for the authenticating user."},"password":{"type":"string"},"authFactorToken":{"type":"string"},"allowTakendown":{"type":"boolean","description":"When true, instead of throwing error for takendown accounts, a valid response with a narrow scoped token will be returned"}}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["accessJwt","refreshJwt","handle","did"],"properties":{"accessJwt":{"type":"string"},"refreshJwt":{"type":"string"},"handle":{"type":"string","format":"handle"},"did":{"type":"string","format":"did"},"didDoc":{"type":"unknown"},"email":{"type":"string"},"emailConfirmed":{"type":"boolean"},"emailAuthFactor":{"type":"boolean"},"active":{"type":"boolean"},"status":{"type":"string","description":"If active=false, this optional field indicates a possible reason for why the account is not active. If active=false and no status is supplied, then the host makes no claim for why the repository is no longer being hosted.","knownValues":["takendown","suspended","deactivated"]}}}},"errors":[{"name":"AccountTakedown"},{"name":"AuthFactorTokenRequired"}]}}} diff --git a/src/lexicons/data/com.atproto.server.getServiceAuth.json b/src/lexicons/data/com.atproto.server.getServiceAuth.json new file mode 100644 index 0000000..0127a49 --- /dev/null +++ b/src/lexicons/data/com.atproto.server.getServiceAuth.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"com.atproto.server.getServiceAuth","defs":{"main":{"type":"query","description":"Get a signed token on behalf of the requesting DID for the requested service.","parameters":{"type":"params","required":["aud"],"properties":{"aud":{"type":"string","format":"did","description":"The DID of the service that the token will be used to authenticate with"},"exp":{"type":"integer","description":"The time in Unix Epoch seconds that the JWT expires. Defaults to 60 seconds in the future. The service may enforce certain time bounds on tokens depending on the requested scope."},"lxm":{"type":"string","format":"nsid","description":"Lexicon (XRPC) method to bind the requested token to"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["token"],"properties":{"token":{"type":"string"}}}},"errors":[{"name":"BadExpiration","description":"Indicates that the requested expiration date is not a valid. May be in the past or may be reliant on the requested scopes."}]}}} diff --git a/src/lexicons/data/com.atproto.server.refreshSession.json b/src/lexicons/data/com.atproto.server.refreshSession.json new file mode 100644 index 0000000..d7df4f6 --- /dev/null +++ b/src/lexicons/data/com.atproto.server.refreshSession.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"com.atproto.server.refreshSession","defs":{"main":{"type":"procedure","description":"Refresh an authentication session. Requires auth using the 'refreshJwt' (not the 'accessJwt').","output":{"encoding":"application/json","schema":{"type":"object","required":["accessJwt","refreshJwt","handle","did"],"properties":{"accessJwt":{"type":"string"},"refreshJwt":{"type":"string"},"handle":{"type":"string","format":"handle"},"did":{"type":"string","format":"did"},"didDoc":{"type":"unknown"},"email":{"type":"string"},"emailConfirmed":{"type":"boolean"},"emailAuthFactor":{"type":"boolean"},"active":{"type":"boolean"},"status":{"type":"string","description":"Hosting status of the account. If not specified, then assume 'active'.","knownValues":["takendown","suspended","deactivated"]}}}},"errors":[{"name":"AccountTakedown"},{"name":"InvalidToken"},{"name":"ExpiredToken"}]}}} diff --git a/src/lexicons/data/sh.tangled.actor.profile.json b/src/lexicons/data/sh.tangled.actor.profile.json new file mode 100644 index 0000000..40546f0 --- /dev/null +++ b/src/lexicons/data/sh.tangled.actor.profile.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.actor.profile","defs":{"main":{"type":"record","description":"A declaration of a Tangled account profile.","key":"literal:self","record":{"type":"object","required":["bluesky"],"properties":{"avatar":{"type":"blob","description":"Small image to be displayed next to posts from account. AKA, 'profile picture'","accept":["image/png","image/jpeg"],"maxSize":1000000},"description":{"type":"string","description":"Free-form profile description text.","maxGraphemes":256,"maxLength":2560},"links":{"type":"array","minLength":0,"maxLength":5,"items":{"type":"string","description":"Any URI, intended for social profiles or websites, can be used to link DIDs/AT-URIs too.","format":"uri"}},"stats":{"type":"array","minLength":0,"maxLength":2,"items":{"type":"string","description":"Vanity stats.","enum":["merged-pull-request-count","closed-pull-request-count","open-pull-request-count","open-issue-count","closed-issue-count","repository-count","star-count"]}},"bluesky":{"type":"boolean","description":"Include link to this account on Bluesky."},"location":{"type":"string","description":"Free-form location text.","maxGraphemes":40,"maxLength":400},"pinnedRepositories":{"type":"array","description":"Pinned repositories. Values are repo DIDs for repos that have them, or AT-URIs for legacy repos.","minLength":0,"maxLength":6,"items":{"type":"string"}},"pronouns":{"type":"string","description":"Preferred gender pronouns.","maxLength":40},"preferredHandle":{"type":"string","description":"A handle the user prefers to be displayed as.","format":"handle","maxLength":253}}}}}} diff --git a/src/lexicons/data/sh.tangled.feed.reaction.json b/src/lexicons/data/sh.tangled.feed.reaction.json new file mode 100644 index 0000000..72a5167 --- /dev/null +++ b/src/lexicons/data/sh.tangled.feed.reaction.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.feed.reaction","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["subject","reaction","createdAt"],"properties":{"subject":{"type":"string","format":"at-uri"},"reaction":{"type":"string","enum":["👍","👎","😆","🎉","🫤","❤️","🚀","👀"]},"createdAt":{"type":"string","format":"datetime"}}}}}} diff --git a/src/lexicons/data/sh.tangled.feed.star.json b/src/lexicons/data/sh.tangled.feed.star.json new file mode 100644 index 0000000..c907ac9 --- /dev/null +++ b/src/lexicons/data/sh.tangled.feed.star.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.feed.star","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["createdAt"],"properties":{"subject":{"type":"string","format":"at-uri"},"subjectDid":{"type":"string","format":"did"},"createdAt":{"type":"string","format":"datetime"}}}}}} diff --git a/src/lexicons/data/sh.tangled.git.refUpdate.json b/src/lexicons/data/sh.tangled.git.refUpdate.json new file mode 100644 index 0000000..e5e835f --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.refUpdate.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.refUpdate","defs":{"main":{"type":"record","description":"An update to a git repository, emitted by knots.","key":"tid","record":{"type":"object","required":["ref","committerDid","repoName","oldSha","newSha","meta"],"properties":{"ref":{"type":"string","description":"Ref being updated","maxGraphemes":256,"maxLength":2560},"committerDid":{"type":"string","description":"did of the user that pushed this ref","format":"did"},"ownerDid":{"type":"string","description":"did of the owner of the repo","format":"did"},"repoDid":{"type":"string","description":"DID of the repo itself","format":"did"},"repoName":{"type":"string","description":"name of the repo"},"oldSha":{"type":"string","description":"old SHA of this ref","minLength":40,"maxLength":40},"newSha":{"type":"string","description":"new SHA of this ref","minLength":40,"maxLength":40},"meta":{"type":"ref","ref":"#meta"}}}},"meta":{"type":"object","required":["isDefaultRef","commitCount"],"properties":{"isDefaultRef":{"type":"boolean","default":false},"langBreakdown":{"type":"ref","ref":"#langBreakdown"},"commitCount":{"type":"ref","ref":"#commitCountBreakdown"}}},"langBreakdown":{"type":"object","properties":{"inputs":{"type":"array","items":{"type":"ref","ref":"#individualLanguageSize"}}}},"individualLanguageSize":{"type":"object","required":["lang","size"],"properties":{"lang":{"type":"string"},"size":{"type":"integer"}}},"commitCountBreakdown":{"type":"object","required":[],"properties":{"byEmail":{"type":"array","items":{"type":"ref","ref":"#individualEmailCommitCount"}}}},"individualEmailCommitCount":{"type":"object","required":["email","count"],"properties":{"email":{"type":"string"},"count":{"type":"integer"}}}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.analyzeMerge.json b/src/lexicons/data/sh.tangled.git.temp.analyzeMerge.json new file mode 100644 index 0000000..99d230e --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.analyzeMerge.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.analyzeMerge","defs":{"main":{"type":"query","description":"Check if a merge is possible between two branches","parameters":{"type":"params","required":["repo","patch","branch"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"patch":{"type":"string","description":"Patch or pull request to check for merge conflicts"},"branch":{"type":"string","description":"Target branch to merge into"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["is_conflicted"],"properties":{"is_conflicted":{"type":"boolean","description":"Whether the merge has conflicts"},"conflicts":{"type":"array","description":"List of files with merge conflicts","items":{"type":"ref","ref":"#conflictInfo"}}}}}},"conflictInfo":{"type":"object","required":["filename","reason"],"properties":{"filename":{"type":"string","description":"Name of the conflicted file"},"reason":{"type":"string","description":"Reason for the conflict"}}}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.defs.json b/src/lexicons/data/sh.tangled.git.temp.defs.json new file mode 100644 index 0000000..25c5802 --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.defs.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.defs","defs":{"blob":{"type":"object","description":"blob metadata. This object doesn't include the blob content","required":["name","mode","size","lastCommit"],"properties":{"name":{"type":"string","description":"The file name"},"mode":{"type":"string"},"size":{"type":"integer","description":"File size in bytes"},"lastCommit":{"type":"ref","ref":"#commit"},"submodule":{"type":"ref","ref":"#submodule","description":"Submodule information if path is a submodule"}}},"branch":{"type":"object","required":["name","commit"],"properties":{"name":{"type":"string","description":"branch name"},"commit":{"type":"ref","ref":"#commit","description":"hydrated commit object"}}},"tag":{"type":"object","required":["name","tagger","target"],"properties":{"name":{"type":"string","description":"tag name"},"tagger":{"type":"ref","ref":"#signature"},"message":{"type":"string"},"target":{"type":"unknown"}}},"commit":{"type":"object","required":["hash","author","committer","message","tree"],"properties":{"hash":{"type":"ref","ref":"#hash"},"author":{"type":"ref","ref":"#signature"},"committer":{"type":"ref","ref":"#signature"},"message":{"type":"string"},"tree":{"type":"ref","ref":"#hash"}}},"hash":{"type":"string"},"signature":{"type":"object","required":["name","email","when"],"properties":{"name":{"type":"string","description":"Person name"},"email":{"type":"string","description":"Person email"},"when":{"type":"string","format":"datetime","description":"Timestamp of the signature"}}},"submodule":{"type":"object","required":["name","url"],"properties":{"name":{"type":"string","description":"Submodule name"},"url":{"type":"string","description":"Submodule repository URL"},"branch":{"type":"string","description":"Branch to track in the submodule"}}}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.getArchive.json b/src/lexicons/data/sh.tangled.git.temp.getArchive.json new file mode 100644 index 0000000..173e2e4 --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.getArchive.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.getArchive","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","ref"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"ref":{"type":"string","description":"Git reference (branch, tag, or commit SHA)"},"format":{"type":"string","description":"Archive format","enum":["tar","zip","tar.gz","tar.bz2","tar.xz"],"default":"tar.gz"},"prefix":{"type":"string","description":"Prefix for files in the archive"}}},"output":{"encoding":"*/*","description":"Binary archive data"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"RefNotFound","description":"Git reference not found"},{"name":"InvalidRequest","description":"Invalid request parameters"},{"name":"ArchiveError","description":"Failed to create archive"}]}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.getBlob.json b/src/lexicons/data/sh.tangled.git.temp.getBlob.json new file mode 100644 index 0000000..e0037db --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.getBlob.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.getBlob","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","path"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"ref":{"type":"string","description":"Git reference (branch, tag, or commit SHA)","default":"HEAD"},"path":{"type":"string","description":"Path within the repository tree"}}},"output":{"encoding":"*/*","description":"raw blob served in octet-stream"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"BlobNotFound","description":"Blob not found"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.getBranch.json b/src/lexicons/data/sh.tangled.git.temp.getBranch.json new file mode 100644 index 0000000..2c4755e --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.getBranch.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.getBranch","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","name"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"name":{"type":"string","description":"Branch name to get information for"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["name","hash","when"],"properties":{"name":{"type":"string","description":"Branch name"},"hash":{"type":"string","description":"Latest commit hash on this branch"},"when":{"type":"string","format":"datetime","description":"Timestamp of latest commit"},"message":{"type":"string","description":"Latest commit message"},"author":{"type":"ref","ref":"sh.tangled.git.temp.defs#signature"}}}},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"BranchNotFound","description":"Branch not found"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.getCommit.json b/src/lexicons/data/sh.tangled.git.temp.getCommit.json new file mode 100644 index 0000000..5026f19 --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.getCommit.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.getCommit","defs":{"main":{"type":"query","description":"resolve commit from given ref","parameters":{"type":"params","required":["repo","ref"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"ref":{"type":"string","description":"reference name to resolve"}}},"output":{"encoding":"application/json","schema":{"type":"ref","ref":"sh.tangled.git.temp.defs#commit"}},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"CommitNotFound","description":"Commit not found"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.getDiff.json b/src/lexicons/data/sh.tangled.git.temp.getDiff.json new file mode 100644 index 0000000..55ee1a5 --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.getDiff.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.getDiff","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","rev1","rev2"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"rev1":{"type":"string","description":"First revision (commit, branch, or tag)"},"rev2":{"type":"string","description":"Second revision (commit, branch, or tag)"}}},"output":{"encoding":"*/*","description":"Compare output in application/json"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"RevisionNotFound","description":"One or both revisions not found"},{"name":"InvalidRequest","description":"Invalid request parameters"},{"name":"CompareError","description":"Failed to compare revisions"}]}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.getEntity.json b/src/lexicons/data/sh.tangled.git.temp.getEntity.json new file mode 100644 index 0000000..61c1fe4 --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.getEntity.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.getEntity","defs":{"main":{"type":"query","description":"get metadata of blob by ref and path","parameters":{"type":"params","required":["repo","path"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"ref":{"type":"string","description":"Git reference (branch, tag, or commit SHA)","default":"HEAD"},"path":{"type":"string","description":"path of the entity"}}},"output":{"encoding":"application/json","schema":{"type":"ref","ref":"sh.tangled.git.temp.defs#blob"}},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"BlobNotFound","description":"Blob not found"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.getHead.json b/src/lexicons/data/sh.tangled.git.temp.getHead.json new file mode 100644 index 0000000..d78e98f --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.getHead.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.getHead","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"}}},"output":{"encoding":"application/json","schema":{"type":"ref","ref":"sh.tangled.git.temp.defs#branch"}},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.getTag.json b/src/lexicons/data/sh.tangled.git.temp.getTag.json new file mode 100644 index 0000000..617f294 --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.getTag.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.getTag","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","tag"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"tag":{"type":"string","description":"Name of tag, such as v1.3.0"}}},"output":{"encoding":"*/*"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"TagNotFound","description":"Tag not found"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.getTree.json b/src/lexicons/data/sh.tangled.git.temp.getTree.json new file mode 100644 index 0000000..0f69bcf --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.getTree.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.getTree","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","ref"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"ref":{"type":"string","description":"Git reference (branch, tag, or commit SHA)"},"path":{"type":"string","description":"Path within the repository tree","default":""}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["ref","files"],"properties":{"ref":{"type":"string","description":"The git reference used"},"parent":{"type":"string","description":"The parent path in the tree"},"dotdot":{"type":"string","description":"Parent directory path"},"readme":{"type":"ref","ref":"#readme","description":"Readme for this file tree"},"lastCommit":{"type":"ref","ref":"#lastCommit"},"files":{"type":"array","items":{"type":"ref","ref":"#treeEntry"}}}}},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"RefNotFound","description":"Git reference not found"},{"name":"PathNotFound","description":"Path not found in repository tree"},{"name":"InvalidRequest","description":"Invalid request parameters"}]},"readme":{"type":"object","required":["filename","contents"],"properties":{"filename":{"type":"string","description":"Name of the readme file"},"contents":{"type":"string","description":"Contents of the readme file"}}},"treeEntry":{"type":"object","required":["name","mode","size"],"properties":{"name":{"type":"string","description":"Relative file or directory name"},"mode":{"type":"string","description":"File mode"},"size":{"type":"integer","description":"File size in bytes"},"last_commit":{"type":"ref","ref":"#lastCommit"}}},"lastCommit":{"type":"object","required":["hash","message","when"],"properties":{"hash":{"type":"string","description":"Commit hash"},"message":{"type":"string","description":"Commit message"},"author":{"type":"ref","ref":"#signature"},"when":{"type":"string","format":"datetime","description":"Commit timestamp"}}},"signature":{"type":"object","required":["name","email","when"],"properties":{"name":{"type":"string","description":"Author name"},"email":{"type":"string","description":"Author email"},"when":{"type":"string","format":"datetime","description":"Author timestamp"}}}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.listBranches.json b/src/lexicons/data/sh.tangled.git.temp.listBranches.json new file mode 100644 index 0000000..b3bc867 --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.listBranches.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.listBranches","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"limit":{"type":"integer","description":"Maximum number of branches to return","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string","description":"Pagination cursor"}}},"output":{"encoding":"*/*"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.listCommits.json b/src/lexicons/data/sh.tangled.git.temp.listCommits.json new file mode 100644 index 0000000..4ec92cb --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.listCommits.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.listCommits","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"ref":{"type":"string","description":"Git reference (branch, tag, or commit SHA)"},"limit":{"type":"integer","description":"Maximum number of commits to return","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string","description":"Pagination cursor (commit SHA)"}}},"output":{"encoding":"*/*"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"RefNotFound","description":"Git reference not found"},{"name":"PathNotFound","description":"Path not found in repository"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.listLanguages.json b/src/lexicons/data/sh.tangled.git.temp.listLanguages.json new file mode 100644 index 0000000..029bb2b --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.listLanguages.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.listLanguages","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"ref":{"type":"string","description":"Git reference (branch, tag, or commit SHA)","default":"HEAD"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["ref","languages"],"properties":{"ref":{"type":"string","description":"The git reference used"},"languages":{"type":"array","items":{"type":"ref","ref":"#language"}},"totalSize":{"type":"integer","description":"Total size of all analyzed files in bytes"},"totalFiles":{"type":"integer","description":"Total number of files analyzed"}}}},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"RefNotFound","description":"Git reference not found"},{"name":"InvalidRequest","description":"Invalid request parameters"}]},"language":{"type":"object","required":["name","size","percentage"],"properties":{"name":{"type":"string","description":"Programming language name"},"size":{"type":"integer","description":"Total size of files in this language (bytes)"},"percentage":{"type":"integer","description":"Percentage of total codebase (0-100)"},"fileCount":{"type":"integer","description":"Number of files in this language"},"color":{"type":"string","description":"Hex color code for this language"},"extensions":{"type":"array","items":{"type":"string"},"description":"File extensions associated with this language"}}}}} diff --git a/src/lexicons/data/sh.tangled.git.temp.listTags.json b/src/lexicons/data/sh.tangled.git.temp.listTags.json new file mode 100644 index 0000000..7088661 --- /dev/null +++ b/src/lexicons/data/sh.tangled.git.temp.listTags.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.git.temp.listTags","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"limit":{"type":"integer","description":"Maximum number of tags to return","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string","description":"Pagination cursor"}}},"output":{"encoding":"*/*"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.graph.follow.json b/src/lexicons/data/sh.tangled.graph.follow.json new file mode 100644 index 0000000..bdc7545 --- /dev/null +++ b/src/lexicons/data/sh.tangled.graph.follow.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.graph.follow","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["subject","createdAt"],"properties":{"subject":{"type":"string","format":"did"},"createdAt":{"type":"string","format":"datetime"}}}}}} diff --git a/src/lexicons/data/sh.tangled.knot.json b/src/lexicons/data/sh.tangled.knot.json new file mode 100644 index 0000000..9e858fb --- /dev/null +++ b/src/lexicons/data/sh.tangled.knot.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.knot","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"any","record":{"type":"object","required":["createdAt"],"properties":{"createdAt":{"type":"string","format":"datetime"}}}}}} diff --git a/src/lexicons/data/sh.tangled.knot.listKeys.json b/src/lexicons/data/sh.tangled.knot.listKeys.json new file mode 100644 index 0000000..c711a1a --- /dev/null +++ b/src/lexicons/data/sh.tangled.knot.listKeys.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.knot.listKeys","defs":{"main":{"type":"query","description":"List all public keys stored in the knot server","parameters":{"type":"params","properties":{"limit":{"type":"integer","description":"Maximum number of keys to return","minimum":1,"maximum":1000,"default":100},"cursor":{"type":"string","description":"Pagination cursor"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["keys"],"properties":{"keys":{"type":"array","items":{"type":"ref","ref":"#publicKey"}},"cursor":{"type":"string","description":"Pagination cursor for next page"}}}},"errors":[{"name":"InternalServerError","description":"Failed to retrieve public keys"}]},"publicKey":{"type":"object","required":["did","key","createdAt"],"properties":{"did":{"type":"string","format":"did","description":"DID associated with the public key"},"key":{"type":"string","maxLength":4096,"description":"Public key contents"},"createdAt":{"type":"string","format":"datetime","description":"Key upload timestamp"}}}}} diff --git a/src/lexicons/data/sh.tangled.knot.member.json b/src/lexicons/data/sh.tangled.knot.member.json new file mode 100644 index 0000000..9cd4fe7 --- /dev/null +++ b/src/lexicons/data/sh.tangled.knot.member.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.knot.member","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["subject","domain","createdAt"],"properties":{"subject":{"type":"string","format":"did"},"domain":{"type":"string","description":"domain that this member now belongs to"},"createdAt":{"type":"string","format":"datetime"}}}}}} diff --git a/src/lexicons/data/sh.tangled.knot.subscribeRepos.json b/src/lexicons/data/sh.tangled.knot.subscribeRepos.json new file mode 100644 index 0000000..c0f5480 --- /dev/null +++ b/src/lexicons/data/sh.tangled.knot.subscribeRepos.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.knot.subscribeRepos","defs":{"main":{"type":"subscription","description":"Repository event stream, aka Firehose endpoint. Outputs repo commits with diff data, and identity update events, for all repositories on the current server. See the atproto specifications for details around stream sequencing, repo versioning, CAR diff format, and more. Public and does not require auth; implemented by PDS and Relay.","parameters":{"type":"params","properties":{"cursor":{"type":"integer","description":"The last known event seq number to backfill from."}}},"message":{"schema":{"type":"union","refs":["#identity","sh.tangled.git.refUpdate"]}},"errors":[{"name":"FutureCursor"},{"name":"ConsumerTooSlow","description":"If the consumer of the stream can not keep up with events, and a backlog gets too large, the server will drop the connection."}]},"identity":{"type":"object","required":["seq","did","time"],"properties":{"seq":{"type":"integer","description":"The stream sequence number of this message."},"did":{"type":"string","format":"did","description":"Repository DID identifier"},"time":{"type":"string","format":"datetime"}}},"gitSync1":{"type":"object","required":["seq","did"],"properties":{"seq":{"type":"integer","description":"The stream sequence number of this message."},"did":{"type":"string","format":"did","description":"Repository DID identifier"}}},"gitSync2":{"type":"object","required":["seq","repo"],"properties":{"seq":{"type":"integer","description":"The stream sequence number of this message."},"repo":{"type":"string","format":"at-uri","description":"Repository AT-URI identifier"}}}}} diff --git a/src/lexicons/data/sh.tangled.knot.version.json b/src/lexicons/data/sh.tangled.knot.version.json new file mode 100644 index 0000000..dd8cfdf --- /dev/null +++ b/src/lexicons/data/sh.tangled.knot.version.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.knot.version","defs":{"main":{"type":"query","description":"Get the version of a knot","output":{"encoding":"application/json","schema":{"type":"object","required":["version"],"properties":{"version":{"type":"string"}}}},"errors":[]}}} diff --git a/src/lexicons/data/sh.tangled.label.definition.json b/src/lexicons/data/sh.tangled.label.definition.json new file mode 100644 index 0000000..bb059aa --- /dev/null +++ b/src/lexicons/data/sh.tangled.label.definition.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.label.definition","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"any","record":{"type":"object","required":["name","valueType","scope","createdAt"],"properties":{"name":{"type":"string","description":"The display name of this label.","minGraphemes":1,"maxGraphemes":40},"valueType":{"type":"ref","ref":"#valueType","description":"The type definition of this label. Appviews may allow sorting for certain types."},"scope":{"type":"array","description":"The areas of the repo this label may apply to, eg.: sh.tangled.repo.issue. Appviews may choose to respect this.","items":{"type":"string","format":"nsid"}},"color":{"type":"string","description":"The hex value for the background color for the label. Appviews may choose to respect this."},"createdAt":{"type":"string","format":"datetime"},"multiple":{"type":"boolean","description":"Whether this label can be repeated for a given entity, eg.: [reviewer:foo, reviewer:bar]"}}}},"valueType":{"type":"object","required":["type","format"],"properties":{"type":{"type":"string","enum":["null","boolean","integer","string"],"description":"The concrete type of this label's value."},"format":{"type":"string","enum":["any","did","nsid"],"description":"An optional constraint that can be applied on string concrete types."},"enum":{"type":"array","description":"Closed set of values that this label can take.","items":{"type":"string"}}}}}} diff --git a/src/lexicons/data/sh.tangled.label.op.json b/src/lexicons/data/sh.tangled.label.op.json new file mode 100644 index 0000000..368c06e --- /dev/null +++ b/src/lexicons/data/sh.tangled.label.op.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.label.op","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["subject","add","delete","performedAt"],"properties":{"subject":{"type":"string","format":"at-uri","description":"The subject (task, pull or discussion) of this label. Appviews may apply a `scope` check and refuse this op."},"performedAt":{"type":"string","format":"datetime"},"add":{"type":"array","items":{"type":"ref","ref":"#operand"}},"delete":{"type":"array","items":{"type":"ref","ref":"#operand"}}}}},"operand":{"type":"object","required":["key","value"],"properties":{"key":{"type":"string","format":"at-uri","description":"ATURI to the label definition"},"value":{"type":"string","description":"Stringified value of the label. This is first unstringed by appviews and then interpreted as a concrete value."}}}}} diff --git a/src/lexicons/data/sh.tangled.owner.json b/src/lexicons/data/sh.tangled.owner.json new file mode 100644 index 0000000..3f0876f --- /dev/null +++ b/src/lexicons/data/sh.tangled.owner.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.owner","defs":{"main":{"type":"query","description":"Get the owner of a service","output":{"encoding":"application/json","schema":{"type":"object","required":["owner"],"properties":{"owner":{"type":"string","format":"did"}}}},"errors":[{"name":"OwnerNotFound","description":"Owner is not set for this service"}]}}} diff --git a/src/lexicons/data/sh.tangled.pipeline.cancelPipeline.json b/src/lexicons/data/sh.tangled.pipeline.cancelPipeline.json new file mode 100644 index 0000000..f272785 --- /dev/null +++ b/src/lexicons/data/sh.tangled.pipeline.cancelPipeline.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.pipeline.cancelPipeline","defs":{"main":{"type":"procedure","description":"Cancel a running pipeline","input":{"encoding":"application/json","schema":{"type":"object","required":["repo","pipeline","workflow"],"properties":{"repo":{"type":"string","format":"at-uri","description":"repo at-uri, spindle can't resolve repo from pipeline at-uri yet"},"pipeline":{"type":"string","format":"at-uri","description":"pipeline at-uri"},"workflow":{"type":"string","description":"workflow name"}}}}}}} diff --git a/src/lexicons/data/sh.tangled.pipeline.json b/src/lexicons/data/sh.tangled.pipeline.json new file mode 100644 index 0000000..ff0364e --- /dev/null +++ b/src/lexicons/data/sh.tangled.pipeline.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.pipeline","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["triggerMetadata","workflows"],"properties":{"triggerMetadata":{"type":"ref","ref":"#triggerMetadata"},"workflows":{"type":"array","items":{"type":"ref","ref":"#workflow"}}}}},"triggerMetadata":{"type":"object","required":["kind","repo"],"properties":{"kind":{"type":"string","enum":["push","pull_request","manual"]},"repo":{"type":"ref","ref":"#triggerRepo"},"push":{"type":"ref","ref":"#pushTriggerData"},"pullRequest":{"type":"ref","ref":"#pullRequestTriggerData"},"manual":{"type":"ref","ref":"#manualTriggerData"}}},"triggerRepo":{"type":"object","required":["knot","did","defaultBranch"],"properties":{"knot":{"type":"string"},"did":{"type":"string","format":"did"},"repoDid":{"type":"string","description":"DID of the repo itself","format":"did"},"repo":{"type":"string"},"defaultBranch":{"type":"string"}}},"pushTriggerData":{"type":"object","required":["ref","newSha","oldSha"],"properties":{"ref":{"type":"string"},"newSha":{"type":"string","minLength":40,"maxLength":40},"oldSha":{"type":"string","minLength":40,"maxLength":40}}},"pullRequestTriggerData":{"type":"object","required":["sourceBranch","targetBranch","sourceSha","action"],"properties":{"sourceBranch":{"type":"string"},"targetBranch":{"type":"string"},"sourceSha":{"type":"string","minLength":40,"maxLength":40},"action":{"type":"string"}}},"manualTriggerData":{"type":"object","properties":{"inputs":{"type":"array","items":{"type":"ref","ref":"#pair"}}}},"workflow":{"type":"object","required":["name","engine","clone","raw"],"properties":{"name":{"type":"string"},"engine":{"type":"string"},"clone":{"type":"ref","ref":"#cloneOpts"},"raw":{"type":"string"}}},"cloneOpts":{"type":"object","required":["skip","depth","submodules"],"properties":{"skip":{"type":"boolean"},"depth":{"type":"integer"},"submodules":{"type":"boolean"}}},"pair":{"type":"object","required":["key","value"],"properties":{"key":{"type":"string"},"value":{"type":"string"}}}}} diff --git a/src/lexicons/data/sh.tangled.pipeline.status.json b/src/lexicons/data/sh.tangled.pipeline.status.json new file mode 100644 index 0000000..1b42a27 --- /dev/null +++ b/src/lexicons/data/sh.tangled.pipeline.status.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.pipeline.status","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["pipeline","workflow","status","createdAt"],"properties":{"pipeline":{"type":"string","format":"at-uri","description":"ATURI of the pipeline"},"workflow":{"type":"string","format":"at-uri","description":"name of the workflow within this pipeline"},"status":{"type":"string","description":"status of the workflow","enum":["pending","running","failed","timeout","cancelled","success"]},"createdAt":{"type":"string","format":"datetime","description":"time of creation of this status update"},"error":{"type":"string","description":"error message if failed"},"exitCode":{"type":"integer","description":"exit code if failed"}}}}}} diff --git a/src/lexicons/data/sh.tangled.publicKey.json b/src/lexicons/data/sh.tangled.publicKey.json new file mode 100644 index 0000000..824f819 --- /dev/null +++ b/src/lexicons/data/sh.tangled.publicKey.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.publicKey","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["key","name","createdAt"],"properties":{"key":{"type":"string","maxLength":4096,"description":"public key contents"},"name":{"type":"string","description":"human-readable name for this key"},"createdAt":{"type":"string","format":"datetime","description":"key upload timestamp"}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.addSecret.json b/src/lexicons/data/sh.tangled.repo.addSecret.json new file mode 100644 index 0000000..48351c5 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.addSecret.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.addSecret","defs":{"main":{"type":"procedure","description":"Add a CI secret","input":{"encoding":"application/json","schema":{"type":"object","required":["repo","key","value"],"properties":{"repo":{"type":"string","format":"at-uri"},"key":{"type":"string","maxLength":50,"minLength":1},"value":{"type":"string","maxLength":200,"minLength":1}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.archive.json b/src/lexicons/data/sh.tangled.repo.archive.json new file mode 100644 index 0000000..2bde638 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.archive.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.archive","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","ref"],"properties":{"repo":{"type":"string","description":"Repository identifier in format 'did:plc:.../repoName'"},"ref":{"type":"string","description":"Git reference (branch, tag, or commit SHA)"},"format":{"type":"string","description":"Archive format","enum":["tar","zip","tar.gz","tar.bz2","tar.xz"],"default":"tar.gz"},"prefix":{"type":"string","description":"Prefix for files in the archive"}}},"output":{"encoding":"*/*","description":"Binary archive data"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"RefNotFound","description":"Git reference not found"},{"name":"InvalidRequest","description":"Invalid request parameters"},{"name":"ArchiveError","description":"Failed to create archive"}]}}} diff --git a/src/lexicons/data/sh.tangled.repo.artifact.json b/src/lexicons/data/sh.tangled.repo.artifact.json new file mode 100644 index 0000000..8df0d4c --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.artifact.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.artifact","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["name","tag","createdAt","artifact"],"properties":{"name":{"type":"string","description":"name of the artifact"},"repo":{"type":"string","format":"at-uri","description":"repo that this artifact is being uploaded to"},"repoDid":{"type":"string","format":"did"},"tag":{"type":"bytes","description":"hash of the tag object that this artifact is attached to (only annotated tags are supported)","minLength":20,"maxLength":20},"createdAt":{"type":"string","format":"datetime","description":"time of creation of this artifact"},"artifact":{"type":"blob","description":"the artifact","accept":["*/*"],"maxSize":52428800}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.blob.json b/src/lexicons/data/sh.tangled.repo.blob.json new file mode 100644 index 0000000..2d9a950 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.blob.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.blob","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","ref","path"],"properties":{"repo":{"type":"string","description":"Repository identifier in format 'did:plc:.../repoName'"},"ref":{"type":"string","description":"Git reference (branch, tag, or commit SHA)"},"path":{"type":"string","description":"Path to the file within the repository"},"raw":{"type":"boolean","description":"Return raw file content instead of JSON response","default":false}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["ref","path"],"properties":{"ref":{"type":"string","description":"The git reference used"},"path":{"type":"string","description":"The file path"},"content":{"type":"string","description":"File content (base64 encoded for binary files)"},"encoding":{"type":"string","description":"Content encoding","enum":["utf-8","base64"]},"size":{"type":"integer","description":"File size in bytes"},"isBinary":{"type":"boolean","description":"Whether the file is binary"},"mimeType":{"type":"string","description":"MIME type of the file"},"submodule":{"type":"ref","ref":"#submodule","description":"Submodule information if path is a submodule"},"lastCommit":{"type":"ref","ref":"#lastCommit"}}}},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"RefNotFound","description":"Git reference not found"},{"name":"FileNotFound","description":"File not found at the specified path"},{"name":"InvalidRequest","description":"Invalid request parameters"}]},"lastCommit":{"type":"object","required":["hash","message","when"],"properties":{"hash":{"type":"string","description":"Commit hash"},"message":{"type":"string","description":"Commit message"},"author":{"type":"ref","ref":"#signature"},"when":{"type":"string","format":"datetime","description":"Commit timestamp"}}},"signature":{"type":"object","required":["name","email","when"],"properties":{"name":{"type":"string","description":"Author name"},"email":{"type":"string","description":"Author email"},"when":{"type":"string","format":"datetime","description":"Author timestamp"}}},"submodule":{"type":"object","required":["name","url"],"properties":{"name":{"type":"string","description":"Submodule name"},"url":{"type":"string","description":"Submodule repository URL"},"branch":{"type":"string","description":"Branch to track in the submodule"}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.branch.json b/src/lexicons/data/sh.tangled.repo.branch.json new file mode 100644 index 0000000..3bf4d96 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.branch.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.branch","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","name"],"properties":{"repo":{"type":"string","description":"Repository identifier in format 'did:plc:.../repoName'"},"name":{"type":"string","description":"Branch name to get information for"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["name","hash","when"],"properties":{"name":{"type":"string","description":"Branch name"},"hash":{"type":"string","description":"Latest commit hash on this branch"},"shortHash":{"type":"string","description":"Short commit hash"},"when":{"type":"string","format":"datetime","description":"Timestamp of latest commit"},"message":{"type":"string","description":"Latest commit message"},"author":{"type":"ref","ref":"#signature"},"isDefault":{"type":"boolean","description":"Whether this is the default branch"}}}},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"BranchNotFound","description":"Branch not found"},{"name":"InvalidRequest","description":"Invalid request parameters"}]},"signature":{"type":"object","required":["name","email","when"],"properties":{"name":{"type":"string","description":"Author name"},"email":{"type":"string","description":"Author email"},"when":{"type":"string","format":"datetime","description":"Author timestamp"}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.branches.json b/src/lexicons/data/sh.tangled.repo.branches.json new file mode 100644 index 0000000..dcdadfd --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.branches.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.branches","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo"],"properties":{"repo":{"type":"string","description":"Repository identifier in format 'did:plc:.../repoName'"},"limit":{"type":"integer","description":"Maximum number of branches to return","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string","description":"Pagination cursor"}}},"output":{"encoding":"*/*"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.repo.collaborator.json b/src/lexicons/data/sh.tangled.repo.collaborator.json new file mode 100644 index 0000000..dbe4ab2 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.collaborator.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.collaborator","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["subject","createdAt"],"properties":{"subject":{"type":"string","format":"did"},"repo":{"type":"string","description":"repo to add this user to","format":"at-uri"},"repoDid":{"type":"string","format":"did"},"createdAt":{"type":"string","format":"datetime"}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.compare.json b/src/lexicons/data/sh.tangled.repo.compare.json new file mode 100644 index 0000000..e0a2e0d --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.compare.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.compare","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","rev1","rev2"],"properties":{"repo":{"type":"string","description":"Repository identifier in format 'did:plc:.../repoName'"},"rev1":{"type":"string","description":"First revision (commit, branch, or tag)"},"rev2":{"type":"string","description":"Second revision (commit, branch, or tag)"}}},"output":{"encoding":"*/*","description":"Compare output in application/json"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"RevisionNotFound","description":"One or both revisions not found"},{"name":"InvalidRequest","description":"Invalid request parameters"},{"name":"CompareError","description":"Failed to compare revisions"}]}}} diff --git a/src/lexicons/data/sh.tangled.repo.create.json b/src/lexicons/data/sh.tangled.repo.create.json new file mode 100644 index 0000000..d08d27b --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.create.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.create","defs":{"main":{"type":"procedure","description":"Create a new repository","input":{"encoding":"application/json","schema":{"type":"object","required":["rkey","name"],"properties":{"rkey":{"type":"string","description":"Rkey of the repository record"},"name":{"type":"string","description":"Name of the repository"},"defaultBranch":{"type":"string","description":"Default branch to push to"},"source":{"type":"string","description":"A source URL to clone from, populate this when forking or importing a repository."},"repoDid":{"type":"string","format":"did","description":"Optional user-provided did:web to use as the repo identity instead of minting a did:plc."}}}},"output":{"encoding":"application/json","schema":{"type":"object","properties":{"repoDid":{"type":"string","format":"did"}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.delete.json b/src/lexicons/data/sh.tangled.repo.delete.json new file mode 100644 index 0000000..dece1da --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.delete.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.delete","defs":{"main":{"type":"procedure","description":"Delete a repository","input":{"encoding":"application/json","schema":{"type":"object","required":["did","name","rkey"],"properties":{"did":{"type":"string","format":"did","description":"DID of the repository owner"},"name":{"type":"string","description":"Name of the repository to delete"},"rkey":{"type":"string","description":"Rkey of the repository record"}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.deleteBranch.json b/src/lexicons/data/sh.tangled.repo.deleteBranch.json new file mode 100644 index 0000000..5121949 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.deleteBranch.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.deleteBranch","defs":{"main":{"type":"procedure","description":"Delete a branch on this repository","input":{"encoding":"application/json","schema":{"type":"object","required":["repo","branch"],"properties":{"repo":{"type":"string","format":"at-uri"},"branch":{"type":"string"}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.diff.json b/src/lexicons/data/sh.tangled.repo.diff.json new file mode 100644 index 0000000..71ce7e0 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.diff.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.diff","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","ref"],"properties":{"repo":{"type":"string","description":"Repository identifier in format 'did:plc:.../repoName'"},"ref":{"type":"string","description":"Git reference (branch, tag, or commit SHA)"}}},"output":{"encoding":"*/*"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"RefNotFound","description":"Git reference not found"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.repo.forkStatus.json b/src/lexicons/data/sh.tangled.repo.forkStatus.json new file mode 100644 index 0000000..2462b8e --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.forkStatus.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.forkStatus","defs":{"main":{"type":"procedure","description":"Check fork status relative to upstream source","input":{"encoding":"application/json","schema":{"type":"object","required":["did","name","source","branch","hiddenRef"],"properties":{"did":{"type":"string","format":"did","description":"DID of the fork owner"},"name":{"type":"string","description":"Name of the forked repository"},"source":{"type":"string","description":"Source repository URL"},"branch":{"type":"string","description":"Branch to check status for"},"hiddenRef":{"type":"string","description":"Hidden ref to use for comparison"}}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["status"],"properties":{"status":{"type":"integer","description":"Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch"}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.forkSync.json b/src/lexicons/data/sh.tangled.repo.forkSync.json new file mode 100644 index 0000000..71b957b --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.forkSync.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.forkSync","defs":{"main":{"type":"procedure","description":"Sync a forked repository with its upstream source","input":{"encoding":"application/json","schema":{"type":"object","required":["did","source","name","branch"],"properties":{"did":{"type":"string","format":"did","description":"DID of the fork owner"},"source":{"type":"string","format":"at-uri","description":"AT-URI of the source repository"},"name":{"type":"string","description":"Name of the forked repository"},"branch":{"type":"string","description":"Branch to sync"}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.getDefaultBranch.json b/src/lexicons/data/sh.tangled.repo.getDefaultBranch.json new file mode 100644 index 0000000..fe2717d --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.getDefaultBranch.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.getDefaultBranch","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo"],"properties":{"repo":{"type":"string","description":"Repository identifier in format 'did:plc:.../repoName'"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["name","hash","when"],"properties":{"name":{"type":"string","description":"Default branch name"},"hash":{"type":"string","description":"Latest commit hash on default branch"},"shortHash":{"type":"string","description":"Short commit hash"},"when":{"type":"string","format":"datetime","description":"Timestamp of latest commit"},"message":{"type":"string","description":"Latest commit message"},"author":{"type":"ref","ref":"#signature"}}}},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"InvalidRequest","description":"Invalid request parameters"}]},"signature":{"type":"object","required":["name","email","when"],"properties":{"name":{"type":"string","description":"Author name"},"email":{"type":"string","description":"Author email"},"when":{"type":"string","format":"datetime","description":"Author timestamp"}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.hiddenRef.json b/src/lexicons/data/sh.tangled.repo.hiddenRef.json new file mode 100644 index 0000000..ed0abfb --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.hiddenRef.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.hiddenRef","defs":{"main":{"type":"procedure","description":"Create a hidden ref in a repository","input":{"encoding":"application/json","schema":{"type":"object","required":["repo","forkRef","remoteRef"],"properties":{"repo":{"type":"string","format":"at-uri","description":"AT-URI of the repository"},"forkRef":{"type":"string","description":"Fork reference name"},"remoteRef":{"type":"string","description":"Remote reference name"}}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["success"],"properties":{"success":{"type":"boolean","description":"Whether the hidden ref was created successfully"},"ref":{"type":"string","description":"The created hidden ref name"},"error":{"type":"string","description":"Error message if creation failed"}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.issue.comment.json b/src/lexicons/data/sh.tangled.repo.issue.comment.json new file mode 100644 index 0000000..b659bf3 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.issue.comment.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.issue.comment","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["issue","body","createdAt"],"properties":{"issue":{"type":"string","format":"at-uri"},"body":{"type":"string"},"createdAt":{"type":"string","format":"datetime"},"replyTo":{"type":"string","format":"at-uri"},"mentions":{"type":"array","items":{"type":"string","format":"did"}},"references":{"type":"array","items":{"type":"string","format":"at-uri"}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.issue.json b/src/lexicons/data/sh.tangled.repo.issue.json new file mode 100644 index 0000000..6ca9765 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.issue.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.issue","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["title","createdAt"],"properties":{"repo":{"type":"string","format":"at-uri"},"repoDid":{"type":"string","format":"did"},"title":{"type":"string"},"body":{"type":"string"},"createdAt":{"type":"string","format":"datetime"},"mentions":{"type":"array","items":{"type":"string","format":"did"}},"references":{"type":"array","items":{"type":"string","format":"at-uri"}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.issue.state.closed.json b/src/lexicons/data/sh.tangled.repo.issue.state.closed.json new file mode 100644 index 0000000..2bc5ad8 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.issue.state.closed.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.issue.state.closed","needsCbor":true,"needsType":true,"defs":{"main":{"type":"token","description":"closed issue"}}} diff --git a/src/lexicons/data/sh.tangled.repo.issue.state.json b/src/lexicons/data/sh.tangled.repo.issue.state.json new file mode 100644 index 0000000..f11ec3b --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.issue.state.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.issue.state","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["issue","state"],"properties":{"issue":{"type":"string","format":"at-uri"},"state":{"type":"string","description":"state of the issue","knownValues":["sh.tangled.repo.issue.state.open","sh.tangled.repo.issue.state.closed"],"default":"sh.tangled.repo.issue.state.open"}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.issue.state.open.json b/src/lexicons/data/sh.tangled.repo.issue.state.open.json new file mode 100644 index 0000000..cb55491 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.issue.state.open.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.issue.state.open","needsCbor":true,"needsType":true,"defs":{"main":{"type":"token","description":"open issue"}}} diff --git a/src/lexicons/data/sh.tangled.repo.json b/src/lexicons/data/sh.tangled.repo.json new file mode 100644 index 0000000..8ad1356 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["name","knot","createdAt"],"properties":{"name":{"type":"string","description":"name of the repo"},"knot":{"type":"string","description":"knot where the repo was created"},"spindle":{"type":"string","description":"CI runner to send jobs to and receive results from"},"description":{"type":"string","minGraphemes":1,"maxGraphemes":140},"website":{"type":"string","format":"uri","description":"Any URI related to the repo"},"topics":{"type":"array","description":"Topics related to the repo","items":{"type":"string","minLength":1,"maxLength":50},"maxLength":50},"source":{"type":"string","format":"uri","description":"source of the repo"},"labels":{"type":"array","description":"List of labels that this repo subscribes to","items":{"type":"string","format":"at-uri"}},"repoDid":{"type":"string","format":"did","description":"DID of the repo itself, if assigned"},"createdAt":{"type":"string","format":"datetime"}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.languages.json b/src/lexicons/data/sh.tangled.repo.languages.json new file mode 100644 index 0000000..3910bed --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.languages.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.languages","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo"],"properties":{"repo":{"type":"string","description":"Repository identifier in format 'did:plc:.../repoName'"},"ref":{"type":"string","description":"Git reference (branch, tag, or commit SHA)","default":"HEAD"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["ref","languages"],"properties":{"ref":{"type":"string","description":"The git reference used"},"languages":{"type":"array","items":{"type":"ref","ref":"#language"}},"totalSize":{"type":"integer","description":"Total size of all analyzed files in bytes"},"totalFiles":{"type":"integer","description":"Total number of files analyzed"}}}},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"RefNotFound","description":"Git reference not found"},{"name":"InvalidRequest","description":"Invalid request parameters"}]},"language":{"type":"object","required":["name","size","percentage"],"properties":{"name":{"type":"string","description":"Programming language name"},"size":{"type":"integer","description":"Total size of files in this language (bytes)"},"percentage":{"type":"integer","description":"Percentage of total codebase (0-100)"},"fileCount":{"type":"integer","description":"Number of files in this language"},"color":{"type":"string","description":"Hex color code for this language"},"extensions":{"type":"array","items":{"type":"string"},"description":"File extensions associated with this language"}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.listSecrets.json b/src/lexicons/data/sh.tangled.repo.listSecrets.json new file mode 100644 index 0000000..710b356 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.listSecrets.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.listSecrets","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo"],"properties":{"repo":{"type":"string","format":"at-uri"}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["secrets"],"properties":{"secrets":{"type":"array","items":{"type":"ref","ref":"#secret"}}}}}},"secret":{"type":"object","required":["repo","key","createdAt","createdBy"],"properties":{"repo":{"type":"string","format":"at-uri"},"key":{"type":"string","maxLength":50,"minLength":1},"createdAt":{"type":"string","format":"datetime"},"createdBy":{"type":"string","format":"did"}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.log.json b/src/lexicons/data/sh.tangled.repo.log.json new file mode 100644 index 0000000..f4b35e6 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.log.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.log","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","ref"],"properties":{"repo":{"type":"string","description":"Repository identifier in format 'did:plc:.../repoName'"},"ref":{"type":"string","description":"Git reference (branch, tag, or commit SHA)"},"path":{"type":"string","description":"Path to filter commits by","default":""},"limit":{"type":"integer","description":"Maximum number of commits to return","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string","description":"Pagination cursor (commit SHA)"}}},"output":{"encoding":"*/*"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"RefNotFound","description":"Git reference not found"},{"name":"PathNotFound","description":"Path not found in repository"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.repo.merge.json b/src/lexicons/data/sh.tangled.repo.merge.json new file mode 100644 index 0000000..30694c3 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.merge.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.merge","defs":{"main":{"type":"procedure","description":"Merge a patch into a repository branch","input":{"encoding":"application/json","schema":{"type":"object","required":["did","name","patch","branch"],"properties":{"did":{"type":"string","format":"did","description":"DID of the repository owner"},"name":{"type":"string","description":"Name of the repository"},"patch":{"type":"string","description":"Patch content to merge"},"branch":{"type":"string","description":"Target branch to merge into"},"authorName":{"type":"string","description":"Author name for the merge commit"},"authorEmail":{"type":"string","description":"Author email for the merge commit"},"commitBody":{"type":"string","description":"Additional commit message body"},"commitMessage":{"type":"string","description":"Merge commit message"}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.mergeCheck.json b/src/lexicons/data/sh.tangled.repo.mergeCheck.json new file mode 100644 index 0000000..b88c7dc --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.mergeCheck.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.mergeCheck","defs":{"main":{"type":"procedure","description":"Check if a merge is possible between two branches","input":{"encoding":"application/json","schema":{"type":"object","required":["did","name","patch","branch"],"properties":{"did":{"type":"string","format":"did","description":"DID of the repository owner"},"name":{"type":"string","description":"Name of the repository"},"patch":{"type":"string","description":"Patch or pull request to check for merge conflicts"},"branch":{"type":"string","description":"Target branch to merge into"}}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["is_conflicted"],"properties":{"is_conflicted":{"type":"boolean","description":"Whether the merge has conflicts"},"conflicts":{"type":"array","description":"List of files with merge conflicts","items":{"type":"ref","ref":"#conflictInfo"}},"message":{"type":"string","description":"Additional message about the merge check"},"error":{"type":"string","description":"Error message if check failed"}}}}},"conflictInfo":{"type":"object","required":["filename","reason"],"properties":{"filename":{"type":"string","description":"Name of the conflicted file"},"reason":{"type":"string","description":"Reason for the conflict"}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.pull.comment.json b/src/lexicons/data/sh.tangled.repo.pull.comment.json new file mode 100644 index 0000000..732a114 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.pull.comment.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.pull.comment","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["pull","body","createdAt"],"properties":{"pull":{"type":"string","format":"at-uri"},"body":{"type":"string"},"createdAt":{"type":"string","format":"datetime"},"mentions":{"type":"array","items":{"type":"string","format":"did"}},"references":{"type":"array","items":{"type":"string","format":"at-uri"}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.pull.json b/src/lexicons/data/sh.tangled.repo.pull.json new file mode 100644 index 0000000..ec2a197 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.pull.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.pull","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["target","title","createdAt","rounds"],"properties":{"title":{"type":"string"},"body":{"type":"string"},"rounds":{"type":"array","items":{"type":"ref","ref":"#round"}},"source":{"type":"ref","ref":"#source"},"target":{"type":"ref","ref":"#target"},"createdAt":{"type":"string","format":"datetime"},"mentions":{"type":"array","items":{"type":"string","format":"did"}},"references":{"type":"array","items":{"type":"string","format":"at-uri"}},"dependentOn":{"type":"string","format":"at-uri"}}}},"target":{"type":"object","required":["branch"],"properties":{"repo":{"type":"string","format":"at-uri"},"repoDid":{"type":"string","format":"did"},"branch":{"type":"string"}}},"source":{"type":"object","required":["branch"],"properties":{"branch":{"type":"string"},"repo":{"type":"string","format":"at-uri"},"repoDid":{"type":"string","format":"did"}}},"round":{"type":"object","required":["patchBlob","createdAt"],"description":"revisions of this pull request, newer rounds are appended to this array. appviews may reject records do not treat this field as append-only. the blob format is gzipped text-based git-format-patches.","properties":{"createdAt":{"type":"string","format":"datetime"},"patchBlob":{"type":"blob","accept":["application/gzip"]}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.pull.status.closed.json b/src/lexicons/data/sh.tangled.repo.pull.status.closed.json new file mode 100644 index 0000000..a44fc2c --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.pull.status.closed.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.pull.status.closed","needsCbor":true,"needsType":true,"defs":{"main":{"type":"token","description":"closed pull request"}}} diff --git a/src/lexicons/data/sh.tangled.repo.pull.status.json b/src/lexicons/data/sh.tangled.repo.pull.status.json new file mode 100644 index 0000000..2c1fc3b --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.pull.status.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.pull.status","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["pull","status"],"properties":{"pull":{"type":"string","format":"at-uri"},"status":{"type":"string","description":"status of the pull request","knownValues":["sh.tangled.repo.pull.status.open","sh.tangled.repo.pull.status.closed","sh.tangled.repo.pull.status.merged"],"default":"sh.tangled.repo.pull.status.open"}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.pull.status.merged.json b/src/lexicons/data/sh.tangled.repo.pull.status.merged.json new file mode 100644 index 0000000..d5b2298 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.pull.status.merged.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.pull.status.merged","needsCbor":true,"needsType":true,"defs":{"main":{"type":"token","description":"merged pull request"}}} diff --git a/src/lexicons/data/sh.tangled.repo.pull.status.open.json b/src/lexicons/data/sh.tangled.repo.pull.status.open.json new file mode 100644 index 0000000..cef1682 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.pull.status.open.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.pull.status.open","needsCbor":true,"needsType":true,"defs":{"main":{"type":"token","description":"open pull request"}}} diff --git a/src/lexicons/data/sh.tangled.repo.removeSecret.json b/src/lexicons/data/sh.tangled.repo.removeSecret.json new file mode 100644 index 0000000..bbcf703 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.removeSecret.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.removeSecret","defs":{"main":{"type":"procedure","description":"Remove a CI secret","input":{"encoding":"application/json","schema":{"type":"object","required":["repo","key"],"properties":{"repo":{"type":"string","format":"at-uri"},"key":{"type":"string","maxLength":50,"minLength":1}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.setDefaultBranch.json b/src/lexicons/data/sh.tangled.repo.setDefaultBranch.json new file mode 100644 index 0000000..f2f3076 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.setDefaultBranch.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.setDefaultBranch","defs":{"main":{"type":"procedure","description":"Set the default branch for a repository","input":{"encoding":"application/json","schema":{"type":"object","required":["repo","defaultBranch"],"properties":{"repo":{"type":"string","format":"at-uri"},"defaultBranch":{"type":"string"}}}}}}} diff --git a/src/lexicons/data/sh.tangled.repo.tag.json b/src/lexicons/data/sh.tangled.repo.tag.json new file mode 100644 index 0000000..5128e5b --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.tag.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.tag","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","tag"],"properties":{"repo":{"type":"string","description":"Repository identifier in format 'did:plc:.../repoName'"},"tag":{"type":"string","description":"Name of tag, such as v1.3.0"}}},"output":{"encoding":"*/*"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"TagNotFound","description":"Tag not found"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.repo.tags.json b/src/lexicons/data/sh.tangled.repo.tags.json new file mode 100644 index 0000000..424d8a7 --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.tags.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.tags","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo"],"properties":{"repo":{"type":"string","description":"Repository identifier in format 'did:plc:.../repoName'"},"limit":{"type":"integer","description":"Maximum number of tags to return","minimum":1,"maximum":100,"default":50},"cursor":{"type":"string","description":"Pagination cursor"}}},"output":{"encoding":"*/*"},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"InvalidRequest","description":"Invalid request parameters"}]}}} diff --git a/src/lexicons/data/sh.tangled.repo.tree.json b/src/lexicons/data/sh.tangled.repo.tree.json new file mode 100644 index 0000000..f84839a --- /dev/null +++ b/src/lexicons/data/sh.tangled.repo.tree.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.repo.tree","defs":{"main":{"type":"query","parameters":{"type":"params","required":["repo","ref"],"properties":{"repo":{"type":"string","description":"Repository identifier in format 'did:plc:.../repoName'"},"ref":{"type":"string","description":"Git reference (branch, tag, or commit SHA)"},"path":{"type":"string","description":"Path within the repository tree","default":""}}},"output":{"encoding":"application/json","schema":{"type":"object","required":["ref","files"],"properties":{"ref":{"type":"string","description":"The git reference used"},"parent":{"type":"string","description":"The parent path in the tree"},"dotdot":{"type":"string","description":"Parent directory path"},"readme":{"type":"ref","ref":"#readme","description":"Readme for this file tree"},"lastCommit":{"type":"ref","ref":"#lastCommit"},"files":{"type":"array","items":{"type":"ref","ref":"#treeEntry"}}}}},"errors":[{"name":"RepoNotFound","description":"Repository not found or access denied"},{"name":"RefNotFound","description":"Git reference not found"},{"name":"PathNotFound","description":"Path not found in repository tree"},{"name":"InvalidRequest","description":"Invalid request parameters"}]},"readme":{"type":"object","required":["filename","contents"],"properties":{"filename":{"type":"string","description":"Name of the readme file"},"contents":{"type":"string","description":"Contents of the readme file"}}},"treeEntry":{"type":"object","required":["name","mode","size"],"properties":{"name":{"type":"string","description":"Relative file or directory name"},"mode":{"type":"string","description":"File mode"},"size":{"type":"integer","description":"File size in bytes"},"last_commit":{"type":"ref","ref":"#lastCommit"}}},"lastCommit":{"type":"object","required":["hash","message","when"],"properties":{"hash":{"type":"string","description":"Commit hash"},"message":{"type":"string","description":"Commit message"},"author":{"type":"ref","ref":"#signature"},"when":{"type":"string","format":"datetime","description":"Commit timestamp"}}},"signature":{"type":"object","required":["name","email","when"],"properties":{"name":{"type":"string","description":"Author name"},"email":{"type":"string","description":"Author email"},"when":{"type":"string","format":"datetime","description":"Author timestamp"}}}}} diff --git a/src/lexicons/data/sh.tangled.spindle.json b/src/lexicons/data/sh.tangled.spindle.json new file mode 100644 index 0000000..c7e1abc --- /dev/null +++ b/src/lexicons/data/sh.tangled.spindle.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.spindle","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"any","record":{"type":"object","required":["createdAt"],"properties":{"createdAt":{"type":"string","format":"datetime"}}}}}} diff --git a/src/lexicons/data/sh.tangled.spindle.member.json b/src/lexicons/data/sh.tangled.spindle.member.json new file mode 100644 index 0000000..050996a --- /dev/null +++ b/src/lexicons/data/sh.tangled.spindle.member.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.spindle.member","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["subject","instance","createdAt"],"properties":{"subject":{"type":"string","format":"did"},"instance":{"type":"string","description":"spindle instance that the subject is now a member of"},"createdAt":{"type":"string","format":"datetime"}}}}}} diff --git a/src/lexicons/data/sh.tangled.string.json b/src/lexicons/data/sh.tangled.string.json new file mode 100644 index 0000000..023edd6 --- /dev/null +++ b/src/lexicons/data/sh.tangled.string.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.string","needsCbor":true,"needsType":true,"defs":{"main":{"type":"record","key":"tid","record":{"type":"object","required":["filename","description","createdAt","contents"],"properties":{"filename":{"type":"string","maxGraphemes":140,"minGraphemes":1},"description":{"type":"string","maxGraphemes":280},"createdAt":{"type":"string","format":"datetime"},"contents":{"type":"string","minGraphemes":1}}}}}} diff --git a/src/lexicons/data/sh.tangled.sync.requestCrawl.json b/src/lexicons/data/sh.tangled.sync.requestCrawl.json new file mode 100644 index 0000000..f165436 --- /dev/null +++ b/src/lexicons/data/sh.tangled.sync.requestCrawl.json @@ -0,0 +1 @@ +{"lexicon":1,"id":"sh.tangled.sync.requestCrawl","defs":{"main":{"type":"procedure","description":"Request a service to persistently crawl hosted repos. Does not require auth.","input":{"encoding":"application/json","schema":{"type":"object","required":["hostname"],"properties":{"hostname":{"type":"string","description":"Hostname of the current service (eg, Knot) that is requesting to be crawled."},"ensureRepo":{"type":"string","format":"at-uri","description":"specific repository to ensure crawling"}}}},"errors":[{"name":"HostBanned"}]}}} diff --git a/src/lexicons/generated.ts b/src/lexicons/generated.ts new file mode 100644 index 0000000..a98e413 --- /dev/null +++ b/src/lexicons/generated.ts @@ -0,0 +1,170 @@ +// Auto-generated by scripts/vendor-lexicons.ts — do not edit by hand. +// Regenerate with: bun run vendor + +import lex0 from "./data/com.atproto.repo.deleteRecord.json" with { type: "json" }; +import lex1 from "./data/com.atproto.repo.describeRepo.json" with { type: "json" }; +import lex2 from "./data/com.atproto.repo.getRecord.json" with { type: "json" }; +import lex3 from "./data/com.atproto.repo.listRecords.json" with { type: "json" }; +import lex4 from "./data/com.atproto.repo.putRecord.json" with { type: "json" }; +import lex5 from "./data/com.atproto.server.createSession.json" with { type: "json" }; +import lex6 from "./data/com.atproto.server.getServiceAuth.json" with { type: "json" }; +import lex7 from "./data/com.atproto.server.refreshSession.json" with { type: "json" }; +import lex8 from "./data/sh.tangled.actor.profile.json" with { type: "json" }; +import lex9 from "./data/sh.tangled.feed.reaction.json" with { type: "json" }; +import lex10 from "./data/sh.tangled.feed.star.json" with { type: "json" }; +import lex11 from "./data/sh.tangled.git.refUpdate.json" with { type: "json" }; +import lex12 from "./data/sh.tangled.git.temp.analyzeMerge.json" with { type: "json" }; +import lex13 from "./data/sh.tangled.git.temp.defs.json" with { type: "json" }; +import lex14 from "./data/sh.tangled.git.temp.getArchive.json" with { type: "json" }; +import lex15 from "./data/sh.tangled.git.temp.getBlob.json" with { type: "json" }; +import lex16 from "./data/sh.tangled.git.temp.getBranch.json" with { type: "json" }; +import lex17 from "./data/sh.tangled.git.temp.getCommit.json" with { type: "json" }; +import lex18 from "./data/sh.tangled.git.temp.getDiff.json" with { type: "json" }; +import lex19 from "./data/sh.tangled.git.temp.getEntity.json" with { type: "json" }; +import lex20 from "./data/sh.tangled.git.temp.getHead.json" with { type: "json" }; +import lex21 from "./data/sh.tangled.git.temp.getTag.json" with { type: "json" }; +import lex22 from "./data/sh.tangled.git.temp.getTree.json" with { type: "json" }; +import lex23 from "./data/sh.tangled.git.temp.listBranches.json" with { type: "json" }; +import lex24 from "./data/sh.tangled.git.temp.listCommits.json" with { type: "json" }; +import lex25 from "./data/sh.tangled.git.temp.listLanguages.json" with { type: "json" }; +import lex26 from "./data/sh.tangled.git.temp.listTags.json" with { type: "json" }; +import lex27 from "./data/sh.tangled.graph.follow.json" with { type: "json" }; +import lex28 from "./data/sh.tangled.knot.json" with { type: "json" }; +import lex29 from "./data/sh.tangled.knot.listKeys.json" with { type: "json" }; +import lex30 from "./data/sh.tangled.knot.member.json" with { type: "json" }; +import lex31 from "./data/sh.tangled.knot.subscribeRepos.json" with { type: "json" }; +import lex32 from "./data/sh.tangled.knot.version.json" with { type: "json" }; +import lex33 from "./data/sh.tangled.label.definition.json" with { type: "json" }; +import lex34 from "./data/sh.tangled.label.op.json" with { type: "json" }; +import lex35 from "./data/sh.tangled.owner.json" with { type: "json" }; +import lex36 from "./data/sh.tangled.pipeline.cancelPipeline.json" with { type: "json" }; +import lex37 from "./data/sh.tangled.pipeline.json" with { type: "json" }; +import lex38 from "./data/sh.tangled.pipeline.status.json" with { type: "json" }; +import lex39 from "./data/sh.tangled.publicKey.json" with { type: "json" }; +import lex40 from "./data/sh.tangled.repo.addSecret.json" with { type: "json" }; +import lex41 from "./data/sh.tangled.repo.archive.json" with { type: "json" }; +import lex42 from "./data/sh.tangled.repo.artifact.json" with { type: "json" }; +import lex43 from "./data/sh.tangled.repo.blob.json" with { type: "json" }; +import lex44 from "./data/sh.tangled.repo.branch.json" with { type: "json" }; +import lex45 from "./data/sh.tangled.repo.branches.json" with { type: "json" }; +import lex46 from "./data/sh.tangled.repo.collaborator.json" with { type: "json" }; +import lex47 from "./data/sh.tangled.repo.compare.json" with { type: "json" }; +import lex48 from "./data/sh.tangled.repo.create.json" with { type: "json" }; +import lex49 from "./data/sh.tangled.repo.delete.json" with { type: "json" }; +import lex50 from "./data/sh.tangled.repo.deleteBranch.json" with { type: "json" }; +import lex51 from "./data/sh.tangled.repo.diff.json" with { type: "json" }; +import lex52 from "./data/sh.tangled.repo.forkStatus.json" with { type: "json" }; +import lex53 from "./data/sh.tangled.repo.forkSync.json" with { type: "json" }; +import lex54 from "./data/sh.tangled.repo.getDefaultBranch.json" with { type: "json" }; +import lex55 from "./data/sh.tangled.repo.hiddenRef.json" with { type: "json" }; +import lex56 from "./data/sh.tangled.repo.issue.comment.json" with { type: "json" }; +import lex57 from "./data/sh.tangled.repo.issue.json" with { type: "json" }; +import lex58 from "./data/sh.tangled.repo.issue.state.closed.json" with { type: "json" }; +import lex59 from "./data/sh.tangled.repo.issue.state.json" with { type: "json" }; +import lex60 from "./data/sh.tangled.repo.issue.state.open.json" with { type: "json" }; +import lex61 from "./data/sh.tangled.repo.json" with { type: "json" }; +import lex62 from "./data/sh.tangled.repo.languages.json" with { type: "json" }; +import lex63 from "./data/sh.tangled.repo.listSecrets.json" with { type: "json" }; +import lex64 from "./data/sh.tangled.repo.log.json" with { type: "json" }; +import lex65 from "./data/sh.tangled.repo.merge.json" with { type: "json" }; +import lex66 from "./data/sh.tangled.repo.mergeCheck.json" with { type: "json" }; +import lex67 from "./data/sh.tangled.repo.pull.comment.json" with { type: "json" }; +import lex68 from "./data/sh.tangled.repo.pull.json" with { type: "json" }; +import lex69 from "./data/sh.tangled.repo.pull.status.closed.json" with { type: "json" }; +import lex70 from "./data/sh.tangled.repo.pull.status.json" with { type: "json" }; +import lex71 from "./data/sh.tangled.repo.pull.status.merged.json" with { type: "json" }; +import lex72 from "./data/sh.tangled.repo.pull.status.open.json" with { type: "json" }; +import lex73 from "./data/sh.tangled.repo.removeSecret.json" with { type: "json" }; +import lex74 from "./data/sh.tangled.repo.setDefaultBranch.json" with { type: "json" }; +import lex75 from "./data/sh.tangled.repo.tag.json" with { type: "json" }; +import lex76 from "./data/sh.tangled.repo.tags.json" with { type: "json" }; +import lex77 from "./data/sh.tangled.repo.tree.json" with { type: "json" }; +import lex78 from "./data/sh.tangled.spindle.json" with { type: "json" }; +import lex79 from "./data/sh.tangled.spindle.member.json" with { type: "json" }; +import lex80 from "./data/sh.tangled.string.json" with { type: "json" }; +import lex81 from "./data/sh.tangled.sync.requestCrawl.json" with { type: "json" }; + +export const VENDORED_LEXICONS: { id: string; doc: unknown }[] = [ + { id: "com.atproto.repo.deleteRecord", doc: lex0 }, + { id: "com.atproto.repo.describeRepo", doc: lex1 }, + { id: "com.atproto.repo.getRecord", doc: lex2 }, + { id: "com.atproto.repo.listRecords", doc: lex3 }, + { id: "com.atproto.repo.putRecord", doc: lex4 }, + { id: "com.atproto.server.createSession", doc: lex5 }, + { id: "com.atproto.server.getServiceAuth", doc: lex6 }, + { id: "com.atproto.server.refreshSession", doc: lex7 }, + { id: "sh.tangled.actor.profile", doc: lex8 }, + { id: "sh.tangled.feed.reaction", doc: lex9 }, + { id: "sh.tangled.feed.star", doc: lex10 }, + { id: "sh.tangled.git.refUpdate", doc: lex11 }, + { id: "sh.tangled.git.temp.analyzeMerge", doc: lex12 }, + { id: "sh.tangled.git.temp.defs", doc: lex13 }, + { id: "sh.tangled.git.temp.getArchive", doc: lex14 }, + { id: "sh.tangled.git.temp.getBlob", doc: lex15 }, + { id: "sh.tangled.git.temp.getBranch", doc: lex16 }, + { id: "sh.tangled.git.temp.getCommit", doc: lex17 }, + { id: "sh.tangled.git.temp.getDiff", doc: lex18 }, + { id: "sh.tangled.git.temp.getEntity", doc: lex19 }, + { id: "sh.tangled.git.temp.getHead", doc: lex20 }, + { id: "sh.tangled.git.temp.getTag", doc: lex21 }, + { id: "sh.tangled.git.temp.getTree", doc: lex22 }, + { id: "sh.tangled.git.temp.listBranches", doc: lex23 }, + { id: "sh.tangled.git.temp.listCommits", doc: lex24 }, + { id: "sh.tangled.git.temp.listLanguages", doc: lex25 }, + { id: "sh.tangled.git.temp.listTags", doc: lex26 }, + { id: "sh.tangled.graph.follow", doc: lex27 }, + { id: "sh.tangled.knot", doc: lex28 }, + { id: "sh.tangled.knot.listKeys", doc: lex29 }, + { id: "sh.tangled.knot.member", doc: lex30 }, + { id: "sh.tangled.knot.subscribeRepos", doc: lex31 }, + { id: "sh.tangled.knot.version", doc: lex32 }, + { id: "sh.tangled.label.definition", doc: lex33 }, + { id: "sh.tangled.label.op", doc: lex34 }, + { id: "sh.tangled.owner", doc: lex35 }, + { id: "sh.tangled.pipeline.cancelPipeline", doc: lex36 }, + { id: "sh.tangled.pipeline", doc: lex37 }, + { id: "sh.tangled.pipeline.status", doc: lex38 }, + { id: "sh.tangled.publicKey", doc: lex39 }, + { id: "sh.tangled.repo.addSecret", doc: lex40 }, + { id: "sh.tangled.repo.archive", doc: lex41 }, + { id: "sh.tangled.repo.artifact", doc: lex42 }, + { id: "sh.tangled.repo.blob", doc: lex43 }, + { id: "sh.tangled.repo.branch", doc: lex44 }, + { id: "sh.tangled.repo.branches", doc: lex45 }, + { id: "sh.tangled.repo.collaborator", doc: lex46 }, + { id: "sh.tangled.repo.compare", doc: lex47 }, + { id: "sh.tangled.repo.create", doc: lex48 }, + { id: "sh.tangled.repo.delete", doc: lex49 }, + { id: "sh.tangled.repo.deleteBranch", doc: lex50 }, + { id: "sh.tangled.repo.diff", doc: lex51 }, + { id: "sh.tangled.repo.forkStatus", doc: lex52 }, + { id: "sh.tangled.repo.forkSync", doc: lex53 }, + { id: "sh.tangled.repo.getDefaultBranch", doc: lex54 }, + { id: "sh.tangled.repo.hiddenRef", doc: lex55 }, + { id: "sh.tangled.repo.issue.comment", doc: lex56 }, + { id: "sh.tangled.repo.issue", doc: lex57 }, + { id: "sh.tangled.repo.issue.state.closed", doc: lex58 }, + { id: "sh.tangled.repo.issue.state", doc: lex59 }, + { id: "sh.tangled.repo.issue.state.open", doc: lex60 }, + { id: "sh.tangled.repo", doc: lex61 }, + { id: "sh.tangled.repo.languages", doc: lex62 }, + { id: "sh.tangled.repo.listSecrets", doc: lex63 }, + { id: "sh.tangled.repo.log", doc: lex64 }, + { id: "sh.tangled.repo.merge", doc: lex65 }, + { id: "sh.tangled.repo.mergeCheck", doc: lex66 }, + { id: "sh.tangled.repo.pull.comment", doc: lex67 }, + { id: "sh.tangled.repo.pull", doc: lex68 }, + { id: "sh.tangled.repo.pull.status.closed", doc: lex69 }, + { id: "sh.tangled.repo.pull.status", doc: lex70 }, + { id: "sh.tangled.repo.pull.status.merged", doc: lex71 }, + { id: "sh.tangled.repo.pull.status.open", doc: lex72 }, + { id: "sh.tangled.repo.removeSecret", doc: lex73 }, + { id: "sh.tangled.repo.setDefaultBranch", doc: lex74 }, + { id: "sh.tangled.repo.tag", doc: lex75 }, + { id: "sh.tangled.repo.tags", doc: lex76 }, + { id: "sh.tangled.repo.tree", doc: lex77 }, + { id: "sh.tangled.spindle", doc: lex78 }, + { id: "sh.tangled.spindle.member", doc: lex79 }, + { id: "sh.tangled.string", doc: lex80 }, + { id: "sh.tangled.sync.requestCrawl", doc: lex81 }, +]; diff --git a/src/lexicons/index.ts b/src/lexicons/index.ts new file mode 100644 index 0000000..6b28e7b --- /dev/null +++ b/src/lexicons/index.ts @@ -0,0 +1,72 @@ +import { Lexicons } from "@atproto/lexicon"; +import type { LexiconDoc } from "@atproto/lexicon"; +import { VENDORED_LEXICONS } from "./generated.ts"; + +let _lex: Lexicons | null = null; + +export function lexicons(): Lexicons { + if (_lex) return _lex; + const lex = new Lexicons(); + for (const { doc } of VENDORED_LEXICONS) { + lex.add(doc as LexiconDoc); + } + _lex = lex; + return lex; +} + +export function listNsids(): string[] { + return VENDORED_LEXICONS.map((l) => l.id); +} + +export function getLexiconDoc(nsid: string): unknown | null { + const found = VENDORED_LEXICONS.find((l) => l.id === nsid); + return found ? found.doc : null; +} + +export interface ValidationOk { + ok: true; +} +export interface ValidationErr { + ok: false; + message: string; +} + +export function validateRecord(nsid: string, value: unknown): ValidationOk | ValidationErr { + try { + const result = lexicons().validate(nsid, value); + if (result.success) return { ok: true }; + return { ok: false, message: result.error.message }; + } catch (e) { + return { ok: false, message: (e as Error).message }; + } +} + +export function validateXrpcInput(nsid: string, value: unknown): ValidationOk | ValidationErr { + try { + lexicons().assertValidXrpcInput(`${nsid}#main`, value); + return { ok: true }; + } catch (e) { + return { ok: false, message: (e as Error).message }; + } +} + +export function validateXrpcOutput(nsid: string, value: unknown): ValidationOk | ValidationErr { + try { + lexicons().assertValidXrpcOutput(`${nsid}#main`, value); + return { ok: true }; + } catch (e) { + return { ok: false, message: (e as Error).message }; + } +} + +export function validateXrpcParams( + nsid: string, + value: Record, +): ValidationOk | ValidationErr { + try { + lexicons().assertValidXrpcParams(`${nsid}#main`, value); + return { ok: true }; + } catch (e) { + return { ok: false, message: (e as Error).message }; + } +} diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 0000000..aa96d98 --- /dev/null +++ b/src/output.ts @@ -0,0 +1,27 @@ +import { Command } from "commander"; + +export interface GlobalOpts { + json?: boolean; +} + +/** + * Read --json from the command's or any ancestor's parsed options. Commander + * doesn't walk up the chain by default, so we do it ourselves. + */ +export function globalOpts(cmd: Command): GlobalOpts { + let c: Command | null = cmd; + while (c) { + const o = c.opts() as GlobalOpts; + if (o.json !== undefined) return { json: o.json }; + c = c.parent; + } + return {}; +} + +export function jsonOut(value: unknown): void { + process.stdout.write(JSON.stringify(value) + "\n"); +} + +export function textOut(line: string): void { + process.stdout.write(line.endsWith("\n") ? line : line + "\n"); +} diff --git a/src/pds.ts b/src/pds.ts index c69621c..279be1d 100644 --- a/src/pds.ts +++ b/src/pds.ts @@ -63,7 +63,7 @@ export class PdsClient { headers.set("authorization", `Bearer ${this.account.accessJwt}`); const resp = await fetch(`${this.account.pds}${path}`, { ...init, headers }); - if (resp.status === 401 && !retried) { + if (!retried && (await shouldRefresh(resp))) { const refreshed = await refreshSession(this.account.pds, this.account.refreshJwt); this.account.accessJwt = refreshed.accessJwt; this.account.refreshJwt = refreshed.refreshJwt; @@ -132,6 +132,21 @@ export class PdsClient { } } +async function shouldRefresh(resp: Response): Promise { + if (resp.status === 401) return true; + if (resp.status !== 400) return false; + // atproto returns 400 with {"error":"ExpiredToken",...} for expired access + // JWTs. Peek at the body without consuming it (we clone here so the caller + // can still read the original response if no retry happens). + try { + const clone = resp.clone(); + const body = (await clone.json()) as { error?: string }; + return body.error === "ExpiredToken"; + } catch { + return false; + } +} + async function parseJson(resp: Response, nsid: string): Promise { if (!resp.ok) { const body = (await resp.json().catch(() => ({}))) as { error?: string; message?: string };