// what a lore space is keyed on. a git remote when there is one, so the same // project maps to the same space from any checkout on any machine and for any // member; the absolute path only when there is nothing better. // // tangled remotes come in two forms, `owner/name` and the repo's own DID, so // both are reduced to `tangled.org/`: stable across renames and the // same for everyone who clones it either way. import { execFileSync } from "node:child_process"; import { Type } from "typebox"; import { Parse } from "typebox/value"; import { resolveIdentity } from "./xrpc.ts"; export type ProjectKey = { key: string; kind: "remote" | "path" | "name"; title: string }; export type Remote = { host: string; path: string }; const TANGLED = new Set(["tangled.org", "tangled.sh"]); /** a remote url reduced to host and path: no scheme, user, port, or .git */ export function normalizeRemote(url: string): Remote | null { const trimmed = url.trim(); if (!trimmed) return null; let host: string; let path: string; const scp = trimmed.match(/^(?:[\w.-]+@)?([\w.-]+):(?!\/\/)(.+)$/); if (scp && scp[1] && scp[2]) [host, path] = [scp[1], scp[2]]; else { let parsed: URL; try { parsed = new URL(trimmed); } catch { return null; } [host, path] = [parsed.hostname, parsed.pathname]; } path = path.replace(/\.git$/, "").replace(/^\/+|\/+$/g, ""); host = host.toLowerCase(); if (!path) return null; return { host: TANGLED.has(host) ? "tangled.org" : host, path: path.startsWith("did:") ? path : path.toLowerCase() }; } const repoRecord = Type.Object({ value: Type.Object({ repoDid: Type.String() }) }); const repoRecords = Type.Object({ cursor: Type.Optional(Type.String()), records: Type.Array(Type.Object({ value: Type.Object({ name: Type.Optional(Type.String()), repoDid: Type.Optional(Type.String()) }) })), }); /** the repo DID behind a tangled `owner/name`, from the owner's sh.tangled.repo record. */ export async function tangledRepoDid(owner: string, name: string): Promise { const { did, pds } = await resolveIdentity(owner); const host = pds.replace(/\/+$/, ""); const rec = await fetch(`${host}/xrpc/com.atproto.repo.getRecord?${new URLSearchParams({ repo: did, collection: "sh.tangled.repo", rkey: name })}`); if (rec.ok) return Parse(repoRecord, await rec.json()).value.repoDid; // older repo records carry a TID key and the name inside the record let cursor: string | undefined; do { const params = new URLSearchParams({ repo: did, collection: "sh.tangled.repo", limit: "100" }); if (cursor) params.set("cursor", cursor); const page = await fetch(`${host}/xrpc/com.atproto.repo.listRecords?${params}`); if (!page.ok) throw new Error(`${page.status} listing ${owner}'s repos`); const body = Parse(repoRecords, await page.json()); const hit = body.records.find((r) => r.value.name === name && r.value.repoDid); if (hit?.value.repoDid) return hit.value.repoDid; cursor = body.records.length === 100 ? body.cursor : undefined; } while (cursor); throw new Error(`no repo record for ${owner}/${name}`); } export type TangledResolver = (owner: string, name: string) => Promise; export type TangledNamer = (repoDid: string) => Promise; /** the appview redirects a bare repo DID to its owner/name page; that is the title. */ export async function tangledRepoName(repoDid: string): Promise { try { const r = await fetch(`https://tangled.org/${repoDid}`, { method: "HEAD", redirect: "manual", signal: AbortSignal.timeout(6000) }); const location = r.headers.get("location"); const m = location?.match(/^(?:https:\/\/tangled\.org)?\/([^/?#]+\/[^/?#]+)/); return m?.[1] ?? null; } catch { return null; } } export type Keyed = { key: string; title: string }; /** the canonical key for a remote, and how to show it. tangled remotes key on the repo DID. */ export async function keyForRemote(remote: Remote, resolveTangled: TangledResolver = tangledRepoDid, nameTangled: TangledNamer = tangledRepoName): Promise { const plain = `${remote.host}/${remote.path}`; if (remote.host !== "tangled.org") return { key: plain, title: plain }; const [first, second] = remote.path.split("/"); if (!first) return { key: plain, title: plain }; if (first.startsWith("did:")) { const name = await nameTangled(first); return { key: `tangled.org/${first}`, title: name ? `tangled.org/${name}` : plain }; } if (!second) return { key: plain, title: plain }; try { return { key: `tangled.org/${await resolveTangled(first, second)}`, title: `tangled.org/${first}/${second}` }; } catch { return { key: plain, title: plain }; } } export function originOf(dir: string): string | null { try { return execFileSync("git", ["-C", dir, "remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); } catch { return null; } } export async function projectKeyFor(dir: string, resolveTangled: TangledResolver = tangledRepoDid, nameTangled: TangledNamer = tangledRepoName): Promise { const origin = originOf(dir); const remote = origin ? normalizeRemote(origin) : null; if (remote) return { ...(await keyForRemote(remote, resolveTangled, nameTangled)), kind: "remote" }; return { key: dir, kind: "path", title: dir }; }