UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

390 lines (389 loc) 14 kB
import { execFileSync, spawn, spawnSync } from "child_process"; import * as fs from "fs"; import { probeTcp } from "./helpers.js"; import * as path from "path"; import { fileURLToPath } from "url"; import { parse as parseYaml } from "yaml"; import { meshCacheDir } from "../../utils/cache-home.js"; import { MeshCliError } from "../../utils/errors.js"; import { startHeartbeat } from "../../utils/log.js"; export const COMPOSE_PROJECT = "mesh-local"; export function localLogsDir() { return meshCacheDir("mesh-local", "logs"); } export function localProbesDir() { return meshCacheDir("mesh-local", "probes"); } export function writeAppServiceProbes(args) { const entries = Object.entries(args.services); if (entries.length === 0) return undefined; const healthPath = args.healthPath ?? "/health"; const targets = entries.map(([name, port]) => ({ targets: [`http://host.docker.internal:${port}${healthPath}`], labels: { type: "service", tenant: args.tenant, env: args.env, app: args.app, service: name, target: name, }, })); const file = path.join(localProbesDir(), `${args.tenant}-${args.app}-services.json`); fs.writeFileSync(file, `${JSON.stringify(targets, null, 2)}\n`); return file; } export const STACK_ENDPOINTS = [ { service: "temporal", label: "Temporal gRPC", url: "localhost:7233", probe: { kind: "tcp", port: 7233 }, }, { service: "temporal-ui", label: "Temporal UI", url: "http://localhost:8233/namespaces/local-dev/workflows", probe: { kind: "http", url: "http://localhost:8233" }, hint: "seeded namespace: local-dev", }, { service: "zitadel", label: "Zitadel console", url: "http://localhost:8080", probe: { kind: "http", url: "http://localhost:8080/debug/healthz" }, hint: "admin@local.mesh / LocalDev1!", }, { service: "mailpit", label: "Mailbox (local mail)", url: "http://localhost:8025", probe: { kind: "http", url: "http://localhost:8025/readyz" }, hint: "every activation + password-reset mail Zitadel sends locally lands here", }, { service: "database", label: "Postgres", url: "postgres://postgres:postgres@localhost:5433", probe: { kind: "tcp", port: 5433 }, hint: "databases: temporal, app, spicedb, hub", }, { service: "spicedb", label: "SpiceDB gRPC", url: "localhost:50051", probe: { kind: "tcp", port: 50051 }, hint: "preshared key: local-dev-key", }, { service: "ministack", label: "ministack (local AWS endpoint)", url: "http://localhost:4566", probe: { kind: "http", url: "http://localhost:4566/_ministack/health" }, hint: "SSM registry under /mesh-platform/local/dev", }, { service: "stackport", label: "StackPort (local AWS console)", url: "http://localhost:4567", probe: { kind: "http", url: "http://localhost:4567" }, hint: "browse the registry (SSM), secrets, S3 artifacts", }, { service: "memcached", label: "Memcached", url: "localhost:11211", probe: { kind: "tcp", port: 11211 }, }, { service: "loki", label: "Loki (logs)", url: "http://localhost:3100", probe: { kind: "http", url: "http://localhost:3100/ready" }, hint: "mesh dev --local service logs, hosted label scheme", }, { service: "tempo", label: "Tempo (traces)", url: "http://localhost:3200", probe: { kind: "http", url: "http://localhost:3200/ready" }, }, { service: "prometheus", label: "Prometheus (metrics)", url: "http://localhost:9090", probe: { kind: "http", url: "http://localhost:9090/-/ready" }, }, { service: "otel-collector", label: "OTel collector (OTLP in)", url: "http://localhost:4318", probe: { kind: "http", url: "http://localhost:13133" }, hint: "apps: OTEL_EXPORTER_OTLP_ENDPOINT (injected by mesh dev --local)", }, { service: "elasticsearch", label: "OpenSearch (Temporal visibility)", url: "http://localhost:9200", probe: { kind: "http", url: "http://localhost:9200" }, }, ]; export const DEFAULT_HUB_PORT = "9000"; export function hubPort() { const raw = process.env.MESH_HUB_PORT?.trim(); if (!raw) return DEFAULT_HUB_PORT; if (!/^\d+$/.test(raw) || Number(raw) < 1 || Number(raw) > 65535) { throw new MeshCliError(`MESH_HUB_PORT must be a TCP port number 1–65535 (got '${raw}').`, { remediation: { command: "unset MESH_HUB_PORT # or export a valid port, e.g. 9100" }, }); } return raw; } export function hubEndpoints() { return [ { service: "hub-api", label: "Hub API", url: "http://localhost:4568", probe: { kind: "http", url: "http://localhost:4568/health" }, }, { service: "hub-ui", label: "Hub UI", url: `http://localhost:${hubPort()}`, probe: { kind: "http", url: `http://localhost:${hubPort()}/ping` }, hint: "sign in: admin@local.mesh or dev@local.mesh / LocalDev1! (oauth2-proxy, same as deployed)", }, ]; } export function findPackageRoot(startDir) { let dir = startDir ?? path.dirname(fileURLToPath(import.meta.url)); for (let i = 0; i < 8; i++) { const pkgPath = path.join(dir, "package.json"); if (fs.existsSync(pkgPath)) { try { const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); if (pkg.name === "@mesh-tech/mesh-cli") return dir; } catch { } } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } throw new MeshCliError("Could not locate the mesh-cli package root (stack assets missing).", { remediation: { command: "npm install -g @mesh-tech/mesh-cli" }, }); } export function stackDir() { const dir = path.join(findPackageRoot(), "stack"); if (!fs.existsSync(path.join(dir, "docker-compose.yml"))) { throw new MeshCliError(`Local stack assets not found at ${dir}.`, { remediation: { command: "npm install -g @mesh-tech/mesh-cli" }, }); } return dir; } export const HUB_OVERLAY_FILE = "docker-compose.hub.yml"; export function parseComposeServiceNames(yamlText) { const doc = parseYaml(yamlText); return Object.keys(doc?.services ?? {}); } let hubOverlayServicesCache; export function hubOverlayServices() { if (!hubOverlayServicesCache) { const file = path.join(stackDir(), HUB_OVERLAY_FILE); if (!fs.existsSync(file)) { throw new MeshCliError(`Local stack assets are incomplete: ${file} is missing.`, { remediation: { command: "npm install -g @mesh-tech/mesh-cli" }, }); } hubOverlayServicesCache = new Set(parseComposeServiceNames(fs.readFileSync(file, "utf-8"))); } return hubOverlayServicesCache; } export function stackOwnedElsewhere() { try { const first = compose(["ps", "-q"]).trim().split("\n").filter(Boolean)[0]; if (!first) return undefined; const label = execFileSync("docker", ["inspect", first, "--format", '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}'], { encoding: "utf-8" }).trim(); if (!label) return undefined; const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } }; return real(label) === real(stackDir()) ? undefined : label; } catch { return undefined; } } export function ensureDockerAvailable() { const probe = spawnSync("docker", ["info", "--format", "{{.ServerVersion}}"], { stdio: ["ignore", "pipe", "pipe"], }); if (probe.error || probe.status !== 0) { throw new MeshCliError("Docker is not available (is Docker Desktop / the docker daemon running?).", { remediation: { docs: "https://docs.docker.com/get-docker/" }, }); } } export function compose(args, opts = {}) { const dir = stackDir(); const files = ["-f", path.join(dir, "docker-compose.yml")]; if (opts.hub) files.push("-f", path.join(dir, "docker-compose.hub.yml")); const fullArgs = ["compose", "-p", COMPOSE_PROJECT, ...files, ...args]; return execFileSync("docker", fullArgs, { cwd: dir, encoding: "utf-8", env: { ...process.env, MESH_LOCAL_LOGS: localLogsDir(), MESH_LOCAL_PROBES: localProbesDir(), COMPOSE_IGNORE_ORPHANS: "1", ...opts.env, }, stdio: opts.inherit ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"], }); } export function summarizeComposeFailure(output) { const lines = output .split("\n") .map((l) => l.trim()) .filter(Boolean); const errorish = lines.filter((l) => /\b(error|failed|failure|fatal|denied|unauthorized|cannot|no such)\b/i.test(l)); const line = errorish.at(-1) ?? lines.at(-1) ?? "no output captured"; return line.length > 300 ? `${line.slice(0, 297)}…` : line; } export function composeLogPath(op) { const dir = meshCacheDir("mesh-local"); return path.join(dir, `compose-${op.replace(/[^a-z0-9-]/gi, "_")}.log`); } export async function composeStreamed(args, opts = {}) { const dir = stackDir(); const files = ["-f", path.join(dir, "docker-compose.yml")]; if (opts.hub) files.push("-f", path.join(dir, "docker-compose.hub.yml")); const fullArgs = ["compose", "-p", COMPOSE_PROJECT, ...files, ...args]; const env = { ...process.env, MESH_LOCAL_LOGS: localLogsDir(), MESH_LOCAL_PROBES: localProbesDir(), COMPOSE_IGNORE_ORPHANS: "1", ...opts.env, }; const op = args[0] ?? "cmd"; if (process.stderr.isTTY) { const res = spawnSync("docker", fullArgs, { cwd: dir, env, stdio: ["ignore", "inherit", "inherit"], }); if (res.status !== 0) { throw new MeshCliError(`docker compose ${op} failed (exit ${res.status ?? "?"}) — the compose output above has the details.`, { remediation: { command: `docker compose -p ${COMPOSE_PROJECT} ps && docker compose -p ${COMPOSE_PROJECT} logs --tail 50`, }, }); } return; } const logPath = composeLogPath(op); const logStream = fs.createWriteStream(logPath); const heartbeat = startHeartbeat(`docker compose ${args.join(" ")}`); let captured = ""; let exitCode; try { exitCode = await new Promise((resolve, reject) => { const child = spawn("docker", fullArgs, { cwd: dir, env, stdio: ["ignore", "pipe", "pipe"], }); const consume = (chunk) => { heartbeat.touch(); const text = chunk.toString(); captured += text; logStream.write(text); process.stderr.write(text); }; child.stdout.on("data", consume); child.stderr.on("data", consume); child.on("error", reject); child.on("close", (code) => resolve(code ?? 1)); }); } finally { heartbeat.stop(); await new Promise((resolve) => logStream.end(resolve)); } if (exitCode !== 0) { throw new MeshCliError(`docker compose ${op} failed (exit ${exitCode}): ${summarizeComposeFailure(captured)}`, { remediation: { command: `docker compose -p ${COMPOSE_PROJECT} logs --tail 50`, docs: logPath, }, }); } } export function parseComposePs(output) { const services = []; for (const line of output.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; try { const entry = JSON.parse(trimmed); services.push({ name: entry.Service ?? entry.Name ?? "unknown", state: entry.State ?? "unknown", health: entry.Health || undefined, }); } catch { } } return services; } export function stackServices() { return parseComposePs(compose(["ps", "-a", "--format", "json"], { hub: true })); } export const ONE_SHOT_SERVICES = new Set(["spicedb-migrate", "zitadel-machinekey-init"]); export function crashedServices(services, opts) { return services.filter((s) => s.state === "exited" && !ONE_SHOT_SERVICES.has(s.name) && (opts.overlayStarted || !opts.overlayServices.has(s.name))); } export function hubOverlayRunning(services, overlayServices) { return services.some((s) => overlayServices.has(s.name) && s.state === "running"); } export function hubApiRunning(services) { return services.some((s) => s.name === "hub-api" && s.state === "running"); } export async function probeEndpoint(endpoint) { if (endpoint.probe.kind === "http") { try { const res = await fetch(endpoint.probe.url, { signal: AbortSignal.timeout(3000) }); return res.status < 500; } catch { return false; } } if (endpoint.probe.kind === "tcp") { return probeTcp(endpoint.probe.port, { timeoutMs: 3000 }); } return true; }