clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

feat/write-verdict-foundations
lofi-node / cli.ts
cli.ts 12.4K
1/**2 * The lofi-node CLI: `init`, `start`, `pair`, `ticket issue|list|revoke`,3 * `status`.4 *5 * Run it straight from JSR:6 *7 * ```sh8 * dx -A jsr:@nzip/lofi-node/cli init --port 48029 * dx -A jsr:@nzip/lofi-node/cli start10 * ```11 *12 * Ticket verbs write `<dataDir>/tickets.json` directly; a RUNNING daemon13 * picks changes up via the ticket store's mtime hot-reload, so issue/revoke14 * take effect without a restart and without IPC.15 *16 * @module17 */1819import {20 initConfig,21 loadConfig,22 type RelayConfig,23 saveConfig,24 type StorageConfig,25 validateRelay,26} from "./src/config.ts";27import { createSyncNode } from "./src/node.ts";28import { looksLikeTicket } from "./src/ticket.ts";29import {30 AppTicketStore,31 encodeAppTicket,32 isRevokedByLineage,33 looksLikeAppTicket,34} from "./src/appticket.ts";3536const USAGE = `lofi-node — self-hostable sync node for lofi apps3738Usage:39 lofi-node init [--dir <dataDir>] [--app-id <id>] [--port <n>]40 [--public-url <base>] [--open] [--storage-path <path>] [--memory]41 [--relay <url[,url…]>] [--no-relay]42 lofi-node start [--dir <dataDir>]43 lofi-node pair <node-ticket> [--dir <dataDir>]44 lofi-node ticket issue [--label <s>] [--url <base>] [--provision] [--dir <dataDir>]45 lofi-node ticket list [--dir <dataDir>]46 lofi-node ticket revoke <id> [--dir <dataDir>]47 lofi-node status [--dir <dataDir>]4849The data directory defaults to ./lofi-node-data. New inits are ticket-gated50(--open opts out); app tickets are issued with \`ticket issue\` and pasted51into the lofi app.5253Relays default to n0's public servers — rate-limited, fine for development.54For production point --relay at your own iroh-relay (comma-separated for55more than one), or pass --no-relay for direct connections only.`;5657interface Args {58 command: string;59 positional: string[];60 dir: string;61 appId?: string;62 port?: number;63 publicUrl?: string;64 label?: string;65 url?: string;66 open: boolean;67 memory: boolean;68 provision: boolean;69 storagePath?: string;70 relayUrls: string[];71 noRelay: boolean;72}7374function parseArgs(argv: string[]): Args {75 const args: Args = {76 command: argv[0] ?? "",77 positional: [],78 dir: "./lofi-node-data",79 open: false,80 memory: false,81 provision: false,82 relayUrls: [],83 noRelay: false,84 };85 for (let i = 1; i < argv.length; i++) {86 const arg = argv[i];87 if (arg === "--dir") args.dir = argv[++i];88 else if (arg === "--app-id") args.appId = argv[++i];89 else if (arg === "--port") args.port = Number(argv[++i]);90 else if (arg === "--public-url") args.publicUrl = argv[++i];91 else if (arg === "--label") args.label = argv[++i];92 else if (arg === "--url") args.url = argv[++i];93 else if (arg === "--open") args.open = true;94 else if (arg === "--memory") args.memory = true;95 else if (arg === "--provision") args.provision = true;96 else if (arg === "--storage-path") args.storagePath = argv[++i];97 else if (arg === "--relay") {98 args.relayUrls.push(...(argv[++i] ?? "").split(",").map((u) => u.trim()).filter(Boolean));99 } else if (arg === "--no-relay") args.noRelay = true;100 else args.positional.push(arg);101 }102 return args;103}104105function fail(message: string): never {106 console.error(`error: ${message}`);107 Deno.exit(1);108}109110async function requireConfig(dir: string) {111 const config = await loadConfig(dir);112 if (!config) fail(`no config at ${dir}/config.json — run: lofi-node init --dir ${dir}`);113 return config;114}115116function describeStorage(storage: StorageConfig, dir: string): string {117 if (storage.type === "memory") return "memory (no persistence)";118 return `sqlite (${storage.path ?? `${dir}/jazz`})`;119}120121function describeRelay(relay: RelayConfig | undefined): string {122 if (relay === undefined || relay === "n0") return "n0 public relays (dev default)";123 if (relay === "disabled") return "disabled (direct connections only)";124 return `custom (${relay.urls.join(", ")})`;125}126127function lanAddress(): string | null {128 try {129 for (const iface of Deno.networkInterfaces()) {130 if (iface.family === "IPv4" && !iface.address.startsWith("127.")) return iface.address;131 }132 } catch {133 // permission or platform issue — fall through134 }135 return null;136}137138async function cmdInit(args: Args) {139 const existing = await loadConfig(args.dir);140 if (existing) fail(`already initialized: ${args.dir}/config.json`);141 const storage: StorageConfig = args.memory142 ? { type: "memory" }143 : { type: "sqlite", path: args.storagePath };144 if (args.noRelay && args.relayUrls.length > 0) {145 fail("--no-relay and --relay are mutually exclusive");146 }147 const relay: RelayConfig | undefined = args.noRelay148 ? "disabled"149 : args.relayUrls.length > 0150 ? { urls: args.relayUrls }151 : undefined;152 if (relay) {153 try {154 validateRelay(relay);155 } catch (e) {156 fail((e as Error).message);157 }158 }159 const config = await initConfig(args.dir, {160 appId: args.appId,161 listenPort: args.port,162 access: args.open ? "open" : "ticket",163 storage,164 publicUrl: args.publicUrl,165 relay,166 });167 console.log(`initialized ${args.dir}`);168 console.log(` app id: ${config.appId}`);169 console.log(` access: ${config.access}`);170 console.log(` storage: ${describeStorage(config.storage, args.dir)}`);171 console.log(` relay: ${describeRelay(config.relay)}`);172 console.log(` listen: ${config.listenPort ?? "(auto)"}`);173 if (config.access === "ticket") {174 console.log(`\nNext: lofi-node start --dir ${args.dir}`);175 console.log(`Then: lofi-node ticket issue --dir ${args.dir} # connect an app`);176 } else {177 console.log(`\nNext: lofi-node start --dir ${args.dir}`);178 }179}180181async function cmdStart(args: Args) {182 const config = await requireConfig(args.dir);183 const node = await createSyncNode({184 appId: config.appId,185 backendSecret: config.backendSecret,186 adminSecret: config.adminSecret,187 dataDir: args.dir,188 listen: config.listenPort ? { port: config.listenPort } : undefined,189 access: config.access,190 storage: config.storage,191 publicUrl: config.publicUrl,192 upstream: config.upstream,193 relay: config.relay,194 allowLocalFirstAuth: config.allowLocalFirstAuth,195 });196 const status = node.status();197 console.log(`lofi-node up`);198 console.log(` app id: ${node.appId}`);199 console.log(` access: ${status.access}`);200 if (status.access === "ticket") {201 console.log(` gate: ${node.url} (apps connect with an issued ticket URL)`);202 console.log(203 ` tickets: ${204 status.tickets.filter((t) => !t.revoked).length205 } active — issue with: lofi-node ticket issue --dir ${args.dir}`,206 );207 } else {208 console.log(` jazz url: ${node.url} <- point JAZZ_SERVER_URL here`);209 }210 console.log(` storage: ${describeStorage(status.jazz.storage, args.dir)}`);211 console.log(212 ` upstream: ${213 status.upstream === "none"214 ? "none"215 : "url" in status.upstream216 ? status.upstream.url217 : "peer (over iroh)"218 }`,219 );220 if (status.mesh.state === "up") {221 console.log(` mesh: up (node ${status.mesh.nodeId.slice(0, 16)}…)`);222 console.log(` relay: ${describeRelay(config.relay)}`);223 console.log(` ticket: ${status.mesh.ticket}`);224 } else if (status.mesh.state === "unavailable") {225 console.log(` mesh: UNAVAILABLE — ${status.mesh.reason}`);226 console.log(` (LAN-only Jazz server; pairing disabled)`);227 }228 console.log(`\nCtrl-C to stop.`);229 Deno.addSignalListener("SIGINT", async () => {230 console.log("\nstopping…");231 try {232 await node.stop();233 } catch (e) {234 console.error(`shutdown error: ${(e as Error).message}`);235 }236 Deno.exit(0);237 });238 await new Promise(() => {});239}240241async function cmdPair(args: Args) {242 const ticket = args.positional[0];243 if (!ticket) fail("usage: lofi-node pair <node-ticket>");244 if (looksLikeAppTicket(ticket)) {245 fail(246 "that is an app-connect ticket (lofisync1.…); pairing takes a NODE ticket " +247 "(the endpoint… string printed by lofi-node start on the other node)",248 );249 }250 if (!looksLikeTicket(ticket)) fail("that does not look like a node-pairing ticket");251 const config = await requireConfig(args.dir);252 config.upstream = { peer: ticket };253 await saveConfig(args.dir, config);254 console.log("pairing saved. Restart the node to apply: lofi-node start");255}256257async function cmdTicket(args: Args) {258 const sub = args.positional[0];259 const config = await requireConfig(args.dir);260 if (config.access !== "ticket") {261 fail(`node access is "${config.access}" — app tickets require access: "ticket"`);262 }263 const store = await AppTicketStore.load(args.dir);264265 if (sub === "issue") {266 if (!config.listenPort && !args.url && !config.publicUrl) {267 fail(268 "ticket URLs need a stable address: set a fixed port (init --port) plus " +269 "--public-url/--url, or pass --url explicitly",270 );271 }272 let base = args.url ?? config.publicUrl;273 if (!base) {274 const lan = lanAddress();275 base = `http://${lan ?? "127.0.0.1"}:${config.listenPort}`;276 if (lan) {277 console.error(278 `note: auto-detected LAN address ${lan} — pin with --public-url if this is wrong`,279 );280 }281 }282 const scope = args.provision ? "provision" as const : "sync" as const;283 const { record, secret } = await store.issue(args.label, scope);284 const ticket = encodeAppTicket({285 v: 1,286 appId: config.appId,287 url: `${base.replace(/\/+$/, "")}/t/${secret}`,288 scope: scope === "provision" ? "provision" : undefined,289 label: args.label,290 });291 console.log(292 `issued ${scope} ticket ${record.id}${args.label ? ` (${args.label})` : ""}`,293 );294 console.log(`\n${ticket}\n`);295 console.log("Paste this into the lofi app. The secret is NOT stored and cannot");296 console.log("be shown again; a running node accepts it immediately.");297 if (scope === "provision") {298 console.log("PROVISION scope: this ticket also unlocks store administration");299 console.log("(schema deploys) through the gate. Issue per provisioning context.");300 }301 return;302 }303304 if (sub === "list") {305 const tickets = await store.list();306 if (tickets.length === 0) {307 console.log("no tickets issued");308 return;309 }310 for (const t of tickets) {311 // Lineage-aware: a derived ticket shows REVOKED once its parent is.312 const dead = isRevokedByLineage(t, tickets);313 console.log(314 `${t.id} ${dead ? "REVOKED" : "active "} ${315 (t.scope ?? "sync").padEnd(9)316 } ${t.createdAt} ${t.label ?? ""}${t.parentId ? ` [from ${t.parentId}]` : ""}`,317 );318 }319 return;320 }321322 if (sub === "revoke") {323 const id = args.positional[1];324 if (!id) fail("usage: lofi-node ticket revoke <id>");325 const record = await store.revoke(id);326 if (!record) fail(`no ticket with id ${id} (see: lofi-node ticket list)`);327 console.log(`revoked ${id} — takes effect immediately if the node is running`);328 return;329 }330331 fail("usage: lofi-node ticket <issue|list|revoke>");332}333334async function cmdStatus(args: Args) {335 const config = await requireConfig(args.dir);336 const store = await AppTicketStore.load(args.dir);337 const tickets = await store.list();338 console.log(` app id: ${config.appId}`);339 console.log(` access: ${config.access}`);340 console.log(` storage: ${describeStorage(config.storage, args.dir)}`);341 console.log(` relay: ${describeRelay(config.relay)}`);342 console.log(` listen: ${config.listenPort ?? "(auto)"}`);343 console.log(344 ` tickets: ${tickets.filter((t) => !t.revokedAt).length} active / ${tickets.length} issued`,345 );346 console.log(347 ` upstream: ${348 config.upstream === "none"349 ? "none"350 : "url" in config.upstream351 ? config.upstream.url352 : "peer (over iroh)"353 }`,354 );355 if (config.listenPort) {356 try {357 const res = await fetch(`http://127.0.0.1:${config.listenPort}/health`, {358 signal: AbortSignal.timeout(1500),359 });360 console.log(` daemon: ${res.status === 200 ? "running" : `unhealthy (${res.status})`}`);361 } catch {362 console.log(` daemon: not running`);363 }364 }365}366367const args = parseArgs(Deno.args);368switch (args.command) {369 case "init":370 await cmdInit(args);371 break;372 case "start":373 await cmdStart(args);374 break;375 case "pair":376 await cmdPair(args);377 break;378 case "ticket":379 await cmdTicket(args);380 break;381 case "status":382 await cmdStatus(args);383 break;384 default:385 console.log(USAGE);386 Deno.exit(args.command ? 1 : 0);387}