the tangled network observatory ◈ every knot, its health, its version, in your terminal
tangled cli tui knot monitoring observability atproto federation
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: add fid check with tiered exit codes

+262 -16
+15
src/api/identity.ts
··· 12 12 13 13 const DAY = 24 * 60 * 60 * 1000; 14 14 15 + const ResolvedHandle = z.object({ did: z.string() }); 16 + 17 + export async function resolveHandle(handleOrDid: string): Promise<string> { 18 + if (handleOrDid.startsWith("did:")) return handleOrDid; 19 + const handle = handleOrDid.replace(/^@/, ""); 20 + return cached(`handle-${handle}`, DAY, async () => { 21 + const res = await fetch( 22 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, 23 + { signal: AbortSignal.timeout(10_000) }, 24 + ); 25 + if (!res.ok) throw new Error(`could not resolve ${handle} (${res.status})`); 26 + return ResolvedHandle.parse(await res.json()).did; 27 + }); 28 + } 29 + 15 30 export async function resolveDid(did: string): Promise<Identity> { 16 31 return cached(`did-${did}`, DAY, async () => { 17 32 const url = did.startsWith("did:web:")
+17
src/api/knot.ts
··· 27 27 } 28 28 } 29 29 30 + // a knot's root page is its motd — the ascii ship plus whatever the operator added. 31 + // fails soft: 403'd roots, html, and dead hosts all just mean "no motd". 32 + export async function fetchMotd(hostname: string): Promise<string | null> { 33 + try { 34 + const res = await fetch(`https://${hostname}/`, { 35 + signal: AbortSignal.timeout(5000), 36 + }); 37 + if (!res.ok || !res.headers.get("content-type")?.includes("text/plain")) 38 + return null; 39 + const body = (await res.text()).trimEnd(); 40 + if (!body) return null; 41 + return body.split("\n").slice(0, 12).join("\n").slice(0, 1024); 42 + } catch { 43 + return null; 44 + } 45 + } 46 + 30 47 // managed = hosted by tangled.org; keep the heuristic here, it may need refinement 31 48 export function isManaged(hostname: string): boolean { 32 49 return (
+2 -2
src/banner.ts
··· 1 + import { styleText } from "node:util"; 1 2 import { 2 3 close, 3 4 createTerm, ··· 19 20 const TAGLINE = "a fid works knots. this one works tangled’s."; 20 21 const HEIGHT = ART.length + 2; // art + blank + tagline 21 22 22 - const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; 23 - export const staticBanner = `\n${ART.join("\n")}\n\n${dim(TAGLINE)}\n`; 23 + export const staticBanner = `\n${ART.join("\n")}\n\n${styleText("dim", TAGLINE)}\n`; 24 24 25 25 const GREEN = rgba(74, 222, 128); 26 26 const WHITE = rgba(255, 255, 255);
+16 -6
src/cli.ts
··· 4 4 import { animateBanner } from "./banner.js"; 5 5 6 6 const args = parse(process.argv.slice(2), { 7 - boolean: ["json", "all", "fresh", "help", "version"], 7 + boolean: ["json", "all", "fresh", "help", "version", "mine"], 8 + string: ["as"], 8 9 alias: { h: "help", v: "version" }, 9 10 }); 10 11 11 12 const usage = `usage: 12 - fid list every knot on the network 13 - --json machine-readable output 14 - --all include localhost/dev junk knots 15 - --fresh skip the 5-minute cache 16 - fid --version print the version 13 + fid list every knot on the network 14 + --json machine-readable output 15 + --all include localhost/dev junk knots 16 + --fresh skip the 5-minute cache 17 + fid check <host> health-check one knot (exit 0 ok / 1 stale / 2 down) 18 + --mine check every knot you own instead 19 + --as <handle> who you are (or {"handle":"..."} in ~/.config/fid/config.json) 20 + fid --version print the version 17 21 `; 18 22 19 23 if (args.version) { ··· 35 39 switch (command) { 36 40 case "list": 37 41 await (await import("./commands/list.js")).run(args); 42 + break; 43 + case "check": 44 + await (await import("./commands/check.js")).run( 45 + args, 46 + args._[1] === undefined ? undefined : String(args._[1]), 47 + ); 38 48 break; 39 49 default: 40 50 console.error(`unknown command: ${command}\n`);
+116
src/commands/check.ts
··· 1 + import { readFileSync } from "node:fs"; 2 + import { homedir } from "node:os"; 3 + import { join } from "node:path"; 4 + import { styleText } from "node:util"; 5 + import * as p from "@clack/prompts"; 6 + import { resolveDid, resolveHandle } from "../api/identity.js"; 7 + import { fetchMotd, isLocalHost, probeKnot } from "../api/knot.js"; 8 + import { fetchKnotHosts } from "../api/relay.js"; 9 + import { assessKnot, versionOf } from "../core/health.js"; 10 + 11 + type Flags = { json?: boolean; mine?: boolean; as?: string }; 12 + 13 + function configHandle(): string | undefined { 14 + try { 15 + const file = join( 16 + process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), 17 + "fid", 18 + "config.json", 19 + ); 20 + return JSON.parse(readFileSync(file, "utf8")).handle; 21 + } catch { 22 + return undefined; 23 + } 24 + } 25 + 26 + async function targets(flags: Flags, hostArg?: string): Promise<string[]> { 27 + if (hostArg) return [hostArg.trim().toLowerCase()]; 28 + if (!flags.mine) 29 + throw new Error("usage: fid check <host>, or fid check --mine"); 30 + const who = flags.as ?? configHandle(); 31 + if (!who) 32 + throw new Error( 33 + 'tell me who you are: --as <handle-or-did>, or {"handle":"..."} in ~/.config/fid/config.json', 34 + ); 35 + const did = await resolveHandle(who); 36 + const id = await resolveDid(did); 37 + if (!id.pds) throw new Error(`no pds found for ${who}`); 38 + const hosts = [...new Set(await fetchKnotHosts(id.pds, did))]; 39 + if (hosts.length === 0) 40 + throw new Error(`${who} owns no knots (no sh.tangled.knot records)`); 41 + return hosts; 42 + } 43 + 44 + export async function run(flags: Flags, hostArg?: string) { 45 + if (!flags.json) p.intro(styleText("green", "fiddling with knots")); 46 + const spin = flags.json ? null : p.spinner(); 47 + 48 + let hosts: string[]; 49 + try { 50 + spin?.start("finding knots"); 51 + hosts = await targets(flags, hostArg); 52 + spin?.message( 53 + `probing ${hosts.length === 1 ? hosts[0] : `${hosts.length} knots`}`, 54 + ); 55 + } catch (err) { 56 + spin?.stop("cannot check", 1); 57 + console.error(err instanceof Error ? err.message : err); 58 + process.exit(2); 59 + } 60 + 61 + const results = await Promise.all( 62 + hosts.map(async (host) => { 63 + const local = isLocalHost(host); 64 + const [probe, motd] = local 65 + ? [null, null] 66 + : await Promise.all([probeKnot(host), fetchMotd(host)]); 67 + const health = assessKnot(host, probe); 68 + return { 69 + ...health, 70 + version: versionOf(probe), 71 + latencyMs: probe?.latencyMs ?? null, 72 + motd, 73 + }; 74 + }), 75 + ); 76 + spin?.stop( 77 + `probed ${results.length === 1 ? results[0].host : `${results.length} knots`}`, 78 + ); 79 + 80 + const worst = Math.max(...results.map((r) => r.exitCode)); 81 + process.exitCode = worst; 82 + 83 + if (flags.json) { 84 + console.log(JSON.stringify(results, null, 2)); 85 + return; 86 + } 87 + 88 + const paint = { 89 + healthy: "green", 90 + stale: "yellow", 91 + down: "red", 92 + local: "dim", 93 + } as const; 94 + for (const r of results) { 95 + const meta = [r.version, r.latencyMs !== null && `${r.latencyMs}ms`] 96 + .filter(Boolean) 97 + .join(", "); 98 + const card = [ 99 + `${r.host} — ${styleText(paint[r.status], r.status)} ${styleText("dim", `(${meta})`)}`, 100 + ...r.warnings.map((w) => styleText("yellow", ` ⚠ ${w}`)), 101 + ...(r.motd 102 + ? r.motd.split("\n").map((l) => styleText("dim", ` ${l}`)) 103 + : []), 104 + ]; 105 + p.log.message(card.join("\n")); 106 + } 107 + 108 + p.outro( 109 + worst === 0 110 + ? styleText("green", "all clear") 111 + : styleText("dim", `exit ${worst} — `) + 112 + (worst === 2 113 + ? styleText("red", "knot down") 114 + : styleText("yellow", "knot needs an upgrade")), 115 + ); 116 + }
+6 -8
src/commands/list.ts
··· 1 + import { styleText } from "node:util"; 1 2 import * as p from "@clack/prompts"; 2 3 import { assembleNetwork, type Network } from "../core/network.js"; 3 4 import { cached } from "../util.js"; 4 5 5 - const dim = (s: string) => `\x1b[2m${s}\x1b[0m`; 6 - const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; 7 - const green = (s: string) => `\x1b[32m${s}\x1b[0m`; 8 - const red = (s: string) => `\x1b[31m${s}\x1b[0m`; 6 + const dim = (s: string) => styleText("dim", s); 9 7 10 8 export async function run(flags: { 11 9 json?: boolean; 12 10 all?: boolean; 13 11 fresh?: boolean; 14 12 }) { 15 - if (!flags.json) p.intro(green("fiddling with knots")); 13 + if (!flags.json) p.intro(styleText("green", "fiddling with knots")); 16 14 const spin = flags.json ? null : p.spinner(); 17 15 spin?.start("assembling the network"); 18 16 let network: Network; ··· 39 37 40 38 const versionLabel = (k: (typeof shown)[number]) => { 41 39 if (k.local) return dim("local"); 42 - if (!k.probe?.reachable) return red("down"); 43 - if (k.version === "v1.15+") return green("v1.15+"); 44 - return yellow("pre-v1.15 ⚠"); // pre-v1.14 knots silently drop pushes/prs/issues 40 + if (!k.probe?.reachable) return styleText("red", "down"); 41 + if (k.version === "v1.15+") return styleText("green", "v1.15+"); 42 + return styleText("yellow", "pre-v1.15 ⚠"); // pre-v1.14 knots silently drop pushes/prs/issues 45 43 }; 46 44 47 45 const rows = shown.map((k) => [
+45
src/core/health.ts
··· 1 + import { isLocalHost, type KnotProbe } from "../api/knot.js"; 2 + 3 + export type Health = { 4 + host: string; 5 + status: "healthy" | "stale" | "down" | "local"; 6 + exitCode: 0 | 1 | 2; 7 + warnings: string[]; 8 + }; 9 + 10 + // warning rules live here — version thresholds, silent-data-loss, etc. 11 + // exit codes are the cron/ci contract: 0 healthy, 1 stale, 2 down. 12 + export function assessKnot(host: string, probe: KnotProbe | null): Health { 13 + if (isLocalHost(host)) { 14 + return { 15 + host, 16 + status: "local", 17 + exitCode: 0, // a leftover localhost record shouldn't page anyone 18 + warnings: ["local/private host — not probeable from the internet"], 19 + }; 20 + } 21 + if (!probe?.reachable) { 22 + return { 23 + host, 24 + status: "down", 25 + exitCode: 2, 26 + warnings: ["unreachable — no https answer within 5s"], 27 + }; 28 + } 29 + if (!probe.xrpcOk) { 30 + return { 31 + host, 32 + status: "stale", 33 + exitCode: 1, 34 + warnings: [ 35 + "pre-v1.15 — silently drops pushes, prs, issues, and invites; upgrade this knot", 36 + ], 37 + }; 38 + } 39 + return { host, status: "healthy", exitCode: 0, warnings: [] }; 40 + } 41 + 42 + export function versionOf(probe: KnotProbe | null): string { 43 + if (!probe?.reachable) return "unknown"; 44 + return probe.xrpcOk ? "v1.15+" : "pre-v1.15"; 45 + }
+45
tests/health.test.ts
··· 1 + import { expect, test } from "vitest"; 2 + import { assessKnot, versionOf } from "../src/core/health.js"; 3 + 4 + test("healthy: xrpc answers", () => { 5 + const h = assessKnot("knot1.tangled.sh", { 6 + reachable: true, 7 + xrpcOk: true, 8 + status: 200, 9 + latencyMs: 40, 10 + }); 11 + expect(h).toMatchObject({ status: "healthy", exitCode: 0, warnings: [] }); 12 + }); 13 + 14 + test("stale: http answers but no xrpc — silent-data-loss warning", () => { 15 + const h = assessKnot("git.jcs.org", { 16 + reachable: true, 17 + xrpcOk: false, 18 + status: 404, 19 + }); 20 + expect(h.status).toBe("stale"); 21 + expect(h.exitCode).toBe(1); 22 + expect(h.warnings[0]).toMatch(/silently drops/); 23 + }); 24 + 25 + test("down: no answer at all", () => { 26 + const h = assessKnot("ghost.example.com", { 27 + reachable: false, 28 + xrpcOk: false, 29 + }); 30 + expect(h).toMatchObject({ status: "down", exitCode: 2 }); 31 + }); 32 + 33 + test("local: never probed, never pages", () => { 34 + const h = assessKnot("localhost:5555", null); 35 + expect(h.status).toBe("local"); 36 + expect(h.exitCode).toBe(0); 37 + expect(h.warnings.length).toBe(1); 38 + }); 39 + 40 + test("versionOf maps probe to display string", () => { 41 + expect(versionOf({ reachable: true, xrpcOk: true })).toBe("v1.15+"); 42 + expect(versionOf({ reachable: true, xrpcOk: false })).toBe("pre-v1.15"); 43 + expect(versionOf({ reachable: false, xrpcOk: false })).toBe("unknown"); 44 + expect(versionOf(null)).toBe("unknown"); 45 + });