diff --git a/src/api/relay.ts b/src/api/relay.ts index dfb94d0..0172f21 100644 --- a/src/api/relay.ts +++ b/src/api/relay.ts @@ -71,7 +71,18 @@ async function listRecords( // pre-~v1.x records carry a host field with a tid rkey; newer ones drop the // field entirely and the rkey IS the hostname (at://did/sh.tangled.knot/knot.example.com) const KnotRecord = z.object({ host: z.string().min(1).optional() }); -const RepoRecord = z.object({ knot: z.string().min(1) }); +const RepoRecord = z.object({ + knot: z.string().min(1), + description: z.string().optional(), + createdAt: z.string().optional(), +}); + +export type RepoRef = { + name: string; + knot: string; + description?: string; + createdAt?: string; +}; const host = (h: string) => h.trim().toLowerCase(); @@ -89,14 +100,13 @@ export async function fetchKnotHosts( }); } -// knot host of each repo record this owner holds — one entry per repo -export async function fetchRepoKnots( - pds: string, - did: string, -): Promise { +// this owner's repos — rkey is the repo name on tangled, display rkeys +export async function fetchRepos(pds: string, did: string): Promise { const records = await listRecords(pds, did, "sh.tangled.repo"); return records.flatMap((r) => { const parsed = RepoRecord.safeParse(r.value); - return parsed.success ? [host(parsed.data.knot)] : []; + if (!parsed.success) return []; + const name = r.uri.split("/").pop() ?? ""; + return [{ ...parsed.data, knot: host(parsed.data.knot), name }]; }); } diff --git a/src/cli.ts b/src/cli.ts index c0aca3f..fdc9cda 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,7 @@ const usage = `usage: --mine check every knot you own instead --as who you are (or {"handle":"..."} in ~/.config/fid/config.json) fid stats network aggregates: versions, hosting, top knots + fid repos what repos live on a knot fid --version print the version `; @@ -50,6 +51,12 @@ switch (command) { case "stats": await (await import("./commands/stats.js")).run(args); break; + case "repos": + await (await import("./commands/repos.js")).run( + args, + args._[1] === undefined ? undefined : String(args._[1]), + ); + break; default: console.error(`unknown command: ${command}\n`); console.log(usage); diff --git a/src/commands/repos.ts b/src/commands/repos.ts new file mode 100644 index 0000000..b171838 --- /dev/null +++ b/src/commands/repos.ts @@ -0,0 +1,66 @@ +import { styleText } from "node:util"; +import * as p from "@clack/prompts"; +import { loadNetwork, type Network } from "../core/network.js"; + +const dim = (s: string) => styleText("dim", s); + +export async function run( + flags: { json?: boolean; fresh?: boolean }, + hostArg?: string, +) { + if (!hostArg) { + console.error("usage: fid repos "); + process.exit(1); + } + const host = hostArg.trim().toLowerCase(); + + if (!flags.json) p.intro(styleText("green", "fiddling with knots")); + const spin = flags.json ? null : p.spinner(); + spin?.start("assembling the network"); + let network: Network; + try { + network = await loadNetwork(!!flags.fresh, (msg) => spin?.message(msg)); + } catch (err) { + spin?.stop("upstream failure", 1); + console.error(err instanceof Error ? err.message : err); + process.exit(2); + } + spin?.stop(`network assembled ${dim(`(${network.generatedAt})`)}`); + + const repos = network.repos + .filter((r) => r.knot === host) + .sort((a, b) => a.name.localeCompare(b.name)); + + if (flags.json) { + console.log(JSON.stringify(repos, null, 2)); + return; + } + + if (repos.length === 0) { + const known = network.knots.some((k) => k.host === host); + if (!known) { + p.outro( + styleText("red", `${host} is not a knot this network knows about`), + ); + process.exit(1); + } + p.outro(dim(`no repos on ${host}`)); + return; + } + + const clip = (s: string, max = 60) => + s.length > max ? `${s.slice(0, max - 1)}…` : s; + const nameW = Math.max(...repos.map((r) => r.name.length), 4); + const ownerW = Math.max(...repos.map((r) => r.owner.handle.length + 1), 5); + p.log.message( + [ + dim(`${"NAME".padEnd(nameW)} ${"OWNER".padEnd(ownerW)} DESCRIPTION`), + ...repos.map( + (r) => + `${r.name.padEnd(nameW)} ${`@${r.owner.handle}`.padEnd(ownerW)} ${dim(clip(r.description ?? ""))}`, + ), + ].join("\n"), + ); + + p.outro(dim(`${repos.length} repos on ${host}`)); +} diff --git a/src/core/network.ts b/src/core/network.ts index c81e0c9..badd7dd 100644 --- a/src/core/network.ts +++ b/src/core/network.ts @@ -1,8 +1,9 @@ import { resolveDid, type Identity } from "../api/identity.js"; import { fetchKnotHosts, - fetchRepoKnots, + fetchRepos, listDidsByCollection, + type RepoRef, } from "../api/relay.js"; import { isLocalHost, @@ -22,8 +23,11 @@ export type Knot = { version: "v1.15+" | "pre-v1.15" | "unknown"; }; +export type RepoInfo = RepoRef & { owner: { did: string; handle: string } }; + export type Network = { knots: Knot[]; + repos: RepoInfo[]; totalRepos: number; repoOwners: number; knotOwners: number; @@ -33,7 +37,7 @@ export type Network = { // pure assembly: data in, data out — the io lives in assembleNetwork below export function groupNetwork(input: { knotOwners: { identity: Identity; hosts: string[] }[]; - repoKnots: string[]; // one host entry per repo record on the network + repos: RepoInfo[]; // every repo record on the network repoOwners: number; probes: Map; }): Network { @@ -68,14 +72,15 @@ export function groupNetwork(input: { knot(host).owners.push({ did: identity.did, handle: identity.handle }); } } - for (const host of input.repoKnots) knot(host).repoCount++; + for (const repo of input.repos) knot(repo.knot).repoCount++; const knots = [...byHost.values()].sort( (a, b) => b.repoCount - a.repoCount || a.host.localeCompare(b.host), ); return { knots, - totalRepos: input.repoKnots.length, + repos: input.repos, + totalRepos: input.repos.length, repoOwners: input.repoOwners, knotOwners: input.knotOwners.length, generatedAt: new Date().toISOString(), @@ -127,20 +132,26 @@ export async function assembleNetwork( const repoOwnersRaw = await hydrate( repoDids, "reading repo records", - fetchRepoKnots, + fetchRepos, ); const knotOwners = knotOwnersRaw.map(({ identity, result }) => ({ identity, hosts: result ?? [], })); - const repoKnots = repoOwnersRaw.flatMap(({ result }) => result ?? []); + const repos: RepoInfo[] = repoOwnersRaw.flatMap(({ identity, result }) => + (result ?? []).map((r) => ({ + ...r, + owner: { did: identity.did, handle: identity.handle }, + })), + ); const publicHosts = [ ...new Set( - [...knotOwners.flatMap((k) => k.hosts), ...repoKnots].filter( - (h) => !isLocalHost(h), - ), + [ + ...knotOwners.flatMap((k) => k.hosts), + ...repos.map((r) => r.knot), + ].filter((h) => !isLocalHost(h)), ), ]; const probeLimit = pLimit(16); @@ -159,7 +170,7 @@ export async function assembleNetwork( return groupNetwork({ knotOwners, - repoKnots, + repos, repoOwners: repoDids.length, probes, }); @@ -169,7 +180,8 @@ export async function loadNetwork( fresh: boolean, onProgress?: (msg: string) => void, ): Promise { - return cached("network", fresh ? 0 : 5 * 60 * 1000, () => + // key bumped when the snapshot shape changes — v1 lacked `repos` + return cached("network2", fresh ? 0 : 5 * 60 * 1000, () => assembleNetwork(onProgress), ); } diff --git a/src/core/stats.ts b/src/core/stats.ts index 1b22dab..37b2b0a 100644 --- a/src/core/stats.ts +++ b/src/core/stats.ts @@ -46,13 +46,11 @@ export function computeStats(network: Network): Stats { }, repos: { total: network.totalRepos, owners: network.repoOwners }, // network.knots is already sorted by repoCount desc (groupNetwork) - topKnots: pub - .slice(0, 10) - .map((k) => ({ - host: k.host, - repoCount: k.repoCount, - version: k.version, - })), + topKnots: pub.slice(0, 10).map((k) => ({ + host: k.host, + repoCount: k.repoCount, + version: k.version, + })), histogram, }; } diff --git a/tests/network.test.ts b/tests/network.test.ts index caad0cd..f9de90b 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -1,7 +1,13 @@ import { readFileSync } from "node:fs"; import { afterEach, expect, test, vi } from "vitest"; -import { fetchKnotHosts, fetchRepoKnots } from "../src/api/relay.js"; -import { groupNetwork } from "../src/core/network.js"; +import { fetchKnotHosts, fetchRepos } from "../src/api/relay.js"; +import { groupNetwork, type RepoInfo } from "../src/core/network.js"; + +const repo = (name: string, knot: string, handle = "someone"): RepoInfo => ({ + name, + knot, + owner: { did: `did:plc:${handle}`, handle }, +}); test("groupNetwork groups repos by knot host and classifies versions", () => { const network = groupNetwork({ @@ -15,11 +21,11 @@ test("groupNetwork groups repos by knot host and classifies versions", () => { hosts: ["knot.krasovs.ky"], }, ], - repoKnots: [ - "knot1.tangled.sh", - "knot1.tangled.sh", - "knot.krasovs.ky", - "ghost.example.com", + repos: [ + repo("alpha", "knot1.tangled.sh"), + repo("beta", "knot1.tangled.sh"), + repo("gamma", "knot.krasovs.ky"), + repo("delta", "ghost.example.com"), ], repoOwners: 3, probes: new Map([ @@ -52,6 +58,7 @@ test("groupNetwork groups repos by knot host and classifies versions", () => { expect(ghost).toMatchObject({ owners: [], version: "unknown" }); expect(local).toMatchObject({ local: true, probe: null }); expect(network.totalRepos).toBe(4); + expect(network.repos.map((r) => r.name)).toContain("alpha"); }); afterEach(() => vi.unstubAllGlobals()); @@ -72,7 +79,7 @@ test("fetchKnotHosts handles both lexicon shapes: host field and rkey-as-host", expect(hosts).toEqual(["knot.example.com", "localhost:5557"]); }); -test("fetchRepoKnots parses real pds output, skips malformed records", async () => { +test("fetchRepos parses real pds output, skips malformed records", async () => { const fixture = JSON.parse( readFileSync( new URL("./fixtures/listRecords.repo.json", import.meta.url), @@ -83,9 +90,13 @@ test("fetchRepoKnots parses real pds output, skips malformed records", async () "fetch", vi.fn(async () => new Response(JSON.stringify(fixture))), ); - const knots = await fetchRepoKnots( + const repos = await fetchRepos( "https://pds.example", "did:plc:qfpnj4og54vl56wngdriaxug", ); - expect(knots).toEqual(["knot1.tangled.sh", "localhost:6444"]); + expect(repos.map((r) => [r.name, r.knot])).toEqual([ + ["valley-sans", "knot1.tangled.sh"], + ["test-microvms", "localhost:6444"], + ]); + expect(repos[0].description).toMatch(/valley-sans/); }); diff --git a/tests/stats.test.ts b/tests/stats.test.ts index ea764f8..97092c4 100644 --- a/tests/stats.test.ts +++ b/tests/stats.test.ts @@ -40,6 +40,7 @@ const net: Network = { version: "unknown", }), ], + repos: [], totalRepos: 169, repoOwners: 42, knotOwners: 5,