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 { AppTicketStore, encodeAppTicket, looksLikeAppTicket } from "./src/appticket.ts";3031const USAGE = `lofi-node — self-hostable sync node for lofi apps3233Usage:34 lofi-node init [--dir <dataDir>] [--app-id <id>] [--port <n>]35 [--public-url <base>] [--open] [--storage-path <path>] [--memory]36 [--relay <url[,url…]>] [--no-relay]37 lofi-node start [--dir <dataDir>]38 lofi-node pair <node-ticket> [--dir <dataDir>]39 lofi-node ticket issue [--label <s>] [--url <base>] [--provision] [--dir <dataDir>]40 lofi-node ticket list [--dir <dataDir>]41 lofi-node ticket revoke <id> [--dir <dataDir>]42 lofi-node status [--dir <dataDir>]4344The data directory defaults to ./lofi-node-data. New inits are ticket-gated45(--open opts out); app tickets are issued with \`ticket issue\` and pasted46into the lofi app.4748Relays default to n0's public servers — rate-limited, fine for development.49For production point --relay at your own iroh-relay (comma-separated for50more than one), or pass --no-relay for direct connections only.`;5152interface Args {53 command: string;54 positional: string[];55 dir: string;56 appId?: string;57 port?: number;58 publicUrl?: string;59 label?: string;60 url?: string;61 open: boolean;62 memory: boolean;63 provision: boolean;64 storagePath?: string;65 relayUrls: string[];66 noRelay: boolean;67}6869function parseArgs(argv: string[]): Args {70 const args: Args = {71 command: argv[0] ?? "",72 positional: [],73 dir: "./lofi-node-data",74 open: false,75 memory: false,76 provision: false,77 relayUrls: [],78 noRelay: false,79 };80 for (let i = 1; i < argv.length; i++) {81 const arg = argv[i];82 if (arg === "--dir") args.dir = argv[++i];83 else if (arg === "--app-id") args.appId = argv[++i];84 else if (arg === "--port") args.port = Number(argv[++i]);85 else if (arg === "--public-url") args.publicUrl = argv[++i];86 else if (arg === "--label") args.label = argv[++i];87 else if (arg === "--url") args.url = argv[++i];88 else if (arg === "--open") args.open = true;89 else if (arg === "--memory") args.memory = true;90 else if (arg === "--provision") args.provision = true;91 else if (arg === "--storage-path") args.storagePath = argv[++i];92 else if (arg === "--relay") {93 args.relayUrls.push(...(argv[++i] ?? "").split(",").map((u) => u.trim()).filter(Boolean));94 } else if (arg === "--no-relay") args.noRelay = true;95 else args.positional.push(arg);96 }97 return args;98}99100function fail(message: string): never {101 console.error(`error: ${message}`);102 Deno.exit(1);103}104105async function requireConfig(dir: string) {106 const config = await loadConfig(dir);107 if (!config) fail(`no config at ${dir}/config.json — run: lofi-node init --dir ${dir}`);108 return config;109}110111function describeStorage(storage: StorageConfig, dir: string): string {112 if (storage.type === "memory") return "memory (no persistence)";113 return `sqlite (${storage.path ?? `${dir}/jazz`})`;114}115116function describeRelay(relay: RelayConfig | undefined): string {117 if (relay === undefined || relay === "n0") return "n0 public relays (dev default)";118 if (relay === "disabled") return "disabled (direct connections only)";119 return `custom (${relay.urls.join(", ")})`;120}121122function lanAddress(): string | null {123 try {124 for (const iface of Deno.networkInterfaces()) {125 if (iface.family === "IPv4" && !iface.address.startsWith("127.")) return iface.address;126 }127 } catch {128 // permission or platform issue — fall through129 }130 return null;131}132133async function cmdInit(args: Args) {134 const existing = await loadConfig(args.dir);135 if (existing) fail(`already initialized: ${args.dir}/config.json`);136 const storage: StorageConfig = args.memory137 ? { type: "memory" }138 : { type: "sqlite", path: args.storagePath };139 if (args.noRelay && args.relayUrls.length > 0) {140 fail("--no-relay and --relay are mutually exclusive");141 }142 const relay: RelayConfig | undefined = args.noRelay143 ? "disabled"144 : args.relayUrls.length > 0145 ? { urls: args.relayUrls }146 : undefined;147 if (relay) {148 try {149 validateRelay(relay);150 } catch (e) {151 fail((e as Error).message);152 }153 }154 const config = await initConfig(args.dir, {155 appId: args.appId,156 listenPort: args.port,157 access: args.open ? "open" : "ticket",158 storage,159 publicUrl: args.publicUrl,160 relay,161 });162 console.log(`initialized ${args.dir}`);163 console.log(` app id: ${config.appId}`);164 console.log(` access: ${config.access}`);165 console.log(` storage: ${describeStorage(config.storage, args.dir)}`);166 console.log(` relay: ${describeRelay(config.relay)}`);167 console.log(` listen: ${config.listenPort ?? "(auto)"}`);168 if (config.access === "ticket") {169 console.log(`\nNext: lofi-node start --dir ${args.dir}`);170 console.log(`Then: lofi-node ticket issue --dir ${args.dir} # connect an app`);171 } else {172 console.log(`\nNext: lofi-node start --dir ${args.dir}`);173 }174}175176async function cmdStart(args: Args) {177 const config = await requireConfig(args.dir);178 const node = await createSyncNode({179 appId: config.appId,180 backendSecret: config.backendSecret,181 adminSecret: config.adminSecret,182 dataDir: args.dir,183 listen: config.listenPort ? { port: config.listenPort } : undefined,184 access: config.access,185 storage: config.storage,186 publicUrl: config.publicUrl,187 upstream: config.upstream,188 relay: config.relay,189 allowLocalFirstAuth: config.allowLocalFirstAuth,190 });191 const status = node.status();192 console.log(`lofi-node up`);193 console.log(` app id: ${node.appId}`);194 console.log(` access: ${status.access}`);195 if (status.access === "ticket") {196 console.log(` gate: ${node.url} (apps connect with an issued ticket URL)`);197 console.log(198 ` tickets: ${199 status.tickets.filter((t) => !t.revoked).length200 } active — issue with: lofi-node ticket issue --dir ${args.dir}`,201 );202 } else {203 console.log(` jazz url: ${node.url} <- point JAZZ_SERVER_URL here`);204 }205 console.log(` storage: ${describeStorage(status.jazz.storage, args.dir)}`);206 console.log(207 ` upstream: ${208 status.upstream === "none"209 ? "none"210 : "url" in status.upstream211 ? status.upstream.url212 : "peer (over iroh)"213 }`,214 );215 if (status.mesh.state === "up") {216 console.log(` mesh: up (node ${status.mesh.nodeId.slice(0, 16)}…)`);217 console.log(` relay: ${describeRelay(config.relay)}`);218 console.log(` ticket: ${status.mesh.ticket}`);219 } else if (status.mesh.state === "unavailable") {220 console.log(` mesh: UNAVAILABLE — ${status.mesh.reason}`);221 console.log(` (LAN-only Jazz server; pairing disabled)`);222 }223 console.log(`\nCtrl-C to stop.`);224 Deno.addSignalListener("SIGINT", async () => {225 console.log("\nstopping…");226 try {227 await node.stop();228 } catch (e) {229 console.error(`shutdown error: ${(e as Error).message}`);230 }231 Deno.exit(0);232 });233 await new Promise(() => {});234}235236async function cmdPair(args: Args) {237 const ticket = args.positional[0];238 if (!ticket) fail("usage: lofi-node pair <node-ticket>");239 if (looksLikeAppTicket(ticket)) {240 fail(241 "that is an app-connect ticket (lofisync1.…); pairing takes a NODE ticket " +242 "(the endpoint… string printed by lofi-node start on the other node)",243 );244 }245 if (!looksLikeTicket(ticket)) fail("that does not look like a node-pairing ticket");246 const config = await requireConfig(args.dir);247 config.upstream = { peer: ticket };248 await saveConfig(args.dir, config);249 console.log("pairing saved. Restart the node to apply: lofi-node start");250}251252async function cmdTicket(args: Args) {253 const sub = args.positional[0];254 const config = await requireConfig(args.dir);255 if (config.access !== "ticket") {256 fail(`node access is "${config.access}" — app tickets require access: "ticket"`);257 }258 const store = await AppTicketStore.load(args.dir);259260 if (sub === "issue") {261 if (!config.listenPort && !args.url && !config.publicUrl) {262 fail(263 "ticket URLs need a stable address: set a fixed port (init --port) plus " +264 "--public-url/--url, or pass --url explicitly",265 );266 }267 let base = args.url ?? config.publicUrl;268 if (!base) {269 const lan = lanAddress();270 base = `http://${lan ?? "127.0.0.1"}:${config.listenPort}`;271 if (lan) {272 console.error(273 `note: auto-detected LAN address ${lan} — pin with --public-url if this is wrong`,274 );275 }276 }277 const scope = args.provision ? "provision" as const : "sync" as const;278 const { record, secret } = await store.issue(args.label, scope);279 const ticket = encodeAppTicket({280 v: 1,281 appId: config.appId,282 url: `${base.replace(/\/+$/, "")}/t/${secret}`,283 scope: scope === "provision" ? "provision" : undefined,284 label: args.label,285 });286 console.log(287 `issued ${scope} ticket ${record.id}${args.label ? ` (${args.label})` : ""}`,288 );289 console.log(`\n${ticket}\n`);290 console.log("Paste this into the lofi app. The secret is NOT stored and cannot");291 console.log("be shown again; a running node accepts it immediately.");292 if (scope === "provision") {293 console.log("PROVISION scope: this ticket also unlocks store administration");294 console.log("(schema deploys) through the gate. Issue per provisioning context.");295 }296 return;297 }298299 if (sub === "list") {300 const tickets = await store.list();301 if (tickets.length === 0) {302 console.log("no tickets issued");303 return;304 }305 for (const t of tickets) {306 console.log(307 `${t.id} ${t.revokedAt ? "REVOKED" : "active "} ${308 (t.scope ?? "sync").padEnd(9)309 } ${t.createdAt} ${t.label ?? ""}`,310 );311 }312 return;313 }314315 if (sub === "revoke") {316 const id = args.positional[1];317 if (!id) fail("usage: lofi-node ticket revoke <id>");318 const record = await store.revoke(id);319 if (!record) fail(`no ticket with id ${id} (see: lofi-node ticket list)`);320 console.log(`revoked ${id} — takes effect immediately if the node is running`);321 return;322 }323324 fail("usage: lofi-node ticket <issue|list|revoke>");325}326327async function cmdStatus(args: Args) {328 const config = await requireConfig(args.dir);329 const store = await AppTicketStore.load(args.dir);330 const tickets = await store.list();331 console.log(` app id: ${config.appId}`);332 console.log(` access: ${config.access}`);333 console.log(` storage: ${describeStorage(config.storage, args.dir)}`);334 console.log(` relay: ${describeRelay(config.relay)}`);335 console.log(` listen: ${config.listenPort ?? "(auto)"}`);336 console.log(337 ` tickets: ${tickets.filter((t) => !t.revokedAt).length} active / ${tickets.length} issued`,338 );339 console.log(340 ` upstream: ${341 config.upstream === "none"342 ? "none"343 : "url" in config.upstream344 ? config.upstream.url345 : "peer (over iroh)"346 }`,347 );348 if (config.listenPort) {349 try {350 const res = await fetch(`http://127.0.0.1:${config.listenPort}/health`, {351 signal: AbortSignal.timeout(1500),352 });353 console.log(` daemon: ${res.status === 200 ? "running" : `unhealthy (${res.status})`}`);354 } catch {355 console.log(` daemon: not running`);356 }357 }358}359360const args = parseArgs(Deno.args);361switch (args.command) {362 case "init":363 await cmdInit(args);364 break;365 case "start":366 await cmdStart(args);367 break;368 case "pair":369 await cmdPair(args);370 break;371 case "ticket":372 await cmdTicket(args);373 break;374 case "status":375 await cmdStatus(args);376 break;377 default:378 console.log(USAGE);379 Deno.exit(args.command ? 1 : 0);380}