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