UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

2,403 lines 106 kB
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);
            }
        }
    });
}