UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

625 lines (618 loc) 20.5 kB
// libs/mesh-cli/src/utils/errors.ts import chalk from "chalk"; var MeshCliError = class extends Error { remediation; exitCode; constructor(message, options = {}) { super(message, options.cause !== void 0 ? { cause: options.cause } : void 0); this.name = "MeshCliError"; this.remediation = options.remediation; this.exitCode = options.exitCode ?? 1; } }; function renderErrorBody(err) { const message = err instanceof Error ? err.message : String(err); if (!(err instanceof MeshCliError)) return message; const lines = [message]; if (err.remediation?.command) { lines.push(chalk.cyan(" \u2192 run: ") + err.remediation.command); } if (err.remediation?.docs) { lines.push(chalk.cyan(" \u2192 see: ") + err.remediation.docs); } return lines.join("\n"); } function renderError(err) { return chalk.red("\u2717") + " " + renderErrorBody(err); } function renderErrorJson(err) { if (err instanceof MeshCliError) { return JSON.stringify({ error: err.message, ...err.remediation ? { remediation: err.remediation } : {} }); } return JSON.stringify({ error: err instanceof Error ? err.message : String(err) }); } var jsonPayloadEmitted = false; function emitJsonPayload(payload) { console.log(JSON.stringify(payload, null, 2)); jsonPayloadEmitted = true; } function hasEmittedJsonPayload() { return jsonPayloadEmitted; } function resetJsonPayloadEmitted() { jsonPayloadEmitted = false; } function handleCliError(err) { const wantsJson = process.argv.includes("--json"); if (wantsJson && !hasEmittedJsonPayload()) { console.log(renderErrorJson(err)); } else { console.error(renderError(err)); } process.exit(err instanceof MeshCliError ? err.exitCode : 1); } // libs/mesh-cli/src/utils/log.ts import chalk2 from "chalk"; function logPrefix(opts) { const enabled = opts.envFlag === "1" || opts.envFlag !== "0" && !opts.isTTY; return enabled ? `[${opts.now.toISOString().slice(11, 19)}] ` : ""; } function prefix() { return chalk2.dim( logPrefix({ isTTY: !!process.stderr.isTTY, envFlag: process.env.MESH_LOG_TIMESTAMPS, now: /* @__PURE__ */ new Date() }) ); } function logInfo(message) { console.error(prefix() + chalk2.blue("\u2139"), message); } function logSuccess(message) { console.error(prefix() + chalk2.green("\u2713"), message); } function logWarn(message) { console.error(prefix() + chalk2.yellow("\u26A0"), message); } function logError(message) { console.error(prefix() + chalk2.red("\u2717"), message); } function formatElapsed(ms) { const totalSec = Math.max(0, Math.round(ms / 1e3)); const h = Math.floor(totalSec / 3600); const m = Math.floor(totalSec % 3600 / 60); const s = totalSec % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } function startHeartbeat(label, intervalMs = 15e3) { const startedAt = Date.now(); let lastOutputAt = startedAt; const timer = setInterval(() => { if (Date.now() - lastOutputAt < intervalMs) return; logInfo(`\u2026 still working: ${label} (${formatElapsed(Date.now() - startedAt)} elapsed)`); }, intervalMs); timer.unref?.(); return { stop: () => clearInterval(timer), touch: () => { lastOutputAt = Date.now(); } }; } // libs/mesh-cli/src/utils/context.ts import * as fs from "fs"; import * as path from "path"; function findFileUpward(filename, startDir = process.cwd()) { let currentDir = startDir; const root = path.parse(currentDir).root; while (currentDir !== root) { const filePath = path.join(currentDir, filename); if (fs.existsSync(filePath)) { return filePath; } currentDir = path.dirname(currentDir); } return void 0; } function detectContext(stageArg) { let tenant; let stage; const cwd = process.cwd(); if (fs.existsSync("Pulumi.yaml")) { const files = fs.readdirSync(".").filter( (f) => f.startsWith("Pulumi.") && f.endsWith(".yaml") && f !== "Pulumi.yaml" ); const configFile = files[0]; if (configFile) { const content = fs.readFileSync(configFile, "utf-8"); const tenantMatch = content.match(/^\s*mesh:tenant:\s*["']?([^"'\n]+)["']?/m); if (tenantMatch?.[1]) { tenant = tenantMatch[1].trim(); } const stageMatch = configFile.match(/Pulumi\.(.+)\.yaml/); if (stageMatch?.[1]) { stage = stageMatch[1]; } } } const sstConfigPath = findFileUpward("sst.config.ts"); let sstDir; if (sstConfigPath) { sstDir = path.dirname(sstConfigPath); const parentConfigPath = path.join(sstDir, "..", "config.ts"); if (fs.existsSync(parentConfigPath)) { const configContent = fs.readFileSync(parentConfigPath, "utf-8"); const tenantMatch = configContent.match(/tenant:\s*["']([^"']+)["']/); if (tenantMatch) { tenant = tenantMatch[1]; } } const sstStagePath = path.join(sstDir, ".sst", "stage"); if (fs.existsSync(sstStagePath)) { stage = fs.readFileSync(sstStagePath, "utf-8").trim(); } } if (!tenant) { const pathMatch = cwd.match(/tenants\/([^/]+)/); if (pathMatch) { tenant = pathMatch[1]; } } stage = stageArg || process.env.MESH_STAGE || process.env.SST_STAGE || stage || "dev"; tenant = process.env.MESH_TENANT || tenant || "mesh"; let platformEnv = stage; let platformEnvMap = {}; let defaultPlatformEnv; const configPath = sstDir ? path.join(sstDir, "..", "config.ts") : "../config.ts"; if (fs.existsSync(configPath)) { const configContent = fs.readFileSync(configPath, "utf-8"); const mapMatch = configContent.match(/platformEnvMap:\s*\{([^}]+)\}/); if (mapMatch?.[1]) { const entries = mapMatch[1].matchAll(/(\w+):\s*["']([^"']+)["']/g); for (const entry of entries) { const key = entry[1]; const value = entry[2]; if (key && value) { platformEnvMap[key] = value; } } } const defaultMatch = configContent.match(/defaultPlatformEnv:\s*["']([^"']+)["']/); if (defaultMatch?.[1]) { defaultPlatformEnv = defaultMatch[1]; } } const tenantPrefix = `${tenant}-`; const parsedEnv = stage.startsWith(tenantPrefix) ? stage.slice(tenantPrefix.length) : stage; platformEnv = platformEnvMap[parsedEnv] ?? platformEnvMap[stage] ?? defaultPlatformEnv ?? parsedEnv; logInfo(`Stage: ${stage}, Platform: ${platformEnv}, Tenant: ${tenant}`); return { tenant, stage, platformEnv }; } // libs/mesh-cli/src/utils/bastion.ts import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; async function getPlatformBastionInfo(tenant, platformEnv, region) { const ssm = new SSMClient(region ? { region } : {}); let primaryError; const platformPath = `/mesh-platform/${tenant}/${platformEnv}/platform`; logInfo(`Looking up platform bastion from ${platformPath}...`); try { const response = await ssm.send( new GetParameterCommand({ Name: platformPath }) ); if (response.Parameter?.Value) { const platform = JSON.parse(response.Parameter.Value); if (platform.platformBastion) { const info = platform.platformBastion; logSuccess(`Found bastion: ${info.instanceId}`); const serviceNames = Object.keys(info.services); if (serviceNames.length > 0) { logInfo(`Available services: ${serviceNames.join(", ")}`); } return info; } } } catch (err) { primaryError = err; } const legacyPath = `/mesh-platform/${tenant}/${platformEnv}/platform-bastion`; logInfo(`Trying legacy path ${legacyPath}...`); try { const response = await ssm.send( new GetParameterCommand({ Name: legacyPath }) ); if (!response.Parameter?.Value) { throw new Error(`Platform bastion not found`); } const info = JSON.parse(response.Parameter.Value); logSuccess(`Found bastion: ${info.instanceId}`); const serviceNames = Object.keys(info.services); if (serviceNames.length > 0) { logInfo(`Available services: ${serviceNames.join(", ")}`); } return info; } catch (legacyError) { const cause = primaryError ?? legacyError; const name = cause?.name ?? ""; const credsProblem = /Expired|UnrecognizedClient|InvalidClientTokenId|InvalidSignature|CredentialsProviderError|AccessDenied/i.test( name ); if (credsProblem) { logError( `Could not read the platform bastion from ${platformPath} \u2014 AWS error: ${name}.` ); logInfo( "This is almost always a CREDENTIALS problem, not a missing bastion." ); logInfo( " \u2022 The read uses the ambient AWS creds of this process; they must be valid AND able to read the HUB param above." ); logInfo( " \u2022 Check: `aws sts get-caller-identity` (ExpiredToken \u2192 refresh; AccessDenied \u2192 those creds lack hub read \u2014 use InfraAdmin-grade creds)." ); logInfo( " \u2022 Stale creds often hide in the tmux GLOBAL env (`tmux show-environment -g | grep AWS_`); a per-shell `unset` won't clear them." ); } else { logError(`Platform bastion not found in ${platformPath} or ${legacyPath}`); logInfo("Make sure platformBastion is enabled in your platform config and deployed."); } throw cause; } } async function getBastionInfo(tenant, platformEnv) { const info = await getPlatformBastionInfo(tenant, platformEnv); const rdsService = info.services["rds"]; if (!rdsService) { throw new Error("RDS service not available in platform bastion. Is RDS enabled?"); } return { instanceId: info.instanceId, rdsEndpoint: rdsService.host, rdsPort: rdsService.port }; } // libs/mesh-cli/src/utils/credentials.ts import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager"; async function getDatabaseUrl(tenant, stage, options) { const secretsManager = new SecretsManagerClient({}); const secretName = options?.app ? `mesh/${tenant}/${stage}/${options.app}/db-credentials` : `mesh/${tenant}/${stage}/db-credentials`; logInfo(`Looking up credentials at ${secretName}...`); try { const response = await secretsManager.send( new GetSecretValueCommand({ SecretId: secretName }) ); if (response.SecretString) { const secret = JSON.parse(response.SecretString); if (secret.DATABASE_URL) { const parsed = new URL(secret.DATABASE_URL); logSuccess(`Got credentials for user: ${parsed.username}`); return secret.DATABASE_URL; } if (secret.username && secret.password) { logSuccess(`Got credentials for user: ${secret.username}`); const database = secret.dbname ?? secret.database ?? "postgres"; return `postgresql://${secret.username}:${encodeURIComponent(secret.password)}@${secret.host ?? "localhost"}:${secret.port ?? 5432}/${database}`; } throw new Error(`Secret ${secretName} has unexpected format (needs DATABASE_URL or username/password)`); } } catch (error) { if (error.message?.includes("unexpected format")) { throw error; } } throw new Error(`Could not find credentials at ${secretName}`); } async function getDbCredentials(tenant, stage, _platformEnv, rdsHost, rdsPort, options) { const databaseUrl = await getDatabaseUrl(tenant, stage, options); const parsed = new URL(databaseUrl); return { username: parsed.username, password: decodeURIComponent(parsed.password), host: rdsHost, port: rdsPort, database: parsed.pathname.slice(1) }; } function rewriteDatabaseUrl(url, options) { try { const parsed = new URL(url); if (options.endpoint) { if (options.endpoint.includes(":")) { const colonIndex = options.endpoint.lastIndexOf(":"); parsed.hostname = options.endpoint.slice(0, colonIndex); parsed.port = options.endpoint.slice(colonIndex + 1); } else { parsed.hostname = options.endpoint; parsed.port = parsed.port || "5432"; } } if (options.sslMode) { parsed.searchParams.set("sslmode", options.sslMode); if (options.sslMode === "require" || options.sslMode === "no-verify") { parsed.searchParams.set("sslaccept", "accept_invalid_certs"); } } return parsed.toString(); } catch { return url; } } function buildDatabaseUrl(creds, options) { const host = options?.endpoint?.split(":")[0] ?? creds.host; const port = options?.endpoint?.split(":")[1] ?? String(creds.port); let url = `postgresql://${creds.username}:${encodeURIComponent(creds.password)}@${host}:${port}/${creds.database}`; if (options?.sslMode) { url += `?sslmode=${options.sslMode}`; if (options.sslMode === "require" || options.sslMode === "no-verify") { url += "&sslaccept=accept_invalid_certs"; } } return url; } async function readSstOutputs() { const fs3 = await import("fs"); if (!fs3.existsSync(".sst/outputs.json")) { return null; } try { const content = fs3.readFileSync(".sst/outputs.json", "utf-8"); const outputs = JSON.parse(content); if (outputs.databaseUrl) { logInfo("Found SST outputs with database config"); const secretArnMatch = outputs.databaseUrl.match(/secretArn=([^&]+)/); const secretArn = secretArnMatch ? decodeURIComponent(secretArnMatch[1]) : void 0; return { databaseUrl: outputs.databaseUrl, databaseName: outputs.databaseName, secretArn }; } } catch { } return null; } // libs/mesh-cli/src/utils/pulumi.ts import { execFileSync } from "child_process"; import * as path2 from "path"; import * as fs2 from "fs"; function findAppRoot(startDir) { let dir = startDir; while (true) { if (fs2.existsSync(path2.join(dir, "Pulumi.yaml"))) return dir; const parent = path2.dirname(dir); if (parent === dir) return null; dir = parent; } } function findStackConfigs(appRoot) { return fs2.readdirSync(appRoot).filter((f) => /^Pulumi\..+\.yaml$/.test(f) && f !== "Pulumi.yaml").map((f) => f.replace(/^Pulumi\./, "").replace(/\.yaml$/, "")); } function getCurrentStack(appRoot) { try { const result = execFileSync("pulumi", ["stack", "--show-name"], { encoding: "utf-8", cwd: appRoot, stdio: ["pipe", "pipe", "pipe"] }); return result.trim() || null; } catch { return null; } } function readStackConfig(appRoot, stack, key) { const configFile = path2.join(appRoot, `Pulumi.${stack}.yaml`); if (!fs2.existsSync(configFile)) return null; const content = fs2.readFileSync(configFile, "utf-8"); const pattern = new RegExp(`^\\s+${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(.+)$`, "m"); const match = content.match(pattern); if (!match) return null; return match[1].trim().replace(/^["']|["']$/g, ""); } function pulumiStackOutput(appRoot, key, extraArgs, env) { const execEnv = env ? { ...process.env, ...env } : void 0; try { const result = execFileSync( "pulumi", ["stack", "output", key, "--json", ...extraArgs], { cwd: appRoot, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: execEnv } ); if (result.includes('"[secret]"')) { return execFileSync( "pulumi", ["stack", "output", key, "--json", "--show-secrets", ...extraArgs], { cwd: appRoot, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: execEnv } ); } return result; } catch (err) { const errMsg = err?.stderr ?? ""; if (errMsg.includes("kms:") || errMsg.includes("KMS") || errMsg.includes("secrets manager")) { throw err; } return execFileSync( "pulumi", ["stack", "output", key, "--json", "--show-secrets", ...extraArgs], { cwd: appRoot, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: execEnv } ); } } // libs/mesh-cli/src/utils/vpn.ts import { execFile, execFileSync as execFileSync2 } from "node:child_process"; import { existsSync as existsSync3 } from "node:fs"; var TAILSCALE_PATHS = [ "tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale" ]; async function isVpnConnected() { try { const stdout = await execTailscaleCmd(["status", "--json"]); const status = JSON.parse(stdout); return status.BackendState === "Running" && status.Self?.Online === true; } catch { return false; } } var TAILSCALE_SOCKET_PATHS = ["/var/run/tailscale/tailscaled.sock", "/tmp/tailscale.sock"]; function headscaleDnsConfig(tenant = "mesh", env = "dev", overrides) { return { namespace: `${tenant}-${env}-headscale`, pod: "headscale-0", container: "dns-writer", filePath: "/var/lib/headscale/dns/extra-records.json", ...overrides }; } function execTailscaleCmd(args) { const socketPath = TAILSCALE_SOCKET_PATHS.find(existsSync3); const attempts = []; for (const binary of TAILSCALE_PATHS) { if (socketPath) { attempts.push({ binary, args: ["--socket", socketPath, ...args] }); } attempts.push({ binary, args }); } return new Promise((resolve, reject) => { let index = 0; function tryNext() { if (index >= attempts.length) { reject(new Error("All tailscale binary attempts failed")); return; } const attempt = attempts[index++]; execFile(attempt.binary, attempt.args, (error, stdout) => { if (error) { tryNext(); } else { resolve(stdout); } }); } tryNext(); }); } async function getTailscaleInfo() { try { const stdout = await execTailscaleCmd(["status", "--json"]); const status = JSON.parse(stdout); if (status.BackendState === "Running" && status.Self?.Online === true && status.Self.HostName && status.Self.TailscaleIPs?.length) { return { hostname: status.Self.HostName, ip: status.Self.TailscaleIPs[0] }; } return null; } catch { return null; } } function dnsWriterExec(cfg, cmd, interactive = false) { return [ "exec", ...interactive ? ["-i"] : [], "-n", cfg.namespace, cfg.pod, "-c", cfg.container, "--", ...cmd ]; } function readDnsRecords(cfg) { try { const raw = execFileSync2("kubectl", dnsWriterExec(cfg, ["cat", cfg.filePath]), { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }); const trimmed = raw.trim(); if (!trimmed || trimmed === "[]") return []; try { return JSON.parse(trimmed); } catch { console.warn(`[vpn] DNS records file contains invalid JSON, treating as empty`); return []; } } catch (err) { const msg = err.message ?? String(err); if (msg.includes("not found") || msg.includes("Unable to connect")) { throw new Error( `Cannot reach Headscale DNS writer (namespace=${cfg.namespace}, pod=${cfg.pod}). Verify kubectl context and that the Headscale pod is running: kubectl get pods -n ${cfg.namespace}` ); } if (msg.includes("No such file")) { return []; } throw err; } } function registerDnsRecords(cfg, records) { const json = JSON.stringify(records); try { execFileSync2( "kubectl", dnsWriterExec(cfg, ["sh", "-c", `cat > '${cfg.filePath}'`], true), { input: json, stdio: ["pipe", "pipe", "pipe"] } ); } catch (err) { const msg = err.message ?? String(err); throw new Error( `Failed to write DNS records to Headscale (namespace=${cfg.namespace}, pod=${cfg.pod}). ${msg.includes("not found") ? "Pod not found. " : ""}Verify kubectl context: kubectl get pods -n ${cfg.namespace}` ); } } function unregisterDnsRecords(cfg, suffix) { try { const records = readDnsRecords(cfg); const filtered = records.filter((r) => !r.name.endsWith(suffix)); registerDnsRecords(cfg, filtered); } catch { } } export { MeshCliError, buildDatabaseUrl, detectContext, emitJsonPayload, findAppRoot, findStackConfigs, formatElapsed, getBastionInfo, getCurrentStack, getDatabaseUrl, getDbCredentials, getPlatformBastionInfo, getTailscaleInfo, handleCliError, hasEmittedJsonPayload, headscaleDnsConfig, isVpnConnected, logError, logInfo, logPrefix, logSuccess, logWarn, pulumiStackOutput, readDnsRecords, readSstOutputs, readStackConfig, registerDnsRecords, renderError, renderErrorBody, renderErrorJson, resetJsonPayloadEmitted, rewriteDatabaseUrl, startHeartbeat, unregisterDnsRecords };