UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

382 lines (381 loc) 17.9 kB
import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; import * as net from "node:net"; import * as os from "node:os"; import * as path from "node:path"; import chalk from "chalk"; import { buildLaunchCommand, waitForPort, writeEnvFile } from "../dev-launch.js"; import { tunnelClientAddress } from "../dev.js"; import { MeshCliError } from "../../utils/errors.js"; import { DEFAULT_HUB_PORT } from "../local/stack.js"; import { logInfo, logSuccess, logWarn } from "../../utils/log.js"; import { findAppRoot } from "../../utils/pulumi.js"; export const HUB_SESSION = "mesh-hub-dev"; export const DEFAULT_HUB_UI_PORT = Number(DEFAULT_HUB_PORT); export const DEFAULT_HUB_API_PORT = 3002; export function devSessionsDir() { return path.join(os.tmpdir(), "mesh-dev-sessions"); } export function listDevSessions(dir = devSessionsDir()) { if (!fs.existsSync(dir)) return []; const sessions = []; for (const entry of fs.readdirSync(dir)) { if (!entry.endsWith(".json")) continue; try { const state = JSON.parse(fs.readFileSync(path.join(dir, entry), "utf-8")); if (state && typeof state === "object" && state.devOutput) { sessions.push({ name: entry.slice(0, -".json".length), state }); } } catch { } } return sessions; } export function pickDevSession(sessions, opts) { if (opts.session) { const match = sessions.find((s) => s.name === opts.session); if (!match) { throw new MeshCliError(`No dev session state found for '${opts.session}'. Known sessions: ${sessions.map((s) => s.name).join(", ") || "(none)"}`, { remediation: { command: "mesh dev # from the app repo, to start the stack session" } }); } return match; } if (sessions.length === 0) { throw new MeshCliError("No running `mesh dev` session found — the Hub needs a dev-local stack to observe.", { remediation: { command: "mesh dev # from your app repo, then re-run mesh hub dev" } }); } if (opts.cwdAppRoot) { const root = path.resolve(opts.cwdAppRoot); const match = sessions.find((s) => path.resolve(s.state.appRoot) === root); if (match) return match; } if (sessions.length === 1) return sessions[0]; throw new MeshCliError(`Multiple dev sessions found and none matches this directory: ${sessions .map((s) => `${s.name} (${s.state.appRoot})`) .join(", ")}`, { remediation: { command: "mesh hub dev --session <name>" } }); } export function parseTmuxEnv(output) { const env = {}; for (const line of output.split("\n")) { if (!line || line.startsWith("-")) continue; const eq = line.indexOf("="); if (eq <= 0) continue; env[line.slice(0, eq)] = line.slice(eq + 1); } return env; } function readTmuxSessionEnv(sessionName) { try { const out = execFileSync("tmux", ["show-environment", "-t", sessionName], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], }); return parseTmuxEnv(out); } catch { return {}; } } function tmuxSessionExists(sessionName) { try { execFileSync("tmux", ["has-session", "-t", sessionName], { stdio: "ignore" }); return true; } catch { return false; } } const FORWARD_PREFIXES = ["AWS_", "DEV_USER_"]; export function forwardedSessionVars(sessionEnv) { const out = {}; for (const [key, value] of Object.entries(sessionEnv)) { if (FORWARD_PREFIXES.some((p) => key.startsWith(p))) out[key] = value; } return out; } export function assembleHubEnv(session, sessionEnv, opts) { const warnings = []; const notes = []; const platform = session.state.devOutput.platform; if (!platform) { throw new MeshCliError(`Dev session '${session.name}' has no platform context in its state file — cannot derive the Hub scope.`, { remediation: { command: "mesh dev --kill && mesh dev # relaunch to refresh session state" } }); } const isLocalPlatform = platform.tenant === "local"; if (isLocalPlatform) { notes.push("Local platform session — the Hub runs from this checkout against `mesh start`. " + "Its containerized Hub keeps :9000, so pass --port/--api-port to run both."); } const hubTenant = platform.name ?? "mesh"; const scopeEnv = platform.env; const scopeTenants = (opts.tenants ?? platform.tenant) .split(",") .map((t) => t.trim()) .filter(Boolean); if (scopeTenants.length === 0) { throw new MeshCliError("Tenant scope resolved EMPTY (HUB_SCOPE_ENV set, no HUB_SCOPE_TENANTS) — the Hub would launch but show zero tenants everywhere.", { remediation: { command: "mesh hub dev --tenants <tenant>[,<tenant>…]" } }); } const forwarded = forwardedSessionVars(sessionEnv); if (!Object.keys(forwarded).some((k) => k.startsWith("AWS_"))) { warnings.push("No AWS_* vars found in the dev session env — the Hub API will fall back to ambient credentials (SSM reads may 403)."); } if (!forwarded.DEV_USER_ID_TOKEN && !forwarded.DEV_USER_ACCESS_TOKEN) { warnings.push("No DEV_USER_* identity in the dev session env — the Hub will show an unauthenticated state. Run `mesh login mesh." + `${scopeEnv}\` and relaunch \`mesh dev\`.`); } if (!forwarded.DEV_USER_TOKEN_URL) { notes.push("Dev session predates the per-session token-server — starting a dedicated one so tokens stay fresh past ~1h."); } const temporalAddress = sessionEnv.TEMPORAL_ADDRESS ?? (session.state.devOutput.tunnels["temporal"] ? tunnelClientAddress(session.state.devOutput.tunnels["temporal"]) : isLocalPlatform ? "localhost:7233" : undefined); if (!temporalAddress) { throw new MeshCliError(`Dev session '${session.name}' exposes no Temporal tunnel — the Hub can't reach the stack's Temporal.`, { remediation: { command: "mesh dev # relaunch; the session records tunnels.temporal in its state" } }); } const apiEnv = { ...forwarded, PORT: String(opts.apiPort), TEMPORAL_ADDRESS: temporalAddress, HUB_TENANT: hubTenant, HUB_SCOPE_ENV: scopeEnv, HUB_SCOPE_TENANTS: scopeTenants.join(","), ...(isLocalPlatform ? localPlatformEnv(sessionEnv) : {}), }; const uiEnv = { ...forwarded, PORT: String(opts.uiPort), API_URL: `http://localhost:${opts.apiPort}`, HUB_TENANT: hubTenant, DEFAULT_ORG: scopeTenants[0], DEFAULT_ENV: scopeEnv, }; return { apiEnv, uiEnv, scope: { hubTenant, scopeEnv, scopeTenants }, temporalAddress, warnings, notes }; } const localPlatformEnv = (sessionEnv) => ({ ZITADEL_ISSUER: sessionEnv.ZITADEL_ISSUER ?? "http://localhost:8080", OPS_DB_HOST: "localhost", OPS_DB_PORT: "5433", OPS_DB_NAME: "hub", OPS_DB_USER: "postgres", DATABASE_PASSWORD: "postgres", PGSSLMODE: "disable", DATABASE_URL: "postgres://postgres:postgres@localhost:5433/hub?sslmode=disable", ZITADEL_OPSHUB_SECRET_NAME: "mesh/local/dev/zitadel/ops-hub", SPICEDB_ENDPOINT: "localhost:50051", SPICEDB_HTTP_ENDPOINT: "http://localhost:8443", SPICEDB_HTTP_SCHEME: "http", SPICEDB_PRESHARED_KEY: "local-dev-key", LOKI_URL: "http://localhost:3100", TEMPO_URL: "http://localhost:3200", PROMETHEUS_URL: "http://localhost:9090", TEMPORAL_UI_URL: "http://localhost:8233", TEMPORAL_NAMESPACE: "local-dev", MESH_DAGSTER_LOCAL_HOST: "localhost", }); function isPlatformRoot(dir) { return (fs.existsSync(path.join(dir, "apps", "hub", "api", "package.json")) && fs.existsSync(path.join(dir, "apps", "hub", "ui", "package.json"))); } export function resolvePlatformDir(explicit, cwd, env = process.env) { if (explicit) { const resolved = path.resolve(explicit); if (isPlatformRoot(resolved)) return resolved; throw new MeshCliError(`'${resolved}' is not a mesh-platform checkout (apps/hub/{api,ui} not found).`, { remediation: { command: "mesh hub dev --platform-dir <path-to-mesh-platform>" } }); } if (env.MESH_PLATFORM_DIR) { const resolved = path.resolve(env.MESH_PLATFORM_DIR); if (isPlatformRoot(resolved)) return resolved; logWarn(`MESH_PLATFORM_DIR='${resolved}' is not a mesh-platform checkout (apps/hub/{api,ui} not found) — ignoring it.`); } let dir = path.resolve(cwd); for (;;) { if (isPlatformRoot(dir)) return dir; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } throw new MeshCliError("Can't find a mesh-platform checkout (the Hub's source). Run from inside one, or point at one explicitly.", { remediation: { command: "mesh hub dev --platform-dir <path-to-mesh-platform> # or export MESH_PLATFORM_DIR", }, }); } async function findFreePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); server.close(() => typeof address === "object" && address ? resolve(address.port) : reject(new Error("no port"))); }); server.on("error", reject); }); } function isPortListening(port) { return new Promise((resolve) => { const socket = net.connect({ host: "127.0.0.1", port, timeout: 400 }); const done = (ok) => { socket.removeAllListeners(); socket.destroy(); resolve(ok); }; socket.once("connect", () => done(true)); socket.once("timeout", () => done(false)); socket.once("error", () => done(false)); }); } function hubEnvDir() { return path.join(os.tmpdir(), "mesh-dev-sessions", HUB_SESSION); } export function parsePort(raw, fallback, source) { if (raw === undefined) return fallback; const n = Number(raw); if (!Number.isInteger(n) || n < 1 || n > 65535) { throw new MeshCliError(`Invalid port '${raw}' from ${source} — expected an integer 1–65535.`, { remediation: { command: `mesh hub dev ${source.startsWith("--") ? source : "--port"} <1-65535>` }, }); } return n; } const SENSITIVE_RE = /TOKEN|SECRET|KEY|PASSWORD/i; export function redactEnv(env) { const out = {}; for (const [key, value] of Object.entries(env)) { const sensitive = SENSITIVE_RE.test(key) && !key.endsWith("_URL") && !key.endsWith("_FILE"); out[key] = sensitive ? `<redacted ${value.length} chars>` : value; } return out; } async function hubDevAction(opts) { if (opts.kill) { if (tmuxSessionExists(HUB_SESSION)) { execFileSync("tmux", ["kill-session", "-t", HUB_SESSION], { stdio: "ignore" }); fs.rmSync(hubEnvDir(), { recursive: true, force: true }); logSuccess(`Killed Hub session '${HUB_SESSION}'.`); } else { logInfo(`No '${HUB_SESSION}' tmux session running.`); } return; } const uiPort = opts.port ? parsePort(opts.port, DEFAULT_HUB_UI_PORT, "--port") : parsePort(process.env.MESH_HUB_DEV_PORT, DEFAULT_HUB_UI_PORT, "MESH_HUB_DEV_PORT"); const apiPort = parsePort(opts.apiPort, DEFAULT_HUB_API_PORT, "--api-port"); const platformDir = resolvePlatformDir(opts.platformDir, process.cwd()); const cwdAppRoot = findAppRoot(process.cwd()); const session = pickDevSession(listDevSessions(), { session: opts.session, cwdAppRoot }); if (!tmuxSessionExists(session.name)) { throw new MeshCliError(`Dev session '${session.name}' has a state file but no live tmux session — its tunnels and token-server are gone.`, { remediation: { command: `mesh dev # from ${session.state.appRoot}` } }); } const sessionEnv = readTmuxSessionEnv(session.name); const assembled = assembleHubEnv(session, sessionEnv, { tenants: opts.tenants, uiPort, apiPort }); if (opts.printEnv) { console.log(chalk.bold("\nhub-api env:")); for (const [k, v] of Object.entries(redactEnv(assembled.apiEnv))) console.log(` ${k}=${v}`); console.log(chalk.bold("\nhub-ui env:")); for (const [k, v] of Object.entries(redactEnv(assembled.uiEnv))) console.log(` ${k}=${v}`); return; } if (tmuxSessionExists(HUB_SESSION)) { throw new MeshCliError(`A '${HUB_SESSION}' tmux session is already running.`, { remediation: { command: "mesh hub dev --kill # then re-run mesh hub dev" }, }); } for (const [label, port, flag] of [ ["API", apiPort, "--api-port"], ["UI", uiPort, "--port"], ]) { if (await isPortListening(port)) { throw new MeshCliError(`Hub ${label} port ${port} is already in use — another process (a \`mesh start\` Hub, or another \`mesh hub dev\`) is listening there.`, { remediation: { command: `mesh hub dev ${flag} <free-port> # or free :${port} first` } }); } } logInfo(`Dev session : ${session.name} (${session.state.appRoot})`); logInfo(`Hub source : ${platformDir}`); logInfo(`Scope : HUB_TENANT=${assembled.scope.hubTenant} HUB_SCOPE_ENV=${assembled.scope.scopeEnv} HUB_SCOPE_TENANTS=${assembled.scope.scopeTenants.join(",")}`); logInfo(`Temporal : ${assembled.temporalAddress}`); for (const note of assembled.notes) logInfo(note); for (const warning of assembled.warnings) logWarn(warning); const apiDir = path.join(platformDir, "apps", "hub", "api"); const uiDir = path.join(platformDir, "apps", "hub", "ui"); execFileSync("tmux", ["new-session", "-d", "-s", HUB_SESSION, "-n", "api", "-c", apiDir]); if (!assembled.apiEnv.DEV_USER_TOKEN_URL && assembled.apiEnv.DEV_USER_ID_TOKEN) { const platform = session.state.devOutput.platform; const credContext = `mesh.${platform.env}`; const tokenPort = await findFreePort(); const tokenUrl = `http://127.0.0.1:${tokenPort}`; execFileSync("tmux", ["new-window", "-t", HUB_SESSION, "-n", "token-server", "-c", platformDir]); execFileSync("tmux", ["set-option", "-t", `${HUB_SESSION}:token-server`, "remain-on-exit", "on"], { stdio: "ignore", }); execFileSync("tmux", [ "send-keys", "-t", `${HUB_SESSION}:token-server`, `npx mesh dev __token-server ${tokenPort} ${credContext}`, "Enter", ]); for (let i = 0; i < 15 && !(await isPortListening(tokenPort)); i++) { await new Promise((r) => setTimeout(r, 200)); } assembled.apiEnv.DEV_USER_TOKEN_URL = tokenUrl; assembled.uiEnv.DEV_USER_TOKEN_URL = tokenUrl; logSuccess(`Dev-user token-server: ${tokenUrl} (context ${credContext})`); } const launch = (window, dir, env, cmd, createWindow) => { const envFile = path.join(hubEnvDir(), `${window}.env.sh`); writeEnvFile(envFile, env); if (createWindow) { execFileSync("tmux", ["new-window", "-t", HUB_SESSION, "-n", window, "-c", dir]); } execFileSync("tmux", ["set-option", "-t", `${HUB_SESSION}:${window}`, "remain-on-exit", "on"], { stdio: "ignore", }); execFileSync("tmux", ["send-keys", "-t", `${HUB_SESSION}:${window}`, buildLaunchCommand(envFile, dir, cmd), "Enter"]); }; launch("api", apiDir, assembled.apiEnv, "pnpm dev", false); launch("ui", uiDir, assembled.uiEnv, `pnpm dev --port ${uiPort} --strictPort`, true); logInfo("Waiting for the Hub to come up…"); const apiUp = await waitForPort("127.0.0.1", apiPort, 90_000); const uiUp = apiUp && (await waitForPort("127.0.0.1", uiPort, 90_000)); if (!apiUp || !uiUp) { throw new MeshCliError(`Hub ${apiUp ? "UI" : "API"} did not start listening (api :${apiPort}, ui :${uiPort}).`, { remediation: { command: `tmux attach -t ${HUB_SESSION} # inspect the ${apiUp ? "ui" : "api"} window` } }); } console.log(""); logSuccess(`Mesh Hub (dev-local scope ${assembled.scope.scopeTenants.join(",")} @ ${assembled.scope.scopeEnv})`); console.log(` ${chalk.bold.cyan(`http://localhost:${uiPort}`)} (also reachable on your tailnet)`); console.log(` api http://localhost:${apiPort}`); console.log(` logs tmux attach -t ${HUB_SESSION}`); console.log(` stop mesh hub dev --kill`); } export function registerHubCommands(program) { const hub = program.command("hub").description("Local Hub over dev-local stacks"); hub .command("dev") .description("Launch the current-code Hub (api + ui) against a running `mesh dev` session — env auto-assembled, zero hand-set vars") .option("--session <name>", "dev session to observe (default: auto-detect from cwd)") .option("--tenants <list>", "comma-separated HUB_SCOPE_TENANTS override (default: the session's tenant)") .option("--port <port>", `Hub UI host port (default: $MESH_HUB_DEV_PORT or ${DEFAULT_HUB_UI_PORT})`) .option("--api-port <port>", `Hub API port (default: ${DEFAULT_HUB_API_PORT})`) .option("--platform-dir <dir>", "mesh-platform checkout to run the Hub from (default: $MESH_PLATFORM_DIR or walk up from cwd)") .option("--print-env", "print the assembled env (secrets redacted) and exit without launching") .option("--kill", "tear down the running Hub session") .action(hubDevAction); }