@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
2,403 lines • 106 kB
JavaScript
import { execFileSync, spawn, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import { Option } from 'commander';
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import { logInfo, logSuccess, logError, logWarn, isVpnConnected, getTailscaleInfo, headscaleDnsConfig, registerDnsRecords, unregisterDnsRecords, readDnsRecords, getPlatformBastionInfo, } from '../utils/index.js';
import { derivePlatformContext, renderCredentialProcessProfile, resolveAwsCredentials, resolveStableMeshBin, selectRoleForCaller, stripBareProfile, upsertManagedAwsConfigSection, } from '../utils/aws-auth.js';
import { atomicWriteFileSync, readCredentials, getContextConfig, probeCredentials, getValidToken } from './login.js';
import { resolveVpnJoinBroker, mintPreAuthKey } from '../utils/vpn-join.js';
import { credProbeToPreflightError } from '../utils/pulumi-run.js';
import { startTailscaleTunnels, tailscaleAvailable, daemonState as tailscaleDaemonState, readTunnelState as readTailscaleState, readDaemonMeta as readTailscaleDaemonMeta, } from '../utils/tailscale.js';
import { pulumiStackOutput, readStackConfig } from '../utils/pulumi.js';
import { ensureKubeconfig, resolveHubPlatformName, sessionKubeconfigPath, } from '../utils/kubeconfig.js';
import { resolveTemporalAuth } from '../utils/temporal-auth.js';
import { probeTcpReachable, probeConnectionHolds } from '../utils/reachability.js';
import { fingerprintWorkflowSource } from '../utils/workflow-fingerprint.js';
import { buildLaunchCommand, envFileName, waitForPort, writeEnvFile } from './dev-launch.js';
import { buildLocalDevOutput, detectLocalTenant, ensureLocalPlatformRunning, hasStackBacking, localAppNamespace } from './local/dev-local.js';
import { ensureAppTenantAuth, ensureSignInApp, registerLocalApp } from './local/auth-provision.js';
import { localAwsEnv, ensureTemporalNamespace } from './local/seed.js';
import { composeExternalUp, composeExternalsDown, externalMode, localProbesRemove, parseExternalsSelection, readLocalMocks, seedLocalMock, } from './local/mocks.js';
import { logShipperPath } from './local/dev-local.js';
import { writeAppServiceProbes } from './local/stack.js';
import { writeDevCompose, dockerDevUp, dockerDevDown, dockerDevPs, dockerDevLogs, dockerDevRestart } from './local/docker-runner.js';
import { MeshCliError } from '../utils/errors.js';
import { resolveStackOption } from '../utils/stack-flag.js';
import { rewriteClusterHostsToLocal } from './peer-addressing.js';
import { ALL_CHECKS, aggregateStatus, renderHuman, runChecks, runDoctor, } from './dev-doctor.js';
import { resolveWorktreeIdentity, withAppScopedPortBlock, blockBasePort, PORT_BLOCK_SERVICE_SUBRANGE, } from '../utils/worktree-identity.js';
export function registerServiceProbes(devOutput, tenant, probeFiles) {
const services = {};
for (const [name, service] of Object.entries(devOutput.services)) {
if (name.startsWith("mock-"))
continue;
services[name] = service.port;
}
try {
const file = writeAppServiceProbes({
tenant,
env: devOutput.platform?.env ?? "dev",
app: devOutput.app ?? "",
services,
});
if (file)
probeFiles.push(file);
}
catch (err) {
logWarn(`Could not register uptime probes for this session (${err instanceof Error ? err.message : err}) — the Hub will show no uptime for these services.`);
}
}
function deriveSessionName(projectName, wt) {
return wt.isPrimary ? `${projectName}-dev` : `${projectName}-${wt.slug}`;
}
function printDevPlan(sessionName, appRoot, wt, devOutput) {
const kind = wt.isPrimary ? 'primary checkout' : `linked worktree (block ${wt.portBlock})`;
console.log(`\nmesh dev plan — ${kind}\n`);
console.log(` worktree root : ${wt.worktreeRoot}`);
console.log(` app root : ${appRoot}`);
console.log(` token : ${wt.token || '(none — primary)'}`);
console.log(` tmux session : ${sessionName}`);
console.log(` session state : ${getSessionStatePath(sessionName)}`);
console.log(` env dir : ${getSessionEnvDir(sessionName)}`);
const tq = wt.taskQueueSuffix
? `<app-task-queue>${wt.taskQueueSuffix}`
: '<app-task-queue> (primary — unchanged)';
console.log(` task queue : ${tq}`);
console.log(`\n services (port · resolved source dir):`);
const locals = Object.entries(devOutput.services).filter(([, s]) => (s.port ?? 0) >= 0 && s.command && s.command.length > 0);
if (locals.length === 0) {
console.log(` (none — all services are deployed/K8s)`);
}
else {
const monorepoRoot = findMonorepoRoot();
const wtWithSep = wt.worktreeRoot.endsWith(path.sep) ? wt.worktreeRoot : wt.worktreeRoot + path.sep;
for (const [name, s] of locals) {
const dir = path.resolve(appRoot, rebaseServiceSrc(s.src, monorepoRoot));
const outside = dir !== wt.worktreeRoot && !dir.startsWith(wtWithSep);
const port = s.port > 0 ? `:${s.port}` : '(no port)';
console.log(` ${name.padEnd(18)} ${port.padEnd(7)} ${dir}${outside ? ' ⚠ OUTSIDE worktree' : ''}`);
}
}
console.log('');
}
function getSessionStatePath(sessionName) {
const dir = path.join(os.tmpdir(), 'mesh-dev-sessions');
if (!fs.existsSync(dir))
fs.mkdirSync(dir, { recursive: true });
return path.join(dir, `${sessionName}.json`);
}
function saveSessionState(sessionName, state) {
fs.writeFileSync(getSessionStatePath(sessionName), JSON.stringify(state, null, 2));
}
function loadSessionState(sessionName) {
const filePath = getSessionStatePath(sessionName);
if (!fs.existsSync(filePath))
return null;
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
catch {
return null;
}
}
export function workflowChanged(prior, current) {
return Boolean(prior) && Boolean(current) && current !== prior;
}
function workflowChangeWouldStrand(priorState, appRoot, force) {
const prior = priorState?.workflowFingerprint;
if (!prior)
return false;
const current = fingerprintWorkflowSource(appRoot, priorState?.devOutput?.services);
if (!workflowChanged(prior, current))
return false;
logWarn('Workflow code changed since this session started.\n' +
' Restarting the worker replays any in-flight conversations against the NEW code —\n' +
' a replay-incompatible change strands them (they get stuck and become unviewable).\n' +
' Keep it replay-compatible: gate the change with wf.patched() and regenerate the replay\n' +
' goldens (see the temporal-workflow-safety skill), or accept the risk.');
if (force) {
logWarn(' Proceeding anyway (--force).');
return false;
}
logError(' Refusing to restart the worker. Re-run with --force once the change is replay-safe.');
return true;
}
function getSessionEnvDir(sessionName) {
return path.join(os.tmpdir(), 'mesh-dev-sessions', sessionName);
}
function getServiceEnvFilePath(sessionName, serviceName) {
return path.join(getSessionEnvDir(sessionName), envFileName(serviceName));
}
function removeSessionState(sessionName) {
try {
fs.unlinkSync(getSessionStatePath(sessionName));
}
catch { }
try {
fs.rmSync(getSessionEnvDir(sessionName), { recursive: true, force: true });
}
catch { }
}
export async function isPortFree(port) {
const bindSucceeds = (host) => new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
const onListening = () => server.close(() => resolve(true));
if (host === undefined)
server.listen(port, onListening);
else
server.listen(port, host, onListening);
});
if (!(await bindSucceeds()))
return false;
return bindSucceeds('127.0.0.1');
}
async function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
server.close(() => resolve(port));
});
});
}
function isPortListening(port) {
return new Promise((resolve) => {
const s = new net.Socket();
let done = false;
const fin = (ok) => {
if (done)
return;
done = true;
s.destroy();
resolve(ok);
};
s.setTimeout(300);
s.once('connect', () => fin(true));
s.once('timeout', () => fin(false));
s.once('error', () => fin(false));
s.connect(port, '127.0.0.1');
});
}
export function rewriteOwnServicePort(service, oldPort, newPort) {
for (const [key, spec] of Object.entries(service.env ?? {})) {
if ((key === 'PORT' || key.endsWith('_PORT')) && spec.value === String(oldPort)) {
spec.value = String(newPort);
}
}
}
async function allocatePorts(devOutput, worktree) {
const allocated = JSON.parse(JSON.stringify(devOutput));
const usedPorts = new Set();
const portRemaps = new Map();
const blockBase = worktree.isPrimary ? 0 : blockBasePort(worktree.portBlock);
let blockCursor = 0;
for (const [name, service] of Object.entries(allocated.services)) {
if (service.port <= 0)
continue;
if (!service.command || service.command.length === 0)
continue;
const preferred = service.port;
let target = -1;
if (worktree.isPrimary) {
if (!usedPorts.has(preferred) && (await isPortFree(preferred))) {
target = preferred;
}
else {
target = await findFreePort();
logWarn(`Port ${preferred} busy for service '${name}', using ${target}`);
}
}
else {
for (let i = blockCursor; i < PORT_BLOCK_SERVICE_SUBRANGE; i++) {
const candidate = blockBase + i;
if (!usedPorts.has(candidate) && (await isPortFree(candidate))) {
target = candidate;
blockCursor = i + 1;
break;
}
}
if (target < 0) {
target = await findFreePort();
logWarn(`Worktree port block exhausted for '${name}', using ${target}`);
}
}
if (target !== preferred) {
rewriteOwnServicePort(service, preferred, target);
portRemaps.set(preferred, target);
service.port = target;
}
usedPorts.add(target);
}
rewriteCrossServicePorts(allocated, portRemaps);
rewriteClusterHostsToLocal(allocated);
return allocated;
}
function rewriteCrossServicePorts(devOutput, portRemaps) {
if (portRemaps.size === 0)
return;
for (const service of Object.values(devOutput.services)) {
if (!service.env)
continue;
for (const envVar of Object.values(service.env)) {
for (const [oldPort, newPort] of portRemaps) {
if (envVar.value.includes(`localhost:${oldPort}`)) {
envVar.value = envVar.value.replace(`localhost:${oldPort}`, `localhost:${newPort}`);
}
}
}
}
}
export function mergePinnedPorts(fresh, pinned) {
const merged = JSON.parse(JSON.stringify(fresh));
const pinnedTunnels = JSON.parse(JSON.stringify(pinned.tunnels ?? {}));
merged.tunnels = { ...(merged.tunnels ?? {}), ...pinnedTunnels };
const portRemaps = new Map();
for (const [name, mergedSvc] of Object.entries(merged.services ?? {})) {
const pinnedSvc = pinned.services?.[name];
if (!pinnedSvc || typeof pinnedSvc.port !== 'number')
continue;
const freshPort = mergedSvc.port;
mergedSvc.port = pinnedSvc.port;
if (mergedSvc.env?.PORT) {
mergedSvc.env.PORT.value = String(pinnedSvc.port);
}
if (typeof freshPort === 'number' && freshPort > 0 && freshPort !== pinnedSvc.port) {
portRemaps.set(freshPort, pinnedSvc.port);
}
}
rewriteCrossServicePorts(merged, portRemaps);
rewriteClusterHostsToLocal(merged);
return merged;
}
function findMonorepoRoot() {
let dir = process.cwd();
const root = path.parse(dir).root;
while (dir !== root) {
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) ||
fs.existsSync(path.join(dir, 'turbo.json'))) {
return dir;
}
const gitPath = path.join(dir, '.git');
if (fs.existsSync(gitPath)) {
return dir;
}
dir = path.dirname(dir);
}
return null;
}
function discoverApps(monorepoRoot) {
const apps = [];
function addApp(appDir, tenant) {
const projectName = getProjectName(appDir);
const stacks = fs
.readdirSync(appDir)
.filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml')
.map((f) => f.replace(/^Pulumi\./, '').replace(/\.yaml$/, ''));
apps.push({
name: projectName,
tenant,
appPath: appDir,
relativePath: path.relative(monorepoRoot, appDir),
stacks,
});
}
const searchDirs = ['tenants', 'tests/tenants'];
for (const searchDir of searchDirs) {
const base = path.join(monorepoRoot, searchDir);
if (!fs.existsSync(base))
continue;
for (const tenant of fs.readdirSync(base)) {
const appsDir = path.join(base, tenant, 'apps');
if (!fs.existsSync(appsDir) || !fs.statSync(appsDir).isDirectory())
continue;
for (const app of fs.readdirSync(appsDir)) {
const appDir = path.join(appsDir, app);
if (!fs.existsSync(path.join(appDir, 'Pulumi.yaml')))
continue;
addApp(appDir, tenant);
}
}
}
const flatAppsDir = path.join(monorepoRoot, 'apps');
if (fs.existsSync(flatAppsDir) && fs.statSync(flatAppsDir).isDirectory()) {
for (const app of fs.readdirSync(flatAppsDir)) {
const appDir = path.join(flatAppsDir, app);
if (!fs.statSync(appDir).isDirectory())
continue;
if (!fs.existsSync(path.join(appDir, 'Pulumi.yaml')))
continue;
let tenant = 'unknown';
const stackConfigs = fs
.readdirSync(appDir)
.filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml');
if (stackConfigs.length > 0) {
try {
const content = fs.readFileSync(path.join(appDir, stackConfigs[0]), 'utf-8');
const tenantMatch = content.match(/mesh:tenant:\s*(\S+)/);
if (tenantMatch)
tenant = tenantMatch[1];
}
catch { }
}
addApp(appDir, tenant);
}
}
return apps;
}
function findAppRoot(appPath) {
if (appPath) {
const resolved = path.resolve(appPath);
if (fs.existsSync(path.join(resolved, 'Pulumi.yaml')))
return resolved;
const mono = findMonorepoRoot();
if (mono) {
const fromMono = path.resolve(mono, appPath);
if (fs.existsSync(path.join(fromMono, 'Pulumi.yaml')))
return fromMono;
}
logError(`No Pulumi.yaml found at: ${appPath}`);
process.exit(1);
}
let dir = process.cwd();
const root = path.parse(dir).root;
while (dir !== root) {
if (fs.existsSync(path.join(dir, 'Pulumi.yaml'))) {
return dir;
}
dir = path.dirname(dir);
}
return process.cwd();
}
function getProjectName(appRoot) {
const yamlPath = path.join(appRoot, 'Pulumi.yaml');
if (!fs.existsSync(yamlPath))
return path.basename(appRoot);
const content = fs.readFileSync(yamlPath, 'utf-8');
const match = content.match(/^name:\s*(.+)$/m);
return match?.[1]?.trim() ?? path.basename(appRoot);
}
function detectStack(appRoot, stageArg) {
if (stageArg)
return stageArg;
if (process.env.MESH_STAGE)
return process.env.MESH_STAGE;
try {
const result = execFileSync('pulumi', ['stack', '--show-name'], {
cwd: appRoot,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
if (result)
return result;
}
catch {
}
try {
const files = fs
.readdirSync(appRoot)
.filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml');
if (files.length === 1) {
const match = files[0].match(/^Pulumi\.(.+)\.yaml$/);
if (match?.[1]) {
logInfo(`Auto-detected stack from ${files[0]}`);
return match[1];
}
}
}
catch {
}
return 'dev';
}
function stackArgs(appRoot, stack) {
try {
const selected = execFileSync('pulumi', ['stack', '--show-name'], {
cwd: appRoot,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
if (selected === stack)
return [];
}
catch {
}
try {
execFileSync('pulumi', ['stack', 'select', stack], {
cwd: appRoot,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
return [];
}
catch {
}
return ['--stack', stack];
}
class MissingStackOutputError extends Error {
stackName;
constructor(stackName, cause) {
super(`No Pulumi stack output for '${stackName}'`, { cause });
this.stackName = stackName;
this.name = 'MissingStackOutputError';
}
}
function getDevOutput(appRoot, stack, awsEnv) {
const sa = stackArgs(appRoot, stack);
try {
const result = pulumiStackOutput(appRoot, 'app', sa, awsEnv);
const appOutput = JSON.parse(result);
if (appOutput.dev) {
return appOutput.dev;
}
}
catch {
}
try {
const result = pulumiStackOutput(appRoot, 'dev', sa, awsEnv);
return JSON.parse(result);
}
catch (err) {
throw new MissingStackOutputError(stack, err);
}
}
function hasTmux() {
try {
execFileSync('which', ['tmux'], { stdio: 'ignore' });
return true;
}
catch {
return false;
}
}
function sessionExists(name) {
try {
execFileSync('tmux', ['has-session', '-t', name], { stdio: 'ignore' });
return true;
}
catch {
return false;
}
}
function killSession(name) {
try {
execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' });
return true;
}
catch {
return false;
}
}
const AWS_STATIC_CREDENTIAL_ENV_KEYS = [
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'AWS_WEB_IDENTITY_TOKEN_FILE',
'AWS_ROLE_ARN',
];
function applyAwsProfileOverride(profile) {
process.env.AWS_PROFILE = profile;
for (const key of AWS_STATIC_CREDENTIAL_ENV_KEYS) {
delete process.env[key];
}
}
function getAwsEnvVars() {
const result = {};
const vars = [
'AWS_PROFILE',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'AWS_REGION',
'AWS_DEFAULT_REGION',
];
for (const name of vars) {
const value = process.env[name];
if (value)
result[name] = value;
}
return result;
}
export function buildChildAwsEnv(awsEnv, method, profile, opts) {
const result = {};
if (awsEnv.AWS_REGION)
result.AWS_REGION = awsEnv.AWS_REGION;
if (awsEnv.AWS_DEFAULT_REGION)
result.AWS_DEFAULT_REGION = awsEnv.AWS_DEFAULT_REGION;
if (method === 'sso' && profile) {
result.AWS_PROFILE = profile;
return result;
}
if (method === 'zitadel' && opts) {
const sanitizedContext = opts.context.replace(/[^A-Za-z0-9_-]/g, '-');
const profileName = `mesh-dev-${sanitizedContext}`;
const configPath = path.join(opts.sessionScratchDir, 'aws-config');
const region = awsEnv.AWS_REGION ?? awsEnv.AWS_DEFAULT_REGION ?? '';
fs.mkdirSync(opts.sessionScratchDir, { recursive: true });
const profileBlock = renderCredentialProcessProfile({
profileName,
context: opts.context,
roleArn: opts.roleArn,
region,
meshBin: opts.meshBin,
});
const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
const next = upsertManagedAwsConfigSection(stripBareProfile(existing, profileName), `${opts.context} (mesh dev)`, profileBlock);
if (next !== existing)
atomicWriteFileSync(configPath, next, 0o600);
const out = {
AWS_PROFILE: profileName,
AWS_CONFIG_FILE: configPath,
};
if (region)
out.AWS_REGION = region;
return out;
}
for (const key of ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN']) {
if (awsEnv[key])
result[key] = awsEnv[key];
}
if (profile && !result.AWS_ACCESS_KEY_ID) {
result.AWS_PROFILE = profile;
}
return result;
}
function rebaseServiceSrc(src, monorepoRoot) {
if (!monorepoRoot)
return src;
const rel = monorepoRelativeSrc(src);
if (rel === null)
return src;
const rebased = path.join(monorepoRoot, rel);
return fs.existsSync(rebased) ? rebased : src;
}
export function isLinkedDependencyDir(serviceDir, appRoot) {
let pkgRoot = null;
let name = '';
for (let dir = serviceDir;; dir = path.dirname(dir)) {
try {
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
if (typeof manifest?.name === 'string' && manifest.name) {
pkgRoot = dir;
name = manifest.name;
break;
}
}
catch {
}
const parent = path.dirname(dir);
if (parent === dir)
break;
}
if (!pkgRoot)
return false;
for (let dir = appRoot;; dir = path.dirname(dir)) {
try {
if (fs.realpathSync(path.join(dir, 'node_modules', name)) === pkgRoot)
return true;
}
catch {
}
const parent = path.dirname(dir);
if (parent === dir)
return false;
}
}
export function monorepoRelativeSrc(src) {
if (!path.isAbsolute(src))
return src.replace(/^\.\//, '') || '.';
const m = src.match(/^.*\/mesh-platform(?:\/(.*))?$/);
if (!m)
return null;
const rest = (m[1] ?? '').replace(/^\.worktrees\/[^/]+(?:\/|$)/, '');
return rest === '' ? '.' : rest;
}
function getServiceEnvVars(service, tunnels) {
const result = {};
if (service.env) {
for (const [key, spec] of Object.entries(service.env)) {
let value = spec.value;
if (spec.tunnel) {
const tunnel = tunnels[spec.tunnel];
if (tunnel) {
try {
const url = new URL(value);
if (url.hostname) {
url.hostname = tunnel.host;
url.port = String(tunnel.port);
value = url.toString();
}
else {
value = `${tunnel.host}:${tunnel.port}`;
}
}
catch {
value = `${tunnel.host}:${tunnel.port}`;
}
}
}
result[key] = value;
}
}
return result;
}
function setTmuxEnv(sessionName, vars) {
for (const [key, value] of Object.entries(vars)) {
execFileSync('tmux', ['set-environment', '-t', sessionName, key, value], { stdio: 'ignore' });
}
}
function envPrefix(vars) {
const entries = Object.entries(vars);
if (entries.length === 0)
return '';
const parts = entries.map(([k, v]) => `${k}=${shellEscape(v)}`);
return `env ${parts.join(' ')} `;
}
function shellEscape(s) {
if (/^[a-zA-Z0-9_./:@=+,-]+$/.test(s))
return s;
return `'${s.replace(/'/g, "'\\''")}'`;
}
async function resolveSecrets(secrets) {
const client = new SecretsManagerClient({});
const resolved = {};
for (const [name, secret] of Object.entries(secrets)) {
try {
const response = await client.send(new GetSecretValueCommand({ SecretId: secret.secretName }));
if (!response.SecretString)
continue;
const values = JSON.parse(response.SecretString);
for (const [secretKey, envVar] of Object.entries(secret.envMapping)) {
const value = values[secretKey];
if (value === undefined)
continue;
resolved[envVar] = value;
}
logSuccess(`Secret resolved: ${name} (${secret.secretName})`);
}
catch (err) {
logWarn(`Could not resolve secret '${name}' (${secret.secretName}): ${err instanceof Error ? err.message : String(err)}`);
}
}
return resolved;
}
function resolveTemporalEncodingKey(tenant, env, appName) {
const namespace = `${tenant}-${env}-${appName}`;
const secretName = `${namespace}-temporal-encoding-key`;
try {
const b64 = execFileSync('kubectl', [
'get',
'secret',
secretName,
'-n',
namespace,
'-o',
'jsonpath={.data.TEMPORAL_ENCODING_KEY}',
], { encoding: 'utf-8', timeout: 10_000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
if (b64) {
const key = Buffer.from(b64, 'base64').toString('utf-8');
logSuccess(`Temporal encoding key resolved from K8s secret (${secretName})`);
return key;
}
}
catch {
}
return undefined;
}
async function resolveTemporalAuthVars(sessionName, devOutput) {
const tenant = devOutput.platform?.tenant ?? 'mesh';
const platformEnv = devOutput.platform?.env ?? 'dev';
const platformName = resolveHubPlatformName(devOutput.platform);
const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {
onError: ({ parameter, error }) => logWarn(`EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`),
});
if (kubeconfigPath) {
process.env.KUBECONFIG = kubeconfigPath;
setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });
}
else if (!process.env.KUBECONFIG) {
const existing = sessionKubeconfigPath(sessionName);
if (fs.existsSync(existing)) {
process.env.KUBECONFIG = existing;
}
}
let temporalAuthVars = {};
if (devOutput.tunnels['temporal']) {
temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
const appName = devOutput.app ?? '';
if (appName) {
const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);
if (encodingKey) {
temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;
}
}
const temporalTunnel = devOutput.tunnels['temporal'];
temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);
}
return temporalAuthVars;
}
function loginContextFor(platform) {
return `${platform.name ?? 'mesh'}.${platform.env}`;
}
function resolveDevUserVars(devOutput) {
const credContext = devOutput.platform
? devOutput.platform.tenant === 'local'
? 'local'
: loginContextFor(devOutput.platform)
: null;
if (!credContext)
return {};
const meshCreds = readCredentials(credContext);
if (!meshCreds?.idToken)
return {};
const vars = { DEV_USER_ID_TOKEN: meshCreds.idToken };
try {
const payload = JSON.parse(Buffer.from(meshCreds.idToken.split('.')[1], 'base64url').toString());
if (payload.sub)
vars.DEV_USER_ID = payload.sub;
if (payload.email)
vars.DEV_USER_EMAIL = payload.email;
}
catch {
}
if (meshCreds.accessToken) {
vars.DEV_USER_ACCESS_TOKEN = meshCreds.accessToken;
}
return vars;
}
const SSM_TUNNEL_BASE_PORT = 20000;
const TUNNEL_READY_TIMEOUT_MS = 60_000;
export function tunnelClientAddress(tunnel) {
const host = tunnel.host === 'localhost' ? '127.0.0.1' : tunnel.host;
return `${host}:${tunnel.port}`;
}
export function formatTunnelHealth(health) {
if (health.length === 0)
return [];
const lines = ['Connections:'];
for (const h of health) {
const icon = h.reachable ? '✓' : '⚠';
const suffix = h.reachable ? '' : ' DOWN';
lines.push(` ${icon} ${h.name.padEnd(16)} ${h.address}${suffix}`);
}
const down = health.filter((h) => !h.reachable).map((h) => h.name);
if (down.length > 0) {
const noun = down.length === 1 ? 'tunnel' : 'tunnels';
lines.push('');
lines.push(` ⚠ ${down.length} ${noun} down (${down.join(', ')}) — dependent services are ` +
`retrying (ECONNREFUSED spam is expected). Relaunch: mesh dev`);
}
return lines;
}
async function probeTunnelHealth(tunnels) {
return Promise.all(Object.entries(tunnels).map(async ([name, tun]) => ({
name,
address: tunnelClientAddress(tun),
reachable: await probeConnectionHolds(tun.host === 'localhost' ? '127.0.0.1' : tun.host, tun.port, 800),
})));
}
export async function firstUnroutableTunnel(tunnels) {
const temporal = tunnels['temporal'];
if (!temporal || temporal.host !== 'localhost')
return null;
const holds = await probeConnectionHolds('127.0.0.1', temporal.port, 800);
return holds ? null : 'temporal';
}
export function resolveTransport(flag, ctx) {
if (flag === "tailscale" || flag === "ssm")
return flag;
if (flag !== undefined && flag !== "auto") {
throw new Error(`Invalid --transport '${flag}'. Valid values: auto (default), tailscale, ssm.`);
}
const legacy = ctx.vpnConnected ? "vpn-direct" : ctx.hasSsmPlugin ? "ssm" : "vpn-direct";
return ctx.tailscaleAvailable ? "tailscale" : legacy;
}
export function reachabilityFallbackTransport(transport, temporalReachable, hasSsmPlugin) {
if (transport !== "vpn-direct")
return { transport, reason: "not-vpn-direct" };
if (temporalReachable)
return { transport, reason: "reachable" };
if (hasSsmPlugin)
return { transport: "ssm", reason: "fallback-ssm" };
return { transport, reason: "unreachable-no-plugin" };
}
async function gateVpnDirectReachability(transport, devOutput) {
if (transport !== "vpn-direct")
return transport;
const temporalTunnel = devOutput.tunnels?.["temporal"];
if (!temporalTunnel)
return transport;
const { host, port } = temporalTunnel;
const reachable = await probeTcpReachable(host, port, 1500);
const decision = reachabilityFallbackTransport(transport, reachable, hasSessionManagerPlugin());
if (decision.reason === "reachable") {
logInfo(`Transport: VPN-direct — Temporal VPC endpoint ${host}:${port} is reachable.`);
}
else if (decision.reason === "fallback-ssm") {
logWarn(`Transport: VPN reports connected but the Temporal VPC endpoint ${host}:${port} is unreachable ` +
`(tailnet-blind presence check) — falling back to SSM tunnels (2XXXX port range; VPN-only features ` +
`like in-cluster kubectl are unavailable). Pin with --transport=ssm to skip this probe.`);
}
else if (decision.reason === "unreachable-no-plugin") {
logWarn(`Transport: Temporal VPC endpoint ${host}:${port} is unreachable and the SSM session-manager ` +
`plugin is missing — VPN-direct will likely fail. Fix VPN routing or install the plugin ` +
`(see: mesh dev doctor).`);
}
return decision.transport;
}
function deriveLoginServer(context) {
const cfg = getContextConfig(context);
if (!cfg?.issuer)
return null;
try {
const u = new URL(cfg.issuer);
const parts = u.hostname.split(".");
parts[0] = "vpn";
return `https://${parts.join(".")}`;
}
catch {
return null;
}
}
export function preferredSsmLocalPort(remotePort) {
const preferred = SSM_TUNNEL_BASE_PORT + remotePort;
if (preferred > 65535) {
throw new Error(`Cannot allocate SSM tunnel port for remote port ${remotePort}: preferred local port ${preferred} exceeds 65535`);
}
return preferred;
}
export function reserveSsmLocalPortCandidate(remotePort, reservedPorts, startAt = preferredSsmLocalPort(remotePort)) {
for (let port = startAt; port <= 65535; port += 1) {
if (!reservedPorts.has(port)) {
reservedPorts.add(port);
return port;
}
}
throw new Error(`Cannot allocate SSM tunnel port for remote port ${remotePort}: no free candidate ports remain`);
}
async function allocateSsmLocalPort(remotePort, reservedPorts) {
let nextCandidate = preferredSsmLocalPort(remotePort);
while (nextCandidate <= 65535) {
const candidate = reserveSsmLocalPortCandidate(remotePort, reservedPorts, nextCandidate);
if (await isPortFree(candidate)) {
return candidate;
}
nextCandidate = candidate + 1;
}
throw new Error(`Cannot allocate SSM tunnel port for remote port ${remotePort}: no local ports are available`);
}
function hasSessionManagerPlugin() {
try {
execFileSync('which', ['session-manager-plugin'], { stdio: 'ignore' });
return true;
}
catch {
return false;
}
}
async function startSsmTunnels(sessionName, devOutput) {
const platformEnv = devOutput.platform?.env ?? 'dev';
const bastionTenant = devOutput.platform?.name ?? 'mesh';
const bastion = await getPlatformBastionInfo(bastionTenant, platformEnv).catch(() => null);
if (!bastion) {
logError('Could not read platform bastion info from SSM.');
logInfo('Make sure PlatformBastion is deployed in your platform stack.');
logInfo("If you don't have a bastion, connect via VPN instead: mesh vpn connect");
throw new Error('SSM tunnel fallback unavailable: no bastion found');
}
const rewritten = JSON.parse(JSON.stringify(devOutput));
const tunnelCount = Object.keys(devOutput.tunnels).length;
if (tunnelCount === 0) {
logInfo('No tunnels defined — SSM fallback not needed.');
return rewritten;
}
logInfo(`Starting ${tunnelCount} SSM tunnel(s) via bastion ${bastion.instanceId}...`);
const tunnelToBastionKey = {
temporal: 'temporal-frontend',
'temporal-ui': 'temporal-ui',
rds: 'rds',
db: 'rds',
database: 'rds',
};
const OPTIONAL_TUNNEL_NAMES = new Set([
'loki',
'prometheus',
'tempo',
'pushgateway',
'grafana',
]);
const missingEndpoints = [];
const skippedOptional = [];
const reservedPorts = new Set();
const plannedTunnels = [];
for (const [tunnelName] of Object.entries(devOutput.tunnels)) {
const bastionKey = tunnelToBastionKey[tunnelName] ?? tunnelName;
const endpoint = bastion.services[bastionKey];
if (!endpoint) {
if (OPTIONAL_TUNNEL_NAMES.has(tunnelName)) {
skippedOptional.push(`${tunnelName} (bastion key: ${bastionKey})`);
}
else {
missingEndpoints.push(`${tunnelName} (bastion key: ${bastionKey})`);
}
continue;
}
plannedTunnels.push({
tunnelName,
endpoint,
localPort: await allocateSsmLocalPort(endpoint.port, reservedPorts),
});
}
if (skippedOptional.length > 0) {
logWarn('Skipping optional observability tunnel(s) not exposed by the bastion (SSM fallback):');
for (const skipped of skippedOptional) {
logInfo(` Skipped: ${skipped}`);
}
logInfo(' Monitoring views (logs/metrics/traces) stay unavailable until VPN is connected.');
}
if (missingEndpoints.length > 0) {
logError('SSM tunnel fallback cannot cover every required dev tunnel.');
for (const missing of missingEndpoints) {
logInfo(` Missing: ${missing}`);
}
logInfo(` Available bastion services: ${Object.keys(bastion.services).join(', ') || '(none)'}`);
logInfo('Connect VPN instead or deploy/update PlatformBastion with the missing service endpoints.');
throw new Error('SSM tunnel fallback unavailable: missing bastion endpoints');
}
for (const { tunnelName, endpoint, localPort } of plannedTunnels) {
const windowName = `tunnel-${tunnelName}`;
const ssmCmd = [
'aws',
'ssm',
'start-session',
'--target',
bastion.instanceId,
'--document-name',
'AWS-StartPortForwardingSessionToRemoteHost',
'--parameters',
`'${JSON.stringify({
host: [endpoint.host],
portNumber: [String(endpoint.port)],
localPortNumber: [String(localPort)],
})}'`,
].join(' ');
execFileSync('tmux', ['new-window', '-t', sessionName, '-n', windowName]);
execFileSync('tmux', ['set-option', '-t', `${sessionName}:${windowName}`, 'remain-on-exit', 'on'], { stdio: 'ignore' });
execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${windowName}`, ssmCmd, 'Enter']);
rewritten.tunnels[tunnelName] = {
host: 'localhost',
port: localPort,
};
logSuccess(` ${tunnelName}: localhost:${localPort} → ${endpoint.host}:${endpoint.port} (via SSM)`);
}
return rewritten;
}
async function updateDevboxProxy(devOutput, appRoot) {
const appName = devOutput.app ?? path.basename(appRoot);
const tenant = devOutput.platform?.tenant ?? 'mesh';
const env = devOutput.platform?.env ?? 'dev';
const stack = devOutput.stack ?? 'dev';
const baseDomain = `${tenant}-${env}.mesh.local`;
const services = {};
for (const [name, service] of Object.entries(devOutput.services)) {
if (service.port) {
services[`${name}.${stack}.${appName}.${baseDomain}`] = service.port;
}
}
for (const [name, tunnel] of Object.entries(devOutput.tunnels)) {
if (tunnel.port) {
services[`${name}.${stack}.${appName}.${baseDomain}`] = tunnel.port;
}
}
if (Object.keys(services).length === 0)
return;
const lines = [
'# Auto-generated by mesh dev — do not edit manually.',
'',
':8080 {',
' respond /health "OK" 200',
'}',
'',
];
for (const [hostname, port] of Object.entries(services)) {
lines.push(`http://${hostname} {`);
lines.push(` reverse_proxy localhost:${port}`);
lines.push('}');
lines.push('');
}
const caddyfile = lines.join('\n');
execFileSync('docker', [
'run',
'--rm',
'-v',
'/etc/caddy:/etc/caddy',
'busybox',
'sh',
'-c',
`cat > /etc/caddy/Caddyfile << 'CADDYEOF'\n${caddyfile}\nCADDYEOF`,
], { stdio: 'pipe' });
execFileSync('docker', [
'run',
'--rm',
'--pid=host',
'--privileged',
'busybox',
'nsenter',
'-t',
'1',
'-m',
'--',
'caddy',
'reload',
'--config',
'/etc/caddy/Caddyfile',
'--adapter',
'caddyfile',
], { stdio: 'pipe' });
logSuccess('DevBox proxy updated:');
for (const [hostname, port] of Object.entries(services)) {
logInfo(` http://${hostname} → localhost:${port}`);
}
}
async function setupSubdomainRouting(devOutput, appRoot) {
const tsInfo = await getTailscaleInfo();
if (!tsInfo)
return null;
const appName = devOutput.app ?? path.basename(appRoot);
const stack = devOutput.stack ?? 'dev';
const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;
const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };
const dnsConfig = headscaleDnsConfig(platform.name ?? platform.tenant, platform.env);
const records = [];
for (const [name, service] of Object.entries(devOutput.services)) {
if (service.port) {
records.push({ name: `${name}.${baseDomain}`, type: 'A', value: tsInfo.ip });
}
}
for (const [name] of Object.entries(devOutput.tunnels)) {
records.push({ name: `${name}.${baseDomain}`, type: 'A', value: tsInfo.ip });
}
try {
unregisterDnsRecords(dnsConfig, baseDomain);
}
catch {
}
const caddyDir = '/tmp/mesh-dev-caddy';
const caddyfile = path.join(caddyDir, 'Caddyfile');
if (!fs.existsSync(caddyDir))
fs.mkdirSync(caddyDir, { recursive: true });
const caddyLines = [`# mesh-dev: ${appName}/${stack} (${tsInfo.hostname})`, ''];
for (const [name, service] of Object.entries(devOutput.services)) {
if (service.port) {
caddyLines.push(`http://${name}.${baseDomain} {`);
caddyLines.push(` bind ${tsInfo.ip}`);
caddyLines.push(` reverse_proxy localhost:${service.port}`);
caddyLines.push('}');
caddyLines.push('');
}
}
for (const [name, tunnel] of Object.entries(devOutput.tunnels)) {
caddyLines.push(`http://${name}.${baseDomain} {`);
caddyLines.push(` bind ${tsInfo.ip}`);
caddyLines.push(` reverse_proxy ${tunnel.host}:${tunnel.port}`);
caddyLines.push('}');
caddyLines.push('');
}
fs.writeFileSync(caddyfile, caddyLines.join('\n'));
let caddyRunning = false;
try {
execFileSync('pgrep', ['-f', 'caddy run.*mesh-dev-caddy'], { stdio: 'pipe' });
caddyRunning = true;
}
catch {
}
if (caddyRunning) {
try {
execFileSync('caddy', ['reload', '--config', caddyfile, '--adapter', 'caddyfile'], {
stdio: 'pipe',
});
}
catch {
logWarn('Caddy reload failed — check Caddyfile syntax');
return null;
}
}
else {
try {
execFileSync('which', ['caddy'], { stdio: 'pipe' });
}
catch {
logWarn('Caddy not found. Install for subdomain routing: curl -fsSL https://caddyserver.com/api/download?os=linux&arch=arm64 -o /usr/local/bin/caddy && chmod +x /usr/local/bin/caddy');
return null;
}
const caddy = spawn('caddy', ['run', '--config', caddyfile, '--adapter', 'caddyfile'], {
stdio: 'ignore',
detached: true,
cwd: caddyDir,
});
caddy.unref();
}
try {
const existing = readDnsRecords(dnsConfig);
const allRecords = existing.filter((r) => !r.name.endsWith(baseDomain));
allRecords.push(...records);
registerDnsRecords(dnsConfig, allRecords);
}
catch {
logWarn('DNS registration failed — Headscale pod may be unreachable. Dev routing may not work.');
return null;
}
return { tsHostname: tsInfo.hostname, tsIp: tsInfo.ip, baseDomain, dnsConfig };
}
async function startServices(sessionName, appRoot, devOutput, headless, awsEnv, tunnelPlan = { transport: 'vpn-direct' }, worktreeRoot, taskQueueSuffix = '') {
if (!hasTmux()) {
logError('tmux is required. Install with: brew install tmux');
process.exit(1);
}
if (sessionExists(sessionName)) {
logInfo(`Killing existing session: ${sessionName}`);
killSession(sessionName);
}
const serviceNames = Object.keys(devOutput.services);
if (serviceNames.length === 0) {
logWarn('No services defined in dev output.');
return devOutput;
}
logInfo(`Creating tmux session: ${sessionName}`);
execFileSync('tmux', ['new-session', '-d', '-s', sessionName, '-n', 'status', '-c', appRoot]);
setTmuxEnv(sessionName, awsEnv);
const hasTunnels = Object.keys(devOutput.tunnels).length > 0;
let effectiveTransport = tunnelPlan.transport;
if (hasTunnels && effectiveTransport === 'tailscale' && tunnelPlan.tailscale) {
try {
const rewritten = await startTailscaleTunnels(devOutput, tunnelPlan.tailscale);
const dead = await firstUnroutableTunnel(rewritten.tunnels);
if (dead) {
throw new Error(`forwarder for '${dead}' bound but does not route the VPC ` +
`(dead SOCKS upstream or non-routing tailnet)`);
}
devOutput = rewritten;
logInfo('Connected: userspace-Tailscale tunnels for VPC resources (shared per tenant).');
}
catch (err) {
const reason = err instanceof Error ? err.message : String(err);
if (tunnelPlan.explicit) {
logError(`Tailscale transport failed: ${reason}`);
logInfo('If a VPN registration URL was shown above, open it to authorize this machine, then re-run.');
logInfo('Or switch backing explicitly: mesh dev --transport ssm');
killSession(sessionName);
process.exit(1);
}
logWarn(`Tailscale transport unavailable (${reason}) — falling back to SSM tunnels.`);
effectiveTransport = 'ssm';
}
}
if (hasTunnels && effectiveTransport === 'ssm') {
devOutput = await startSsmTunnels(sessionName, devOutput);
}
if (hasTunnels && effectiveTransport !== 'vpn-direct') {
for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {
if (tunnel.host !== 'localhost')
continue;
logInfo(`Waiting for tunnel ${tName} (localhost:${tunnel.port})...`);
const ready = await waitForPort('localhost', tunnel.port, TUNNEL_READY_TIMEOUT_MS);
if (!ready) {
logError(`Tunnel '${tName}' did not become ready on localhost:${tunnel.port} within ${TUNNEL_READY_TIMEOUT_MS / 1000}s.`);
logInfo(` Check the tunnel window for errors: tmux attach -t ${sessionName} (window tunnel-${tName})`);
logInfo(' Common causes: expired AWS credentials, bastion stopped, session-manager-plugin errors.');
throw new Error(`SSM tunnel '${tName}' not ready on localhost:${tunnel.port} after ${TUNNEL_READY_TIMEOUT_MS / 1000}s`);
}
logSuccess(` Tunnel ready: ${tName} (localhost:${tunnel.port})`);
}
}
const tunnelNames = Object.keys(devOutput.tunnels);
const subdomainCtx = await setupSubdomainRouting(devOutput, appRoot);
if (tunnelNames.length > 0) {
const isSSM = Object.values(devOutput.tunnels).some((t) => t.host === 'localhost' && t.port >= SSM_TUNNEL_BASE_PORT);
logSuccess(isSSM ? 'SSM tunnel connections:' : 'VPN direct connections:');
for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {
if (subdomainCtx) {
logInfo(` ${tName} \u2192 http://${tName}.${subdomainCtx.baseDomain}`);
}
else {
logInfo(` ${tName} \u2192 ${tunnel.host}:${tunnel.port}`);
}
}
}
let secretEnvVars = {};
if (devOutput.secrets && Object.keys(devOutput.secrets).length > 0) {
secretEnvVars = await resolveSecrets(devOutput.secrets);
}
const tenant = devOutput.platform?.tenant ?? 'mesh';
const platformEnv = devOutput.platform?.env ?? 'dev';
const isLocalPlatform = tenant === 'local';
if (!isLocalPlatform) {
const platformName = resolveHubPlatformName(devOutput.platform);
const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {
onError: ({ parameter, error }) => logWarn(`EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`),
});
if (kubeconfigPath) {
process.env.KUBECONFIG = kubeconfigPath;
setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });
logSuccess(`Kubeconfig resolved from SSM (hub ${platformName}/${platformEnv}) → ${kubeconfigPath}`);
}
else {
logWarn('Could not resolve EKS kubeconfig from SSM; kubectl calls will use ambient config (if any).');
}
}
let temporalAuthVars = {};
if (devOutput.tunnels['temporal']) {
temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
const appName = devOutput.app ?? '';
if (appName) {
const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);
if (encodingKey) {
temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;
}
}
const temporalTunnel = devOutput.tunnels['temporal'];
temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);
}
setTmuxEnv(sessionName, secretEnvVars);
setTmuxEnv(sessionName, temporalAuthVars);
const credContext = devOutput.platform
? isLocalPlatform
? 'local'
: loginContextFor(devOutput.platform)
: null;
const devUserVars = resolveDevUserVars(devOutput);
if (Object.keys(devUserVars).length > 0) {
setTmuxEnv(sessionName, devUserVars);
logSuccess(`Dev user injected from mesh login: ${credContext}`);
const tokenPort = await findFreePort();
const tokenUrl = `http://127.0.0.1:${tokenPort}`;
setTmuxEnv(sessionName, { DEV_USER_TOKEN_URL: tokenUrl });
execFileSync('tmux', ['new-window', '-t', sessionName, '-n', 'token-server']);
execFileSync('tmux', ['set-option', '-t', `${sessionName}:token-server`, 'remain-on-exit', 'on'], { stdio: 'ignore' });
execFileSync('tmux', [
'send-keys',
'-t',
`${sessionName}: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));
}
logSuccess(`Dev-user token-server: ${tokenUrl} (context ${credContext})`);
}
else if (credContext) {
logWarn(`No mesh login credentials found for '${credContext}'.`);
logInfo(` Run: mesh login ${credContext}`);
logInfo(' Hub UI will show unauthenticated state without valid credentials.');
}
const monorepoRoot = findMonorepoRoot();
for (const [name, service] of Object.entries(devOutput.services)) {
const cmd = service.command.join(' ');
if (!cmd) {
logInfo(`Skipped: ${name} (no dev command, deployed to K8s)`);
continue;
}
const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, monorepoRoot));
if (worktreeRoot && fs.existsSync(serviceDir)) {
const wtRootWithSep = worktreeRoot.endsWith(path.sep) ? worktreeRoot : worktreeRoot + path.sep;
const outside = serviceDir !== worktreeRoot && !serviceDir.startsWith(wtRootWithSep);
if (outside && isLinkedDependencyDir(serviceDir, appRoot)) {
logInfo(`${name}: source is a dev-linked package (${serviceDir})`);
}
else if (outside) {
logError(`Refusing to launch '${name}': its source resolved to ${serviceDir}, ` +
`outside this worktree (${worktreeRoot}), and it is not a package this ` +
`app resolves there. The stack was likely deployed from another ` +
`worktree. Re-run \`mesh deploy up\` here, or pass --app.`);
process.exit(1);
}
}
if (!fs.existsSync(serviceDir)) {
logWarn(`Skipped: ${name} (source directory not found: ${serviceDir}` +
`${serviceDir !== service.src ? ` — rebased from ${service.src}` : ''})`);
continue;
}
const serviceVars = {
...getServiceEnvVars(service, devOutput.tunnels),
...temporalAuthVars,
...devUserVars,
...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
...awsEnv,
};
const envFilePath = getServiceEnvFilePath(sessionName, name);
writeEnvFile(envFilePath, serviceVars);
const launchCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined);
execFileSync('tmux', ['new-window', '-t', sessionName, '-n', name, '-c', serviceDir]);
execFileSync('tmux', ['set-option', '-t', `${sessionName}:${name}`, 'remain-on-exit', 'on'], {
stdio: 'ignore',
});
execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${name}`, launchCmd, 'Enter']);
logSuccess(`Started: ${name} (${serviceDir}, port ${service.port})`);
}
if (process.env.DEVCONTAINER === '1') {
try {
await updateDevboxProxy(devOutput, appRoot);
}
catch (e) {
logWarn(`Could not update devbox proxy: ${e.message}`);
}
}
const statusCmd = `watch -n2 -t npx mesh dev --status --session '${sessionName}'`;
execFileSync('tmux', ['send-keys', '-t', `${sessionName}:status`, statusCmd, 'Enter']);
console.log('');
logSuccess(`Dev session started: ${sessionName}`);
console.log('');
if (tunnelNames.length > 0) {
console.log('Connections (VPN direct):');
for (const tName of tunnelNames) {
const t = devOutput.tunnels[tName];
if (subdomainCtx) {
console.log(` ${tName.padEnd(20)} http://${tName}.${subdomainCtx.baseDomain}`);
}
else {
console.log(` ${tName.padEnd(20)} ${t.host}:${t.port}`);
}
}
console.log('');
}
console.log('Services:');
for (const name of serviceNames) {
const s = devOutput.services[name];
const hasCmd = s.command && s.command.length > 0;
if (hasCmd) {
if (subdomainCtx && s.port) {
console.log(` ${name.padEnd(20)} http://${name}.${subdomainCtx.baseDomain}`);
}
else {
const addr = s.port ? `http://localhost:${s.port}` : '(no port)';
console.log(` ${name.padEnd(20)} ${addr}`);
}
}
else {
console.log(` ${name.padEnd(20)} (deployed)`);
}
}
console.log('');
if (!headless) {
logInfo('Attaching to tmux session...');
if (process.env.TMUX) {
spawnSync('tmux', ['switch-client', '-t', sessionName], { stdio: 'inherit' });
}
else {
spawnSync('tmux', ['attach', '-t', sessionName], { stdio: 'inherit' });
}
}
else {
console.log(`Attach with: tmux attach -t ${sessionName}`);
console.log(`Stop with: mesh dev --kill`);
console.log('');
}
return devOutput;
}
async function showStatus(sessionName, devOutput, asJson) {
if (!sessionExists(sessionName)) {
if (asJson) {
console.log(JSON.stringify({ running: false, session: sessionName }));
}
else {
logInfo(`No active session: ${sessionName}`);
}
return;
}
let windows = [];
try {
const raw = execFileSync('tmux', ['list-windows', '-t', sessionName, '-F', '#{window_name} #{pane_dead}'], { encoding: 'utf-8' });
windows = raw.trim().split('\n');
}
catch {
}
const windowStatus = {};
for (const line of windows) {
const [name, dead] = line.split(' ');
if (name)
windowStatus[name] = dead === '1' ? 'exited' : 'running';
}
const tunnelNames = Object.keys(devOutput.tunnels);
const tunnelHealth = await probeTunnelHealth(devOutput.tunnels);
const healthByName = new Map(tunnelHealth.map((h) => [h.name, h]));
if (asJson) {
const services = {};
for (const [name, svc] of Object.entries(devOutput.services)) {
services[name] = {
status: windowStatus[name] ?? 'unknown',
port: svc.port,
src: svc.src,
};
}
const tunnels = {};
for (const [name, tun] of Object.entries(devOutput.tunnels)) {
tunnels[name] = {
host: tun.host,
port: tun.port,
reachable: healthByName.get(name)?.reachable ?? false,
};
}
console.log(JSON.stringify({ running: true, session: sessionName, services, tunnels }, null, 2));
}
else {
console.log('\u2500\u2500 mesh dev \u2500\u2500');
console.log('');
console.log(`Session: ${sessionName}`);
if (tunnelNames.length > 0) {
console.log('');
for (const line of formatTunnelHealth(tunnelHealth))
console.log(line);
}
console.log('');
console.log('Services:');
for (const [name, svc] of Object.entries(devOutput.services)) {
const hasCmd = svc.command && svc.command.length > 0;
if (!hasCmd) {
console.log(` ☁ ${name.padEnd(16)} deployed ${svc.src}`);
continue;
}
const status = windowStatus[name] ?? 'unknown';
const icon = status === 'running' ? '●' : status === 'exited' ? '✗' : '?';
const addr = svc.port ? `:${svc.port}` : '';
console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(10)} ${addr.padEnd(8)} ${svc.src}`);
}
console.log('');
console.log('Commands:');
console.log(' mesh dev restart <s> Restart a service');
console.log(' mesh dev logs <s> Tail service logs');
console.log(' mesh dev --kill Stop everything');
console.log('');
console.log('tmux: Ctrl+b n/p switch windows, d detach');
}
}
async function restartService(sessionName, serviceName, appRoot, devOutput, awsEnv, opts) {
let service = devOutput.services[serviceName];
if (!service) {
logError(`Unknown service: ${serviceName}`);
logInfo(`Available: ${Object.keys(devOutput.services).join(', ')}`);
process.exit(1);
}
if (!sessionExists(sessionName)) {
logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);
process.exit(1);
}
if (opts?.refreshEnv) {
if (!opts.stack) {
logError('--refresh-env: no stack resolved; keeping existing env and NOT restarting.');
return;
}
const pinned = opts.sessionState?.devOutput ?? devOutput;
let fresh;
try {
fresh = getDevOutput(appRoot, opts.stack, awsEnv);
}
catch (err) {
logError(`--refresh-env: failed to read stack output for '${opts.stack}'. Keeping the existing env file for '${serviceName}' and NOT restarting.`);
logInfo(String(err?.message ?? err));
return;
}
const merged = mergePinnedPorts(fresh, pinned);
const mergedService = merged.services[serviceName];
if (!mergedService) {
logError(`--refresh-env: service '${serviceName}' is absent from the fresh stack output. Keeping the existing env file and NOT restarting.`);
return;
}
const temporalAuthVars = await resolveTemporalAuthVars(sessionName, merged);
const { taskQueueSuffix } = resolveWorktreeIdentity(appRoot);
const serviceVars = {
...getServiceEnvVars(mergedService, merged.tunnels),
...temporalAuthVars,
...resolveDevUserVars(merged),
...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
...awsEnv,
};
writeEnvFile(getServiceEnvFilePath(sessionName, serviceName), serviceVars);
service = mergedService;
logSuccess(`Regenerated env for '${serviceName}' from current stack/SSM outputs.`);
}
const target = `${sessionName}:${serviceName}`;
try {
execFileSync('tmux', ['respawn-pane', '-k', '-t', target], { stdio: 'ignore' });
}
catch {
logError(`Window '${serviceName}' not found in session.`);
process.exit(1);
}
setTmuxEnv(sessionName, awsEnv);
const cmd = service.command.join(' ');
const envFilePath = getServiceEnvFilePath(sessionName, serviceName);
let restartCmd;
if (fs.existsSync(envFilePath)) {
const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, findMonorepoRoot()));
restartCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined);
}
else {
logWarn(`No launch env file for '${serviceName}' (${envFilePath}) — session predates env-file launches.`);
logWarn('Falling back to reconstructed env; restart `mesh dev` for a faithful environment.');
const serviceVars = getServiceEnvVars(service, devOutput.tunnels);
restartCmd = `${envPrefix(serviceVars)}${cmd}`;
}
execFileSync('tmux', ['send-keys', '-t', target, restartCmd, 'Enter']);
logSuccess(`Restarted: ${serviceName}`);
}
function showLogs(sessionName, serviceName, tail) {
if (!sessionExists(sessionName)) {
logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);
process.exit(1);
}
const target = `${sessionName}:${serviceName}`;
try {
const result = execFileSync('tmux', ['capture-pane', '-t', target, '-p', '-S', `-${tail}`], {
encoding: 'utf-8',
});
process.stdout.write(result);
}
catch {
logError(`Could not capture logs for '${serviceName}'.`);
logInfo('Is the service name correct? Check with: mesh dev --status');
process.exit(1);
}
}
function buildDoctorContext(appRoot, stack, sessionName) {
const state = loadSessionState(sessionName);
const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
const platformContext = derivePlatformContext(appRoot, stack);
let deployerRole = defaultDeployerRole;
if (defaultDeployerRole) {
const meshCreds = platformContext ? readCredentials(platformContext) : null;
const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;
deployerRole = selectRoleForCaller(idToken, {
defaultRole: defaultDeployerRole,
adminRole: adminDeployerRole ?? undefined,
});
}
return {
appRoot,
stack,
sessionName,
deployerRole,
platformContext,
credMethod: null,
sessionState: state,
};
}
export function registerDevCommand(program) {
const dev = program
.command('dev')
.description('Start local dev environment (reads Pulumi stack outputs)')
.option('--app <path>', 'Path to a Pulumi app (relative to monorepo root or cwd)')
.option('--stack <stack>', 'Pulumi stack name (default: auto-detect)')
.addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
.option('--headless', 'Start without attaching to tmux')
.option('--kill', 'Kill existing dev session')
.option('--status', 'Show service status')
.option('--json', 'Output status as JSON (with --status)')
.option('--session <name>', 'Override the tmux session name (auto-derived per git worktree by default — omit to keep concurrent worktrees isolated)')
.option('--dry-run', 'Print the resolved worktree/session/port plan and exit without launching')
.option('--force', 'Relaunch even if workflow code changed since launch (may strand in-flight conversations)')
.option('--profile <name>', 'AWS SSO profile to use (e.g., mesh-dev)')
.addOption(new Option('--transport <mode>', 'Tunnel backing (default: auto — Tailscale when available, else SSM/VPN)').choices(['auto', 'ssm', 'tailscale']))
.option('--local', 'Run against the local Mesh platform from `mesh start` (no AWS, no VPN, no Pulumi state). Auto-selected when the app has no Pulumi.yaml.')
.option('--externals [names]', "Also realize the app's declared external services (package.json → mesh.externals): all of them, or a comma-separated subset of name[=mode] entries. Modes: mock (emulate — OpenAPI spec via Prism, or a mock process), local (a local version via docker compose, e.g. a vendor DB replica), remote (connect to the actual service — vendor sandbox credentials, or the external configured in the app's tenant environment). name=mode overrides the declaration's default for this run (e.g. plaid-db=remote). Each realization seeds the ExternalService credential secret so resolveCredentials() runs unchanged. Local mode only.")
.option('--mock [names]', 'Alias for --externals.')
.option('--runner <runner>', "Process runner for local mode: 'tmux' (default — dev machines) or 'docker' (CI/headless: services run as a docker compose project with host networking; Linux semantics).", 'tmux');
dev
.command('__token-server <port> <context>', { hidden: true })
.action(async (port, context) => {
const { startTokenServer } = await import('./dev-token-server.js');
await startTokenServer(Number(port), context);
});
dev.action(async (options) => {
if (options.profile) {
applyAwsProfileOverride(options.profile);
}
if (options.session && (options.status || options.kill)) {
const sessionName = options.session;
if (loadSessionState(sessionName)?.runner === 'docker') {
if (options.kill) {
dockerDevDown(sessionName);
composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
removeSessionState(sessionName);
logSuccess(`Killed docker dev session: ${sessionName}`);
}
else {
console.log(dockerDevPs(sessionName));
}
return;
}
if (options.kill) {
composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
try {
const devOutput = loadSessionState(sessionName)?.devOutput;
if (devOutput) {
const tsInfo = await getTailscaleInfo();
if (tsInfo) {
const appName = devOutput.app ?? sessionName.replace(/-dev$/, '');
const stack = devOutput.stack ?? 'dev';
const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;
const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };
unregisterDnsRecords(headscaleDnsConfig(platform.name ?? platform.tenant, platform.env), baseDomain);
}
}
}
catch {
}
if (sessionExists(sessionName)) {
killSession(sessionName);
logSuccess(`Killed session: ${sessionName}`);
}
else {
logInfo(`No active session: ${sessionName}`);
}
removeSessionState(sessionName);
return;
}
const state = loadSessionState(sessionName);
if (state) {
await showStatus(sessionName, state.devOutput, !!options.json);
return;
}
}
const appRoot = findAppRoot(options.app);
const projectName = getProjectName(appRoot);
const worktree = withAppScopedPortBlock(resolveWorktreeIdentity(appRoot), appRoot);
const sessionName = options.session ?? deriveSessionName(projectName, worktree);
if (options.kill) {
composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
if (loadSessionState(sessionName)?.runner === 'docker') {
dockerDevDown(sessionName);
removeSessionState(sessionName);
logSuccess(`Killed docker dev session: ${sessionName}`);
return;
}
const killTsTenant = loadSessionState(sessionName)?.devOutput.platform?.name ?? 'mesh';
try {
const devOutput = loadSessionState(sessionName)?.devOutput;
if (devOutput) {
const tsInfo = await getTailscaleInfo();
if (tsInfo) {
const appName = devOutput.app ?? projectName;
const stack = devOutput.stack ?? 'dev';
const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;
const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };
unregisterDnsRecords(headscaleDnsConfig(platform.name ?? platform.tenant, platform.env), baseDomain);
}
}
}
catch {
}
if (sessionExists(sessionName)) {
killSession(sessionName);
logSuccess(`Killed session: ${sessionName}`);
}
else {
logInfo(`No active session: ${sessionName}`);
}
removeSessionState(sessionName);
if (readTailscaleState(killTsTenant)) {
logInfo(`VPN tunnels persist across sessions — stop them with: mesh vpn tunnel down --tenant ${killTsTenant}`);
}
return;
}
const localMode = !!options.local || !hasStackBacking(appRoot);
if (localMode) {
if (options.status) {
const state = loadSessionState(sessionName);
if (state?.runner === 'docker') {
console.log(dockerDevPs(sessionName));
return;
}
const statusOutput = state?.devOutput ??
buildLocalDevOutput(appRoot, detectLocalTenant(appRoot), { mocks: {} });
showStatus(sessionName, statusOutput, !!options.json);
return;
}
const dockerRunner = options.runner === 'docker';
if (!dockerRunner && !hasTmux()) {
logError('tmux is not installed. Fix: brew install tmux (or use --runner docker)');
process.exit(1);
}
await ensureLocalPlatformRunning();
const localTenant = detectLocalTenant(appRoot);
logInfo(`Project: ${projectName}, Stack: local (mesh start platform)`);
const externalsRequest = options.externals ?? options.mock;
let selectedMocks = {};
const requestedExplicitly = new Set();
if (externalsRequest) {
const declared = readLocalMocks(appRoot);
let requested;
let overrides = new Map();
if (externalsRequest === true) {
requested = Object.keys(declared);
}
else {
({ names: requested, overrides } = parseExternalsSelection(String(externalsRequest)));
for (const name of requested)
requestedExplicitly.add(name);
}
const unknown = requested.filter((name) => !declared[name]);
if (unknown.length > 0) {
throw new MeshCliError(`Unknown external(s): ${unknown.join(', ')} — declared in package.json mesh.externals: ${Object.keys(declared).join(', ') || '(none)'}`, { remediation: { docs: 'package.json → "mesh": { "externals": { … } }' } });
}
if (requested.length === 0) {
logWarn('No externals declared (package.json → mesh.externals) — continuing without.');
}
selectedMocks = Object.fromEntries(requested.map((name) => [
name,
overrides.has(name) ? { ...declared[name], mode: overrides.get(name) } : declared[name],
]));
for (const [name, decl] of Object.entries(selectedMocks))
externalMode(name, decl);
}
const rawDevOutput = buildLocalDevOutput(appRoot, localTenant, {
mocks: selectedMocks,
});
if (options.dryRun) {
printDevPlan(sessionName, appRoot, worktree, await allocatePorts(rawDevOutput, worktree));
for (const [name, decl] of Object.entries(selectedMocks)) {
const mode = externalMode(name, decl);
if (mode === 'local') {
console.log(` external ${name.padEnd(18)} local — docker compose (${decl.compose}) → localhost:${decl.port}`);
}
else if (mode === 'remote') {
console.log(` external ${name.padEnd(18)} remote — actual service credentials (no local process)`);
}
}
return;
}
let signInServices = [];
let appVersion;
try {
const tenant = localTenant;
const app = rawDevOutput.app ?? projectName;
logInfo(`Provisioning auth config for tenant '${tenant}', app '${app}'…`);
const services = Object.keys(rawDevOutput.services);
const authServices = services.filter((name) => !name.startsWith('mock-'));
let authRoles = [];
signInServices = [];
try {
const appPkg = JSON.parse(fs.readFileSync(path.join(appRoot, 'package.json'), 'utf-8'));
if (Array.isArray(appPkg?.mesh?.auth?.roles)) {
authRoles = appPkg.mesh.auth.roles.filter((r) => typeof r === 'string');
}
if (Array.isArray(appPkg?.mesh?.auth?.signIn)) {
signInServices = appPkg.mesh.auth.signIn.filter((r) => typeof r === 'string');
}
if (typeof appPkg?.version === 'string')
appVersion = appPkg.version;
}
catch {
}
await ensureAppTenantAuth({ tenant, app, services: authServices, roles: authRoles });
await ensureTemporalNamespace(localAppNamespace(tenant, app));
}
catch (err) {
logWarn(`Auth auto-provisioning skipped: ${err instanceof Error ? err.message : err}` +
` — if the local Zitadel predates seeding, run: mesh stop --destroy && mesh start`);
}
const devOutput = await allocatePorts(rawDevOutput, worktree);
for (const service of signInServices) {
const port = devOutput.services[service]?.port;
if (!port) {
logWarn(`mesh.auth.signIn names '${service}', which this app does not run — no sign-in app registered.`);
continue;
}
try {
await ensureSignInApp({
tenant: localTenant,
app: devOutput.app ?? projectName,
service,
baseUrl: `http://localhost:${port}`,
});
}
catch (err) {
logWarn(`Could not register the browser sign-in for '${service}' (${err instanceof Error ? err.message : err})` +
` — the Hub's Access → Sign-in tab will report this app has no login.`);
}
}
try {
await registerLocalApp({
tenant: localTenant,
app: devOutput.app ?? projectName,
version: appVersion,
services: Object.keys(devOutput.services),
ports: Object.fromEntries(Object.entries(devOutput.services).map(([name, svc]) => [name, svc.port])),
kinds: Object.fromEntries(Object.entries(devOutput.services).flatMap(([name, svc]) => svc.kind ? [[name, svc.kind]] : [])),
links: Object.values(selectedMocks)
.map((decl) => decl.external)
.filter((n) => !!n),
});
}
catch (err) {
logWarn(`Local registry registration skipped: ${err instanceof Error ? err.message : err}`);
}
const composeExternals = [];
const externalProbeFiles = [];
for (const [name, decl] of Object.entries(selectedMocks)) {
const mode = externalMode(name, decl);
try {
let probeFile;
if (mode === 'local') {
logInfo(`Starting docker external '${name}' (${decl.compose})…`);
const composeRef = await composeExternalUp(appRoot, sessionName, name, decl);
if (composeRef)
composeExternals.push(composeRef);
probeFile = await seedLocalMock({
tenant: localTenant,
app: devOutput.app ?? projectName,
name,
decl,
endpoint: { url: `http://localhost:${decl.port}`, host: 'localhost', port: decl.port },
});
}
else if (mode === 'remote') {
probeFile = await seedLocalMock({
tenant: localTenant,
app: devOutput.app ?? projectName,
name,
decl,
});
}
else {
const mockService = devOutput.services[`mock-${name}`];
if (!mockService)
continue;
probeFile = await seedLocalMock({
tenant: localTenant,
app: devOutput.app ?? projectName,
name,
decl,
endpoint: {
url: `http://localhost:${mockService.port}`,
host: 'localhost',
port: mockService.port,
},
});
}
if (probeFile)
externalProbeFiles.push(probeFile);
}
catch (err) {
if (mode === 'remote' && !requestedExplicitly.has(name) && err instanceof MeshCliError) {
logWarn(`External '${name}': skipped — ${err.message}` +
` (re-run with \`mesh dev --externals ${name}=remote\` to make this fatal).`);
continue;
}
if (err instanceof MeshCliError)
throw err;
if (mode === 'local') {
throw new MeshCliError(`Docker external '${name}' failed to start: ${err instanceof Error ? err.message : err}`, { remediation: { command: `docker compose -f ${decl.compose} up # debug it directly` } });
}
logWarn(`External '${name}' credential seeding failed: ${err instanceof Error ? err.message : err}`);
}
}
if (dockerRunner) {
const composePath = writeDevCompose(sessionName, appRoot, devOutput.services);
logInfo(`Docker runner: ${composePath}`);
dockerDevUp(sessionName);
registerServiceProbes(devOutput, localTenant, externalProbeFiles);
saveSessionState(sessionName, {
appRoot,
stack: 'local',
devOutput,
startedAt: new Date().toISOString(),
runner: 'docker',
composeExternals,
externalProbeFiles,
});
console.log('');
logSuccess(`Dev session started (docker): ${sessionName}`);
console.log('');
console.log('Services:');
for (const [name, service] of Object.entries(devOutput.services)) {
console.log(` ${name.padEnd(20)} http://localhost:${service.port}`);
}
console.log('');
console.log(`Status with: mesh dev --status --session '${sessionName}'`);
console.log('Stop with: mesh dev --kill');
return;
}
const finalDevOutput = await startServices(sessionName, appRoot, devOutput, !!options.headless, localAwsEnv(), { transport: 'vpn-direct' });
registerServiceProbes(finalDevOutput, localTenant, externalProbeFiles);
saveSessionState(sessionName, {
appRoot,
stack: 'local',
devOutput: finalDevOutput,
startedAt: new Date().toISOString(),
runner: 'tmux',
composeExternals,
externalProbeFiles,
});
return;
}
if (options.mock || options.externals) {
logWarn('--externals/--mock is local-mode only for now (tethered dev outputs do not carry external declarations yet). Continuing without them.');
}
const stack = detectStack(appRoot, resolveStackOption(options));
logInfo(`Project: ${projectName}, Stack: ${stack}`);
const monorepoRoot = findMonorepoRoot();
const looksLikeStandaloneApp = ['api', 'worker', 'web'].some((dir) => fs.existsSync(path.join(appRoot, dir, 'package.json')));
if (monorepoRoot && path.resolve(appRoot) === path.resolve(monorepoRoot) && !looksLikeStandaloneApp) {
logError(`mesh dev should be run from an app directory, not the monorepo root.\n`);
logInfo('Try:');
logInfo(' cd apps/hub && mesh dev');
logInfo('');
logInfo('Or specify the app path:');
logInfo(' mesh dev --app apps/hub');
logInfo('');
logInfo('List available apps:');
logInfo(' mesh dev list');
process.exit(1);
}
const issues = [];
const profileHint = options.profile ?? process.env.AWS_PROFILE ?? 'mesh-dev';
const platformContext = derivePlatformContext(appRoot, stack);
const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
let deployerRole = defaultDeployerRole;
if (defaultDeployerRole) {
const meshCreds = platformContext ? readCredentials(platformContext) : null;
const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;
deployerRole = selectRoleForCaller(idToken, {
defaultRole: defaultDeployerRole,
adminRole: adminDeployerRole ?? undefined,
});
if (adminDeployerRole && deployerRole === adminDeployerRole) {
logInfo(`Caller has admin Zitadel role — assuming ${deployerRole.split('/').pop()} (admin variant)`);
}
}
let awsEnv;
let credMethod = 'ambient';
let credMethodForDoctor = 'ambient';
if (deployerRole) {
const resolved = await resolveAwsCredentials(deployerRole, appRoot, stack);
if (!resolved) {
awsEnv = {};
credMethodForDoctor = null;
}
else {
awsEnv = resolved.env;
credMethod = resolved.method;
credMethodForDoctor = resolved.method;
}
}
else {
awsEnv = getAwsEnvVars();
credMethodForDoctor = 'ambient';
}
const vpnConnected = await isVpnConnected();
let transport = resolveTransport(options.transport, {
vpnConnected,
hasSsmPlugin: hasSessionManagerPlugin(),
tailscaleAvailable: tailscaleAvailable(),
});
if (options.transport === 'tailscale' && !tailscaleAvailable()) {
issues.push(' \u2718 --transport=tailscale but the tailscaled binary is missing.\n' +
' Fix: brew install tailscale');
}
if (transport === 'ssm') {
if (hasSessionManagerPlugin()) {
if (!vpnConnected) {
logWarn('VPN not connected \u2014 will use SSM port-forwarding tunnels for VPC resources.');
}
logInfo(' Tunnels use the 2XXXX port range (e.g., PostgreSQL on 25432, Temporal on 27233).');
logInfo(' Limitations: no kubectl exec into pods, no internal ingress/ALB access.');
logInfo(' For full VPC access, connect VPN: mesh vpn connect');
}
else {
issues.push(' \u2718 VPN is not connected and session-manager-plugin is not installed.\n' +
' Option 1 (VPN): mesh vpn connect\n' +
' Option 2 (SSM): brew install --cask session-manager-plugin\n' +
' Option 3 (Tailscale): mesh dev --transport=tailscale');
}
}
else if (transport === 'tailscale') {
logInfo('Bringing up userspace-Tailscale tunnels for VPC resources (shared per tenant)…');
}
const preflightCtx = {
appRoot,
stack,
sessionName,
deployerRole,
platformContext,
credMethod: credMethodForDoctor,
sessionState: null,
};
const preflight = await runChecks(preflightCtx, 'preflight', ALL_CHECKS);
const preflightReport = renderHuman(preflight);
if (preflightReport)
console.log(preflightReport);
const preflightStatus = aggregateStatus(preflight.map((r) => r.result));
if (preflightStatus === 'error' || issues.length > 0) {
logError('\nPrerequisites not met. Fix the above before running mesh dev, then retry: mesh dev');
for (const issue of issues)
console.log(issue);
process.exit(1);
}
const awsRegion = readStackConfig(appRoot, stack, 'aws:region');
if (awsRegion && !awsEnv.AWS_REGION) {
awsEnv.AWS_REGION = awsRegion;
}
Object.assign(process.env, awsEnv);
const zitadelOpts = credMethod === 'zitadel' && deployerRole
? (() => {
const context = derivePlatformContext(appRoot, stack);
return context
? {
context,
roleArn: deployerRole,
sessionScratchDir: getSessionEnvDir(sessionName),
meshBin: resolveStableMeshBin(process.argv[1]),
}
: undefined;
})()
: undefined;
const childAwsEnv = buildChildAwsEnv(awsEnv, credMethod, profileHint, zitadelOpts);
let rawDevOutput;
try {
rawDevOutput = getDevOutput(appRoot, stack, awsEnv);
}
catch (err) {
if (err instanceof MissingStackOutputError) {
logError(`No Pulumi stack output for '${stack}' — mesh dev has nothing to run.`);
logInfo('If this is a new app, initialize and materialize it first:');
logInfo(' mesh stack init # create your personal dev stack');
logInfo(` mesh deploy up --stack ${stack} --yes # produce the app output mesh dev reads`);
logInfo(`Then: mesh dev --stage ${stack} (the stack must export an \`app\` or \`dev\` output.)`);
logInfo('No deployed stack yet? Run against the local platform: mesh dev --local (needs `mesh start`).');
const cause = err.cause?.message;
if (cause)
logInfo(`(underlying stack-read error: ${cause})`);
process.exit(1);
}
throw err;
}
if (options.status) {
const state = loadSessionState(sessionName);
await showStatus(sessionName, state?.devOutput ?? rawDevOutput, !!options.json);
return;
}
const devOutput = await allocatePorts(rawDevOutput, worktree);
transport = await gateVpnDirectReachability(transport, devOutput);
if (options.dryRun) {
printDevPlan(sessionName, appRoot, worktree, devOutput);
return;
}
let tunnelPlan = { transport };
if (transport === 'tailscale') {
const tsTenant = devOutput.platform?.name ?? 'mesh';
const tsEnv = devOutput.platform?.env ?? 'dev';
const tsContext = derivePlatformContext(appRoot, stack) ?? `${tsTenant}.${tsEnv}`;
const loginServer = deriveLoginServer(tsContext);
if (!loginServer) {
const msg = `No login config for context '${tsContext}' — run: mesh login ${tsContext}`;
if (options.transport === 'tailscale') {
logError(msg);
process.exit(1);
}
const legacy = vpnConnected
? 'vpn-direct'
: hasSessionManagerPlugin()
? 'ssm'
: 'vpn-direct';
const gatedLegacy = await gateVpnDirectReachability(legacy, devOutput);
logWarn(`${msg} — falling back to ${gatedLegacy} transport.`);
tunnelPlan = { transport: gatedLegacy };
}
else {
const prior = readTailscaleState(tsTenant);
const daemonUp = tailscaleDaemonState(tsTenant).backendState !== 'Down';
const socksPort = daemonUp
? (readTailscaleDaemonMeta(tsTenant)?.socksPort ?? prior?.socksPort ?? (await findFreePort()))
: await findFreePort();
let preAuthKey;
const brokerUrl = resolveVpnJoinBroker(tsContext);
if (brokerUrl) {
const minted = await mintPreAuthKey(tsContext, brokerUrl, { getValidToken });
if (minted?.authKey) {
preAuthKey = minted.authKey;
}
else {
logInfo('zero-touch VPN join unavailable, falling back to browser registration');
}
}
tunnelPlan = {
transport,
explicit: options.transport === 'tailscale',
tailscale: {
tenant: tsTenant,
env: tsEnv,
region: awsRegion ?? 'us-east-2',
loginServer,
socksPort,
preAuthKey,
},
};
}
}
if (workflowChangeWouldStrand(loadSessionState(sessionName), appRoot, !!options.force)) {
process.exit(1);
}
const finalDevOutput = await startServices(sessionName, appRoot, devOutput, !!options.headless, childAwsEnv, tunnelPlan, worktree.worktreeRoot, worktree.taskQueueSuffix);
saveSessionState(sessionName, {
appRoot,
stack,
devOutput: finalDevOutput,
startedAt: new Date().toISOString(),
workflowFingerprint: fingerprintWorkflowSource(appRoot, finalDevOutput.services),
});
});
dev
.command('logs <service>')
.description('Show logs for a service')
.option('--tail <lines>', 'Number of lines', '100')
.action((service, opts) => {
const sessionName = dev.opts().session;
const resolvedSession = sessionName ?? `${getProjectName(findAppRoot())}-dev`;
if (loadSessionState(resolvedSession)?.runner === 'docker') {
console.log(dockerDevLogs(resolvedSession, service, parseInt(opts.tail, 10)));
return;
}
if (sessionName) {
showLogs(sessionName, service, parseInt(opts.tail, 10));
}
else {
const appRoot = findAppRoot();
const projectName = getProjectName(appRoot);
showLogs(`${projectName}-dev`, service, parseInt(opts.tail, 10));
}
});
dev
.command('restart <service>')
.description('Restart a service')
.option('--stack <stack>', 'Pulumi stack name')
.addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
.option('--refresh-env', "Regenerate this service's env from current stack/SSM outputs before restarting (for config/deploy changes)")
.option('--force', 'Restart the worker even if workflow code changed since launch (may strand in-flight conversations)')
.action(async (service, opts) => {
let ctx = null;
try {
const parentSession = dev.opts().session;
const parentProfile = dev.opts().profile;
if (parentProfile) {
applyAwsProfileOverride(parentProfile);
}
const appRoot = findAppRoot();
const projectName = getProjectName(appRoot);
const sessionName = parentSession ?? `${projectName}-dev`;
const state = loadSessionState(sessionName);
if (state?.runner === 'docker') {
dockerDevRestart(sessionName, service);
logSuccess(`Restarted: ${service}`);
return;
}
if (/worker/i.test(service) && workflowChangeWouldStrand(state, appRoot, !!opts.force)) {
process.exit(1);
}
const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));
ctx = derivePlatformContext(appRoot, stack);
const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
let deployerRole = defaultDeployerRole;
if (defaultDeployerRole) {
const meshCreds = ctx ? readCredentials(ctx) : null;
const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;
deployerRole = selectRoleForCaller(idToken, {
defaultRole: defaultDeployerRole,
adminRole: adminDeployerRole ?? undefined,
});
}
const resolved = deployerRole ? await resolveAwsCredentials(deployerRole, appRoot, stack) : null;
if (deployerRole && !resolved && !process.env.AWS_ACCESS_KEY_ID) {
const pf = ctx ? credProbeToPreflightError(await probeCredentials(ctx, deployerRole), ctx) : null;
logError(pf?.message ??
`Couldn't resolve AWS credentials to restart '${service}'. Run: mesh login ${ctx ?? 'mesh.dev'} --device`);
process.exit(1);
}
const baseAwsEnv = resolved?.env ?? getAwsEnvVars();
const awsRegion = readStackConfig(appRoot, stack, 'aws:region');
if (awsRegion && !baseAwsEnv.AWS_REGION) {
baseAwsEnv.AWS_REGION = awsRegion;
}
Object.assign(process.env, baseAwsEnv);
const profileHint = parentProfile ?? process.env.AWS_PROFILE ?? 'mesh-dev';
const zitadelOpts = resolved?.method === 'zitadel' && deployerRole && ctx
? {
context: ctx,
roleArn: deployerRole,
sessionScratchDir: getSessionEnvDir(sessionName),
meshBin: resolveStableMeshBin(process.argv[1]),
}
: undefined;
const awsEnv = buildChildAwsEnv(baseAwsEnv, resolved?.method ?? 'ambient', profileHint, zitadelOpts);
const devOutput = state?.devOutput ?? getDevOutput(appRoot, stack, baseAwsEnv);
await restartService(sessionName, service, appRoot, devOutput, awsEnv, {
refreshEnv: !!opts.refreshEnv,
stack,
sessionState: state,
});
if (/worker/i.test(service) && state) {
saveSessionState(sessionName, {
...state,
workflowFingerprint: fingerprintWorkflowSource(appRoot, state.devOutput?.services),
});
}
}
catch (err) {
logError(`Failed to restart '${service}': ${err?.message ?? String(err)}`);
logInfo(`If this is a credential/kubeconfig issue, run \`mesh login ${ctx ?? '<your platform context>'} --device\`, ` +
'or `mesh dev` to relaunch the session cleanly.');
process.exit(1);
}
});
dev
.command('doctor')
.description('Diagnose the dev session (creds, tunnels, config, ports, worktree, Temporal) — names the fix')
.option('--stack <stack>', 'Pulumi stack name')
.addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
.option('--json', 'Machine-readable output')
.action(async (opts) => {
const parentProfile = dev.opts().profile;
if (parentProfile) {
applyAwsProfileOverride(parentProfile);
}
const appRoot = findAppRoot();
const projectName = getProjectName(appRoot);
const sessionName = dev.opts().session ?? `${projectName}-dev`;
const state = loadSessionState(sessionName);
const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));
const asJson = !!opts.json || !!dev.opts().json;
const ctx = buildDoctorContext(appRoot, stack, sessionName);
const status = await runDoctor(ctx, { json: asJson });
process.exit(status === 'error' ? 1 : 0);
});
dev
.command('list')
.description('List Pulumi apps in the monorepo')
.action(() => {
const asJson = dev.opts().json;
const mono = findMonorepoRoot();
if (!mono) {
logError('Could not find monorepo root (pnpm-workspace.yaml or .git)');
process.exit(1);
}
const apps = discoverApps(mono);
if (asJson) {
const result = apps.map((app) => {
const sessionName = `${app.name}-dev`;
return {
...app,
running: sessionExists(sessionName),
session: sessionName,
};
});
console.log(JSON.stringify(result, null, 2));
}
else {
if (apps.length === 0) {
logInfo('No Pulumi apps found.');
return;
}
console.log('Apps in monorepo:');
console.log('');
for (const app of apps) {
const sessionName = `${app.name}-dev`;
const running = sessionExists(sessionName);
const icon = running ? '●' : '○';
const stacks = app.stacks.length > 0 ? ` (${app.stacks.join(', ')})` : '';
console.log(` ${icon} ${app.name.padEnd(24)} ${app.tenant.padEnd(12)} ${app.relativePath}${stacks}`);
}
console.log('');
}
});
dev
.command('test-user [name]')
.description('Get Temporal test user credentials (from Pulumi-managed test users)')
.option('--tenant <tenant>', 'Tenant name', 'mesh')
.option('--env <env>', 'Environment', 'dev')
.option('--region <region>', 'AWS region', 'us-east-2')
.action(async (name, opts) => {
const { SSMClient, GetParameterCommand, GetParametersByPathCommand } = await import('@aws-sdk/client-ssm');
const ssm = new SSMClient({ region: opts.region });
const basePath = `/mesh-platform/${opts.tenant}/${opts.env}/temporal/test-users`;
if (!name) {
try {
const resp = await ssm.send(new GetParametersByPathCommand({
Path: basePath,
Recursive: true,
WithDecryption: true,
}));
if (!resp.Parameters || resp.Parameters.length === 0) {
logWarn(`No test users found at ${basePath}`);
logInfo('Test users are defined in Pulumi config under mesh:temporal.authorization.testUsers');
logInfo("They're only available on dev stacks.");
return;
}
console.log('Temporal test users:');
console.log('');
for (const param of resp.Parameters) {
const userName = param.Name.split('/').pop();
const creds = JSON.parse(param.Value);
console.log(` ${userName}`);
console.log(` Email: ${creds.email}`);
console.log(` Password: ${creds.password}`);
console.log(` Roles: ${creds.roles.join(', ')}`);
if (creds.description)
console.log(` Note: ${creds.description}`);
console.log('');
}
logInfo('Login at: https://temporal.dev.mesh-platform.trabian.com');
}
catch (err) {
logError(`Failed to list test users: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
else {
try {
const resp = await ssm.send(new GetParameterCommand({
Name: `${basePath}/${name}`,
WithDecryption: true,
}));
if (!resp.Parameter?.Value) {
logError(`Test user '${name}' not found at ${basePath}/${name}`);
return;
}
const creds = JSON.parse(resp.Parameter.Value);
const asJson = dev.opts().json;
if (asJson) {
console.log(JSON.stringify(creds, null, 2));
}
else {
console.log(`Email: ${creds.email}`);
console.log(`Password: ${creds.password}`);
console.log(`Roles: ${creds.roles.join(', ')}`);
if (creds.description)
console.log(`Note: ${creds.description}`);
console.log('');
logInfo('Login at: https://temporal.dev.mesh-platform.trabian.com');
}
}
catch (err) {
const msg = err.message ?? String(err);
if (msg.includes('ParameterNotFound') || err.name === 'ParameterNotFound') {
logError(`Test user '${name}' not found.`);
logInfo(`List available users with: mesh dev test-user`);
}
else {
logError(`Failed to get test user: ${msg}`);
}
process.exit(1);
}
}
});
}