UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

380 lines (379 loc) 19.6 kB
import * as fs from "fs"; import * as path from "path"; import chalk from "chalk"; import { logError, logInfo, logSuccess, logWarn } from "../../utils/log.js"; import { MeshCliError } from "../../utils/errors.js"; import { compose, COMPOSE_PROJECT, composeStreamed, crashedServices, DEFAULT_HUB_PORT, ensureDockerAvailable, findPackageRoot, hubApiRunning, hubEndpoints, hubOverlayRunning, hubOverlayServices, hubPort, ONE_SHOT_SERVICES, probeEndpoint, STACK_ENDPOINTS, stackOwnedElsewhere, stackServices, } from "./stack.js"; import { probeRegistryToken, REGISTRY_LOGIN_FIX } from "../../utils/auth-preflight.js"; import { ensureHubAuthImage, ensureHubImages, buildHubImagesFromSource, readHubCompiledAuthz, hasRegistryAuth, localHubVersion, planWithHubRefresh, } from "./hub-local.js"; import { reconcileRegistryFromZitadel } from "./auth-provision.js"; import { LOCAL_AWS_CONFIG, localAwsEnv, seedLocalPlatform, verifyFabric } from "./seed.js"; import { LOGIN_CONTEXT, readHubAuth, seedZitadel, TEST_USERS } from "./seed-zitadel.js"; import { publishHubAuthzCatalog } from "./seed-hub-catalog.js"; import { describePortConflicts, findPortConflicts, hostPortsOf } from "./helpers.js"; const WAIT_TIMEOUT_MS = 180_000; const WAIT_POLL_MS = 3_000; function crashError(crashed) { return new MeshCliError(`Service(s) exited during startup: ${crashed.map((s) => s.name).join(", ")}`, { remediation: { command: `docker compose -p ${COMPOSE_PROJECT} logs ${crashed[0].name}`, }, }); } export async function waitForStack(io = { probe: probeEndpoint, services: stackServices, overlayServices: hubOverlayServices, }) { const deadline = Date.now() + WAIT_TIMEOUT_MS; let pending = STACK_ENDPOINTS.filter((e) => e.probe.kind !== "none"); while (true) { const results = await Promise.all(pending.map((e) => io.probe(e))); const stillDown = []; for (let i = 0; i < pending.length; i++) { if (results[i]) { logSuccess(`${pending[i].label} is up (${pending[i].url})`); } else { stillDown.push(pending[i]); } } pending = stillDown; const crashed = crashedServices(io.services(), { overlayServices: io.overlayServices(), overlayStarted: false, }); if (crashed.length > 0) throw crashError(crashed); if (pending.length === 0) break; if (Date.now() > deadline) { throw new MeshCliError(`Timed out waiting for: ${pending.map((e) => e.label).join(", ")}`, { remediation: { command: "mesh status" } }); } logInfo(`Waiting for ${pending.map((e) => e.label).join(", ")} …`); await new Promise((resolve) => setTimeout(resolve, WAIT_POLL_MS)); } } function printEndpoints(hubRunning) { console.log(""); if (hubRunning) { console.log(chalk.bold.cyan("★ Mesh Hub — your operations dashboard (start here)")); console.log(` ${"Hub UI".padEnd(34)} ${chalk.bold.cyan(`http://localhost:${hubPort()}`)}`); console.log(` ${"".padEnd(34)} ${chalk.dim("sign in: admin@local.mesh or dev@local.mesh / LocalDev1!")}`); console.log(` ${"Hub API".padEnd(34)} ${chalk.cyan("http://localhost:4568")}`); console.log(` ${"".padEnd(34)} ${chalk.dim("tenants, apps, workflows, logs, traces — local runs self-register here")}`); } else { logWarn("Hub is not running — it is the platform's front door. Enable it with:"); logInfo(` ${REGISTRY_LOGIN_FIX} (one-time, to build the Hub images — no AWS account needed)`); logInfo(" mesh start (the Hub joins automatically)"); } console.log(""); console.log(chalk.bold("Local platform endpoints")); for (const endpoint of STACK_ENDPOINTS) { console.log(` ${endpoint.label.padEnd(34)} ${chalk.cyan(endpoint.url)}`); if (endpoint.hint) { console.log(` ${"".padEnd(34)} ${chalk.dim(endpoint.hint)}`); } } console.log(""); console.log(chalk.bold("AWS SDK wiring (local fabric)")); console.log(chalk.dim(" Point any AWS SDK process at the local registry — no code changes:")); for (const [key, value] of Object.entries(localAwsEnv())) { console.log(` export ${key}=${chalk.cyan(value)}`); } console.log(""); console.log(chalk.bold("Login")); console.log(` mesh login ${LOGIN_CONTEXT}`); console.log(` ${chalk.dim(`test users: ${TEST_USERS.map((u) => u.email).join(", ")} (password: ${TEST_USERS[0].password})`)}`); printStartHere(hubRunning); } export function startHereLines(hubRunning, up) { const dev = TEST_USERS[0]; const mailbox = STACK_ENDPOINTS.find((e) => e.service === "mailpit"); const flag = (service) => up?.get(service) === false ? chalk.red(" (not responding — see Endpoints below)") : ""; const lines = ["", chalk.bold.cyan("★ Start here")]; if (hubRunning) { lines.push(` ${"Hub".padEnd(10)} ${chalk.bold.cyan(`http://localhost:${hubPort()}`)}${flag("hub-ui")}`, ` ${"".padEnd(10)} ${chalk.dim(`sign in as ${dev.email} / ${dev.password} — an account already exists; do not Register`)}`); } if (mailbox) { lines.push(` ${"Mailbox".padEnd(10)} ${chalk.cyan(mailbox.url)}${flag("mailpit")}`, ` ${"".padEnd(10)} ${chalk.dim("every email the local platform sends (sign-up, verification, password reset) lands here — nothing leaves your machine")}`); } lines.push(` ${"Your app".padEnd(10)} ${chalk.cyan("cd apps/<name> && mesh dev")} ${chalk.dim("no app yet? mesh create-app")}`, ` ${"Check-up".padEnd(10)} ${chalk.cyan("mesh status")}`, ""); return lines; } function printStartHere(hubRunning, up) { for (const line of startHereLines(hubRunning, up)) console.log(line); } function portMoveHints() { return new Map([ [ Number(hubPort()), "MESH_HUB_PORT=<free port> mesh start (or mesh start --no-hub, which starts everything else)", ], ]); } function repoRootForHubSource() { for (let dir = process.cwd();;) { if (fs.existsSync(path.join(dir, "apps", "hub", "package.json"))) return dir; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return path.resolve(findPackageRoot(), "..", ".."); } export function registerLocalCommands(program) { program .command("start") .description("Start the full-local Mesh platform (Docker only — no AWS, no VPN)") .option("--no-seed", "skip first-boot seeding (tenant registry, artifacts bucket)") .option("--no-hub", "start without the Hub (API + UI)") .option("--with-hub", "force-refresh the Hub to the latest published @mesh-tech/hub (the Hub is included by default when its images exist or registry auth is available)") .option("--hub-from-source", "build the Hub images from THIS checkout's apps/hub instead of the published tarball (the only way to exercise a Hub change locally)") .option("--takeover", "re-own a stack that another checkout started (may recreate shared containers with THIS checkout's config)", false) .option("--skip-port-check", "start even though a host port the stack wants is already held (the service that loses the bind will be broken — use when the holder is something you cannot stop)", false) .action(async (opts) => { ensureDockerAvailable(); const foreign = stackOwnedElsewhere(); if (foreign && !opts.takeover) { throw new MeshCliError(`The local platform is already running, started from a different checkout:\n ${foreign}\n` + `Re-running start from here may recreate shared containers with this checkout's config and disrupt work using the current stack.`, { remediation: { command: "mesh status # inspect the running stack (apps deploy tenant-scoped via `mesh dev --local`)\n" + " mesh start --takeover # intentionally re-own the stack from this checkout", }, }); } if (foreign && opts.takeover) { logWarn(`Taking over the running stack (previously driven from ${foreign}).`); } if (!opts.skipPortCheck && !stackServices().some((s) => s.state === "running")) { const ports = hostPortsOf([...STACK_ENDPOINTS, ...(opts.hub ? hubEndpoints() : [])]); const conflicts = await findPortConflicts(ports, `${COMPOSE_PROJECT}-`); if (conflicts.length > 0) { throw new MeshCliError(describePortConflicts(conflicts, portMoveHints()), { remediation: { command: "mesh start # once the port is free" }, }); } } let hubVersion; if (opts.hub) { if (opts.hubFromSource) { hubVersion = await buildHubImagesFromSource(repoRootForHubSource()); } else if (opts.withHub) { const probe = await probeRegistryToken(); const plan = planWithHubRefresh(probe.state, localHubVersion()); if (plan.action === "fail") { throw new MeshCliError(plan.message, { remediation: { command: plan.remediation } }); } if (plan.action === "use-local") { logWarn(plan.warning); hubVersion = plan.version; } else { hubVersion = await ensureHubImages(); } } else { hubVersion = localHubVersion(); if (!hubVersion && hasRegistryAuth()) { const probe = await probeRegistryToken(); if (probe.state === "expired") { logWarn(`CodeArtifact token is expired — starting without the Hub. Fix: ${REGISTRY_LOGIN_FIX}, then: mesh start --with-hub`); } else { try { hubVersion = await ensureHubImages(); } catch (err) { logWarn(`Hub image build failed (${err instanceof Error ? err.message : err}) — starting without the Hub.`); } } } } } logInfo("Starting the local Mesh platform (docker compose project: mesh-local)…"); await composeStreamed(["up", "-d"]); compose(["restart", "prometheus", "blackbox-exporter"], { inherit: true }); await waitForStack(); let hubAuth = readHubAuth(); if (opts.seed) { logInfo("Seeding local platform (tenant registry, artifacts bucket)…"); await seedLocalPlatform(); logInfo("Seeding Zitadel (Platform project, Mesh CLI app, Hub auth, test users)…"); const zitadel = await seedZitadel(LOCAL_AWS_CONFIG); hubAuth = zitadel.hubAuth; if (hubVersion) { const compiled = readHubCompiledAuthz(hubVersion); if (compiled) { const { bundles } = await publishHubAuthzCatalog(compiled, LOCAL_AWS_CONFIG); logSuccess(`Hub role catalog seeded: ${bundles.length} role(s) (${bundles.join(", ")})`); } else { logWarn(`Hub v${hubVersion} ships no compiled role catalog (api/dist/authz/compiled.json) — Users → Roles will be empty. Hub ≥ 2.1.0 publishes one.`); } } try { const reconciled = await reconcileRegistryFromZitadel(); if (reconciled.tenants.length > 0) { logSuccess(`Hub registry reconciled from Zitadel: ${reconciled.tenants.length} app tenant(s) (${reconciled.tenants.join(", ")}), ${reconciled.apps} app(s)`); } } catch (err) { logWarn(`Registry reconcile skipped: ${err instanceof Error ? err.message : err}`); } logInfo("Verifying the local AWS fabric (8 KB Advanced-tier round-trip)…"); await verifyFabric(); } else { logWarn("Skipping seed (--no-seed)."); } if (hubVersion) { if (!hubAuth) { logWarn("Hub auth is not seeded yet (started with --no-seed?) — Hub sign-in needs one seeded run: mesh start"); } if (!opts.seed && hubPort() !== DEFAULT_HUB_PORT) { logWarn(`--no-seed with MESH_HUB_PORT=${hubPort()}: this port's redirect URI is registered during seeding — if Hub sign-in fails with "redirect_uri is missing in the client configuration", re-run without --no-seed.`); } ensureHubAuthImage(); const hubEnv = { MESH_HUB_VERSION: hubVersion }; if (hubAuth) { hubEnv.MESH_HUB_OAUTH2_CLIENT_ID = hubAuth.clientId; hubEnv.MESH_HUB_OAUTH2_CLIENT_SECRET = hubAuth.clientSecret; hubEnv.MESH_HUB_OAUTH2_COOKIE_SECRET = hubAuth.cookieSecret; } await composeStreamed(["up", "-d"], { hub: true, env: hubEnv }); if (opts.seed) { compose(["restart", "hub-api"], { hub: true, env: hubEnv }); } const overlayServices = hubOverlayServices(); for (const endpoint of hubEndpoints()) { logInfo(`Waiting for ${endpoint.label}…`); let up = await probeEndpoint(endpoint); for (let i = 0; i < 60 && !up; i++) { const crashed = crashedServices(stackServices(), { overlayServices, overlayStarted: true, }); if (crashed.length > 0) throw crashError(crashed); await new Promise((resolve) => setTimeout(resolve, 2000)); up = await probeEndpoint(endpoint); } if (!up) { throw new MeshCliError(`Timed out waiting for ${endpoint.label} (${endpoint.url}).`, { remediation: { command: `docker compose -p ${COMPOSE_PROJECT} logs ${endpoint.service}`, }, }); } logSuccess(`${endpoint.label} is up (${endpoint.url})`); } } logSuccess("Local Mesh platform is running."); printEndpoints(!!hubVersion); }); program .command("stop") .description("Stop the local Mesh platform") .option("--destroy", "also remove volumes (resets databases and seeds)", false) .option("--force", "stop even when the stack was started from a different checkout", false) .action((opts) => { ensureDockerAvailable(); const foreign = stackOwnedElsewhere(); if (foreign && !opts.force) { throw new MeshCliError(`The running local platform was started from a different checkout (${foreign}) — another project may be using it.`, { remediation: { command: "mesh stop --force # stop it anyway" } }); } const args = ["down"]; if (opts.destroy) args.push("-v"); compose(args, { inherit: true, hub: true }); logSuccess(opts.destroy ? "Local platform stopped and volumes destroyed." : "Local platform stopped."); }); program .command("status") .description("Show local platform component health, endpoints, and ports") .option("--json", "machine-readable output", false) .action(async (opts) => { ensureDockerAvailable(); const services = stackServices(); if (services.length === 0) { if (opts.json) { console.log(JSON.stringify({ running: false, services: [], endpoints: [] })); return; } throw new MeshCliError("The local Mesh platform is not running.", { remediation: { command: "mesh start" }, }); } const overlayServices = hubOverlayServices(); const overlayStarted = hubOverlayRunning(services, overlayServices); const notStarted = (s) => !overlayStarted && overlayServices.has(s.name) && s.state === "exited"; const hasHub = hubApiRunning(services); const endpoints = hasHub ? [...STACK_ENDPOINTS, ...hubEndpoints()] : STACK_ENDPOINTS; const probes = await Promise.all(endpoints.map(async (endpoint) => ({ service: endpoint.service, label: endpoint.label, url: endpoint.url, up: await probeEndpoint(endpoint), }))); let fabric = null; if (probes.find((p) => p.service === "ministack")?.up) { try { await verifyFabric(); fabric = { ok: true }; } catch (err) { fabric = { ok: false, error: err instanceof Error ? err.message : String(err) }; } } if (opts.json) { console.log(JSON.stringify({ running: true, services, endpoints: probes, fabric, awsEnv: localAwsEnv(), })); if (fabric && !fabric.ok) process.exitCode = 1; return; } printStartHere(hasHub, new Map(probes.map((p) => [p.service, p.up]))); console.log(chalk.bold("Containers")); for (const service of services) { const ok = service.state === "running" && (service.health === undefined || service.health === "healthy"); const oneShotDone = ONE_SHOT_SERVICES.has(service.name) && service.state === "exited"; const idle = notStarted(service); const icon = ok || oneShotDone ? chalk.green("●") : idle ? chalk.dim("○") : chalk.red("●"); const health = service.health ? ` (${service.health})` : ""; const note = idle ? chalk.dim(" (Hub not started this run)") : ""; console.log(` ${icon} ${service.name.padEnd(22)} ${service.state}${health}${note}`); } console.log(""); console.log(chalk.bold("Endpoints")); for (const probe of probes) { const icon = probe.up ? chalk.green("●") : chalk.red("●"); console.log(` ${icon} ${probe.label.padEnd(34)} ${chalk.cyan(probe.url)}`); } if (fabric) { const icon = fabric.ok ? chalk.green("●") : chalk.red("●"); const detail = fabric.ok ? "8 KB Advanced-tier round-trip OK" : fabric.error; console.log(` ${icon} ${"AWS fabric check".padEnd(34)} ${chalk.dim(detail ?? "")}`); } const down = probes.filter((p) => !p.up); const crashed = crashedServices(services, { overlayServices, overlayStarted }); if (fabric && !fabric.ok) process.exitCode = 1; if (down.length > 0 || crashed.length > 0) { console.log(""); logError(`${down.length + crashed.length} component(s) unhealthy — inspect with: docker compose -p mesh-local logs <service>`); process.exitCode = 1; } }); }