diff --git a/src/api/identity.ts b/src/api/identity.ts index f3f57a1..5672b35 100644 --- a/src/api/identity.ts +++ b/src/api/identity.ts @@ -12,6 +12,21 @@ export type Identity = { did: string; handle: string; pds?: string }; const DAY = 24 * 60 * 60 * 1000; +const ResolvedHandle = z.object({ did: z.string() }); + +export async function resolveHandle(handleOrDid: string): Promise { + if (handleOrDid.startsWith("did:")) return handleOrDid; + const handle = handleOrDid.replace(/^@/, ""); + return cached(`handle-${handle}`, DAY, async () => { + const res = await fetch( + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, + { signal: AbortSignal.timeout(10_000) }, + ); + if (!res.ok) throw new Error(`could not resolve ${handle} (${res.status})`); + return ResolvedHandle.parse(await res.json()).did; + }); +} + export async function resolveDid(did: string): Promise { return cached(`did-${did}`, DAY, async () => { const url = did.startsWith("did:web:") diff --git a/src/api/knot.ts b/src/api/knot.ts index 45fb9e7..f35ac1d 100644 --- a/src/api/knot.ts +++ b/src/api/knot.ts @@ -27,6 +27,23 @@ export async function probeKnot(hostname: string): Promise { } } +// a knot's root page is its motd — the ascii ship plus whatever the operator added. +// fails soft: 403'd roots, html, and dead hosts all just mean "no motd". +export async function fetchMotd(hostname: string): Promise { + try { + const res = await fetch(`https://${hostname}/`, { + signal: AbortSignal.timeout(5000), + }); + if (!res.ok || !res.headers.get("content-type")?.includes("text/plain")) + return null; + const body = (await res.text()).trimEnd(); + if (!body) return null; + return body.split("\n").slice(0, 12).join("\n").slice(0, 1024); + } catch { + return null; + } +} + // managed = hosted by tangled.org; keep the heuristic here, it may need refinement export function isManaged(hostname: string): boolean { return ( diff --git a/src/banner.ts b/src/banner.ts index 25bf673..93248da 100644 --- a/src/banner.ts +++ b/src/banner.ts @@ -1,3 +1,4 @@ +import { styleText } from "node:util"; import { close, createTerm, @@ -19,8 +20,7 @@ const ART = [ const TAGLINE = "a fid works knots. this one works tangled’s."; const HEIGHT = ART.length + 2; // art + blank + tagline -const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; -export const staticBanner = `\n${ART.join("\n")}\n\n${dim(TAGLINE)}\n`; +export const staticBanner = `\n${ART.join("\n")}\n\n${styleText("dim", TAGLINE)}\n`; const GREEN = rgba(74, 222, 128); const WHITE = rgba(255, 255, 255); diff --git a/src/cli.ts b/src/cli.ts index 61be0e5..38d2aac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,16 +4,20 @@ import { parse } from "@bomb.sh/args"; import { animateBanner } from "./banner.js"; const args = parse(process.argv.slice(2), { - boolean: ["json", "all", "fresh", "help", "version"], + boolean: ["json", "all", "fresh", "help", "version", "mine"], + string: ["as"], alias: { h: "help", v: "version" }, }); const usage = `usage: - fid list every knot on the network - --json machine-readable output - --all include localhost/dev junk knots - --fresh skip the 5-minute cache - fid --version print the version + fid list every knot on the network + --json machine-readable output + --all include localhost/dev junk knots + --fresh skip the 5-minute cache + fid check health-check one knot (exit 0 ok / 1 stale / 2 down) + --mine check every knot you own instead + --as who you are (or {"handle":"..."} in ~/.config/fid/config.json) + fid --version print the version `; if (args.version) { @@ -36,6 +40,12 @@ switch (command) { case "list": await (await import("./commands/list.js")).run(args); break; + case "check": + await (await import("./commands/check.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/check.ts b/src/commands/check.ts new file mode 100644 index 0000000..19c85e4 --- /dev/null +++ b/src/commands/check.ts @@ -0,0 +1,116 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { styleText } from "node:util"; +import * as p from "@clack/prompts"; +import { resolveDid, resolveHandle } from "../api/identity.js"; +import { fetchMotd, isLocalHost, probeKnot } from "../api/knot.js"; +import { fetchKnotHosts } from "../api/relay.js"; +import { assessKnot, versionOf } from "../core/health.js"; + +type Flags = { json?: boolean; mine?: boolean; as?: string }; + +function configHandle(): string | undefined { + try { + const file = join( + process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), + "fid", + "config.json", + ); + return JSON.parse(readFileSync(file, "utf8")).handle; + } catch { + return undefined; + } +} + +async function targets(flags: Flags, hostArg?: string): Promise { + if (hostArg) return [hostArg.trim().toLowerCase()]; + if (!flags.mine) + throw new Error("usage: fid check , or fid check --mine"); + const who = flags.as ?? configHandle(); + if (!who) + throw new Error( + 'tell me who you are: --as , or {"handle":"..."} in ~/.config/fid/config.json', + ); + const did = await resolveHandle(who); + const id = await resolveDid(did); + if (!id.pds) throw new Error(`no pds found for ${who}`); + const hosts = [...new Set(await fetchKnotHosts(id.pds, did))]; + if (hosts.length === 0) + throw new Error(`${who} owns no knots (no sh.tangled.knot records)`); + return hosts; +} + +export async function run(flags: Flags, hostArg?: string) { + if (!flags.json) p.intro(styleText("green", "fiddling with knots")); + const spin = flags.json ? null : p.spinner(); + + let hosts: string[]; + try { + spin?.start("finding knots"); + hosts = await targets(flags, hostArg); + spin?.message( + `probing ${hosts.length === 1 ? hosts[0] : `${hosts.length} knots`}`, + ); + } catch (err) { + spin?.stop("cannot check", 1); + console.error(err instanceof Error ? err.message : err); + process.exit(2); + } + + const results = await Promise.all( + hosts.map(async (host) => { + const local = isLocalHost(host); + const [probe, motd] = local + ? [null, null] + : await Promise.all([probeKnot(host), fetchMotd(host)]); + const health = assessKnot(host, probe); + return { + ...health, + version: versionOf(probe), + latencyMs: probe?.latencyMs ?? null, + motd, + }; + }), + ); + spin?.stop( + `probed ${results.length === 1 ? results[0].host : `${results.length} knots`}`, + ); + + const worst = Math.max(...results.map((r) => r.exitCode)); + process.exitCode = worst; + + if (flags.json) { + console.log(JSON.stringify(results, null, 2)); + return; + } + + const paint = { + healthy: "green", + stale: "yellow", + down: "red", + local: "dim", + } as const; + for (const r of results) { + const meta = [r.version, r.latencyMs !== null && `${r.latencyMs}ms`] + .filter(Boolean) + .join(", "); + const card = [ + `${r.host} — ${styleText(paint[r.status], r.status)} ${styleText("dim", `(${meta})`)}`, + ...r.warnings.map((w) => styleText("yellow", ` ⚠ ${w}`)), + ...(r.motd + ? r.motd.split("\n").map((l) => styleText("dim", ` ${l}`)) + : []), + ]; + p.log.message(card.join("\n")); + } + + p.outro( + worst === 0 + ? styleText("green", "all clear") + : styleText("dim", `exit ${worst} — `) + + (worst === 2 + ? styleText("red", "knot down") + : styleText("yellow", "knot needs an upgrade")), + ); +} diff --git a/src/commands/list.ts b/src/commands/list.ts index 93940e5..db72fb2 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,18 +1,16 @@ +import { styleText } from "node:util"; import * as p from "@clack/prompts"; import { assembleNetwork, type Network } from "../core/network.js"; import { cached } from "../util.js"; -const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; -const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; -const green = (s: string) => `\x1b[32m${s}\x1b[0m`; -const red = (s: string) => `\x1b[31m${s}\x1b[0m`; +const dim = (s: string) => styleText("dim", s); export async function run(flags: { json?: boolean; all?: boolean; fresh?: boolean; }) { - if (!flags.json) p.intro(green("fiddling with knots")); + 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; @@ -39,9 +37,9 @@ export async function run(flags: { const versionLabel = (k: (typeof shown)[number]) => { if (k.local) return dim("local"); - if (!k.probe?.reachable) return red("down"); - if (k.version === "v1.15+") return green("v1.15+"); - return yellow("pre-v1.15 ⚠"); // pre-v1.14 knots silently drop pushes/prs/issues + if (!k.probe?.reachable) return styleText("red", "down"); + if (k.version === "v1.15+") return styleText("green", "v1.15+"); + return styleText("yellow", "pre-v1.15 ⚠"); // pre-v1.14 knots silently drop pushes/prs/issues }; const rows = shown.map((k) => [ diff --git a/src/core/health.ts b/src/core/health.ts new file mode 100644 index 0000000..277a328 --- /dev/null +++ b/src/core/health.ts @@ -0,0 +1,45 @@ +import { isLocalHost, type KnotProbe } from "../api/knot.js"; + +export type Health = { + host: string; + status: "healthy" | "stale" | "down" | "local"; + exitCode: 0 | 1 | 2; + warnings: string[]; +}; + +// warning rules live here — version thresholds, silent-data-loss, etc. +// exit codes are the cron/ci contract: 0 healthy, 1 stale, 2 down. +export function assessKnot(host: string, probe: KnotProbe | null): Health { + if (isLocalHost(host)) { + return { + host, + status: "local", + exitCode: 0, // a leftover localhost record shouldn't page anyone + warnings: ["local/private host — not probeable from the internet"], + }; + } + if (!probe?.reachable) { + return { + host, + status: "down", + exitCode: 2, + warnings: ["unreachable — no https answer within 5s"], + }; + } + if (!probe.xrpcOk) { + return { + host, + status: "stale", + exitCode: 1, + warnings: [ + "pre-v1.15 — silently drops pushes, prs, issues, and invites; upgrade this knot", + ], + }; + } + return { host, status: "healthy", exitCode: 0, warnings: [] }; +} + +export function versionOf(probe: KnotProbe | null): string { + if (!probe?.reachable) return "unknown"; + return probe.xrpcOk ? "v1.15+" : "pre-v1.15"; +} diff --git a/tests/health.test.ts b/tests/health.test.ts new file mode 100644 index 0000000..c896dd9 --- /dev/null +++ b/tests/health.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "vitest"; +import { assessKnot, versionOf } from "../src/core/health.js"; + +test("healthy: xrpc answers", () => { + const h = assessKnot("knot1.tangled.sh", { + reachable: true, + xrpcOk: true, + status: 200, + latencyMs: 40, + }); + expect(h).toMatchObject({ status: "healthy", exitCode: 0, warnings: [] }); +}); + +test("stale: http answers but no xrpc — silent-data-loss warning", () => { + const h = assessKnot("git.jcs.org", { + reachable: true, + xrpcOk: false, + status: 404, + }); + expect(h.status).toBe("stale"); + expect(h.exitCode).toBe(1); + expect(h.warnings[0]).toMatch(/silently drops/); +}); + +test("down: no answer at all", () => { + const h = assessKnot("ghost.example.com", { + reachable: false, + xrpcOk: false, + }); + expect(h).toMatchObject({ status: "down", exitCode: 2 }); +}); + +test("local: never probed, never pages", () => { + const h = assessKnot("localhost:5555", null); + expect(h.status).toBe("local"); + expect(h.exitCode).toBe(0); + expect(h.warnings.length).toBe(1); +}); + +test("versionOf maps probe to display string", () => { + expect(versionOf({ reachable: true, xrpcOk: true })).toBe("v1.15+"); + expect(versionOf({ reachable: true, xrpcOk: false })).toBe("pre-v1.15"); + expect(versionOf({ reachable: false, xrpcOk: false })).toBe("unknown"); + expect(versionOf(null)).toBe("unknown"); +});