|
| 1 | +/** |
| 2 | + * me serve — run a local web UI for viewing/managing memories. |
| 3 | + * |
| 4 | + * Launches a local HTTP server that: |
| 5 | + * - serves the embedded Vite-built React app |
| 6 | + * - proxies POST /rpc to the configured engine, injecting the stored API key |
| 7 | + * |
| 8 | + * Usage: |
| 9 | + * me serve [--port <port>] [--host <host>] [--no-open] |
| 10 | + * |
| 11 | + * Respects the global --server flag (falls back to ME_SERVER env and |
| 12 | + * stored default_server, same as every other command). |
| 13 | + */ |
| 14 | +import * as clack from "@clack/prompts"; |
| 15 | +import { Command } from "commander"; |
| 16 | +import { resolveCredentials } from "../credentials.ts"; |
| 17 | +import { getOutputFormat, output } from "../output.ts"; |
| 18 | +import { findAvailablePort, startHttpServer } from "../serve/http-server.ts"; |
| 19 | +import { requireEngine } from "../util.ts"; |
| 20 | + |
| 21 | +const DEFAULT_PORT = 3000; |
| 22 | +const DEFAULT_HOST = "127.0.0.1"; |
| 23 | +const MAX_PORT_ATTEMPTS = 20; |
| 24 | + |
| 25 | +export function createServeCommand(): Command { |
| 26 | + return new Command("serve") |
| 27 | + .description("run a local web UI for viewing/managing memories") |
| 28 | + .option( |
| 29 | + "--port <port>", |
| 30 | + `port to bind (default ${DEFAULT_PORT}; auto-increments when unspecified and default is busy)`, |
| 31 | + ) |
| 32 | + .option( |
| 33 | + "--host <host>", |
| 34 | + `host to bind (default ${DEFAULT_HOST})`, |
| 35 | + DEFAULT_HOST, |
| 36 | + ) |
| 37 | + .option("--no-open", "do not auto-open the browser") |
| 38 | + .action(async (opts, cmd) => { |
| 39 | + const globalOpts = cmd.optsWithGlobals(); |
| 40 | + const fmt = getOutputFormat(globalOpts); |
| 41 | + |
| 42 | + const creds = resolveCredentials(globalOpts.server); |
| 43 | + requireEngine(creds, fmt); |
| 44 | + |
| 45 | + const host: string = opts.host ?? DEFAULT_HOST; |
| 46 | + const explicitPortFlag = opts.port !== undefined; |
| 47 | + const requestedPort = explicitPortFlag |
| 48 | + ? parsePort(opts.port, fmt) |
| 49 | + : DEFAULT_PORT; |
| 50 | + |
| 51 | + // Port discovery: explicit --port is strict; default auto-increments. |
| 52 | + let port: number; |
| 53 | + if (explicitPortFlag) { |
| 54 | + port = requestedPort; |
| 55 | + } else { |
| 56 | + try { |
| 57 | + port = await findAvailablePort( |
| 58 | + host, |
| 59 | + requestedPort, |
| 60 | + MAX_PORT_ATTEMPTS, |
| 61 | + ); |
| 62 | + } catch (err) { |
| 63 | + const msg = err instanceof Error ? err.message : String(err); |
| 64 | + if (fmt === "text") { |
| 65 | + clack.log.error(msg); |
| 66 | + } else { |
| 67 | + output({ error: msg }, fmt, () => {}); |
| 68 | + } |
| 69 | + process.exit(1); |
| 70 | + } |
| 71 | + } |
| 72 | + |
| 73 | + let running: ReturnType<typeof startHttpServer>; |
| 74 | + try { |
| 75 | + running = startHttpServer({ |
| 76 | + server: creds.server, |
| 77 | + apiKey: creds.apiKey, |
| 78 | + engineSlug: creds.activeEngine ?? "", |
| 79 | + host, |
| 80 | + port, |
| 81 | + }); |
| 82 | + } catch (err) { |
| 83 | + const msg = err instanceof Error ? err.message : String(err); |
| 84 | + const hint = msg.includes("EADDRINUSE") |
| 85 | + ? ` (port ${port} is already in use)` |
| 86 | + : ""; |
| 87 | + if (fmt === "text") { |
| 88 | + clack.log.error(`Failed to start server${hint}: ${msg}`); |
| 89 | + } else { |
| 90 | + output({ error: msg, port, host }, fmt, () => {}); |
| 91 | + } |
| 92 | + process.exit(1); |
| 93 | + } |
| 94 | + |
| 95 | + if (fmt === "text") { |
| 96 | + clack.log.success(`Memory Engine UI running at ${running.url}`); |
| 97 | + console.log(` Remote server: ${creds.server}`); |
| 98 | + if (creds.activeEngine) { |
| 99 | + console.log(` Active engine: ${creds.activeEngine}`); |
| 100 | + } |
| 101 | + console.log(" Press Ctrl+C to stop."); |
| 102 | + } else { |
| 103 | + output( |
| 104 | + { |
| 105 | + url: running.url, |
| 106 | + host, |
| 107 | + port: port, |
| 108 | + server: creds.server, |
| 109 | + engine: creds.activeEngine, |
| 110 | + }, |
| 111 | + fmt, |
| 112 | + () => {}, |
| 113 | + ); |
| 114 | + } |
| 115 | + |
| 116 | + if (opts.open !== false) { |
| 117 | + openBrowser(running.url).catch((err) => { |
| 118 | + if (fmt === "text") { |
| 119 | + clack.log.warn( |
| 120 | + `Could not open browser automatically: ${err instanceof Error ? err.message : String(err)}`, |
| 121 | + ); |
| 122 | + } |
| 123 | + }); |
| 124 | + } |
| 125 | + |
| 126 | + // Keep the process alive until Ctrl+C. |
| 127 | + await new Promise<void>((resolve) => { |
| 128 | + const shutdown = () => { |
| 129 | + if (fmt === "text") { |
| 130 | + console.log(""); |
| 131 | + clack.log.info("Shutting down…"); |
| 132 | + } |
| 133 | + running.server.stop(true); |
| 134 | + resolve(); |
| 135 | + }; |
| 136 | + process.once("SIGINT", shutdown); |
| 137 | + process.once("SIGTERM", shutdown); |
| 138 | + }); |
| 139 | + }); |
| 140 | +} |
| 141 | + |
| 142 | +/** |
| 143 | + * Parse the --port flag value. Exits on invalid input. |
| 144 | + */ |
| 145 | +function parsePort( |
| 146 | + value: unknown, |
| 147 | + fmt: ReturnType<typeof getOutputFormat>, |
| 148 | +): number { |
| 149 | + const n = |
| 150 | + typeof value === "number" ? value : Number.parseInt(String(value), 10); |
| 151 | + if (!Number.isInteger(n) || n < 1 || n > 65535) { |
| 152 | + const msg = `Invalid --port value: ${String(value)}. Expected an integer 1..65535.`; |
| 153 | + if (fmt === "text") { |
| 154 | + clack.log.error(msg); |
| 155 | + } else { |
| 156 | + output({ error: msg }, fmt, () => {}); |
| 157 | + } |
| 158 | + process.exit(1); |
| 159 | + } |
| 160 | + return n; |
| 161 | +} |
| 162 | + |
| 163 | +/** |
| 164 | + * Open the given URL in the user's default browser. Best-effort; failures |
| 165 | + * are non-fatal (the URL is already printed to stdout). |
| 166 | + */ |
| 167 | +async function openBrowser(url: string): Promise<void> { |
| 168 | + const platform = process.platform; |
| 169 | + const cmd = |
| 170 | + platform === "darwin" |
| 171 | + ? ["open", url] |
| 172 | + : platform === "win32" |
| 173 | + ? ["cmd", "/c", "start", "", url] |
| 174 | + : ["xdg-open", url]; |
| 175 | + |
| 176 | + const proc = Bun.spawn(cmd, { |
| 177 | + stdout: "ignore", |
| 178 | + stderr: "ignore", |
| 179 | + stdin: "ignore", |
| 180 | + }); |
| 181 | + // Don't await full exit — the OS handler may daemonize. A small delay |
| 182 | + // ensures the spawn actually dispatches before the event loop continues. |
| 183 | + await Promise.race([proc.exited, new Promise((r) => setTimeout(r, 200))]); |
| 184 | +} |
0 commit comments