@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
390 lines (389 loc) • 14 kB
JavaScript
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;
}