the tangled network observatory ◈ every knot, its health, its version, in your terminal
tangled
cli
tui
knot
monitoring
observability
atproto
federation
1#!/usr/bin/env node
2import { readFileSync } from "node:fs";
3import { styleText } from "node:util";
4import { parse } from "@bomb.sh/args";
5import { animateBanner } from "./banner.js";
6
7const args = parse(process.argv.slice(2), {
8 // note: args parses `--no-repos` as minimist-style negation → `repos: false`
9 boolean: ["json", "all", "fresh", "help", "version", "mine", "repos"],
10 string: ["as"],
11 alias: { h: "help", v: "version" },
12});
13
14const usage = `usage:
15 fid list every knot on the network
16 --json machine-readable output
17 --all include localhost/dev junk knots
18 --fresh skip the 5-minute cache
19 --no-repos skip the slow repo sweep (no counts, knots-only)
20 fid check <host> health-check one knot (exit 0 ok / 1 stale / 2 down)
21 --mine check every knot you own instead
22 --as <handle> who you are (or {"handle":"..."} in ~/.config/fid/config.json)
23 fid stats network aggregates: versions, hosting, top knots
24 fid repos <host> what repos live on a knot
25 fid spindles the ci runner fleet
26 fid find <query> search repos across the network
27 fid watch live network board (q to quit)
28 fid --version print the version
29`;
30
31// colors go in after the columns are padded, so alignment math never sees ansi
32const colorUsage = (s: string) =>
33 s
34 .split("\n")
35 .map((line) => {
36 if (line === "usage:") return styleText("dim", line);
37 const m = /^(\s+)(\S.*?)(\s{2,})(.*)$/.exec(line);
38 if (!m) return line;
39 const [, indent, left, gap, desc] = m;
40 const colored = left
41 .replace(
42 /^fid (?!--)(\S+)/,
43 (_, cmd: string) => `fid ${styleText(["green", "bold"], cmd)}`,
44 )
45 .replace(/--[\w-]+/g, (f) => styleText("cyan", f))
46 .replace(/<[^>]+>/g, (a) => styleText("magenta", a));
47 return indent + colored + gap + styleText("dim", desc);
48 })
49 .join("\n");
50
51if (args.version) {
52 const pkg = JSON.parse(
53 readFileSync(new URL("../package.json", import.meta.url), "utf8"),
54 );
55 console.log(`fid ${pkg.version}`);
56 process.exit(0);
57}
58
59const [command] = args._;
60// keep --json pipe-clean; watch takes the whole screen immediately
61if (!args.json && command !== "watch") await animateBanner();
62
63if (args.help || !command) {
64 console.log(colorUsage(usage));
65 process.exit(args.help ? 0 : 1);
66}
67
68switch (command) {
69 case "list":
70 await (
71 await import("./commands/list.js")
72 ).run({ ...args, noRepos: args.repos === false });
73 break;
74 case "check":
75 await (await import("./commands/check.js")).run(
76 args,
77 args._[1] === undefined ? undefined : String(args._[1]),
78 );
79 break;
80 case "stats":
81 await (await import("./commands/stats.js")).run(args);
82 break;
83 case "repos":
84 await (await import("./commands/repos.js")).run(
85 args,
86 args._[1] === undefined ? undefined : String(args._[1]),
87 );
88 break;
89 case "spindles":
90 await (await import("./commands/spindles.js")).run(args);
91 break;
92 case "find":
93 await (await import("./commands/find.js")).run(
94 args,
95 args._.slice(1).map(String).join(" "),
96 );
97 break;
98 case "watch":
99 await (await import("./commands/watch.js")).run(args);
100 break;
101 default:
102 console.error(`unknown command: ${command}\n`);
103 console.log(colorUsage(usage));
104 process.exit(1);
105}