@mesh-tech/mesh-cli 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +111 -56
  2. package/dist/bin/mesh.js +4843 -4077
  3. package/dist/bin/mesh.js.map +4 -4
  4. package/dist/build-info.json +2 -2
  5. package/dist/src/commands/create-app.d.ts +28 -0
  6. package/dist/src/commands/create-app.d.ts.map +1 -1
  7. package/dist/src/commands/create-app.js +45 -1
  8. package/dist/src/commands/create-app.js.map +1 -1
  9. package/dist/src/commands/dev-doctor.js +2 -2
  10. package/dist/src/commands/dev-doctor.js.map +1 -1
  11. package/dist/src/commands/init/wizard.d.ts +216 -0
  12. package/dist/src/commands/init/wizard.d.ts.map +1 -0
  13. package/dist/src/commands/init/wizard.js +471 -0
  14. package/dist/src/commands/init/wizard.js.map +1 -0
  15. package/dist/src/commands/init.d.ts +14 -6
  16. package/dist/src/commands/init.d.ts.map +1 -1
  17. package/dist/src/commands/init.js +41 -15
  18. package/dist/src/commands/init.js.map +1 -1
  19. package/dist/src/commands/local/dev-local.d.ts.map +1 -1
  20. package/dist/src/commands/local/dev-local.js +6 -0
  21. package/dist/src/commands/local/dev-local.js.map +1 -1
  22. package/dist/src/commands/login.d.ts +76 -5
  23. package/dist/src/commands/login.d.ts.map +1 -1
  24. package/dist/src/commands/login.js +161 -60
  25. package/dist/src/commands/login.js.map +1 -1
  26. package/dist/src/commands/registry.d.ts +133 -10
  27. package/dist/src/commands/registry.d.ts.map +1 -1
  28. package/dist/src/commands/registry.js +238 -86
  29. package/dist/src/commands/registry.js.map +1 -1
  30. package/dist/src/commands/temporal.js +37 -23
  31. package/dist/src/commands/temporal.js.map +3 -3
  32. package/dist/src/docs/registry-docs.d.ts.map +1 -1
  33. package/dist/src/docs/registry-docs.js +8 -3
  34. package/dist/src/docs/registry-docs.js.map +1 -1
  35. package/dist/src/program.d.ts.map +1 -1
  36. package/dist/src/program.js +5 -1
  37. package/dist/src/program.js.map +1 -1
  38. package/dist/src/utils/auth-preflight.d.ts +51 -20
  39. package/dist/src/utils/auth-preflight.d.ts.map +1 -1
  40. package/dist/src/utils/auth-preflight.js +48 -21
  41. package/dist/src/utils/auth-preflight.js.map +1 -1
  42. package/dist/src/utils/mesh-json.d.ts +58 -0
  43. package/dist/src/utils/mesh-json.d.ts.map +1 -0
  44. package/dist/src/utils/mesh-json.js +85 -0
  45. package/dist/src/utils/mesh-json.js.map +1 -0
  46. package/dist/src/utils/registry-broker.d.ts +12 -29
  47. package/dist/src/utils/registry-broker.d.ts.map +1 -1
  48. package/dist/src/utils/registry-broker.js +12 -29
  49. package/dist/src/utils/registry-broker.js.map +1 -1
  50. package/dist/src/utils/registry-identity.d.ts +170 -0
  51. package/dist/src/utils/registry-identity.d.ts.map +1 -0
  52. package/dist/src/utils/registry-identity.js +273 -0
  53. package/dist/src/utils/registry-identity.js.map +1 -0
  54. package/package.json +3 -2
  55. package/skills/core/SKILL.md +13 -8
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../../../src/utils/log.ts", "../../../src/utils/pulumi.ts", "../../../src/utils/auth-preflight.ts", "../../../src/utils/first-party-contexts.ts", "../../../src/utils/errors.ts", "../../../src/utils/context.ts", "../../../src/utils/bastion.ts", "../../../src/utils/credentials.ts", "../../../src/utils/vpn.ts", "../../../src/utils/index.ts", "../../../src/utils/pid.ts", "../../../src/utils/vpn-join.ts", "../../../src/utils/pulumi-run.ts", "../../../src/utils/kubeconfig.ts", "../../../src/utils/temporal-auth.ts", "../../../src/utils/reachability.ts", "../../../src/utils/workflow-fingerprint.ts", "../../../src/commands/dev-launch.ts", "../../../src/commands/local/seed.ts", "../../../src/commands/local/helpers.ts", "../../../src/utils/cache-home.ts", "../../../src/commands/local/stack.ts", "../../../src/commands/local/mocks.ts", "../../../../api-registry/src/hub-roles.ts", "../../../../api-registry/src/index.ts", "../../../src/commands/local/seed-zitadel.ts", "../../../src/commands/local/auth-provision.ts", "../../../src/commands/local/dev-local.ts", "../../../src/commands/local/docker-runner.ts", "../../../src/utils/stack-flag.ts", "../../../src/commands/peer-addressing.ts", "../../../src/commands/dev-doctor.ts", "../../../src/utils/worktree-identity.ts", "../../../src/commands/dev.ts", "../../../src/utils/tailscale-targets.ts", "../../../src/utils/socks-forward.ts", "../../../src/utils/tunnel-ownership.ts", "../../../src/utils/tailscale.ts", "../../../src/commands/vpn/tunnel.ts", "../../../src/commands/vpn/index.ts", "../../../src/commands/login.ts", "../../../src/utils/aws-auth.ts", "../../../src/utils/capture-history.ts", "../../../src/commands/temporal.ts", "../../../src/utils/recover-conversation.ts", "../../../src/utils/temporal-codec.ts"],
4
- "sourcesContent": ["/**\n * Logging utilities with colored output.\n *\n * Non-TTY streams (CI, agent runs, piped output) get an [HH:MM:SS] prefix on\n * every line so long quiet stretches are attributable after the fact\n * (MESH-2479); interactive terminals stay clean. Override with\n * MESH_LOG_TIMESTAMPS=1 (force on) / =0 (force off).\n */\n\nimport chalk from \"chalk\";\n\n/**\n * Pure prefix decision, exported for tests: timestamps when the env flag says\n * so, else whenever stderr is not a TTY.\n */\nexport function logPrefix(opts: {\n isTTY: boolean;\n envFlag: string | undefined;\n now: Date;\n}): string {\n const enabled = opts.envFlag === \"1\" || (opts.envFlag !== \"0\" && !opts.isTTY);\n return enabled ? `[${opts.now.toISOString().slice(11, 19)}] ` : \"\";\n}\n\nfunction prefix(): string {\n return chalk.dim(\n logPrefix({\n isTTY: !!process.stderr.isTTY,\n envFlag: process.env.MESH_LOG_TIMESTAMPS,\n now: new Date(),\n }),\n );\n}\n\nexport function logInfo(message: string): void {\n console.error(prefix() + chalk.blue(\"\u2139\"), message);\n}\n\nexport function logSuccess(message: string): void {\n console.error(prefix() + chalk.green(\"\u2713\"), message);\n}\n\nexport function logWarn(message: string): void {\n console.error(prefix() + chalk.yellow(\"\u26A0\"), message);\n}\n\nexport function logError(message: string): void {\n console.error(prefix() + chalk.red(\"\u2717\"), message);\n}\n\n/** Render elapsed milliseconds compactly: \"45s\", \"2m 30s\", \"1h 5m\". */\nexport function formatElapsed(ms: number): string {\n const totalSec = Math.max(0, Math.round(ms / 1000));\n const h = Math.floor(totalSec / 3600);\n const m = Math.floor((totalSec % 3600) / 60);\n const s = totalSec % 60;\n if (h > 0) return `${h}h ${m}m`;\n if (m > 0) return `${m}m ${s}s`;\n return `${s}s`;\n}\n\n/**\n * Periodic \"still working\" line during a long silent operation (registry\n * fetches, image builds) so a non-TTY first run never sits at 0 bytes of\n * output for minutes (MESH-2479). `touch()` marks recent real output, which\n * suppresses the next beat \u2014 the heartbeat only fills silence.\n */\nexport function startHeartbeat(\n label: string,\n intervalMs = 15_000,\n): { stop: () => void; touch: () => void } {\n const startedAt = Date.now();\n let lastOutputAt = startedAt;\n const timer = setInterval(() => {\n if (Date.now() - lastOutputAt < intervalMs) return;\n logInfo(`\u2026 still working: ${label} (${formatElapsed(Date.now() - startedAt)} elapsed)`);\n }, intervalMs);\n timer.unref?.();\n return {\n stop: () => clearInterval(timer),\n touch: () => {\n lastOutputAt = Date.now();\n },\n };\n}\n", "/**\n * Pulumi-related utilities shared across CLI commands.\n */\n\nimport { execFileSync } from \"child_process\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\n\n/**\n * Find the Pulumi app root by walking up from a starting directory.\n * Returns the first directory containing `Pulumi.yaml`, or null.\n */\nexport function findAppRoot(startDir: string): string | null {\n let dir = startDir;\n while (true) {\n if (fs.existsSync(path.join(dir, \"Pulumi.yaml\"))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Find Pulumi.<stack>.yaml files in a directory.\n * Returns an array of stack names (without the Pulumi. prefix and .yaml suffix).\n */\nexport function findStackConfigs(appRoot: string): string[] {\n return fs.readdirSync(appRoot)\n .filter((f) => /^Pulumi\\..+\\.yaml$/.test(f) && f !== \"Pulumi.yaml\")\n .map((f) => f.replace(/^Pulumi\\./, \"\").replace(/\\.yaml$/, \"\"));\n}\n\n/**\n * Get the currently selected Pulumi stack name, or null.\n */\nexport function getCurrentStack(appRoot: string): string | null {\n try {\n const result = execFileSync(\"pulumi\", [\"stack\", \"--show-name\"], {\n encoding: \"utf-8\",\n cwd: appRoot,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n return result.trim() || null;\n } catch {\n return null;\n }\n}\n\n/**\n * Read a config value from Pulumi.<stack>.yaml.\n * Simple regex-based parser \u2014 handles single-line string values.\n * For complex values, use `pulumi config` CLI instead.\n */\nexport function readStackConfig(appRoot: string, stack: string, key: string): string | null {\n const configFile = path.join(appRoot, `Pulumi.${stack}.yaml`);\n if (!fs.existsSync(configFile)) return null;\n\n const content = fs.readFileSync(configFile, \"utf-8\");\n const pattern = new RegExp(`^\\\\s+${key.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}:\\\\s*(.+)$`, \"m\");\n const match = content.match(pattern);\n if (!match) return null;\n\n return match[1]!.trim().replace(/^[\"']|[\"']$/g, \"\");\n}\n\n/**\n * Run `pulumi stack output <key> --json` with the given extra args.\n *\n * Pulumi redacts secret-tainted outputs as the literal string `\"[secret]\"`.\n * Outputs like `env.register()`'s `dev` blob are tainted because they transitively\n * include secret values (e.g. `TEMPORAL_ENCODING_KEY`, DB passwords), even though\n * much of the blob (tunnels, namespace, service scaffolding) is non-secret.\n *\n * This helper tries without `--show-secrets` first \u2014 which avoids requiring\n * the local machine to have the stack's secrets provider configured (KMS access\n * etc.) \u2014 and retries with `--show-secrets` only if the first call returns a\n * `\"[secret]\"` placeholder. KMS/secrets-provider errors are not retried because\n * they will fail the same way with the flag set.\n */\nexport function pulumiStackOutput(\n appRoot: string,\n key: string,\n extraArgs: string[],\n env?: Record<string, string>,\n): string {\n const execEnv = env ? { ...process.env, ...env } : undefined;\n // First attempt: no --show-secrets (works without PULUMI_CONFIG_PASSPHRASE / KMS)\n try {\n const result = execFileSync(\n \"pulumi\",\n [\"stack\", \"output\", key, \"--json\", ...extraArgs],\n { cwd: appRoot, encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"pipe\"], env: execEnv }\n );\n // If the output contains \"[secret]\" placeholders, retry with --show-secrets\n if (result.includes('\"[secret]\"')) {\n return execFileSync(\n \"pulumi\",\n [\"stack\", \"output\", key, \"--json\", \"--show-secrets\", ...extraArgs],\n { cwd: appRoot, encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"pipe\"], env: execEnv }\n );\n }\n return result;\n } catch (err: unknown) {\n // If the error is a secrets/KMS issue, don't retry with --show-secrets\n // (it will fail the same way). Only retry for other Pulumi errors.\n const errMsg = (err as { stderr?: string })?.stderr ?? \"\";\n if (errMsg.includes(\"kms:\") || errMsg.includes(\"KMS\") || errMsg.includes(\"secrets manager\")) {\n throw err;\n }\n return execFileSync(\n \"pulumi\",\n [\"stack\", \"output\", key, \"--json\", \"--show-secrets\", ...extraArgs],\n { cwd: appRoot, encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"pipe\"], env: execEnv }\n );\n }\n}\n", "/**\n * First-run auth preflight helpers (MESH-2479).\n *\n * `mesh start`/`mesh dev` do long work (registry tarball fetches, docker\n * builds, pnpm installs) that fails minutes in \u2014 with npm's generic advice \u2014\n * when the CodeArtifact token or AWS SSO session is stale. These helpers\n * verify auth BEFORE the long work and name the exact fixing command.\n *\n * Pure parsers/classifiers are exported separately from the impure probes so\n * the branch logic is unit-testable without network or a home directory.\n */\n\nimport * as fs from \"fs\";\nimport * as os from \"os\";\nimport * as path from \"path\";\n\n/**\n * The context that owns the `@mesh-tech` registry. There is exactly one \u2014 the\n * `mesh-platform` CodeArtifact domain every tenant pulls from \u2014 so this is the\n * context whose broker can mint a read token for anybody, and it is what a\n * printed remediation should name.\n *\n * Safe to hardcode for the same reason `--profile mesh-dev` already was: it\n * identifies the registry, not the reader's own platform. It also resolves on a\n * machine with no AWS at all, via the pinned first-party alias.\n */\nexport const REGISTRY_CONTEXT = \"mesh.dev\";\n\n/**\n * The command that fixes a stale/missing CodeArtifact token \u2014 **broker-first**,\n * so it works for someone with no AWS account at all.\n *\n * Prefer `registryLoginFix(context)` at any call site that knows a better\n * context than the registry's own.\n */\nexport const REGISTRY_LOGIN_FIX = `mesh registry login ${REGISTRY_CONTEXT}`;\n\n/**\n * The AWS-profile form. Only correct for people who **deploy**, or when the\n * platform publishes no registry broker.\n *\n * Kept as a separate named constant rather than the default: `--profile`\n * deliberately skips the broker, so printing it as the generic fix sends a\n * developer with no AWS account to a command that cannot work for them.\n */\nexport const REGISTRY_LOGIN_FIX_AWS = \"mesh registry login --profile mesh-dev\";\n\n/**\n * Render the registry fix for a known context, falling back to the registry's\n * own context when the caller has none. Always returns a command that can be\n * run as printed \u2014 a remediation the reader has to edit first is half a\n * remediation.\n */\nexport function registryLoginFix(context?: string): string {\n return `mesh registry login ${context ?? REGISTRY_CONTEXT}`;\n}\n\n/** The documented AWS SSO login form (root package.json `sso` script). */\nexport const SSO_LOGIN_FIX = \"aws sso login --sso-session=mesh # or: pnpm sso\";\n\n/**\n * The `aws configure sso-session` snippet `pnpm sso` depends on. Printed\n * verbatim when ~/.aws/config has no [sso-session mesh] section \u2014 kept here\n * so the CLI and scripts/sso-login.sh describe the same setup.\n */\nexport const SSO_SESSION_SNIPPET = `[sso-session mesh]\nsso_start_url = https://trabian.awsapps.com/start\nsso_region = us-east-1\nsso_registration_scopes = sso:account:access`;\n\n// ============================================================================\n// ~/.npmrc parsing (pure)\n// ============================================================================\n\n/**\n * The user-level npmrc `aws codeartifact login` writes into \u2014 the token line\n * and, when the namespace flag is omitted, the unscoped `registry=` line.\n *\n * One definition for the whole CLI: the doctor, the probe, and the repair have\n * to be looking at the same file or they disagree about the same machine.\n * `os.homedir()` reads `$HOME` on POSIX, so a scratch-`HOME` test still works.\n */\nexport function homeNpmrcPath(): string {\n return path.join(os.homedir(), \".npmrc\");\n}\n\nexport interface NpmrcRegistryAuth {\n /** Registry base URL, e.g. https://mesh-platform-\u2026codeartifact\u2026/npm/mesh-packages/ */\n endpoint: string;\n token: string;\n}\n\n/**\n * Extract the CodeArtifact registry endpoint + auth token from npmrc content.\n * `aws codeartifact login` writes a line shaped like:\n * //mesh-platform-\u2026d.codeartifact.<region>.amazonaws.com/npm/<repo>/:_authToken=<tok>\n * Returns null when no CodeArtifact token line is present.\n */\nexport function parseNpmrcRegistryAuth(content: string): NpmrcRegistryAuth | null {\n for (const line of content.split(\"\\n\")) {\n const match = line.trim().match(/^\\/\\/([^:]*\\.codeartifact\\.[^:]*):_authToken=(.+)$/);\n if (match && match[1] && match[2]) {\n const endpoint = `https://${match[1]}${match[1].endsWith(\"/\") ? \"\" : \"/\"}`;\n return { endpoint, token: match[2].trim() };\n }\n }\n return null;\n}\n\n/**\n * Find an **unscoped** `registry=` line in npmrc content that points at\n * CodeArtifact \u2014 the line `aws codeartifact login` writes when it is run\n * WITHOUT `--namespace @mesh-tech`.\n *\n * That line makes CodeArtifact the *default* registry, so every public package\n * (lodash, react, \u2026) is fetched through it with the CodeArtifact token. When\n * the token expires 12 hours later, `npm install` of anything at all fails\n * with E401 \u2014 the private-scope breakage everyone expects, plus a total public\n * npm outage nobody does.\n *\n * Deliberately narrow \u2014 it must only match a line that is BOTH unscoped AND a\n * CodeArtifact host, so it never touches:\n * - `@mesh-tech:registry=\u2026` (scoped: the line we WANT)\n * - `//\u2026:_authToken=\u2026` (auth, unaffected by the default registry)\n * - `registry=https://npm.acme.internal/` (a team's legitimate private mirror)\n *\n * Returns the offending line verbatim (as it appears in the file), or null.\n */\nexport function findUnscopedCodeArtifactRegistry(content: string): string | null {\n for (const line of content.split(\"\\n\")) {\n const trimmed = line.trim();\n // npmrc comments start with ; or #\n if (!trimmed || trimmed.startsWith(\";\") || trimmed.startsWith(\"#\")) continue;\n // Anchored at `registry` \u2014 a scoped line starts with `@scope:`, so it can't match.\n const match = trimmed.match(/^registry\\s*=\\s*(\\S+)$/);\n if (!match || !match[1]) continue;\n if (isCodeArtifactRegistryUrl(match[1])) return line;\n }\n return null;\n}\n\n/** True when a registry URL's *host* is a CodeArtifact endpoint. */\nfunction isCodeArtifactRegistryUrl(value: string): boolean {\n try {\n return new URL(value).hostname.includes(\".codeartifact.\");\n } catch {\n // Not a parseable URL (env-var interpolation, protocol-relative, \u2026) \u2014\n // fall back to a substring test so we don't miss an obvious hijack.\n return value.includes(\".codeartifact.\");\n }\n}\n\n/**\n * Remove every unscoped CodeArtifact `registry=` line from npmrc content.\n * Pure: returns the rewritten content plus the first line removed (null when\n * the content was already clean, in which case `content` is returned\n * unchanged and callers should skip the write entirely).\n */\nexport function stripUnscopedCodeArtifactRegistry(content: string): {\n content: string;\n removed: string | null;\n} {\n const offender = findUnscopedCodeArtifactRegistry(content);\n if (offender === null) return { content, removed: null };\n\n const kept = content\n .split(\"\\n\")\n .filter((line) => findUnscopedCodeArtifactRegistry(line) === null);\n // split/join round-trips the file's trailing-newline state exactly.\n return { content: kept.join(\"\\n\"), removed: offender };\n}\n\n/**\n * The `//host/path/:_authToken=` key `aws codeartifact login` writes for a\n * registry endpoint \u2014 the endpoint with its scheme stripped and a leading `//`.\n *\n * Pure and shared so the broker path writes byte-identical lines to the AWS CLI\n * path. Two spellings of the same key would leave a stale token line behind\n * that npm might prefer, and the symptom (E401 hours later) points nowhere\n * near the cause.\n */\nexport function npmrcAuthKeyForEndpoint(endpoint: string): string {\n const withoutScheme = endpoint.replace(/^https?:\\/\\//, \"\");\n return `//${withoutScheme.endsWith(\"/\") ? withoutScheme : `${withoutScheme}/`}`;\n}\n\n/**\n * Upsert `key=value` lines into npmrc content: replace the existing line for a\n * key when present, append it otherwise, and leave every other line untouched.\n *\n * Pure. Always emits a trailing newline, and never reorders lines a\n * user put there themselves \u2014 an npmrc is a config file people hand-edit, and\n * a token refresh that rewrites the whole thing is how unrelated settings\n * silently disappear.\n */\nexport function upsertNpmrcLines(\n content: string,\n entries: ReadonlyArray<{ key: string; value: string }>,\n): string {\n let lines = content.length ? content.split(\"\\n\") : [];\n const trailingNewline = content.endsWith(\"\\n\");\n if (trailingNewline) lines = lines.slice(0, -1);\n\n for (const { key, value } of entries) {\n const line = `${key}=${value}`;\n const idx = lines.findIndex((l) => {\n const trimmed = l.trim();\n if (!trimmed || trimmed.startsWith(\";\") || trimmed.startsWith(\"#\")) return false;\n const eq = trimmed.indexOf(\"=\");\n return eq > 0 && trimmed.slice(0, eq).trim() === key;\n });\n if (idx >= 0) lines[idx] = line;\n else lines.push(line);\n }\n\n return lines.join(\"\\n\") + \"\\n\";\n}\n\n// ============================================================================\n// Registry token probe\n// ============================================================================\n\nexport type RegistryTokenState = \"fresh\" | \"expired\" | \"missing\" | \"unreachable\";\n\n/** Map an HTTP status from an authenticated registry request to a token state. */\nexport function classifyRegistryStatus(status: number): \"fresh\" | \"expired\" {\n // 401/403 mean the token itself was rejected. Anything else (200, 404, \u2026)\n // means auth was accepted \u2014 the token is usable.\n return status === 401 || status === 403 ? \"expired\" : \"fresh\";\n}\n\nexport interface RegistryProbe {\n state: RegistryTokenState;\n detail?: string;\n}\n\n/**\n * Verify the CodeArtifact token in ~/.npmrc actually works, with one small\n * authenticated metadata request (bounded by `timeoutMs`). Never throws.\n */\nexport async function probeRegistryToken(opts?: {\n npmrcPath?: string;\n timeoutMs?: number;\n fetchFn?: typeof fetch;\n}): Promise<RegistryProbe> {\n const npmrcPath = opts?.npmrcPath ?? homeNpmrcPath();\n const timeoutMs = opts?.timeoutMs ?? 8_000;\n const fetchFn = opts?.fetchFn ?? fetch;\n\n let auth: NpmrcRegistryAuth | null = null;\n try {\n auth = parseNpmrcRegistryAuth(fs.readFileSync(npmrcPath, \"utf-8\"));\n } catch {\n // unreadable/missing ~/.npmrc \u2014 same as no auth configured\n }\n if (!auth) return { state: \"missing\" };\n\n try {\n const res = await fetchFn(`${auth.endpoint}${encodeURIComponent(\"@mesh-tech/hub\")}`, {\n method: \"GET\",\n headers: { authorization: `Bearer ${auth.token}` },\n signal: AbortSignal.timeout(timeoutMs),\n });\n const state = classifyRegistryStatus(res.status);\n return state === \"expired\"\n ? { state, detail: `registry answered HTTP ${res.status} for an authenticated request` }\n : { state };\n } catch (err) {\n return {\n state: \"unreachable\",\n detail: err instanceof Error ? err.message : String(err),\n };\n }\n}\n\n// ============================================================================\n// npm / AWS error classification (pure)\n// ============================================================================\n\n/** True when npm output/an error message is an auth failure (E401/E403). */\nexport function isNpmAuthErrorText(text: string): boolean {\n return /\\bE?40[13]\\b|unauthenticated|unable to authenticate|authentication (required|failed)/i.test(\n text,\n );\n}\n\n/** True when an npm exec error (with stderr/message) is an auth failure. */\nexport function isNpmAuthError(err: unknown): boolean {\n const e = err as { stderr?: unknown; message?: unknown };\n const text = [e?.stderr, e?.message]\n .map((v) => (v === null || v === undefined ? \"\" : String(v)))\n .join(\"\\n\");\n return isNpmAuthErrorText(text);\n}\n\n/** True when an AWS SDK/CLI error message means the ambient session expired. */\nexport function isExpiredAwsTokenMessage(message: string): boolean {\n return /expired|invalid.*(security )?token|token.*(is )?invalid|no credential|could not load credentials|could not be found|sso session/i.test(\n message,\n );\n}\n\n// ============================================================================\n// ~/.aws/config parsing (pure) \u2014 `pnpm sso` / --profile prerequisites\n// ============================================================================\n\n/** Names of `[sso-session <name>]` sections in an AWS config file. */\nexport function parseSsoSessionNames(content: string): string[] {\n const names: string[] = [];\n for (const line of content.split(\"\\n\")) {\n const match = line.trim().match(/^\\[sso-session\\s+([^\\]]+)\\]$/);\n if (match && match[1]) names.push(match[1].trim());\n }\n return names;\n}\n\n/** True when the AWS config file declares the named profile. */\nexport function awsConfigHasProfile(content: string, profile: string): boolean {\n const pattern = new RegExp(\n `^\\\\[(?:profile\\\\s+)?${profile.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}\\\\]$`,\n );\n return content.split(\"\\n\").some((line) => pattern.test(line.trim()));\n}\n\n/** Read ~/.aws/config (honoring AWS_CONFIG_FILE); \"\" when unreadable. */\nexport function readAwsConfig(): string {\n const file = process.env.AWS_CONFIG_FILE ?? path.join(os.homedir(), \".aws\", \"config\");\n try {\n return fs.readFileSync(file, \"utf-8\");\n } catch {\n return \"\";\n }\n}\n\n// ============================================================================\n// App dependency scan (for the dev doctor's registry check)\n// ============================================================================\n\n/** True when the app's package.json depends on any @mesh-tech/* package. */\nexport function appUsesMeshPackages(appRoot: string): boolean {\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(appRoot, \"package.json\"), \"utf-8\")) as {\n dependencies?: Record<string, string>;\n devDependencies?: Record<string, string>;\n optionalDependencies?: Record<string, string>;\n };\n return [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some(\n (deps) => deps && Object.keys(deps).some((name) => name.startsWith(\"@mesh-tech/\")),\n );\n } catch {\n return false;\n }\n}\n", "/**\n * Pinned short-name \u2192 FQDN map for FIRST-PARTY Mesh platforms.\n *\n * ## Why this file exists\n *\n * `mesh login mesh.dev` on a clean laptop used to be unresolvable. Discovery\n * has two paths: SSM (needs AWS credentials \u2014 the thing we are removing) and\n * the anonymous `https://cli.<domain>/.well-known/mesh.json` endpoint. The\n * HTTPS path is deliberately gated to contexts with \u22652 dots, because hitting\n * `https://cli.<short-name>/\u2026` for an arbitrary short context would let whoever\n * registers that domain seed our cached issuer/clientId \u2014 login phishing.\n *\n * So the short context every internal doc tells a new developer to use was\n * exactly the one that could not resolve without AWS.\n *\n * ## Why a compiled-in map is the safe fix\n *\n * The alias is resolved from THIS table, which ships inside the CLI and is\n * changed only by a reviewed commit. It is not read from the network, from\n * `~/.config/mesh`, or from an environment variable, so it cannot be seeded by\n * an attacker \u2014 which is the property the \u22652-dot guard protects and the reason\n * that guard must NOT be loosened instead.\n *\n * ## Adding an entry\n *\n * Only add a domain Trabian controls and has verified serves\n * `/.well-known/mesh.json`. The map is the trust anchor; a typo here is a\n * phishing vector, so treat additions as a security review, not a config edit.\n */\nexport const FIRST_PARTY_CONTEXTS: Readonly<Record<string, string>> = Object.freeze({\n // Verified 2026-08-14: https://cli.dev.platform.meshtech.io/.well-known/mesh.json\n // answers with the platform's issuer + CLI client id + registryBroker,\n // anonymously. (Repointed from dev.trabian.meshtech.io \u2014 the trabian tenant\n // platform lives in a different AWS account with no registry; the\n // platform-dev stack in the registry-owning account is the developer front\n // door. mesh-sandbox#32 / #3587.)\n \"mesh.dev\": \"dev.platform.meshtech.io\",\n});\n\n/**\n * The FQDN to run anonymous HTTPS discovery against for a short first-party\n * context, or null when the context is not a pinned alias.\n *\n * `Object.hasOwn` rather than a bare lookup: a plain-object index answers\n * `constructor` / `toString` from the prototype chain, and this function's\n * answer decides which host we trust with our issuer + client id.\n */\nexport function firstPartyDomainFor(context: string): string | null {\n if (!Object.hasOwn(FIRST_PARTY_CONTEXTS, context)) return null;\n const domain = FIRST_PARTY_CONTEXTS[context];\n return typeof domain === \"string\" && domain ? domain : null;\n}\n", "/**\n * Shared CLI error type with structured remediation.\n *\n * Every failure along the golden path should tell the user the exact `mesh`\n * command (or doc) that fixes it. Remediation strings must be commands or\n * docs \u2014 never \"ask <person>\".\n */\n\nimport chalk from \"chalk\";\n\nexport interface Remediation {\n /** Exact command that fixes the failure, e.g. \"mesh login trabian-dev\" */\n command?: string;\n /** Documentation URL or repo-relative doc path with more context */\n docs?: string;\n /** Whether the owning command can apply this fix itself via --fix */\n autoFixable?: boolean;\n}\n\nexport interface MeshCliErrorOptions {\n remediation?: Remediation;\n /** Process exit code (default 1) */\n exitCode?: number;\n cause?: unknown;\n}\n\nexport class MeshCliError extends Error {\n readonly remediation?: Remediation;\n readonly exitCode: number;\n\n constructor(message: string, options: MeshCliErrorOptions = {}) {\n super(message, options.cause !== undefined ? { cause: options.cause } : undefined);\n this.name = \"MeshCliError\";\n this.remediation = options.remediation;\n this.exitCode = options.exitCode ?? 1;\n }\n}\n\n/**\n * Render an error's BODY \u2014 the message plus its \"\u2192 run: <command>\" /\n * \"\u2192 see: <docs>\" remediation lines, with no leading severity glyph.\n *\n * Use this when the caller supplies its own glyph: a recovery path that logs a\n * refusal through `logWarn` (which prefixes \u26A0) and carries on would otherwise\n * print \"\u26A0 \u2717 \u2026\". The remediation lines are the part that must survive \u2014 they\n * name the corrupt file to delete.\n */\nexport function renderErrorBody(err: unknown): string {\n const message = err instanceof Error ? err.message : String(err);\n if (!(err instanceof MeshCliError)) return message;\n const lines = [message];\n if (err.remediation?.command) {\n lines.push(chalk.cyan(\" \u2192 run: \") + err.remediation.command);\n }\n if (err.remediation?.docs) {\n lines.push(chalk.cyan(\" \u2192 see: \") + err.remediation.docs);\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * Render an error for humans. MeshCliError gets its remediation appended as\n * \"\u2192 run: <command>\" / \"\u2192 see: <docs>\" lines; anything else renders its message.\n */\nexport function renderError(err: unknown): string {\n return chalk.red(\"\u2717\") + \" \" + renderErrorBody(err);\n}\n\n/**\n * Render an error as JSON for agents. Same structure as the human output:\n * { error, remediation?: { command?, docs?, autoFixable? } }.\n */\nexport function renderErrorJson(err: unknown): string {\n if (err instanceof MeshCliError) {\n return JSON.stringify({\n error: err.message,\n ...(err.remediation ? { remediation: err.remediation } : {}),\n });\n }\n return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });\n}\n\nlet jsonPayloadEmitted = false;\n\n/**\n * Print a command's `--json` payload \u2014 the single machine-readable document on\n * stdout \u2014 and record that it happened.\n *\n * A command that emits a payload and *then* throws (the `mesh init` doctor\n * throws by design whenever a check fails) used to produce TWO concatenated\n * JSON documents on stdout, because `handleCliError` appended `{\"error\":\u2026}`.\n * Two documents is not JSON: `mesh init app-tenant --json | jq` failed to parse\n * on exactly the failure path a script cares about. Routing every payload\n * through here lets the handler know stdout is already spoken for.\n */\nexport function emitJsonPayload(payload: unknown): void {\n console.log(JSON.stringify(payload, null, 2));\n jsonPayloadEmitted = true;\n}\n\n/** True once a command has written its `--json` payload to stdout. */\nexport function hasEmittedJsonPayload(): boolean {\n return jsonPayloadEmitted;\n}\n\n/** Test seam \u2014 the flag is module state, so specs have to be able to clear it. */\nexport function resetJsonPayloadEmitted(): void {\n jsonPayloadEmitted = false;\n}\n\n/**\n * Top-level handler for bin/mesh.ts. Prints the error (JSON when the\n * invocation asked for --json anywhere, since per-command flags aren't\n * parsed once dispatch has failed) and exits with the error's code.\n *\n * When the command already emitted a `--json` payload, that payload IS the\n * machine surface \u2014 and it says strictly more than this error would (the\n * doctor's payload carries `ok:false` plus a per-check `fix` command). So the\n * error goes to stderr as human diagnostics and stdout keeps exactly one\n * parseable document.\n */\nexport function handleCliError(err: unknown): never {\n const wantsJson = process.argv.includes(\"--json\");\n if (wantsJson && !hasEmittedJsonPayload()) {\n console.log(renderErrorJson(err));\n } else {\n console.error(renderError(err));\n }\n process.exit(err instanceof MeshCliError ? err.exitCode : 1);\n}\n", "/**\n * Context detection - determines tenant, stage, and platform environment\n * from the current directory and environment variables.\n */\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport { logInfo } from \"./log.js\";\n\nexport interface MeshContext {\n tenant: string;\n stage: string;\n platformEnv: string;\n}\n\n/**\n * Find a file by walking up directories from the starting directory.\n * Returns the full path to the file if found, undefined otherwise.\n */\nfunction findFileUpward(filename: string, startDir: string = process.cwd()): string | undefined {\n let currentDir = startDir;\n const root = path.parse(currentDir).root;\n\n while (currentDir !== root) {\n const filePath = path.join(currentDir, filename);\n if (fs.existsSync(filePath)) {\n return filePath;\n }\n currentDir = path.dirname(currentDir);\n }\n return undefined;\n}\n\n/**\n * Detect tenant and stage from current directory and environment.\n *\n * Priority for stage:\n * 1. --stage flag (passed as stageArg)\n * 2. MESH_STAGE env var\n * 3. SST_STAGE env var\n * 4. .sst/stage file\n * 5. Pulumi config file name\n * 6. Default: \"dev\"\n *\n * Priority for tenant:\n * 1. MESH_TENANT env var\n * 2. config.ts tenant field\n * 3. Pulumi config mesh:tenant\n * 4. Directory path (tenants/{tenant}/...)\n * 5. Default: \"mesh\"\n */\nexport function detectContext(stageArg?: string): MeshContext {\n let tenant: string | undefined;\n let stage: string | undefined;\n\n const cwd = process.cwd();\n\n // Try to detect from Pulumi.yaml\n if (fs.existsSync(\"Pulumi.yaml\")) {\n // Look for Pulumi.*.yaml config files\n const files = fs.readdirSync(\".\").filter(\n (f) => f.startsWith(\"Pulumi.\") && f.endsWith(\".yaml\") && f !== \"Pulumi.yaml\"\n );\n\n const configFile = files[0];\n if (configFile) {\n const content = fs.readFileSync(configFile, \"utf-8\");\n\n // Extract tenant from mesh:tenant config\n const tenantMatch = content.match(/^\\s*mesh:tenant:\\s*[\"']?([^\"'\\n]+)[\"']?/m);\n if (tenantMatch?.[1]) {\n tenant = tenantMatch[1].trim();\n }\n\n // Extract stage from filename: Pulumi.<stage>.yaml\n const stageMatch = configFile.match(/Pulumi\\.(.+)\\.yaml/);\n if (stageMatch?.[1]) {\n stage = stageMatch[1];\n }\n }\n }\n\n // Try to detect from SST config (walk up directories to find it)\n const sstConfigPath = findFileUpward(\"sst.config.ts\");\n let sstDir: string | undefined;\n\n if (sstConfigPath) {\n sstDir = path.dirname(sstConfigPath);\n\n // Check parent config.ts for tenant (relative to sst.config.ts location)\n const parentConfigPath = path.join(sstDir, \"..\", \"config.ts\");\n if (fs.existsSync(parentConfigPath)) {\n const configContent = fs.readFileSync(parentConfigPath, \"utf-8\");\n const tenantMatch = configContent.match(/tenant:\\s*[\"']([^\"']+)[\"']/);\n if (tenantMatch) {\n tenant = tenantMatch[1];\n }\n }\n\n // Check .sst/stage for current stage (relative to sst.config.ts location)\n const sstStagePath = path.join(sstDir, \".sst\", \"stage\");\n if (fs.existsSync(sstStagePath)) {\n stage = fs.readFileSync(sstStagePath, \"utf-8\").trim();\n }\n }\n\n // Fallback: infer tenant from directory path\n if (!tenant) {\n const pathMatch = cwd.match(/tenants\\/([^/]+)/);\n if (pathMatch) {\n tenant = pathMatch[1];\n }\n }\n\n // Apply priority: stageArg > MESH_STAGE > SST_STAGE > detected > default\n stage = stageArg || process.env.MESH_STAGE || process.env.SST_STAGE || stage || \"dev\";\n tenant = process.env.MESH_TENANT || tenant || \"mesh\";\n\n // Resolve platform environment from config.ts if available\n let platformEnv = stage;\n let platformEnvMap: Record<string, string> = {};\n let defaultPlatformEnv: string | undefined;\n\n // Try to read platformEnvMap and defaultPlatformEnv from config.ts (relative to sst.config.ts location)\n const configPath = sstDir ? path.join(sstDir, \"..\", \"config.ts\") : \"../config.ts\";\n if (fs.existsSync(configPath)) {\n const configContent = fs.readFileSync(configPath, \"utf-8\");\n\n // Parse platformEnvMap object\n const mapMatch = configContent.match(/platformEnvMap:\\s*\\{([^}]+)\\}/);\n if (mapMatch?.[1]) {\n const entries = mapMatch[1].matchAll(/(\\w+):\\s*[\"']([^\"']+)[\"']/g);\n for (const entry of entries) {\n const key = entry[1];\n const value = entry[2];\n if (key && value) {\n platformEnvMap[key] = value;\n }\n }\n }\n\n // Parse defaultPlatformEnv\n const defaultMatch = configContent.match(/defaultPlatformEnv:\\s*[\"']([^\"']+)[\"']/);\n if (defaultMatch?.[1]) {\n defaultPlatformEnv = defaultMatch[1];\n }\n }\n\n // Parse env from stage by stripping tenant prefix (matches Pulumi pattern)\n // e.g., \"mesh-dev-temporal\" \u2192 env \"dev-temporal\"\n const tenantPrefix = `${tenant}-`;\n const parsedEnv = stage.startsWith(tenantPrefix)\n ? stage.slice(tenantPrefix.length)\n : stage;\n\n // Use config mapping if available, then defaultPlatformEnv, otherwise use parsed env directly\n platformEnv = platformEnvMap[parsedEnv] ?? platformEnvMap[stage] ?? defaultPlatformEnv ?? parsedEnv;\n\n logInfo(`Stage: ${stage}, Platform: ${platformEnv}, Tenant: ${tenant}`);\n\n return { tenant, stage, platformEnv };\n}\n", "/**\n * Bastion utilities for SSM tunneling to platform services\n */\n\nimport { SSMClient, GetParameterCommand } from \"@aws-sdk/client-ssm\";\nimport { logInfo, logSuccess, logError } from \"./log.js\";\n\n/**\n * Service endpoint info\n */\nexport interface ServiceEndpoint {\n host: string;\n port: number;\n}\n\n/**\n * Platform bastion info from SSM\n */\nexport interface PlatformBastionInfo {\n instanceId: string;\n securityGroupId: string;\n services: Record<string, ServiceEndpoint>;\n}\n\n/**\n * Legacy bastion info (for backward compatibility with db commands)\n */\nexport interface BastionInfo {\n instanceId: string;\n rdsEndpoint: string;\n rdsPort: number;\n}\n\n/**\n * Get platform bastion info from SSM parameter store.\n *\n * Reads the platformBastion key from the platform export at\n * /mesh-platform/{tenant}/{platformEnv}/platform. Falls back to the legacy\n * separate parameter at /mesh-platform/{tenant}/{platformEnv}/platform-bastion\n * for backward compatibility.\n */\nexport async function getPlatformBastionInfo(\n tenant: string,\n platformEnv: string,\n region?: string\n): Promise<PlatformBastionInfo> {\n const ssm = new SSMClient(region ? { region } : {});\n\n // Captured so the final error can distinguish a CREDS failure (the common\n // case \u2014 stale/expired ambient AWS creds, or creds lacking cross-tenant read\n // on the hub param) from a genuinely missing bastion. Without this the read\n // error is swallowed and every failure looks like \"bastion not deployed\".\n let primaryError: unknown;\n\n // Primary: read from the platform export (platformBastion is a nested key)\n const platformPath = `/mesh-platform/${tenant}/${platformEnv}/platform`;\n logInfo(`Looking up platform bastion from ${platformPath}...`);\n\n try {\n const response = await ssm.send(\n new GetParameterCommand({ Name: platformPath })\n );\n\n if (response.Parameter?.Value) {\n const platform = JSON.parse(response.Parameter.Value);\n if (platform.platformBastion) {\n const info = platform.platformBastion as PlatformBastionInfo;\n logSuccess(`Found bastion: ${info.instanceId}`);\n\n const serviceNames = Object.keys(info.services);\n if (serviceNames.length > 0) {\n logInfo(`Available services: ${serviceNames.join(\", \")}`);\n }\n\n return info;\n }\n }\n } catch (err) {\n // Don't swallow: keep the real reason (AccessDenied / ExpiredToken / \u2026) so\n // the final error can be actionable. Then try the legacy path.\n primaryError = err;\n }\n\n // Fallback: legacy separate parameter\n const legacyPath = `/mesh-platform/${tenant}/${platformEnv}/platform-bastion`;\n logInfo(`Trying legacy path ${legacyPath}...`);\n\n try {\n const response = await ssm.send(\n new GetParameterCommand({ Name: legacyPath })\n );\n\n if (!response.Parameter?.Value) {\n throw new Error(`Platform bastion not found`);\n }\n\n const info = JSON.parse(response.Parameter.Value) as PlatformBastionInfo;\n logSuccess(`Found bastion: ${info.instanceId}`);\n\n const serviceNames = Object.keys(info.services);\n if (serviceNames.length > 0) {\n logInfo(`Available services: ${serviceNames.join(\", \")}`);\n }\n\n return info;\n } catch (legacyError) {\n // Classify on the PRIMARY read's error (the meaningful one) \u2014 the legacy\n // path is usually just ParameterNotFound and hides the real cause.\n const cause = primaryError ?? legacyError;\n const name = (cause as { name?: string })?.name ?? \"\";\n const credsProblem = /Expired|UnrecognizedClient|InvalidClientTokenId|InvalidSignature|CredentialsProviderError|AccessDenied/i.test(\n name\n );\n\n if (credsProblem) {\n logError(\n `Could not read the platform bastion from ${platformPath} \u2014 AWS error: ${name}.`\n );\n logInfo(\n \"This is almost always a CREDENTIALS problem, not a missing bastion.\"\n );\n logInfo(\n \" \u2022 The read uses the ambient AWS creds of this process; they must be valid AND able to read the HUB param above.\"\n );\n logInfo(\n \" \u2022 Check: `aws sts get-caller-identity` (ExpiredToken \u2192 refresh; AccessDenied \u2192 those creds lack hub read \u2014 use InfraAdmin-grade creds).\"\n );\n logInfo(\n \" \u2022 Stale creds often hide in the tmux GLOBAL env (`tmux show-environment -g | grep AWS_`); a per-shell `unset` won't clear them.\"\n );\n } else {\n logError(`Platform bastion not found in ${platformPath} or ${legacyPath}`);\n logInfo(\"Make sure platformBastion is enabled in your platform config and deployed.\");\n }\n throw cause;\n }\n}\n\n/**\n * Get bastion info from SSM parameter store (legacy format).\n * Reads from /mesh/{tenant}/{platformEnv}/platform-bastion and extracts RDS info.\n *\n * @deprecated Use getPlatformBastionInfo instead\n */\nexport async function getBastionInfo(\n tenant: string,\n platformEnv: string\n): Promise<BastionInfo> {\n const info = await getPlatformBastionInfo(tenant, platformEnv);\n\n const rdsService = info.services[\"rds\"];\n if (!rdsService) {\n throw new Error(\"RDS service not available in platform bastion. Is RDS enabled?\");\n }\n\n return {\n instanceId: info.instanceId,\n rdsEndpoint: rdsService.host,\n rdsPort: rdsService.port,\n };\n}\n", "/**\n * Database credential utilities\n */\n\nimport {\n SecretsManagerClient,\n GetSecretValueCommand,\n} from \"@aws-sdk/client-secrets-manager\";\nimport { logInfo, logSuccess } from \"./log.js\";\n\nexport interface DatabaseCredentials {\n username: string;\n password: string;\n host: string;\n port: number;\n database: string;\n}\n\n/**\n * Options for credential lookup\n */\nexport interface CredentialLookupOptions {\n /** App name (e.g., \"rdc\") - if provided, uses path mesh/{tenant}/{stage}/{app}/db-credentials */\n app?: string;\n}\n\n/**\n * Get DATABASE_URL from Secrets Manager.\n *\n * Credential path patterns:\n * - With app: mesh/{tenant}/{stage}/{app}/db-credentials\n * - Without app: mesh/{tenant}/{stage}/db-credentials\n *\n * Returns the DATABASE_URL string directly. Use rewriteDatabaseUrl() to\n * modify host/port for tunnel usage.\n */\nexport async function getDatabaseUrl(\n tenant: string,\n stage: string,\n options?: CredentialLookupOptions\n): Promise<string> {\n const secretsManager = new SecretsManagerClient({});\n\n // Build secret path - with or without app name\n const secretName = options?.app\n ? `mesh/${tenant}/${stage}/${options.app}/db-credentials`\n : `mesh/${tenant}/${stage}/db-credentials`;\n logInfo(`Looking up credentials at ${secretName}...`);\n\n try {\n const response = await secretsManager.send(\n new GetSecretValueCommand({ SecretId: secretName })\n );\n\n if (response.SecretString) {\n const secret = JSON.parse(response.SecretString);\n\n // Handle DATABASE_URL format (preferred)\n if (secret.DATABASE_URL) {\n const parsed = new URL(secret.DATABASE_URL);\n logSuccess(`Got credentials for user: ${parsed.username}`);\n return secret.DATABASE_URL;\n }\n\n // Handle individual fields format (legacy)\n if (secret.username && secret.password) {\n logSuccess(`Got credentials for user: ${secret.username}`);\n const database = secret.dbname ?? secret.database ?? \"postgres\";\n return `postgresql://${secret.username}:${encodeURIComponent(secret.password)}@${secret.host ?? \"localhost\"}:${secret.port ?? 5432}/${database}`;\n }\n\n throw new Error(`Secret ${secretName} has unexpected format (needs DATABASE_URL or username/password)`);\n }\n } catch (error) {\n if ((error as Error).message?.includes(\"unexpected format\")) {\n throw error;\n }\n // Secret not found - will be caught by caller\n }\n\n throw new Error(`Could not find credentials at ${secretName}`);\n}\n\n/**\n * @deprecated Use getDatabaseUrl instead - returns DATABASE_URL directly\n */\nexport async function getDbCredentials(\n tenant: string,\n stage: string,\n _platformEnv: string,\n rdsHost: string,\n rdsPort: number,\n options?: CredentialLookupOptions\n): Promise<DatabaseCredentials> {\n const databaseUrl = await getDatabaseUrl(tenant, stage, options);\n const parsed = new URL(databaseUrl);\n\n return {\n username: parsed.username,\n password: decodeURIComponent(parsed.password),\n host: rdsHost,\n port: rdsPort,\n database: parsed.pathname.slice(1),\n };\n}\n\n/**\n * Options for rewriting DATABASE_URL\n */\nexport interface DatabaseUrlRewriteOptions {\n /** New host:port endpoint (e.g., \"localhost:5432\") */\n endpoint?: string;\n /** SSL mode (require, no-verify, disable) */\n sslMode?: string;\n}\n\n/**\n * Rewrite DATABASE_URL with new endpoint and/or SSL mode.\n * Preserves username, password, database name, and other query params.\n *\n * Used by both mesh-env-exec (for sst dev) and mesh db exec (for ad-hoc commands).\n */\nexport function rewriteDatabaseUrl(\n url: string,\n options: DatabaseUrlRewriteOptions\n): string {\n try {\n const parsed = new URL(url);\n\n // Rewrite endpoint if provided\n if (options.endpoint) {\n if (options.endpoint.includes(\":\")) {\n const colonIndex = options.endpoint.lastIndexOf(\":\");\n parsed.hostname = options.endpoint.slice(0, colonIndex);\n parsed.port = options.endpoint.slice(colonIndex + 1);\n } else {\n parsed.hostname = options.endpoint;\n parsed.port = parsed.port || \"5432\";\n }\n }\n\n // Set SSL mode if provided\n if (options.sslMode) {\n parsed.searchParams.set(\"sslmode\", options.sslMode);\n // For Prisma: accept invalid/self-signed certificates through tunnels\n if (options.sslMode === \"require\" || options.sslMode === \"no-verify\") {\n parsed.searchParams.set(\"sslaccept\", \"accept_invalid_certs\");\n }\n }\n\n return parsed.toString();\n } catch {\n // If URL parsing fails, return original\n return url;\n }\n}\n\n/**\n * Build a DATABASE_URL from credentials.\n */\nexport function buildDatabaseUrl(\n creds: DatabaseCredentials,\n options?: DatabaseUrlRewriteOptions\n): string {\n const host = options?.endpoint?.split(\":\")[0] ?? creds.host;\n const port = options?.endpoint?.split(\":\")[1] ?? String(creds.port);\n\n let url = `postgresql://${creds.username}:${encodeURIComponent(creds.password)}@${host}:${port}/${creds.database}`;\n\n if (options?.sslMode) {\n url += `?sslmode=${options.sslMode}`;\n if (options.sslMode === \"require\" || options.sslMode === \"no-verify\") {\n url += \"&sslaccept=accept_invalid_certs\";\n }\n }\n\n return url;\n}\n\n/**\n * Try to read database info from SST outputs (when sst dev has run).\n */\nexport async function readSstOutputs(): Promise<{\n databaseUrl?: string;\n databaseName?: string;\n secretArn?: string;\n} | null> {\n const fs = await import(\"fs\");\n\n if (!fs.existsSync(\".sst/outputs.json\")) {\n return null;\n }\n\n try {\n const content = fs.readFileSync(\".sst/outputs.json\", \"utf-8\");\n const outputs = JSON.parse(content);\n\n if (outputs.databaseUrl) {\n logInfo(\"Found SST outputs with database config\");\n\n // Extract secret ARN from URL if present\n const secretArnMatch = outputs.databaseUrl.match(/secretArn=([^&]+)/);\n const secretArn = secretArnMatch\n ? decodeURIComponent(secretArnMatch[1])\n : undefined;\n\n return {\n databaseUrl: outputs.databaseUrl,\n databaseName: outputs.databaseName,\n secretArn,\n };\n }\n } catch {\n // Ignore parse errors\n }\n\n return null;\n}\n", "/**\n * VPN detection utilities\n */\n\nimport { execFile, execFileSync } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nconst TAILSCALE_PATHS = [\n \"tailscale\",\n \"/Applications/Tailscale.app/Contents/MacOS/Tailscale\",\n];\n\n/**\n * Detect whether a Tailscale VPN connection is active.\n *\n * Uses socket-aware `execTailscaleCmd` so it works in both kernel tun mode\n * (standard socket) and legacy userspace networking (/tmp/tailscale.sock).\n * Returns `true` only when BackendState is \"Running\" and Self.Online is true.\n * Returns `false` silently on any failure (binary missing, daemon stopped,\n * parse error, missing fields).\n */\nexport async function isVpnConnected(): Promise<boolean> {\n try {\n const stdout = await execTailscaleCmd([\"status\", \"--json\"]);\n const status: TailscaleDetailedStatus = JSON.parse(stdout);\n return status.BackendState === \"Running\" && status.Self?.Online === true;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// DNS record management & Tailscale info\n// ---------------------------------------------------------------------------\n\n/** Standard socket (kernel tun mode), then legacy (userspace mode). */\nconst TAILSCALE_SOCKET_PATHS = [\"/var/run/tailscale/tailscaled.sock\", \"/tmp/tailscale.sock\"];\n\n/** Connection details for the Headscale DNS writer sidecar. */\nexport interface HeadscaleDnsConfig {\n namespace: string;\n pod: string;\n container: string;\n filePath: string;\n}\n\n/**\n * Build a HeadscaleDnsConfig from tenant/env, following the same naming\n * convention as `mesh vpn` (\u2060{tenant}-{env}-headscale).\n *\n * Pod name, container, and file path are infrastructure conventions that\n * rarely change \u2014 override via the optional parameter if they do.\n */\nexport function headscaleDnsConfig(\n tenant = \"mesh\",\n env = \"dev\",\n overrides?: Partial<HeadscaleDnsConfig>,\n): HeadscaleDnsConfig {\n return {\n namespace: `${tenant}-${env}-headscale`,\n pod: \"headscale-0\",\n container: \"dns-writer\",\n filePath: \"/var/lib/headscale/dns/extra-records.json\",\n ...overrides,\n };\n}\n\nexport interface DnsRecord {\n name: string;\n type: \"A\";\n value: string;\n}\n\ninterface TailscaleDetailedStatus {\n BackendState?: string;\n Self?: {\n Online?: boolean;\n HostName?: string;\n TailscaleIPs?: string[];\n };\n}\n\n/**\n * Execute a tailscale CLI command, trying each known binary path.\n * Automatically appends `--socket <path>` when a tailscale socket is found.\n */\nfunction execTailscaleCmd(args: string[]): Promise<string> {\n const socketPath = TAILSCALE_SOCKET_PATHS.find(existsSync);\n\n interface Attempt {\n binary: string;\n args: string[];\n }\n\n const attempts: Attempt[] = [];\n for (const binary of TAILSCALE_PATHS) {\n if (socketPath) {\n attempts.push({ binary, args: [\"--socket\", socketPath, ...args] });\n }\n attempts.push({ binary, args });\n }\n\n return new Promise((resolve, reject) => {\n let index = 0;\n\n function tryNext(): void {\n if (index >= attempts.length) {\n reject(new Error(\"All tailscale binary attempts failed\"));\n return;\n }\n\n const attempt = attempts[index++]!;\n execFile(attempt.binary, attempt.args, (error, stdout) => {\n if (error) {\n tryNext();\n } else {\n resolve(stdout);\n }\n });\n }\n\n tryNext();\n });\n}\n\n/**\n * Retrieve the current Tailscale hostname and IP address.\n *\n * Returns `null` when Tailscale is not installed, not running, or not online.\n */\nexport async function getTailscaleInfo(): Promise<{ hostname: string; ip: string } | null> {\n try {\n const stdout = await execTailscaleCmd([\"status\", \"--json\"]);\n const status: TailscaleDetailedStatus = JSON.parse(stdout);\n\n if (\n status.BackendState === \"Running\" &&\n status.Self?.Online === true &&\n status.Self.HostName &&\n status.Self.TailscaleIPs?.length\n ) {\n return {\n hostname: status.Self.HostName,\n ip: status.Self.TailscaleIPs[0]!,\n };\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\n/** Helper: build kubectl exec args for the dns-writer sidecar. */\nfunction dnsWriterExec(cfg: HeadscaleDnsConfig, cmd: string[], interactive = false): string[] {\n return [\n \"exec\", ...(interactive ? [\"-i\"] : []),\n \"-n\", cfg.namespace, cfg.pod, \"-c\", cfg.container, \"--\", ...cmd,\n ];\n}\n\n/**\n * Read DNS records from the dns-writer sidecar in the Headscale pod.\n *\n * Throws with an actionable message if kubectl can't reach the pod.\n * Returns an empty array when the file is missing or contains invalid JSON.\n */\nexport function readDnsRecords(cfg: HeadscaleDnsConfig): DnsRecord[] {\n try {\n const raw = execFileSync(\"kubectl\", dnsWriterExec(cfg, [\"cat\", cfg.filePath]), {\n encoding: \"utf-8\",\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n const trimmed = raw.trim();\n if (!trimmed || trimmed === \"[]\") return [];\n try {\n return JSON.parse(trimmed);\n } catch {\n console.warn(`[vpn] DNS records file contains invalid JSON, treating as empty`);\n return [];\n }\n } catch (err) {\n const msg = (err as Error).message ?? String(err);\n if (msg.includes(\"not found\") || msg.includes(\"Unable to connect\")) {\n throw new Error(\n `Cannot reach Headscale DNS writer (namespace=${cfg.namespace}, pod=${cfg.pod}).\\n` +\n `Verify kubectl context and that the Headscale pod is running:\\n` +\n ` kubectl get pods -n ${cfg.namespace}`,\n );\n }\n if (msg.includes(\"No such file\")) {\n return [];\n }\n // Unexpected error \u2014 rethrow\n throw err;\n }\n}\n\n/**\n * Write DNS A records to the dns-writer sidecar in the Headscale pod.\n *\n * Pipes JSON via stdin to avoid shell-escaping issues.\n * Throws with an actionable message if kubectl can't reach the pod.\n */\nexport function registerDnsRecords(cfg: HeadscaleDnsConfig, records: DnsRecord[]): void {\n const json = JSON.stringify(records);\n try {\n execFileSync(\n \"kubectl\",\n dnsWriterExec(cfg, [\"sh\", \"-c\", `cat > '${cfg.filePath}'`], true),\n { input: json, stdio: [\"pipe\", \"pipe\", \"pipe\"] },\n );\n } catch (err) {\n const msg = (err as Error).message ?? String(err);\n throw new Error(\n `Failed to write DNS records to Headscale (namespace=${cfg.namespace}, pod=${cfg.pod}).\\n` +\n `${msg.includes(\"not found\") ? \"Pod not found. \" : \"\"}` +\n `Verify kubectl context: kubectl get pods -n ${cfg.namespace}`,\n );\n }\n}\n\n/**\n * Remove DNS records whose `name` ends with `suffix` from the Headscale pod.\n * Best-effort: logs a warning on failure instead of throwing.\n */\nexport function unregisterDnsRecords(cfg: HeadscaleDnsConfig, suffix: string): void {\n try {\n const records = readDnsRecords(cfg);\n const filtered = records.filter((r) => !r.name.endsWith(suffix));\n registerDnsRecords(cfg, filtered);\n } catch {\n // Best-effort cleanup \u2014 silent on failure\n }\n}\n", "/**\n * Utility exports\n */\n\nexport * from \"./errors.js\";\nexport * from \"./log.js\";\nexport * from \"./context.js\";\nexport * from \"./bastion.js\";\nexport * from \"./credentials.js\";\nexport * from \"./pulumi.js\";\nexport * from \"./vpn.js\";\n", "/**\n * Guard for pids the CLI reads back rather than constructs.\n *\n * Every teardown path in the CLI signals a pid it recovered from somewhere:\n * `tunnel-state.json`, `runner.json`, `daemon.json`, or a `ps` argv scan. None\n * of those are trustworthy \u2014 a truncated write, a stale file from an older\n * build, or a hand-edited state dir can put any integer there. POSIX `kill(2)`\n * overloads the pid argument, so the wrong integer is not a no-op:\n *\n * - `kill(0, sig)` \u2192 every process in the CALLER'S OWN process group.\n * - `kill(-1, sig)` \u2192 every process the caller is permitted to signal.\n * - `kill(1, sig)` \u2192 init. And teardown negates before signalling, so a `1`\n * on disk becomes `kill(-1)` \u2014 the broadcast.\n *\n * That is not hypothetical: a `pid: -1` written by a unit test SIGTERM'd the\n * author's entire login session (tmux server, every pane, an in-flight\n * `mesh login`) on 2026-08-01. Refuse the input instead of signalling it.\n *\n * The guard is intentionally strict about the LOW end (`pid >= 2`) so the\n * NEGATED form used for process-group teardown is safe by construction: if\n * `pid >= 2` then `-pid <= -2`, which is always a real process group and never\n * `0` (own group) or `-1` (broadcast). Callers signalling `-pid` need no\n * second check \u2014 see `killSupervisor`.\n */\n\nimport { MeshCliError } from \"./errors.js\";\n\n/** Where a recovered pid came from, for the refusal message + remediation. */\nexport interface PidSource {\n /** What the pid claims to be, e.g. \"tunnel supervisor pid\". */\n what: string;\n /**\n * Absolute path of the state file it was read from, when there is one.\n * Present \u21D2 the refusal tells the user the exact file to delete, so a corrupt\n * state dir does not brick `mesh tunnel down` (which is the command that\n * would otherwise clear it).\n */\n file?: string;\n}\n\n/**\n * Lowest pid the CLI will ever signal. 0 and 1 are the two overloaded values;\n * anything below 0 is already a process-group id, not a pid.\n */\nconst MIN_SIGNALABLE_PID = 2;\n\n/**\n * Why `pid` must not be handed to `process.kill`, or null if it is signalable.\n * Pure \u2014 every branch is unit-testable without spawning anything.\n */\nfunction unsignalableReason(pid: unknown): string | null {\n if (typeof pid !== \"number\") {\n return `expected an integer, got ${typeof pid} (${String(pid)})`;\n }\n if (!Number.isInteger(pid)) {\n // NaN, \u00B1Infinity, and fractions all land here. Number.isInteger is the one\n // predicate that rejects every non-pid a JSON.parse can produce.\n return `expected an integer, got ${String(pid)}`;\n }\n if (pid === 0) {\n return \"0 is not a pid \u2014 kill(0) signals every process in this shell's own process group\";\n }\n if (pid === 1) {\n return \"1 is init \u2014 and teardown negates first, so kill(-1) would signal every process you can signal\";\n }\n if (pid < MIN_SIGNALABLE_PID) {\n return `${pid} is negative \u2014 that is already a process-group id, not a pid`;\n }\n if (pid === process.pid) {\n return `${pid} is this CLI process itself \u2014 signalling it (or its group) would kill the command doing the teardown`;\n }\n if (pid === process.ppid) {\n return `${pid} is this CLI's parent (your shell) \u2014 signalling its group would kill your terminal session`;\n }\n return null;\n}\n\n/**\n * Throw unless `pid` is safe to pass to `process.kill` (directly or negated).\n *\n * Call this at the top of any function that signals a pid it did not construct\n * itself. Existence checks (`process.kill(pid, 0)`) do not need it \u2014 signal 0\n * delivers nothing \u2014 but they gain nothing from skipping it either.\n *\n * @throws {MeshCliError} with `rm <file>` remediation when `source.file` is set.\n */\nexport function assertSignalablePid(pid: unknown, source: PidSource): void {\n const reason = unsignalableReason(pid);\n if (reason === null) return;\n // \"Corrupt or stale\" is only true when the pid was read back from a state\n // file. Call sites without one (e.g. reclaimPort's lsof-derived holder) get a\n // pid that is CORRECT \u2014 that is exactly why it is refused \u2014 so the causal\n // clause is gated on the same condition as the `rm` remediation.\n throw new MeshCliError(\n `Refusing to signal ${source.what} \u2014 ${reason}. ` +\n (source.file\n ? `The recorded value is corrupt or stale; signalling it could terminate unrelated processes.`\n : `Signalling it could terminate unrelated processes.`),\n source.file ? { remediation: { command: `rm ${source.file}` } } : {},\n );\n}\n", "import { getContextConfig, getValidToken } from \"../commands/login.js\";\n\ntype CtxConfig = { issuer?: string; vpnJoinBroker?: string } | null;\n\n/**\n * Resolve the broker URL, preferring the platform-published value from\n * discovery (ContextConfig.vpnJoinBroker) and falling back to DNS-convention\n * derivation for platforms not yet redeployed with the payload field.\n */\nexport function resolveVpnJoinBroker(\n context: string,\n getCfg: (c: string) => CtxConfig = getContextConfig,\n): string | null {\n const cfg = getCfg(context);\n if (cfg?.vpnJoinBroker) return cfg.vpnJoinBroker;\n return deriveVpnJoinBroker(context, getCfg);\n}\n\n/**\n * FALLBACK ONLY. Derive by convention \u2014 issuer host `identity.<env>.<domain>`\n * \u2192 `vpn-join.<env>.<domain>` \u2014 mirroring deriveLoginServer (dev.ts:1479).\n * Prefer resolveVpnJoinBroker, which reads the platform-published URL first.\n */\nexport function deriveVpnJoinBroker(\n context: string,\n getCfg: (c: string) => CtxConfig = getContextConfig,\n): string | null {\n const cfg = getCfg(context);\n if (!cfg?.issuer) return null;\n try {\n const u = new URL(cfg.issuer);\n const parts = u.hostname.split(\".\");\n parts[0] = \"vpn-join\";\n return `https://${parts.join(\".\")}`;\n } catch {\n return null;\n }\n}\n\nexport interface MintDeps {\n getValidToken: (context: string) => Promise<string | null>;\n fetchFn?: typeof fetch;\n}\n\n/**\n * Fetch a single-use pre-auth key from the broker. Returns null on ANY failure\n * (no token, non-2xx, network error) so the caller falls back to the browser\n * relay join \u2014 the broker path is strictly additive.\n */\nexport async function mintPreAuthKey(\n context: string,\n brokerUrl: string,\n deps: MintDeps,\n): Promise<{ authKey: string; loginServer: string } | null> {\n const token = await deps.getValidToken(context);\n if (!token) return null;\n const doFetch = deps.fetchFn ?? fetch;\n try {\n const res = await doFetch(`${brokerUrl}/preauth`, {\n method: \"POST\",\n headers: { authorization: `Bearer ${token}` },\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { authKey?: string; loginServer?: string };\n if (!body.authKey) return null;\n return { authKey: body.authKey, loginServer: body.loginServer ?? \"\" };\n } catch {\n return null;\n }\n}\n\n// Convenience for production callers that use the real login.ts token source.\nexport function realMintDeps(): MintDeps {\n return { getValidToken: (c) => getValidToken(c) };\n}\n", "/**\n * The single credentialed-Pulumi chokepoint (MESH-2085).\n *\n * Every mesh command that shells `pulumi` against the S3 state backend needs\n * AWS credentials from the stack's `mesh:deployerRole`. Historically each\n * command re-implemented that glue and `mesh stack init` skipped it entirely\n * (\u2192 `NoCredentialProviders`). This module centralizes it:\n *\n * 1. read `mesh:deployerRole` from the stack config (clear error if absent),\n * 2. run a LOGIN PREFLIGHT (`probeCredentials`) that names the real problem\n * (\"run `mesh login --device`\") BEFORE pulumi is invoked \u2014 and before\n * `resolveAwsCredentials`' interactive path can hang a browser login over\n * SSH,\n * 3. resolve the deployer creds and return them as env vars to merge into the\n * `pulumi` child process.\n *\n * Callers thread the returned env into their existing `execFileSync(\"pulumi\", \u2026)`\n * calls (deploy passes it once; stack init reuses it across init/select/config).\n */\nimport { logError, logWarn } from \"./log.js\";\nimport { readStackConfig } from \"./pulumi.js\";\nimport { derivePlatformContext, resolveAwsCredentials } from \"./aws-auth.js\";\nimport { probeCredentials, type CredProbe } from \"../commands/login.js\";\n\n/**\n * Map a credential probe to a preflight message. Pure \u2014 no I/O \u2014 so the\n * remediation wording is unit-testable. `null` means \"proceed\" (`ok`, or a\n * non-fatal warning already surfaced). `fatal` callers should stop before\n * touching pulumi; non-fatal is a warning that lets the run continue.\n */\nexport function credProbeToPreflightError(\n probe: CredProbe,\n context: string,\n): { message: string; fatal: boolean } | null {\n switch (probe.state) {\n case \"ok\":\n return null;\n case \"no-session\":\n return {\n fatal: true,\n message:\n `Not logged in for ${context}. Run:\\n` +\n ` mesh login ${context} --device`,\n };\n case \"expired-session\":\n return {\n fatal: true,\n message:\n `Your mesh login session for ${context} expired. Run:\\n` +\n ` mesh login ${context} --device`,\n };\n case \"assume-denied\":\n return {\n fatal: true,\n message:\n `Logged in, but couldn't assume the deployer role \u2014 ${probe.detail}\\n` +\n ` Check mesh:deployerRole in the stack config and your IAM access.`,\n };\n case \"stale-env-override\":\n return {\n fatal: false,\n message:\n `Stale AWS_* env vars are set and will override your mesh login.\\n` +\n ` If pulumi fails with an auth error, clear them first:\\n` +\n ` unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN`,\n };\n }\n}\n\n/**\n * Resolve deployer AWS credentials for a `pulumi` invocation against `stack`,\n * with an up-front login preflight. Returns env vars to merge into the pulumi\n * child (`AWS_ACCESS_KEY_ID`/`_SECRET_ACCESS_KEY`/`_SESSION_TOKEN`). Exits the\n * process with a clear, actionable message on any hard failure \u2014 callers can\n * treat a return as \"creds are ready\".\n */\nexport async function resolvePulumiEnv(opts: {\n appRoot: string;\n stack: string;\n}): Promise<Record<string, string>> {\n const { appRoot, stack } = opts;\n\n const roleArn = readStackConfig(appRoot, stack, \"mesh:deployerRole\");\n if (!roleArn) {\n // Platform stacks are deployed by operators with ambient credentials\n // (SSO/exported env) and typically carry no mesh:deployerRole \u2014 only app\n // stacks do. With ambient creds present, run Pulumi against them directly.\n if (process.env.AWS_ACCESS_KEY_ID) {\n return {};\n }\n logError(\n `No mesh:deployerRole in Pulumi.${stack}.yaml \u2014 mesh needs it to assume a ` +\n `role for the Pulumi backend.\\n` +\n ` Set it (copy the value from a base stack), e.g.:\\n` +\n ` mesh deploy config set mesh:deployerRole arn:aws:iam::<account>:role/<tenant>-<stage>-apps-deployer --stack ${stack}\\n` +\n ` (or provide operator AWS credentials in the environment for platform stacks).`,\n );\n process.exit(1);\n }\n\n // Skip the login preflight whenever explicit ambient AWS credentials are\n // present \u2014 both the CI/OIDC case (key + session token) AND long-lived static\n // keys (key, no session token). Requiring a session token here regressed\n // static-key users: with no cached mesh session the probe returns `no-session`\n // \u2192 fatal, even though `resolveAwsCredentials`' ambient-SDK AssumeRole path\n // (which accepts a bare static key) would have worked. (MESH-2085 review.)\n const hasAmbientCreds = !!process.env.AWS_ACCESS_KEY_ID;\n const context = derivePlatformContext(appRoot, stack);\n if (!hasAmbientCreds && context) {\n const probe = await probeCredentials(context, roleArn);\n const preflight = credProbeToPreflightError(probe, context);\n if (preflight?.fatal) {\n logError(preflight.message);\n process.exit(1);\n }\n if (preflight) logWarn(preflight.message);\n }\n\n const resolved = await resolveAwsCredentials(roleArn, appRoot, stack);\n if (!resolved) {\n logError(\n `Failed to obtain AWS credentials for ${roleArn}.\\n` +\n (context ? ` Try: mesh login ${context} --device\\n` : \"\") +\n ` (or provide AWS credentials in the environment).`,\n );\n process.exit(1);\n }\n return { ...resolved.env };\n}\n", "/**\n * Turnkey kubeconfig for `mesh dev`.\n *\n * `mesh dev` shells out to `kubectl` (Temporal encoding-key read, temporal-pod\n * env fallback). Rather than depend on the operator's ambient ~/.kube/config\n * (which may be unset/stale, and which the apps-deployer role can't populate via\n * `aws eks update-kubeconfig` \u2014 it lacks `eks:DescribeCluster`), we build a\n * kubeconfig from the platform EKS cluster data in SSM and authenticate via\n * `aws eks get-token` (STS-only \u2014 needs just the cluster name).\n *\n * Mirrors the kubeconfig the Pulumi k8s provider builds in\n * `@mesh-tech/infra-components` AppEnvironment.buildExecKubeconfig.\n */\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\n\ninterface EksClusterData {\n clusterEndpoint: string;\n clusterCaData: string;\n clusterName: string;\n}\n\n/**\n * Build a kubeconfig YAML that authenticates to `cluster` via `aws eks get-token`.\n * Pure \u2014 no I/O. The `region` is for the get-token exec args (the active AWS\n * identity is whatever the process runs as: deployer / SSO / CI).\n */\nexport function buildKubeconfig(cluster: EksClusterData, region: string): string {\n return `apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: ${cluster.clusterEndpoint}\n certificate-authority-data: ${cluster.clusterCaData}\n name: eks-cluster\ncontexts:\n- context:\n cluster: eks-cluster\n user: eks-user\n name: eks-context\ncurrent-context: eks-context\nusers:\n- name: eks-user\n user:\n exec:\n apiVersion: client.authentication.k8s.io/v1beta1\n command: aws\n args:\n - eks\n - get-token\n - --cluster-name\n - ${cluster.clusterName}\n - --region\n - ${region}\n`;\n}\n\n/**\n * Deterministic path of the session-scoped kubeconfig written by\n * {@link ensureKubeconfig}. Exposed so callers can point `KUBECONFIG` at the\n * last-good file when a fresh SSM re-resolve isn't available (e.g. on\n * `mesh dev restart --refresh-env`), rather than letting kubectl fall back to\n * `~/.kube/config`.\n */\nexport function sessionKubeconfigPath(sessionName: string): string {\n return path.join(os.tmpdir(), 'mesh-dev-sessions', `${sessionName}.kubeconfig`);\n}\n\n/** Why {@link ensureKubeconfig} couldn't resolve the cluster. */\nexport interface EnsureKubeconfigFailure {\n /** The SSM parameter name that was read (or attempted). */\n parameter: string;\n /** The exception thrown, or a synthetic Error for empty/malformed data. */\n error: unknown;\n}\n\nexport interface EnsureKubeconfigDeps {\n /** SSM GetParameter (decrypted value or undefined) \u2014 injectable for tests. */\n getParameter?: (name: string) => Promise<string | undefined>;\n /**\n * Invoked when cluster resolution fails, with the parameter tried and the\n * underlying error \u2014 so callers can say WHAT failed instead of guessing\n * \"check your SSM read access\" at the user (MESH-2515).\n */\n onError?: (failure: EnsureKubeconfigFailure) => void;\n}\n\n/**\n * The HUB platform name for platform-level resource lookups (EKS export,\n * bastion). `platform.name` in the dev output is the hub (e.g. \"trabian\");\n * when absent \u2014 outputs predating the field \u2014 fall back to the default hub\n * \"mesh\", NEVER to the app tenant. This one definition pins the axis that\n * regressed in the original MESH-2515 bug.\n */\nexport function resolveHubPlatformName(\n platform: { name?: string; tenant?: string; env?: string } | undefined,\n): string {\n return platform?.name ?? 'mesh';\n}\n\nasync function ssmGetParameter(name: string): Promise<string | undefined> {\n const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');\n const ssm = new SSMClient({ region: process.env.AWS_REGION || 'us-east-2' });\n const resp = await ssm.send(new GetParameterCommand({ Name: name, WithDecryption: true }));\n return resp.Parameter?.Value;\n}\n\n/**\n * Resolve the hub EKS cluster from SSM and write a session-scoped kubeconfig.\n *\n * Reads `/mesh-platform/{platformName}/{env}/core/eks` \u2014 platform-level cluster\n * data lives under the app's HUB platform (`platform.name` in the dev output,\n * e.g. \"trabian\"; \"mesh\" is only the default hub, MESH-2515). Writes to\n * `${tmpdir}/mesh-dev-sessions/{sessionName}.kubeconfig` (never touches\n * ~/.kube/config). Returns the path, or `null` if the cluster data can't be\n * resolved (best-effort \u2014 the caller continues without it; the failure detail\n * goes to `deps.onError`).\n */\nexport async function ensureKubeconfig(\n platformName: string,\n env: string,\n sessionName: string,\n deps: EnsureKubeconfigDeps = {},\n): Promise<string | null> {\n const parameter = `/mesh-platform/${platformName}/${env}/core/eks`;\n const getParameter = deps.getParameter ?? ssmGetParameter;\n\n let cluster: EksClusterData;\n try {\n const value = await getParameter(parameter);\n if (!value) throw new Error('parameter is empty or missing');\n const parsed = JSON.parse(value) as Partial<EksClusterData>;\n if (!parsed.clusterEndpoint || !parsed.clusterCaData || !parsed.clusterName) {\n throw new Error('cluster data is missing clusterEndpoint/clusterCaData/clusterName');\n }\n cluster = {\n clusterEndpoint: parsed.clusterEndpoint,\n clusterCaData: parsed.clusterCaData,\n clusterName: parsed.clusterName,\n };\n } catch (error) {\n deps.onError?.({ parameter, error });\n return null;\n }\n\n const region = process.env.AWS_REGION || 'us-east-2';\n const kubeconfigPath = sessionKubeconfigPath(sessionName);\n if (!fs.existsSync(path.dirname(kubeconfigPath))) {\n fs.mkdirSync(path.dirname(kubeconfigPath), { recursive: true });\n }\n fs.writeFileSync(kubeconfigPath, buildKubeconfig(cluster, region), { mode: 0o600 });\n return kubeconfigPath;\n}\n", "/**\n * Temporal auth resolution for the mesh CLI.\n *\n * Two responsibilities, kept together because they're always used as a pair:\n * 1. `resolveTemporalAuth` \u2014 discover the M2M client_credentials + Zitadel\n * issuer/project for a tenant/env from SSM (with a kubectl pod-env fallback).\n * This is what `mesh dev` injects into worker/service env.\n * 2. `acquireTemporalBearer` \u2014 exchange those credentials for a Zitadel Bearer\n * JWT via the OAuth2 client_credentials grant, so CLI commands that talk to\n * an auth-enforcing Temporal frontend (describe/history/terminate) can attach\n * `authorization: Bearer <token>` gRPC metadata. Without it the server's\n * MeshAuthorizer denies calls with \"Request unauthorized\".\n *\n * The token fetch is intentionally reimplemented here (not imported from\n * `@mesh-tech/app-kit`, which mirrors it) because app-kit *depends on* mesh-cli \u2014\n * importing it back would create a dependency cycle.\n */\n\nimport { execFileSync } from \"node:child_process\";\nimport { logSuccess, logWarn } from \"./log.js\";\n\n/**\n * Discover Temporal M2M credentials + Zitadel issuer/project for a tenant/env.\n *\n * Returns an env-var-shaped record (TEMPORAL_AUTH_CLIENT_ID/SECRET,\n * ZITADEL_ISSUER, ZITADEL_PROJECT_ID, \u2026). Missing pieces are simply absent \u2014\n * callers decide whether what resolved is enough to mint a token.\n *\n * `platformName` keys the Zitadel issuer lookup and is the platform NAME\n * (e.g. \"trabian\"/\"mesh\"), NOT the app tenant \u2014 getting it wrong means the\n * issuer never resolves, so no Bearer, so Temporal denies with \"Request\n * unauthorized\".\n */\nexport async function resolveTemporalAuth(\n tenant: string,\n env: string,\n platformName: string = tenant,\n): Promise<Record<string, string>> {\n const { SSMClient, GetParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient({ region: process.env.AWS_REGION || \"us-east-2\" });\n\n const results: Record<string, string> = {};\n\n // Each SSM lookup is independent so a missing optional parameter\n // (e.g. token-exchange-app) doesn't prevent other credentials from resolving.\n async function trySSM(name: string): Promise<string | undefined> {\n try {\n const resp = await ssm.send(new GetParameterCommand({ Name: name, WithDecryption: true }));\n return resp.Parameter?.Value ?? undefined;\n } catch {\n return undefined;\n }\n }\n\n try {\n // \u2500\u2500 Scheme 1: Zitadel-managed per-tenant credentials \u2500\u2500\n const workerPath = `/mesh-platform/${tenant}/${env}/temporal/worker-service-account-key`;\n const exchangePath = `/mesh-platform/${tenant}/${env}/temporal/token-exchange-app`;\n\n const workerJson = await trySSM(workerPath);\n if (workerJson) {\n const worker = JSON.parse(workerJson) as { clientId: string; clientSecret: string };\n results.TEMPORAL_AUTH_CLIENT_ID = worker.clientId;\n results.TEMPORAL_AUTH_CLIENT_SECRET = worker.clientSecret;\n }\n\n // Token exchange app credentials (optional \u2014 not all tenants have this)\n const exchangeJson = await trySSM(exchangePath);\n if (exchangeJson) {\n const exchange = JSON.parse(exchangeJson) as { clientId: string; clientSecret: string };\n results.ZITADEL_TOKEN_EXCHANGE_CLIENT_ID = exchange.clientId;\n results.ZITADEL_TOKEN_EXCHANGE_CLIENT_SECRET = exchange.clientSecret;\n }\n\n // Zitadel issuer URL and project ID from the PLATFORM's config, keyed by the\n // platform NAME (not the app tenant, not a hardcoded \"mesh\").\n const platformJson = await trySSM(`/mesh-platform/${platformName}/${env}/platform/zitadel`);\n if (platformJson) {\n const platform = JSON.parse(platformJson) as { endpoint?: string; projectId?: string };\n if (platform.endpoint) results.ZITADEL_ISSUER = platform.endpoint;\n if (platform.projectId) results.ZITADEL_PROJECT_ID = platform.projectId;\n }\n\n // \u2500\u2500 Scheme 2: Legacy shared credentials (fallback) \u2500\u2500\n if (!results.TEMPORAL_AUTH_CLIENT_ID) {\n const legacyClientId = await trySSM(\"/platform/temporal/auth/CLIENT_ID\");\n const legacyClientSecret = await trySSM(\"/platform/temporal/auth/CLIENT_SECRET\");\n if (legacyClientId && legacyClientSecret) {\n results.TEMPORAL_AUTH_CLIENT_ID = legacyClientId;\n results.TEMPORAL_AUTH_CLIENT_SECRET = legacyClientSecret;\n }\n }\n\n // \u2500\u2500 Discover Zitadel config from Temporal server pods (final fallback) \u2500\u2500\n if (!results.ZITADEL_ISSUER) {\n try {\n const podEnv = execFileSync(\n \"kubectl\",\n [\n \"get\",\n \"pods\",\n \"-n\",\n `temporal-${env}`,\n \"-l\",\n \"app.kubernetes.io/component=frontend\",\n \"-o\",\n \"jsonpath={.items[0].spec.containers[0].env}\",\n ],\n { encoding: \"utf-8\", timeout: 10_000, stdio: [\"pipe\", \"pipe\", \"pipe\"] },\n ).trim();\n\n if (podEnv) {\n const envVars = JSON.parse(podEnv) as Array<{ name: string; value?: string }>;\n for (const v of envVars) {\n if (v.name === \"ZITADEL_ISSUER_URL\" && v.value && !results.ZITADEL_ISSUER) {\n results.ZITADEL_ISSUER = v.value;\n }\n if (v.name === \"ZITADEL_PROJECT_ID\" && v.value && !results.ZITADEL_PROJECT_ID) {\n results.ZITADEL_PROJECT_ID = v.value;\n }\n }\n }\n } catch (err) {\n const detail = err instanceof Error ? err.message : String(err);\n const stderr = String((err as { stderr?: unknown })?.stderr ?? \"\");\n if (/\\.kube[/\\\\]config|error loading config file/i.test(`${detail} ${stderr}`)) {\n logWarn(\n \"kubectl could not read a kubeconfig and fell back to ~/.kube/config \" +\n \"(macOS blocks it for shells without Full Disk Access). Run inside the \" +\n \"mesh dev tmux session, or export KUBECONFIG=$TMPDIR/mesh-dev-sessions/<session>.kubeconfig.\",\n );\n }\n }\n }\n\n if (results.TEMPORAL_AUTH_CLIENT_ID && results.ZITADEL_ISSUER) {\n logSuccess(`Temporal auth resolved (client: ${results.TEMPORAL_AUTH_CLIENT_ID})`);\n } else if (Object.keys(results).length > 0) {\n logWarn(\"Partial Temporal auth resolved (some credentials missing)\");\n }\n } catch (err) {\n logWarn(\n `Could not resolve Temporal auth from SSM: ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n\n return results;\n}\n\n/**\n * Exchange resolved M2M credentials for a Zitadel Bearer access token via the\n * OAuth2 client_credentials grant. Reads from the passed record first, then the\n * ambient env (which `mesh dev` may already have populated).\n *\n * Returns `undefined` when credentials are incomplete \u2014 the caller then connects\n * unauthenticated (correct for local/non-enforcing Temporal servers).\n *\n * Mirrors `@mesh-tech/app-kit`'s `acquireToken` (kept in sync deliberately; see\n * the module doc for why it isn't imported).\n */\nexport async function acquireTemporalBearer(\n vars: Record<string, string | undefined> = {},\n): Promise<string | undefined> {\n const issuerUrl = vars.ZITADEL_ISSUER ?? process.env.ZITADEL_ISSUER;\n const projectId = vars.ZITADEL_PROJECT_ID ?? process.env.ZITADEL_PROJECT_ID;\n const clientId = vars.TEMPORAL_AUTH_CLIENT_ID ?? process.env.TEMPORAL_AUTH_CLIENT_ID;\n const clientSecret = vars.TEMPORAL_AUTH_CLIENT_SECRET ?? process.env.TEMPORAL_AUTH_CLIENT_SECRET;\n\n if (!issuerUrl || !clientId || !clientSecret) {\n return undefined;\n }\n\n const scopes = [\"openid\"];\n if (projectId) {\n scopes.push(`urn:zitadel:iam:org:project:id:${projectId}:aud`);\n // Request project roles in the token (MeshAuthorizer Path A). Same env gate\n // as app-kit: skipping the roles scope falls back to client_id parsing\n // (Path B), which currently also yields the reader bit.\n if (process.env.TEMPORAL_M2M_SKIP_ROLES_SCOPE !== \"true\") {\n scopes.push(\"urn:zitadel:iam:org:projects:roles\");\n }\n }\n\n const response = await fetch(`${issuerUrl}/oauth/v2/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"client_credentials\",\n client_id: clientId,\n client_secret: clientSecret,\n scope: scopes.join(\" \"),\n }),\n });\n\n if (!response.ok) {\n const text = await response.text();\n throw new Error(\n `Temporal token acquisition failed: ${response.status} ${response.statusText} \u2014 ${text}`,\n );\n }\n\n const data = (await response.json()) as { access_token: string };\n return data.access_token;\n}\n", "/**\n * TCP reachability probe for transport selection.\n *\n * `mesh dev`'s VPN detection is otherwise a *presence* check \u2014 it asks whether a\n * Tailscale/VPN connection exists, not whether the mesh VPC actually routes. On a\n * machine using Tailscale for SSH (but not the mesh Headscale VPN) that presence\n * check returns true, `mesh dev` picks \"VPN-direct\", and the worker then dies with\n * `DnsResolutionError` / `ECONNREFUSED` on the Temporal frontend. Dialing the\n * endpoint before committing to VPN-direct turns that tribal-knowledge failure\n * into an automatic SSM fallback (MESH-2016).\n */\nimport { createConnection } from \"node:net\";\n\n/**\n * Resolve `true` if a TCP connection to `host:port` completes within\n * `timeoutMs`, else `false`. Never rejects \u2014 any failure (timeout, refused, DNS)\n * is a `false`, since for transport selection \"can't reach it\" is the only signal\n * that matters. The socket is always destroyed.\n */\nexport function probeTcpReachable(host: string, port: number, timeoutMs = 1500): Promise<boolean> {\n return new Promise((resolve) => {\n let settled = false;\n const socket = createConnection({ host, port });\n const finish = (ok: boolean) => {\n if (settled) return;\n settled = true;\n socket.destroy();\n resolve(ok);\n };\n socket.setTimeout(timeoutMs);\n socket.once(\"connect\", () => finish(true));\n socket.once(\"timeout\", () => finish(false));\n socket.once(\"error\", () => finish(false));\n });\n}\n\n/**\n * Resolve `true` if a connection to `host:port` completes AND survives a brief\n * grace window (`holdMs`). Stronger than {@link probeTcpReachable} for probing a\n * LOCAL FORWARDER port, where \"the port accepts\" is not proof the tunnel carries\n * traffic: a forwarder (SSM port-forward, or the userspace-Tailscale SOCKS\n * bridge) binds its local port even when its upstream leg is dead \u2014 so a plain\n * connect succeeds while nothing flows. A dead-upstream forwarder accepts the\n * TCP connection and then tears it down SYNCHRONOUSLY (e.g. the Tailscale bridge\n * dials a SOCKS port the daemon isn't on and destroys the client \u2014 MESH-2169);\n * measured, that teardown lands in ~1ms.\n *\n * The window is deliberately SMALL \u2014 just enough to clear that ~1ms fail-fast\n * teardown. It is NOT a latency budget for the upstream: a real upstream need\n * send no bytes and may itself idle-close a bare connection (Temporal's UI drops\n * an idle HTTP connection at ~50ms; Temporal gRPC and Postgres hold indefinitely).\n * So \"closed within ~1ms of connecting\" means the forwarder never reached a real\n * upstream; surviving `holdMs` means it did. A larger window would mis-report a\n * healthy-but-idle-closing service (like the Temporal UI) as DOWN. Never rejects;\n * the socket is always destroyed.\n */\nexport function probeConnectionHolds(\n host: string,\n port: number,\n timeoutMs = 800,\n holdMs = 25,\n): Promise<boolean> {\n return new Promise((resolve) => {\n let settled = false;\n let holdTimer: ReturnType<typeof setTimeout> | undefined;\n const socket = createConnection({ host, port });\n const finish = (ok: boolean) => {\n if (settled) return;\n settled = true;\n if (holdTimer) clearTimeout(holdTimer);\n socket.destroy();\n resolve(ok);\n };\n socket.setTimeout(timeoutMs);\n // Healthy iff the connection survives the grace window after connecting.\n socket.once(\"connect\", () => {\n holdTimer = setTimeout(() => finish(true), holdMs);\n });\n // Closed before the grace window elapsed \u2192 the forwarder's upstream is dead.\n socket.once(\"close\", () => finish(false));\n socket.once(\"timeout\", () => finish(false));\n socket.once(\"error\", () => finish(false));\n });\n}\n", "/**\n * Workflow-source fingerprint for relaunch safety (MESH-2017).\n *\n * A `mesh dev` relaunch (or `restart <worker>`) restarts the Temporal worker onto\n * whatever code is current. If the WORKFLOW code changed since the session\n * started, in-flight conversations replay against the new code and \u2014 if the change\n * is replay-incompatible \u2014 get stranded (observed: conversation 523a4103). The\n * replay-safety gate (MESH-2091) catches this at CI; this catches it at the *local*\n * dev loop, where uncommitted edits haven't been through CI.\n *\n * We fingerprint the source the agent/interpreter worker bundles into Temporal\n * workflows: the workflow packages agent-sdk, workflow-interpreter, agent-contracts\n * (the same set the CI replay-gate path filter covers). A content hash (not mtime)\n * so a `git checkout`/rebase that rewrites mtimes without changing content doesn't\n * produce a false \"code changed\" warning; paths are hashed repo-root-relative so\n * moving/renaming the checkout doesn't either.\n *\n * NOTE (MESH-2017 follow-up): this covers the shared workflow packages, not a\n * tenant repo's own worker source \u2014 `workflowSourceDirs` accepts extra dirs for\n * that, but no caller wires them yet, so in a repo without those packages the\n * fingerprint is empty and the guard is inert. Tracked separately.\n */\nimport { createHash } from \"node:crypto\";\nimport { existsSync, readFileSync, readdirSync } from \"node:fs\";\nimport { dirname, isAbsolute, join, relative } from \"node:path\";\n\n/**\n * Workspace packages whose src the agent/interpreter worker bundles as workflows.\n * MUST stay a subset of the CI replay-gate path filter (`checks.yaml` \u2192\n * `replay-changes`); a drift-guard test asserts it (MESH-2093).\n */\nexport const WORKFLOW_PACKAGE_DIRS = [\n \"packages/agent-sdk/src\",\n \"packages/workflow-interpreter/src\",\n \"packages/agent-contracts/src\",\n];\n\nconst EXCLUDE = /(^|\\/)(node_modules|dist|__tests__)(\\/|$)|\\.(test|spec)\\.[cm]?tsx?$/;\n\n/**\n * Walk up from `startDir` to the monorepo root (the dir with pnpm-workspace.yaml).\n * Falls back to `startDir` if none is found.\n */\nexport function findRepoRoot(startDir: string): string {\n let dir = startDir;\n for (;;) {\n if (existsSync(join(dir, \"pnpm-workspace.yaml\"))) return dir;\n const parent = dirname(dir);\n if (parent === dir) return startDir;\n dir = parent;\n }\n}\n\n/**\n * The set of directories whose `.ts` the worker turns into Temporal workflows.\n *\n * Prefers the shared workflow **packages** (agent-sdk / interpreter / contracts)\n * when they exist \u2014 the precise, low-noise signal for the monorepo agent/interpreter\n * dev loop. When they're absent (a **tenant repo**), falls back to the app's own\n * worker source (`extraDirs`) so the guard isn't silently inert there (MESH-2093).\n * Only existing dirs are returned.\n */\nexport function workflowSourceDirs(repoRoot: string, extraDirs: string[] = []): string[] {\n const pkgDirs = WORKFLOW_PACKAGE_DIRS.map((d) => join(repoRoot, d)).filter((d) => existsSync(d));\n if (pkgDirs.length > 0) return pkgDirs;\n return extraDirs.filter((d) => existsSync(d));\n}\n\n/**\n * Resolve the worker service(s)' source dirs from a dev-output `services` map, so a\n * tenant repo (no shared workflow packages) still fingerprints the code its worker\n * actually runs. A service is a worker when its name matches `/worker/i` (best\n * effort until dev-output carries a `type: \"temporal-worker\"` marker \u2014 MESH-2093).\n */\nexport function resolveWorkerSourceDirs(\n repoRoot: string,\n services: Record<string, { src?: string }> = {},\n): string[] {\n const dirs: string[] = [];\n for (const [name, svc] of Object.entries(services)) {\n if (/worker/i.test(name) && svc?.src) {\n dirs.push(isAbsolute(svc.src) ? svc.src : join(repoRoot, svc.src));\n }\n }\n return dirs;\n}\n\n/**\n * Content fingerprint of every non-test `.ts`/`.tsx` under `dirs`. Stable across\n * runs for identical content; changes iff a workflow source file's content\n * changes. Returns a short hex digest ('' if no files).\n *\n * Each file is labelled by its path relative to `relativeTo` (when given) so the\n * hash is stable across checkout location/renames; falls back to the absolute path\n * otherwise.\n */\nexport function computeWorkflowFingerprint(dirs: string[], relativeTo?: string): string {\n const files: string[] = [];\n for (const dir of dirs) {\n let rels: string[];\n try {\n rels = readdirSync(dir, { recursive: true }) as string[];\n } catch {\n continue; // dir removed mid-scan\n }\n for (const rel of rels) {\n const p = String(rel);\n if (/\\.(ts|tsx)$/.test(p) && !EXCLUDE.test(p)) files.push(join(dir, p));\n }\n }\n files.sort();\n if (files.length === 0) return \"\";\n const h = createHash(\"sha256\");\n for (const f of files) {\n try {\n const content = readFileSync(f);\n h.update(relativeTo ? relative(relativeTo, f) : f);\n h.update(\"\\0\");\n h.update(content);\n h.update(\"\\0\");\n } catch {\n /* unreadable/removed mid-scan \u2014 skip */\n }\n }\n return h.digest(\"hex\").slice(0, 16);\n}\n\n/**\n * Convenience: fingerprint an app's workflow source. Pass the dev-output `services`\n * map so a tenant repo falls back to its worker source when the shared workflow\n * packages aren't present.\n */\nexport function fingerprintWorkflowSource(\n appRoot: string,\n services: Record<string, { src?: string }> = {},\n): string {\n const repoRoot = findRepoRoot(appRoot);\n const workerDirs = resolveWorkerSourceDirs(repoRoot, services);\n return computeWorkflowFingerprint(workflowSourceDirs(repoRoot, workerDirs), repoRoot);\n}\n", "/**\n * Helpers for launching `mesh dev` services via per-session env files.\n *\n * Background: `mesh dev` used to inline every env var into a single giant\n * `env K1=V1 K2=V2 \u2026 <command>` string passed to `tmux send-keys`. With large\n * values (system prompts, encoding keys, OAuth creds) tmux intermittently\n * truncated the string \u2014 the service never started \u2014 and `mesh dev restart`\n * re-derived the env from scratch, drifting from what the service was\n * originally launched with (e.g. a different TEMPORAL_ENCODING_KEY).\n *\n * Instead, the full env is written once at launch to a 0600 file under the\n * session state dir, and the send-keys command is just:\n *\n * cd '<dir>' && source '<envfile>' && <command>\n *\n * Restart sources the SAME file, guaranteeing a byte-identical environment.\n */\n\nimport * as fs from 'fs';\nimport * as net from 'net';\nimport * as path from 'path';\n\n/** Env files contain secrets \u2014 owner read/write only. */\nexport const ENV_FILE_MODE = 0o600;\n\n/** Valid POSIX env var name. Anything else is rejected to avoid shell injection. */\nconst ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\n/**\n * Single-quote a value for POSIX shells (bash/zsh), escaping embedded\n * single quotes via the standard `'\\''` dance. Safe for newlines, spaces,\n * `$`, backticks, etc.\n */\nexport function quoteShellValue(value: string): string {\n return `'${value.replace(/'/g, \"'\\\\''\")}'`;\n}\n\n/**\n * Render a sourceable env file: one `export K='V'` per line.\n * Throws on env var names that aren't valid shell identifiers.\n */\nexport function renderEnvFile(env: Record<string, string>): string {\n const lines: string[] = [\n '# Auto-generated by mesh dev \u2014 do not edit.',\n '# Sourced by service launch/restart commands; this file IS the',\n \"# session's env contract for the service (restart fidelity).\",\n ];\n for (const [key, value] of Object.entries(env)) {\n if (!ENV_KEY_RE.test(key)) {\n throw new Error(`Invalid env var name for env file: ${JSON.stringify(key)}`);\n }\n lines.push(`export ${key}=${quoteShellValue(value)}`);\n }\n return lines.join('\\n') + '\\n';\n}\n\n/** Filesystem-safe env file name for a service. */\nexport function envFileName(serviceName: string): string {\n return `${serviceName.replace(/[^A-Za-z0-9._-]/g, '-')}.env.sh`;\n}\n\n/**\n * Write the env file (creating parent dirs) with mode 0600.\n * chmod is applied explicitly so a pre-existing file is tightened too.\n */\nexport function writeEnvFile(filePath: string, env: Record<string, string>): void {\n fs.mkdirSync(path.dirname(filePath), { recursive: true });\n fs.writeFileSync(filePath, renderEnvFile(env), { mode: ENV_FILE_MODE });\n fs.chmodSync(filePath, ENV_FILE_MODE);\n}\n\n/**\n * Build the short, untruncatable command sent to tmux:\n * cd '<dir>' && source '<envfile>' && <command>\n *\n * `command` is the service's dev command (already shell syntax) and is\n * passed through verbatim.\n *\n * With `logShipper` (mesh dev --local: the path of assets/log-shipper.mjs),\n * the service's combined stdout/stderr is piped through the shipper: raw\n * bytes go to the pane (its stderr), ANSI-stripped lines ship to the local\n * platform's OTel collector via OTLP \u2014 the same ingestion path instrumented\n * apps use, carrying the OTEL_* resource attributes from the env file. Logs\n * never cross the macOS Docker file mount (whose stale attribute caching\n * hides appends from in-container tailers).\n */\nexport function buildLaunchCommand(\n envFilePath: string,\n dir: string,\n command: string,\n logShipper?: string,\n): string {\n const base = `cd ${quoteShellValue(dir)} && source ${quoteShellValue(envFilePath)} && `;\n if (!logShipper) return `${base}${command}`;\n // NODE_OPTIONS= : the shipper must not inherit the service's OTel\n // auto-instrumentation \u2014 its own OTLP POSTs would show up as traces.\n return `${base}{ ${command}; } 2>&1 | NODE_OPTIONS= node ${quoteShellValue(logShipper)}`;\n}\n\n/**\n * Poll until a TCP port accepts connections, or the timeout elapses.\n * Returns true when the port accepted, false on timeout.\n */\nexport async function waitForPort(\n host: string,\n port: number,\n timeoutMs: number,\n intervalMs = 500,\n): Promise<boolean> {\n const deadline = Date.now() + timeoutMs;\n\n // Always attempt at least once, even with a zero/negative timeout.\n for (;;) {\n const remaining = deadline - Date.now();\n const attemptTimeout = Math.max(250, Math.min(1000, remaining));\n if (await tryConnect(host, port, attemptTimeout)) return true;\n if (Date.now() + intervalMs >= deadline) return false;\n await new Promise((resolve) => setTimeout(resolve, intervalMs));\n }\n}\n\nfunction tryConnect(host: string, port: number, timeoutMs: number): Promise<boolean> {\n return new Promise((resolve) => {\n const socket = net.connect({ host, port });\n const done = (ok: boolean) => {\n socket.removeAllListeners();\n socket.destroy();\n resolve(ok);\n };\n socket.setTimeout(timeoutMs);\n socket.once('connect', () => done(true));\n socket.once('timeout', () => done(false));\n socket.once('error', () => done(false));\n });\n}\n", "/**\n * First-boot seeding for the `mesh start` local platform.\n *\n * Registers tenant `local` / env `dev` in the local AWS fabric's (ministack)\n * SSM registry with the same shape TenantEnvironments writes in the cloud\n * (/mesh-platform/{tenant}/{env}/app-tenants \u2192 TenantEnvironmentsExports), and\n * creates the tenant artifacts bucket. Idempotent \u2014 parameters are overwritten,\n * the bucket create tolerates \"already owned by you\".\n *\n * Zitadel org/project/CLI-app + test-user seeding (the `mesh login local`\n * path) is tracked as a follow-up on the mesh start epic.\n */\n\nimport { logInfo, logSuccess } from \"../../utils/log.js\";\nimport { MeshCliError } from \"../../utils/errors.js\";\n\nexport const LOCAL_TENANT = \"local\";\nexport const LOCAL_ENV = \"dev\";\nexport const LOCAL_AWS_ENDPOINT = \"http://localhost:4566\";\nexport const LOCAL_AWS_REGION = \"us-east-2\";\nexport const ARTIFACTS_BUCKET = \"mesh-local-artifacts\";\nexport const APP_TENANTS_PARAM = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/app-tenants`;\n\nexport const LOCAL_AWS_CONFIG = {\n endpoint: LOCAL_AWS_ENDPOINT,\n region: LOCAL_AWS_REGION,\n credentials: { accessKeyId: \"test\", secretAccessKey: \"test\" },\n};\nconst AWS_CONFIG = LOCAL_AWS_CONFIG;\n\n/**\n * Env vars that point any AWS SDK process on the host at the local fabric \u2014\n * the SDK-native endpoint override (AWS_ENDPOINT_URL, supported since SDK JS\n * v3 ~3.428), so no client-code changes are needed. `mesh dev` local mode\n * injects these into app processes; humans can `eval` them from the\n * `mesh start` output. In-network services get the same wiring with\n * http://ministack:4566 (see the compose file's x-local-aws-env anchor).\n */\nexport function localAwsEnv(): Record<string, string> {\n return {\n AWS_ENDPOINT_URL: LOCAL_AWS_ENDPOINT,\n AWS_REGION: LOCAL_AWS_REGION,\n AWS_ACCESS_KEY_ID: \"test\",\n AWS_SECRET_ACCESS_KEY: \"test\",\n };\n}\n\n/**\n * The local tenant registration, shaped like TenantEnvironmentsExports\n * (libs/infra-components \u2026/platform/TenantEnvironments.ts) so consumers of the\n * registry (Hub API, init checks) read local and cloud identically. AWS-only\n * fields carry inert local placeholders.\n */\nexport function buildAppTenantsSeed() {\n const tenantExport = {\n albGroupName: LOCAL_TENANT,\n certificateArn: \"\",\n baseDomain: \"localhost\",\n deployerRoleArn: \"arn:aws:iam::000000000000:role/local-dev-deployer\",\n artifactsBucket: ARTIFACTS_BUCKET,\n spicedb: {\n endpoint: \"localhost:50051\",\n presharedKeySecret: \"local-dev-key\",\n },\n zitadel: {\n orgId: \"local\",\n domain: \"localhost:8080\",\n issuer: \"http://localhost:8080\",\n },\n };\n return {\n hubTenant: LOCAL_TENANT,\n primaryTenant: tenantExport,\n sharedTenants: {},\n };\n}\n\nexport interface SeedResult {\n parameter: string;\n bucket: string;\n}\n\nexport const FABRIC_CHECK_PATH = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/.fabric-check`;\n\n/** The tenant's Temporal namespace, matching the cloud convention\n * TemporalNamespaceProvisioner uses ({tenant}-{env}, e.g. \"acme-dev\"). */\nexport const TEMPORAL_NAMESPACE = `${LOCAL_TENANT}-${LOCAL_ENV}`;\nexport const TEMPORAL_ADDRESS = \"localhost:7233\";\n\n/**\n * Register the local tenant's Temporal namespace ({tenant}-{env} \u2014 the same\n * name TemporalNamespaceProvisioner would create in the cloud). Idempotent:\n * \"already exists\" is success. Dynamic import \u2014 @temporalio/client is an\n * optionalDependency (same pattern as commands/temporal.ts).\n */\nexport async function seedTemporalNamespace(): Promise<void> {\n await ensureTemporalNamespace(\n TEMPORAL_NAMESPACE,\n `Mesh local tenant '${LOCAL_TENANT}' env '${LOCAL_ENV}' (seeded by mesh start)`,\n );\n}\n\n/**\n * Idempotently register a Temporal namespace. `mesh dev --local` uses this\n * for the per-app namespaces ({tenant}-{env}-{app}) the cloud provisioner\n * creates \u2014 the Hub's per-app workflow views resolve exactly that name.\n */\nexport async function ensureTemporalNamespace(namespace: string, description?: string): Promise<void> {\n const { Connection } = await import(\"@temporalio/client\");\n const connection = await Connection.connect({ address: TEMPORAL_ADDRESS });\n try {\n await connection.workflowService.registerNamespace({\n namespace,\n description: description ?? `Mesh local namespace '${namespace}'`,\n // protobufjs accepts plain numbers for int64 at runtime; the generated\n // typing insists on Long (whose package isn't a direct dep), hence any.\n workflowExecutionRetentionPeriod: { seconds: (3 * 24 * 60 * 60) as any },\n });\n // A freshly registered namespace isn't usable until the frontend's\n // namespace cache refreshes (~10s) \u2014 a worker connecting immediately\n // would die on \"Namespace not found\". Wait it out once, here.\n logInfo(`Registered Temporal namespace '${namespace}' \u2014 waiting for it to become active\u2026`);\n const deadline = Date.now() + 20_000;\n while (Date.now() < deadline) {\n try {\n await connection.workflowService.describeTaskQueue({\n namespace,\n taskQueue: { name: \"namespace-propagation-probe\" },\n taskQueueType: 1,\n } as any);\n break;\n } catch {\n await new Promise((resolve) => setTimeout(resolve, 1_000));\n }\n }\n logSuccess(`Temporal namespace '${namespace}' is active`);\n } catch (err: any) {\n if (err?.name === \"NamespaceAlreadyExistsError\" || /already exists/i.test(err?.message ?? \"\")) {\n logInfo(`Temporal namespace '${namespace}' already exists`);\n } else {\n throw err;\n }\n } finally {\n await connection.close();\n }\n}\n\n/**\n * A deterministic >4 KB payload for the Advanced-tier round-trip. Standard\n * SSM params cap at 4 KB; mesh.exports.Export writes tier \"Advanced\" values\n * up to 8 KB (Export.ts), and the emulator's fidelity there is the one open\n * question the design (\u00A72.5) requires this check to answer on every boot.\n */\nexport function buildFabricCheckPayload(targetBytes = 8_000): string {\n const base = { probe: \"advanced-tier-round-trip\", tenant: LOCAL_TENANT, env: LOCAL_ENV, pad: \"\" };\n const overhead = JSON.stringify(base).length;\n return JSON.stringify({ ...base, pad: \"x\".repeat(Math.max(0, targetBytes - overhead)) });\n}\n\n/**\n * Verify the local AWS fabric behaves like real SSM where the platform\n * depends on it: an 8 KB Advanced-tier parameter round-trips intact, and\n * child parameters under a path come back via GetParametersByPath (the\n * shape mesh.exports.Export's splitKeys mode reads). Check params are\n * removed afterwards.\n */\nexport async function verifyFabric(): Promise<void> {\n const { SSMClient, PutParameterCommand, GetParameterCommand, GetParametersByPathCommand, DeleteParametersCommand } =\n await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(AWS_CONFIG);\n const payload = buildFabricCheckPayload();\n const mainParam = FABRIC_CHECK_PATH;\n const childParams = [`${FABRIC_CHECK_PATH}/vpc`, `${FABRIC_CHECK_PATH}/eks`];\n\n try {\n await ssm.send(\n new PutParameterCommand({\n Name: mainParam,\n Type: \"String\",\n Tier: \"Advanced\",\n Overwrite: true,\n Value: payload,\n }),\n );\n for (const name of childParams) {\n await ssm.send(\n new PutParameterCommand({ Name: name, Type: \"String\", Overwrite: true, Value: `{\"probe\":\"${name}\"}` }),\n );\n }\n\n const roundTrip = await ssm.send(new GetParameterCommand({ Name: mainParam }));\n if (roundTrip.Parameter?.Value !== payload) {\n throw new MeshCliError(\n `Local AWS fabric failed the ${payload.length}-byte Advanced-tier SSM round-trip (got ${roundTrip.Parameter?.Value?.length ?? 0} bytes back).`,\n {\n remediation: {\n docs: \"libs/mesh-cli/stack/docker-compose.yml \u2014 swap the ministack image for motoserver/moto (design \u00A72.5 fallback)\",\n },\n },\n );\n }\n\n const byPath = await ssm.send(\n new GetParametersByPathCommand({ Path: FABRIC_CHECK_PATH, Recursive: true }),\n );\n const returned = new Set((byPath.Parameters ?? []).map((p) => p.Name));\n const missing = childParams.filter((name) => !returned.has(name));\n if (missing.length > 0) {\n throw new MeshCliError(\n `Local AWS fabric's GetParametersByPath missed child parameter(s): ${missing.join(\", \")} (splitKeys exports would not resolve).`,\n {\n remediation: {\n docs: \"libs/mesh-cli/stack/docker-compose.yml \u2014 swap the ministack image for motoserver/moto (design \u00A72.5 fallback)\",\n },\n },\n );\n }\n\n logSuccess(`Fabric check passed: ${payload.length}-byte Advanced-tier round-trip + GetParametersByPath`);\n } finally {\n await ssm\n .send(new DeleteParametersCommand({ Names: [mainParam, ...childParams] }))\n .catch(() => {});\n }\n}\n\n/** Write the Hub-readable tenant + environment registrations (the shapes\n * hub-api's SSM assembler requires: RawTenant at /{tenant}, RawEnvironment\n * at /{tenant}/{env}). Exported for reuse by app-tenant provisioning. */\nexport async function registerTenantEnv(\n tenant: string,\n opts: { tier?: \"hub\" | \"dedicated\" | \"shared\" } = {},\n): Promise<void> {\n const { SSMClient, PutParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(AWS_CONFIG);\n await ssm.send(\n new PutParameterCommand({\n Name: `/mesh-platform/${tenant}`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({ name: tenant, tier: opts.tier ?? \"shared\", subdomain: tenant }),\n Description: `Tenant registration (seeded by mesh CLI, local platform)`,\n }),\n );\n await ssm.send(\n new PutParameterCommand({\n Name: `/mesh-platform/${tenant}/${LOCAL_ENV}`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({\n env: LOCAL_ENV,\n region: LOCAL_AWS_REGION,\n baseDomain: \"localhost\",\n // monitoring is true: the local stack runs the same Loki/Tempo/\n // Prometheus backends the hosted platform provides.\n services: { temporal: true, zitadel: true, monitoring: true },\n }),\n Description: `Environment registration (seeded by mesh CLI, local platform)`,\n }),\n );\n}\n\nexport async function seedLocalPlatform(): Promise<SeedResult> {\n const { SSMClient, PutParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(AWS_CONFIG);\n await ssm.send(\n new PutParameterCommand({\n Name: APP_TENANTS_PARAM,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify(buildAppTenantsSeed()),\n Description: `TenantEnvironments exports for ${LOCAL_TENANT}-${LOCAL_ENV} (seeded by mesh start)`,\n }),\n );\n await registerTenantEnv(LOCAL_TENANT, { tier: \"hub\" });\n logSuccess(`Registered tenant '${LOCAL_TENANT}' env '${LOCAL_ENV}' \u2192 ${APP_TENANTS_PARAM}`);\n\n const { S3Client, CreateBucketCommand } = await import(\"@aws-sdk/client-s3\");\n const s3 = new S3Client({ ...AWS_CONFIG, forcePathStyle: true });\n try {\n await s3.send(new CreateBucketCommand({ Bucket: ARTIFACTS_BUCKET }));\n logSuccess(`Created artifacts bucket s3://${ARTIFACTS_BUCKET}`);\n } catch (err: any) {\n if (err?.name === \"BucketAlreadyOwnedByYou\" || err?.name === \"BucketAlreadyExists\") {\n logInfo(`Artifacts bucket s3://${ARTIFACTS_BUCKET} already exists`);\n } else {\n throw err;\n }\n }\n\n await seedTemporalNamespace();\n\n return { parameter: APP_TENANTS_PARAM, bucket: ARTIFACTS_BUCKET };\n}\n", "/**\n * Small shared helpers for the local-platform commands \u2014 extracted from the\n * copies that had grown in auth-provision/mocks (secret upsert) and\n * dev-local/stack (TCP probe).\n */\n\nimport * as net from \"net\";\nimport { LOCAL_AWS_CONFIG } from \"./seed.js\";\n\n/** The subset of SecretsManagerClient upsertLocalSecret depends on. */\nexport interface SecretsSender {\n send(command: unknown): Promise<unknown>;\n}\n\n/**\n * Create-or-update a Secrets Manager secret in the local AWS fabric\n * (ministack). Create first; on ResourceExistsException, put a new value.\n * `client` is injectable for tests; defaults to a client against the local\n * fabric.\n */\nexport async function upsertLocalSecret(\n secretId: string,\n value: Record<string, string>,\n client?: SecretsSender,\n): Promise<void> {\n const { SecretsManagerClient, CreateSecretCommand, PutSecretValueCommand } = await import(\n \"@aws-sdk/client-secrets-manager\"\n );\n const sm = client ?? new SecretsManagerClient(LOCAL_AWS_CONFIG);\n const secretString = JSON.stringify(value);\n try {\n await sm.send(new CreateSecretCommand({ Name: secretId, SecretString: secretString }));\n } catch (err: any) {\n if (err?.name === \"ResourceExistsException\") {\n await sm.send(new PutSecretValueCommand({ SecretId: secretId, SecretString: secretString }));\n } else {\n throw err;\n }\n }\n}\n\n/** True when a TCP connect to host:port succeeds within timeoutMs. */\nexport function probeTcp(\n port: number,\n opts: { host?: string; timeoutMs?: number } = {},\n): Promise<boolean> {\n const { host = \"127.0.0.1\", timeoutMs = 2000 } = opts;\n return new Promise((resolve) => {\n const socket = net.connect({ host, port, timeout: timeoutMs });\n socket.once(\"connect\", () => {\n socket.destroy();\n resolve(true);\n });\n socket.once(\"error\", () => resolve(false));\n socket.once(\"timeout\", () => {\n socket.destroy();\n resolve(false);\n });\n });\n}\n", "import fs from \"node:fs\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\n/**\n * Root of the CLI's on-disk cache \u2014 logs, probe targets, compose output,\n * seeded credentials, hub build contexts.\n *\n * Defaults to `~/.cache/mesh`. `MESH_CACHE_HOME` overrides it, and the override\n * is read on every call rather than captured at import, so a process that sets\n * it after this module loads still gets it.\n *\n * WHY THE OVERRIDE EXISTS. Several helpers here `mkdirSync` eagerly, so merely\n * *calling* one writes to the user's home \u2014 which meant `pnpm test` wrote into\n * `~/.cache/mesh` on any machine that runs it, and fail outright under a sandbox\n * that denies writes outside the workspace (`EPERM: mkdir\n * '/Users/\u2026/.cache/mesh/mesh-local/probes'`). The suite points this at a\n * gitignored directory inside the package \u2014 see `test/setup.ts` \u2014 so tests never\n * reach outside the repo.\n */\nexport function meshCacheHome(): string {\n // `||` not `??`: an empty MESH_CACHE_HOME must fall back, or path.join()\n // resolves cache paths against the filesystem root.\n return process.env.MESH_CACHE_HOME || path.join(os.homedir(), \".cache\", \"mesh\");\n}\n\n/**\n * A directory under the cache root, created if absent.\n *\n * @example\n * meshCacheDir(\"mesh-local\", \"probes\"); // \u2192 \"/Users/me/.cache/mesh/mesh-local/probes\"\n */\nexport function meshCacheDir(...segments: string[]): string {\n const dir = path.join(meshCacheHome(), ...segments);\n fs.mkdirSync(dir, { recursive: true });\n return dir;\n}\n", "/**\n * Helpers for the `mesh start/stop/status` full-local platform stack.\n *\n * The docker compose project (name `mesh-local`) ships inside the CLI package\n * at `<package root>/stack/`, so the stack works in any repo with only Docker\n * installed and is version-locked to the CLI.\n */\n\nimport { execFileSync, spawn, spawnSync } from \"child_process\";\nimport * as fs from \"fs\";\nimport { probeTcp } from \"./helpers.js\";\nimport * as path from \"path\";\nimport { fileURLToPath } from \"url\";\nimport { parse as parseYaml } from \"yaml\";\nimport { meshCacheDir } from \"../../utils/cache-home.js\";\nimport { MeshCliError } from \"../../utils/errors.js\";\nimport { startHeartbeat } from \"../../utils/log.js\";\n\nexport const COMPOSE_PROJECT = \"mesh-local\";\n\n/**\n * Host directory `mesh dev --local` writes service logs into\n * ({namespace}/{service}.log). The stack's OTel collector tails it (filelog\n * receiver) and ships to Loki with the hosted k8s_* label scheme, so the\n * Hub's logs view works on local runs unchanged.\n */\nexport function localLogsDir(): string {\n return meshCacheDir(\"mesh-local\", \"logs\");\n}\n\n/**\n * Host directory holding blackbox probe targets (Prometheus file_sd JSON) \u2014\n * written by `mesh dev --externals` for external services, mounted into the\n * stack's Prometheus. The local analog of the hosted Probe CRs.\n */\nexport function localProbesDir(): string {\n return meshCacheDir(\"mesh-local\", \"probes\");\n}\n\n/**\n * Register the app's OWN services as blackbox probe targets.\n *\n * Uptime in the Hub has three sources: blackbox probes, Kubernetes pod\n * readiness, and external-service probes. Locally the middle one cannot exist \u2014\n * `mesh dev --local` runs services as host processes, not Deployments, so\n * kube-state-metrics has nothing to report \u2014 and nothing used to register the\n * host processes with blackbox either. The result was an app that is plainly\n * running and shows no uptime at all, which reads as a broken panel rather than\n * an unmonitored one.\n *\n * The labels match what `ExternalService` already emits (`tenant`/`env`/`app`),\n * because that is what the Hub's scoped discovery query selects on. `target` is\n * the service name, which is how the Hub names the row.\n *\n * Targets use `host.docker.internal` \u2014 Prometheus and blackbox run inside the\n * stack's network and reach host processes only through that name.\n *\n * Returns the file written, for the session to remove on `--kill`; a stale probe\n * would otherwise report a service down long after its session ended.\n */\nexport function writeAppServiceProbes(args: {\n tenant: string;\n env: string;\n app: string;\n /** Service name \u2192 host port. Mock stand-ins are the caller's to exclude. */\n services: Record<string, number>;\n /** Health path every service exposes. */\n healthPath?: string;\n}): string | undefined {\n const entries = Object.entries(args.services);\n if (entries.length === 0) return undefined;\n const healthPath = args.healthPath ?? \"/health\";\n const targets = entries.map(([name, port]) => ({\n targets: [`http://host.docker.internal:${port}${healthPath}`],\n labels: {\n type: \"service\",\n tenant: args.tenant,\n env: args.env,\n app: args.app,\n service: name,\n target: name,\n },\n }));\n const file = path.join(localProbesDir(), `${args.tenant}-${args.app}-services.json`);\n fs.writeFileSync(file, `${JSON.stringify(targets, null, 2)}\\n`);\n return file;\n}\n\n/** Endpoints the local stack exposes on the host. */\nexport interface StackEndpoint {\n service: string;\n label: string;\n url: string;\n /** How `mesh status` probes it from the host */\n probe: { kind: \"http\"; url: string } | { kind: \"tcp\"; port: number } | { kind: \"none\" };\n /** Extra hint printed under the endpoint (credentials, tokens, \u2026) */\n hint?: string;\n}\n\nexport const STACK_ENDPOINTS: StackEndpoint[] = [\n {\n service: \"temporal\",\n label: \"Temporal gRPC\",\n url: \"localhost:7233\",\n probe: { kind: \"tcp\", port: 7233 },\n },\n {\n service: \"temporal-ui\",\n label: \"Temporal UI\",\n url: \"http://localhost:8233/namespaces/local-dev/workflows\",\n probe: { kind: \"http\", url: \"http://localhost:8233\" },\n hint: \"seeded namespace: local-dev\",\n },\n {\n service: \"zitadel\",\n label: \"Zitadel console\",\n url: \"http://localhost:8080\",\n probe: { kind: \"http\", url: \"http://localhost:8080/debug/healthz\" },\n hint: \"admin@local.mesh / LocalDev1!\",\n },\n {\n service: \"mailpit\",\n label: \"Mailbox (local mail)\",\n url: \"http://localhost:8025\",\n probe: { kind: \"http\", url: \"http://localhost:8025/readyz\" },\n hint: \"every activation + password-reset mail Zitadel sends locally lands here\",\n },\n {\n service: \"database\",\n label: \"Postgres\",\n url: \"postgres://postgres:postgres@localhost:5433\",\n probe: { kind: \"tcp\", port: 5433 },\n hint: \"databases: temporal, app, spicedb, hub\",\n },\n {\n service: \"spicedb\",\n label: \"SpiceDB gRPC\",\n url: \"localhost:50051\",\n probe: { kind: \"tcp\", port: 50051 },\n hint: \"preshared key: local-dev-key\",\n },\n {\n service: \"ministack\",\n label: \"ministack (local AWS endpoint)\",\n url: \"http://localhost:4566\",\n probe: { kind: \"http\", url: \"http://localhost:4566/_ministack/health\" },\n hint: \"SSM registry under /mesh-platform/local/dev\",\n },\n {\n service: \"stackport\",\n label: \"StackPort (local AWS console)\",\n url: \"http://localhost:4567\",\n probe: { kind: \"http\", url: \"http://localhost:4567\" },\n hint: \"browse the registry (SSM), secrets, S3 artifacts\",\n },\n {\n service: \"memcached\",\n label: \"Memcached\",\n url: \"localhost:11211\",\n probe: { kind: \"tcp\", port: 11211 },\n },\n {\n service: \"loki\",\n label: \"Loki (logs)\",\n url: \"http://localhost:3100\",\n probe: { kind: \"http\", url: \"http://localhost:3100/ready\" },\n hint: \"mesh dev --local service logs, hosted label scheme\",\n },\n {\n service: \"tempo\",\n label: \"Tempo (traces)\",\n url: \"http://localhost:3200\",\n probe: { kind: \"http\", url: \"http://localhost:3200/ready\" },\n },\n {\n service: \"prometheus\",\n label: \"Prometheus (metrics)\",\n url: \"http://localhost:9090\",\n probe: { kind: \"http\", url: \"http://localhost:9090/-/ready\" },\n },\n {\n service: \"otel-collector\",\n label: \"OTel collector (OTLP in)\",\n url: \"http://localhost:4318\",\n probe: { kind: \"http\", url: \"http://localhost:13133\" },\n hint: \"apps: OTEL_EXPORTER_OTLP_ENDPOINT (injected by mesh dev --local)\",\n },\n {\n service: \"elasticsearch\",\n label: \"OpenSearch (Temporal visibility)\",\n url: \"http://localhost:9200\",\n probe: { kind: \"http\", url: \"http://localhost:9200\" },\n },\n];\n\n/** Default host port for the containerized local Hub. `mesh hub dev` derives\n * its dev-server default from this too (hub/index.ts), so the two Hubs share\n * one source for \"9000\" while keeping separate override variables\n * (MESH_HUB_PORT here, MESH_HUB_DEV_PORT there). */\nexport const DEFAULT_HUB_PORT = \"9000\";\n\n/**\n * Host port the Hub is published on (docker-compose.hub.yml binds\n * `${MESH_HUB_PORT:-9000}:4180`). Override with MESH_HUB_PORT to run the\n * local Hub beside another process that owns 9000 \u2014 e.g. a hub-ui dev\n * server running from a different checkout. Export it for BOTH `mesh start`\n * and later `mesh status` calls: the compose file and these probes read the\n * same variable (MESH-2256 tracks deriving it from the running stack so\n * `mesh status` stops depending on shell hygiene). `mesh start` reconciles\n * the seeded Zitadel Hub app's redirect URIs to include the chosen port \u2014\n * Zitadel requires every redirect URI to be registered even in devMode\n * (devMode only allows http).\n *\n * Empty string falls back like compose's `${MESH_HUB_PORT:-9000}` does \u2014\n * `??` alone would diverge (probe `:`, publish 9000).\n */\nexport function hubPort(): string {\n const raw = process.env.MESH_HUB_PORT?.trim();\n if (!raw) return DEFAULT_HUB_PORT;\n if (!/^\\d+$/.test(raw) || Number(raw) < 1 || Number(raw) > 65535) {\n throw new MeshCliError(`MESH_HUB_PORT must be a TCP port number 1\u201365535 (got '${raw}').`, {\n remediation: { command: \"unset MESH_HUB_PORT # or export a valid port, e.g. 9100\" },\n });\n }\n return raw;\n}\n\n/** Hub overlay endpoints \u2014 probed only when the hub containers exist.\n * A function (not a module constant) so hubPort() is read at call time:\n * the CLI's env is fixed before node starts, but tests and programmatic\n * callers set MESH_HUB_PORT after import. */\nexport function hubEndpoints(): StackEndpoint[] {\n return [\n {\n service: \"hub-api\",\n label: \"Hub API\",\n url: \"http://localhost:4568\",\n probe: { kind: \"http\", url: \"http://localhost:4568/health\" },\n },\n {\n service: \"hub-ui\",\n label: \"Hub UI\",\n url: `http://localhost:${hubPort()}`,\n probe: { kind: \"http\", url: `http://localhost:${hubPort()}/ping` },\n hint: \"sign in: admin@local.mesh or dev@local.mesh / LocalDev1! (oauth2-proxy, same as deployed)\",\n },\n ];\n}\n\n/**\n * Locate the CLI package root (the directory whose package.json is\n * @mesh-tech/mesh-cli). Works from both the compiled layout (dist/src/\u2026)\n * and the source layout (src/\u2026 under tsx).\n */\nexport function findPackageRoot(startDir?: string): string {\n let dir = startDir ?? path.dirname(fileURLToPath(import.meta.url));\n for (let i = 0; i < 8; i++) {\n const pkgPath = path.join(dir, \"package.json\");\n if (fs.existsSync(pkgPath)) {\n try {\n const pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf-8\"));\n if (pkg.name === \"@mesh-tech/mesh-cli\") return dir;\n } catch {\n // unreadable package.json \u2014 keep walking\n }\n }\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n throw new MeshCliError(\"Could not locate the mesh-cli package root (stack assets missing).\", {\n remediation: { command: \"npm install -g @mesh-tech/mesh-cli\" },\n });\n}\n\nexport function stackDir(): string {\n const dir = path.join(findPackageRoot(), \"stack\");\n if (!fs.existsSync(path.join(dir, \"docker-compose.yml\"))) {\n throw new MeshCliError(`Local stack assets not found at ${dir}.`, {\n remediation: { command: \"npm install -g @mesh-tech/mesh-cli\" },\n });\n }\n return dir;\n}\n\n/** The compose overlay that declares the Hub services. */\nexport const HUB_OVERLAY_FILE = \"docker-compose.hub.yml\";\n\n/** Top-level service names a compose file declares. Pure \u2014 takes the file text. */\nexport function parseComposeServiceNames(yamlText: string): string[] {\n const doc = parseYaml(yamlText) as { services?: Record<string, unknown> } | null;\n return Object.keys(doc?.services ?? {});\n}\n\nlet hubOverlayServicesCache: ReadonlySet<string> | undefined;\n\n/**\n * The services the Hub overlay \u2014 and only the overlay \u2014 declares.\n *\n * Read from `docker-compose.hub.yml` rather than hard-coded beside\n * ONE_SHOT_SERVICES on purpose: a second name list rots the moment the\n * overlay gains or renames a service. (It already declares three \u2014 hub-api,\n * hub-ui, hub-auth \u2014 not the two the Hub is usually described by.)\n */\nexport function hubOverlayServices(): ReadonlySet<string> {\n if (!hubOverlayServicesCache) {\n const file = path.join(stackDir(), HUB_OVERLAY_FILE);\n if (!fs.existsSync(file)) {\n throw new MeshCliError(`Local stack assets are incomplete: ${file} is missing.`, {\n remediation: { command: \"npm install -g @mesh-tech/mesh-cli\" },\n });\n }\n hubOverlayServicesCache = new Set(parseComposeServiceNames(fs.readFileSync(file, \"utf-8\")));\n }\n return hubOverlayServicesCache;\n}\n\n/**\n * The compose project is a MACHINE-WIDE singleton (`mesh-local`), but any\n * checkout of the CLI can drive it \u2014 and `docker compose up` from a checkout\n * whose stack config differs will recreate shared containers out from under\n * whatever else is running against the stack (a second project's `mesh dev`\n * session, a Hub built from another worktree, \u2026).\n *\n * Returns the working dir of the checkout that last (re)created the running\n * stack when it is NOT this one; undefined when the stack isn't running or\n * is already owned here. Callers gate lifecycle commands on it.\n */\nexport function stackOwnedElsewhere(): string | undefined {\n // Fail-open by design: a stack that exists but is NOT running (`ps -q`\n // without -a) has nothing to disrupt, so `start` silently re-owns it; and\n // any docker/inspect hiccup disables the guard rather than blocking\n // legitimate lifecycle commands.\n try {\n const first = compose([\"ps\", \"-q\"]).trim().split(\"\\n\").filter(Boolean)[0];\n if (!first) return undefined;\n const label = execFileSync(\n \"docker\",\n [\"inspect\", first, \"--format\", '{{ index .Config.Labels \"com.docker.compose.project.working_dir\" }}'],\n { encoding: \"utf-8\" },\n ).trim();\n if (!label) return undefined;\n // realpath both sides: symlinked layouts (macOS /tmp \u2192 /private/tmp,\n // linked worktree parents) must not make the same physical dir compare\n // as foreign. Fall back to the raw path when realpath fails (dir gone).\n const real = (p: string) => {\n try {\n return fs.realpathSync(p);\n } catch {\n return path.resolve(p);\n }\n };\n return real(label) === real(stackDir()) ? undefined : label;\n } catch {\n return undefined;\n }\n}\n\n/** Throw a remediated error if the docker daemon isn't reachable. */\nexport function ensureDockerAvailable(): void {\n const probe = spawnSync(\"docker\", [\"info\", \"--format\", \"{{.ServerVersion}}\"], {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n if (probe.error || probe.status !== 0) {\n throw new MeshCliError(\"Docker is not available (is Docker Desktop / the docker daemon running?).\", {\n remediation: { docs: \"https://docs.docker.com/get-docker/\" },\n });\n }\n}\n\n/** Run `docker compose` against the packaged stack. `hub` adds the Hub\n * overlay file (safe for ps/logs/down: services simply may not exist);\n * `env` adds process-env overrides (e.g. MESH_HUB_VERSION). */\nexport function compose(\n args: string[],\n opts: { inherit?: boolean; hub?: boolean; env?: Record<string, string> } = {},\n): string {\n const dir = stackDir();\n const files = [\"-f\", path.join(dir, \"docker-compose.yml\")];\n if (opts.hub) files.push(\"-f\", path.join(dir, \"docker-compose.hub.yml\"));\n const fullArgs = [\"compose\", \"-p\", COMPOSE_PROJECT, ...files, ...args];\n return execFileSync(\"docker\", fullArgs, {\n cwd: dir,\n encoding: \"utf-8\",\n // MESH_LOCAL_LOGS on every call: the compose file mounts it into the\n // OTel collector, and an unset variable would both warn and resolve to\n // a different mount (recreate churn between CLI and raw compose runs).\n // IGNORE_ORPHANS: base-only `up` calls would otherwise flag the hub\n // overlay's running containers as orphans.\n env: {\n ...process.env,\n MESH_LOCAL_LOGS: localLogsDir(),\n MESH_LOCAL_PROBES: localProbesDir(),\n COMPOSE_IGNORE_ORPHANS: \"1\",\n ...opts.env,\n },\n stdio: opts.inherit ? [\"ignore\", \"inherit\", \"inherit\"] : [\"ignore\", \"pipe\", \"pipe\"],\n }) as unknown as string;\n}\n\n/**\n * Pick the one line worth reading out of a failed compose run's output:\n * the last error-looking line, else the last non-empty line (MESH-2479).\n * Pure so the selection heuristic is unit-testable.\n */\nexport function summarizeComposeFailure(output: string): string {\n const lines = output\n .split(\"\\n\")\n .map((l) => l.trim())\n .filter(Boolean);\n const errorish = lines.filter((l) =>\n /\\b(error|failed|failure|fatal|denied|unauthorized|cannot|no such)\\b/i.test(l),\n );\n const line = errorish.at(-1) ?? lines.at(-1) ?? \"no output captured\";\n return line.length > 300 ? `${line.slice(0, 297)}\u2026` : line;\n}\n\n/** Where a lifecycle command's full compose output is written (per operation). */\nexport function composeLogPath(op: string): string {\n const dir = meshCacheDir(\"mesh-local\");\n return path.join(dir, `compose-${op.replace(/[^a-z0-9-]/gi, \"_\")}.log`);\n}\n\n/**\n * `docker compose` for lifecycle commands (`up -d`): stream output like\n * `compose({inherit: true})` does, but never bury the failure \u2014 on a non-zero\n * exit throw a one-line summary instead of leaving ~200 lines of pull/create\n * noise as the only signal (MESH-2479). In non-TTY runs the output is also\n * teed to a log file (named in the error) and a heartbeat fills long quiet\n * stretches; interactive terminals keep docker's own progress rendering.\n */\nexport async function composeStreamed(\n args: string[],\n opts: { hub?: boolean; env?: Record<string, string> } = {},\n): Promise<void> {\n const dir = stackDir();\n const files = [\"-f\", path.join(dir, \"docker-compose.yml\")];\n if (opts.hub) files.push(\"-f\", path.join(dir, \"docker-compose.hub.yml\"));\n const fullArgs = [\"compose\", \"-p\", COMPOSE_PROJECT, ...files, ...args];\n const env = {\n ...process.env,\n MESH_LOCAL_LOGS: localLogsDir(),\n MESH_LOCAL_PROBES: localProbesDir(),\n COMPOSE_IGNORE_ORPHANS: \"1\",\n ...opts.env,\n };\n const op = args[0] ?? \"cmd\";\n\n if (process.stderr.isTTY) {\n const res = spawnSync(\"docker\", fullArgs, {\n cwd: dir,\n env,\n stdio: [\"ignore\", \"inherit\", \"inherit\"],\n });\n if (res.status !== 0) {\n throw new MeshCliError(\n `docker compose ${op} failed (exit ${res.status ?? \"?\"}) \u2014 the compose output above has the details.`,\n {\n remediation: {\n command: `docker compose -p ${COMPOSE_PROJECT} ps && docker compose -p ${COMPOSE_PROJECT} logs --tail 50`,\n },\n },\n );\n }\n return;\n }\n\n const logPath = composeLogPath(op);\n const logStream = fs.createWriteStream(logPath);\n const heartbeat = startHeartbeat(`docker compose ${args.join(\" \")}`);\n let captured = \"\";\n let exitCode: number;\n try {\n exitCode = await new Promise<number>((resolve, reject) => {\n const child = spawn(\"docker\", fullArgs, {\n cwd: dir,\n env,\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n const consume = (chunk: Buffer) => {\n heartbeat.touch();\n const text = chunk.toString();\n captured += text;\n logStream.write(text);\n process.stderr.write(text);\n };\n child.stdout.on(\"data\", consume);\n child.stderr.on(\"data\", consume);\n child.on(\"error\", reject);\n child.on(\"close\", (code) => resolve(code ?? 1));\n });\n } finally {\n heartbeat.stop();\n // Flush the log file before any throw can reach handleCliError's\n // process.exit(), which doesn't wait for pending writes.\n await new Promise<void>((resolve) => logStream.end(resolve));\n }\n if (exitCode !== 0) {\n throw new MeshCliError(\n `docker compose ${op} failed (exit ${exitCode}): ${summarizeComposeFailure(captured)}`,\n {\n remediation: {\n command: `docker compose -p ${COMPOSE_PROJECT} logs --tail 50`,\n docs: logPath,\n },\n },\n );\n }\n}\n\nexport interface ServiceStatus {\n name: string;\n state: string;\n health?: string;\n}\n\n/** Parse `docker compose ps --format json` (NDJSON, one object per line). */\nexport function parseComposePs(output: string): ServiceStatus[] {\n const services: ServiceStatus[] = [];\n for (const line of output.split(\"\\n\")) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n try {\n const entry = JSON.parse(trimmed);\n services.push({\n name: entry.Service ?? entry.Name ?? \"unknown\",\n state: entry.State ?? \"unknown\",\n health: entry.Health || undefined,\n });\n } catch {\n // non-JSON noise from docker \u2014 skip\n }\n }\n return services;\n}\n\nexport function stackServices(): ServiceStatus[] {\n // Include the hub overlay so its containers show up when running.\n return parseComposePs(compose([\"ps\", \"-a\", \"--format\", \"json\"], { hub: true }));\n}\n\n/** Services expected to exit (one-shot jobs) \u2014 an `exited` state is fine. */\nexport const ONE_SHOT_SERVICES = new Set([\"spicedb-migrate\", \"zitadel-machinekey-init\"]);\n\n/**\n * The services whose `exited` state is a real failure *right now*.\n *\n * `stackServices()` scans the whole `mesh-local` compose project, overlay\n * included \u2014 but `mesh start` brings the base stack up first and the Hub\n * overlay only afterwards. Before that point an `exited` hub container is a\n * leftover from a previous run (a Docker restart, a laptop sleep), not a\n * crash: the base `up -d` deliberately never started it. Counting it made\n * `mesh start` abort before it ever tried to start the Hub, pointing at the\n * wrong thing.\n *\n * Once the overlay HAS been started, an exited overlay service is a crash\n * like any other \u2014 pass `overlayStarted: true` and the signal is preserved.\n */\nexport function crashedServices(\n services: ServiceStatus[],\n opts: { overlayServices: ReadonlySet<string>; overlayStarted: boolean },\n): ServiceStatus[] {\n return services.filter(\n (s) =>\n s.state === \"exited\" &&\n !ONE_SHOT_SERVICES.has(s.name) &&\n (opts.overlayStarted || !opts.overlayServices.has(s.name)),\n );\n}\n\n/**\n * Whether the Hub overlay is up in this run \u2014 i.e. at least one overlay\n * container is actually `running`. `mesh status` has no other way to know\n * (it never started anything), and \"a container exists\" is not it: a\n * leftover `exited` one exists too.\n */\nexport function hubOverlayRunning(\n services: ServiceStatus[],\n overlayServices: ReadonlySet<string>,\n): boolean {\n return services.some((s) => overlayServices.has(s.name) && s.state === \"running\");\n}\n\n/**\n * Whether the Hub API is serving. `some(s => s.name === \"hub-api\")` is not\n * the same question \u2014 that is true of a leftover exited container, and\n * probing Hub endpoints that were never meant to be up this run reports the\n * stack unhealthy for a Hub nobody started.\n */\nexport function hubApiRunning(services: ServiceStatus[]): boolean {\n return services.some((s) => s.name === \"hub-api\" && s.state === \"running\");\n}\n\n/** Probe a host endpoint (http 2xx-4xx counts as up; tcp connect counts as up). */\nexport async function probeEndpoint(endpoint: StackEndpoint): Promise<boolean> {\n if (endpoint.probe.kind === \"http\") {\n try {\n const res = await fetch(endpoint.probe.url, { signal: AbortSignal.timeout(3000) });\n return res.status < 500;\n } catch {\n return false;\n }\n }\n if (endpoint.probe.kind === \"tcp\") {\n return probeTcp(endpoint.probe.port, { timeoutMs: 3000 });\n }\n return true;\n}\n", "/**\n * `mesh dev --externals` (alias `--mock`) \u2014 external-service realizations\n * for a local dev session (MESH-1885, design C4).\n *\n * Apps DECLARE how each external dependency is realized locally in the root\n * package.json under `mesh.externals` (legacy key `mesh.mocks` still read).\n * Every declaration runs in exactly one of three MODES:\n *\n * mock \u2014 the service is MIMICKED: no real instance exists locally or\n * remotely. Realized from an OpenAPI spec (`openapi` \u2192 Prism\n * mock server) or a hand-written mock process (`src`).\n * local \u2014 a LOCAL VERSION of the real service runs on this machine,\n * e.g. a vendor-shaped database replica. Realized from a docker\n * compose file (`compose` + published `port`).\n * remote \u2014 the session connects to the ACTUAL external service: either\n * the vendor's real endpoint with inline `credentials` (e.g. a\n * sandbox), or the external service as configured in the app's\n * tenant environment (`remote.env`, e.g. dev \u2014 credentials are\n * pulled from that environment's secret).\n *\n * \"mesh\": {\n * \"externals\": {\n * \"plaid-db\": { // \u2500\u2500 local (default) \u2500\u2500\n * \"mode\": \"local\",\n * \"compose\": \"libs/plaid-db/docker/docker-compose.yml\",\n * \"port\": 1433, // REQUIRED: the published port\n * \"probe\": \"tcp\", // non-HTTP uptime probe\n * \"external\": \"plaid-db\",\n * \"credentials\": { // {{host}}/{{port}} \u2192 localhost:1433\n * \"host\": \"{{host}}\", \"port\": \"{{port}}\",\n * \"name\": \"Plaid_Clean\", \"username\": \"sa\",\n * \"password\": \"{{env:PLAID_DB_PASSWORD}}\"\n * },\n * \"remote\": { \"env\": \"dev\" } // `--externals plaid-db=remote`\n * }, // pulls the dev-configured secret\n * \"plaid\": { // \u2500\u2500 remote \u2500\u2500\n * \"mode\": \"remote\",\n * \"external\": \"plaid\", // the vendor's REAL sandbox\n * \"displayName\": \"Plaid\", // the Hub shows the real name\u2026\n * \"type\": \"api\", // \u2026and the integration type\n * \"docs\": \"https://plaid.com/docs/api/\", // vendor reference docs\n * \"credentials\": {\n * \"endpoint\": \"https://sandbox.plaid.com\",\n * \"client_id\": \"{{env:PLAID_CLIENT_ID}}\",\n * \"secret\": \"{{env:PLAID_SECRET}}\"\n * }\n * },\n * \"vendorpay\": { // \u2500\u2500 mock \u2500\u2500\n * \"mode\": \"mock\",\n * \"openapi\": \"api/docs/spec/openapi.json\", // spec \u2192 Prism emulation\n * \"external\": \"vendorpay\",\n * \"credentials\": { \"baseUrl\": \"{{url}}\", \"apiKey\": \"mock\" }\n * }\n * }\n * }\n *\n * `mode` picks the DEFAULT; a declaration may carry more than one\n * realization (e.g. compose + remote.env) and the CLI overrides per\n * external at run time: `mesh dev --externals plaid-db=remote,plaid`.\n * Declarations without `mode` infer it from their fields (compose \u2192 local,\n * openapi/src \u2192 mock, sandbox/credentials-only \u2192 remote), so pre-mode\n * declarations keep working (`sandbox: true` is a deprecated alias for\n * mode remote).\n *\n * All credential values support `{{env:VAR}}` interpolation from the\n * developer's environment, so real secrets (sandbox keys, DB passwords)\n * never land in package.json.\n *\n * Realization: mock externals join the SAME orchestration as services named\n * mock-{name} (ports, env files, tmux windows, Loki log pipeline); local\n * externals come up via `docker compose up -d --wait` before the app's\n * services and are torn down on `mesh dev --kill`; remote externals spawn\n * nothing. In every mode the ExternalService credential secret is seeded at\n * mesh/{tenant}/{env}/external/{name} so the app's resolveCredentials()\n * path runs UNCHANGED, the vendor is registered in the local Hub's\n * external-services view, and a blackbox uptime probe watches the\n * realization \u2014 remote endpoints included.\n */\n\nimport { execFileSync } from \"child_process\";\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport { logInfo, logSuccess, logWarn } from \"../../utils/log.js\";\nimport { MeshCliError } from \"../../utils/errors.js\";\nimport { LOCAL_ENV, LOCAL_AWS_CONFIG } from \"./seed.js\";\nimport { probeTcp, upsertLocalSecret } from \"./helpers.js\";\nimport { localProbesDir } from \"./stack.js\";\n\nexport interface LocalMockDecl {\n /** Default mode for this external: \"mock\" (emulate via openapi/src),\n * \"local\" (docker compose local version), \"remote\" (the actual service).\n * Inferred from the realization fields when omitted; overridable per run\n * with `mesh dev --externals name=mode`. */\n mode?: ExternalMode;\n /** Mock mode: directory (relative to app root) with a package.json `dev`\n * script \u2014 a hand-written mock process. */\n src?: string;\n /** Local mode: docker compose file (relative to app root) running a local\n * version of the external service \u2014 e.g. a vendor-shaped database\n * replica. `mesh dev` runs `docker compose up -d --wait` before the\n * app's services and `docker compose down` on `--kill`. Requires `port`. */\n compose?: string;\n /** Mock mode: OpenAPI spec file (relative to app root); the CLI emulates\n * the service with a Prism mock server (`npx @stoplight/prism-cli mock`)\n * as mock-{name}. */\n openapi?: string;\n /** @deprecated alias for `mode: \"remote\"` (with inline credentials). */\n sandbox?: boolean;\n /** Remote-mode credential source. When the external runs in remote mode,\n * its credentials come from (first match): `remote.credentials` (inline,\n * env-interpolated), the top-level `credentials` if this declaration has\n * no local/mock realization (a pure-remote declaration), or the external\n * service configured in the app's tenant environment `remote.env`\n * (default \"dev\") \u2014 secret mesh/{tenant}/{env}/external/{name}, read\n * with the developer's ambient AWS credentials. `remote.profile` names\n * the AWS profile for that pull when the shell carries no AWS identity\n * (no AWS_ACCESS_KEY_ID / AWS_PROFILE); the MESH_AWS_PROFILE env var\n * overrides it \u2014 the same convention repo pull scripts use. */\n remote?: { env?: string; credentials?: Record<string, string>; profile?: string };\n /** Human-facing integration name shown in the Hub (e.g. \"Plaid\",\n * \"Unit21\"). @default the external name title-cased (\"plaid-db\" \u2192\n * \"Plaid Db\"). */\n displayName?: string;\n /** Vendor reference documentation for the integration, linked from the\n * Hub \u2014 a URL string or `{ url, provider }`.\n * @example \"https://plaid.com/docs/api/\" */\n docs?: string | { url: string; provider?: string };\n /** Fixed port; omitted = auto-allocated after the app's services.\n * REQUIRED in local (compose) mode \u2014 the compose file's published port. */\n port?: number;\n /** Launch command (default: [\"pnpm\", \"dev\"]; openapi mocks synthesize a\n * Prism command) */\n command?: string[];\n /** Extra env for the mock process */\n env?: Record<string, string>;\n /** ExternalService name this declaration realizes */\n external?: string;\n /** Credential JSON seeded into the external secret (the local/mock\n * realization's template; also the remote source for pure-remote\n * declarations). Tokens: \"{{url}}\" \u2192 the stand-in's URL, \"{{host}}\"/\n * \"{{port}}\" \u2192 its host/port, and \"{{env:VAR}}\" \u2192 the developer's\n * environment (all modes). */\n credentials?: Record<string, string>;\n /** Integration type shown in the Hub \u2014 api, database, object_storage,\n * sftp, banking_core, \u2026 (@default \"other\"). Display only; derived\n * reachability probes key off the credential fields themselves (a\n * `bucket` credential probes TCP to the bucket endpoint regardless of\n * the declared type). */\n type?: string;\n /** Health endpoint path probed by the platform's blackbox exporter\n * (uptime in the Hub). @default \"/health\" for local/mock realizations;\n * remote endpoints default to probing the endpoint root (\"\") since\n * vendors rarely expose /health. */\n healthPath?: string;\n /** Blackbox probe module. \"http\" (default) GETs the health URL and\n * expects 2xx; \"tcp\" only checks the port accepts connections \u2014 REQUIRED\n * for non-HTTP externals (databases, SFTP, \u2026) whose uptime would\n * otherwise always read down. */\n probe?: \"http\" | \"tcp\";\n}\n\n/** The three ways an external service exists in a local dev session. */\nexport type ExternalMode = \"mock\" | \"local\" | \"remote\";\n\nconst EXTERNAL_MODES: readonly ExternalMode[] = [\"mock\", \"local\", \"remote\"];\n\nconst DECL_DOCS = { docs: 'package.json \u2192 \"mesh\": { \"externals\": { \u2026 } }' };\n\n/**\n * Resolve + validate an external's mode: explicit `mode` (or the one\n * inferred from the realization fields), checked against the fields that\n * mode needs. Pre-mode declarations infer: compose \u2192 local, openapi/src \u2192\n * mock, sandbox/credentials-only \u2192 remote.\n */\nexport function externalMode(name: string, decl: LocalMockDecl): ExternalMode {\n const mode: ExternalMode | undefined =\n decl.mode ??\n (decl.compose\n ? \"local\"\n : decl.openapi || decl.src\n ? \"mock\"\n : decl.sandbox || decl.credentials || decl.remote\n ? \"remote\"\n : undefined);\n if (!mode || !EXTERNAL_MODES.includes(mode)) {\n throw new MeshCliError(\n `External '${name}': cannot determine mode \u2014 set \"mode\" to mock | local | remote (or declare openapi/src, compose, or credentials).`,\n { remediation: DECL_DOCS },\n );\n }\n if (mode === \"local\") {\n if (!decl.compose) {\n throw new MeshCliError(\n `External '${name}': local mode requires \"compose\" \u2014 the docker compose file running the local version of the service.`,\n { remediation: DECL_DOCS },\n );\n }\n if (typeof decl.port !== \"number\") {\n throw new MeshCliError(\n `External '${name}': local (compose) mode requires \"port\" \u2014 the port the compose file publishes on localhost.`,\n { remediation: DECL_DOCS },\n );\n }\n }\n if (mode === \"mock\" && !decl.openapi && !decl.src) {\n throw new MeshCliError(\n `External '${name}': mock mode emulates the service \u2014 declare \"openapi\" (spec \u2192 Prism mock) or \"src\" (mock process).`,\n { remediation: DECL_DOCS },\n );\n }\n if (mode === \"remote\" && !remoteInlineCredentials(decl) && !decl.external) {\n throw new MeshCliError(\n `External '${name}': remote mode needs a credential source \u2014 inline \"credentials\", \"remote\": { \"env\": \u2026 }, or \"external\" (pulls that environment's configured secret).`,\n { remediation: DECL_DOCS },\n );\n }\n return mode;\n}\n\n/** A parsed `--externals` selection: the externals named, and the per-run\n * mode overrides typed alongside them (`plaid-db=remote`). */\nexport interface ExternalsSelection {\n names: string[];\n overrides: Map<string, ExternalMode>;\n}\n\n/**\n * Parse a `--externals` value \u2014 a comma-separated list of `name` or\n * `name=mode` entries. An explicit mode overrides that declaration's\n * default FOR THIS RUN; a bare name keeps the declared default.\n *\n * Names are returned in the order typed, de-duplicated (the last mode\n * given for a name wins, so `--externals plaid,plaid=remote` is remote).\n */\nexport function parseExternalsSelection(input: string): ExternalsSelection {\n const names: string[] = [];\n const overrides = new Map<string, ExternalMode>();\n for (const entry of input.split(\",\").map((s) => s.trim()).filter(Boolean)) {\n const [rawName, mode, ...rest] = entry.split(\"=\").map((s) => s.trim());\n const name = rawName ?? \"\";\n if (!name) {\n throw new MeshCliError(\n `Bad --externals entry '${entry}' \u2014 expected name or name=mode with mode \u2208 ${EXTERNAL_MODES.join(\" | \")}.`,\n { remediation: { command: \"mesh dev --externals plaid-db=remote,plaid\" } },\n );\n }\n if (mode !== undefined) {\n if (rest.length > 0 || !EXTERNAL_MODES.includes(mode as ExternalMode)) {\n throw new MeshCliError(\n `Bad --externals entry '${entry}' \u2014 expected name or name=mode with mode \u2208 ${EXTERNAL_MODES.join(\" | \")}.`,\n { remediation: { command: \"mesh dev --externals plaid-db=remote,plaid\" } },\n );\n }\n overrides.set(name, mode as ExternalMode);\n }\n if (!names.includes(name)) names.push(name);\n }\n return { names, overrides };\n}\n\n/**\n * The inline credential template a remote-mode run should use, if any:\n * `remote.credentials` always wins; top-level `credentials` count only when\n * the declaration has no local/mock realization (otherwise they are that\n * realization's template, full of {{host}}/{{port}} tokens that only mean\n * something against a local endpoint).\n */\nexport function remoteInlineCredentials(decl: LocalMockDecl): Record<string, string> | undefined {\n if (decl.remote?.credentials) return decl.remote.credentials;\n if (decl.credentials && !decl.compose && !decl.openapi && !decl.src) return decl.credentials;\n return undefined;\n}\n\n/** Whether this declaration runs as a mock-{name} service in the dev\n * orchestration (mock mode) vs outside it (local compose / remote). */\nexport function isServiceMode(name: string, decl: LocalMockDecl): boolean {\n return externalMode(name, decl) === \"mock\";\n}\n\n/** Endpoint tokens available to credential interpolation. */\nexport interface MockEndpoint {\n url: string;\n host: string;\n port: number;\n}\n\n/**\n * Interpolate one credential value: {{url}}/{{host}}/{{port}} from the\n * stand-in's endpoint, {{env:VAR}} from the developer's environment\n * (missing env vars are a hard error \u2014 a half-seeded secret is worse).\n */\nexport function interpolateCredentialValue(\n raw: string,\n endpoint: MockEndpoint | undefined,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n let value = raw.replace(/\\{\\{env:([A-Za-z_][A-Za-z0-9_]*)\\}\\}/g, (_, name: string) => {\n const resolved = env[name];\n if (resolved === undefined) {\n throw new MeshCliError(\n `Credential value references {{env:${name}}} but ${name} is not set in your environment.`,\n { remediation: { command: `export ${name}=\u2026 # then re-run mesh dev` } },\n );\n }\n return resolved;\n });\n if (endpoint) {\n value = value\n .replaceAll(\"{{url}}\", endpoint.url)\n .replaceAll(\"{{host}}\", endpoint.host)\n .replaceAll(\"{{port}}\", String(endpoint.port));\n }\n return value;\n}\n\n/** The Prism mock command for an openapi-mode declaration. `$PORT` expands\n * in the service's launch shell (tmux and docker runners both source the\n * env file), so port reallocation keeps working. */\nexport function openapiMockCommand(decl: LocalMockDecl): string[] {\n return (\n decl.command ?? [\n \"npx\",\n \"-y\",\n \"@stoplight/prism-cli@5\",\n \"mock\",\n \"-p\",\n \"$PORT\",\n \"-h\",\n \"0.0.0.0\",\n decl.openapi!,\n ]\n );\n}\n\n/** Compose-mode project name \u2014 pinned so `--kill` can tear down exactly\n * what `up` created, regardless of the compose file's own `name:`. */\nexport function composeProjectName(sessionName: string, mockName: string): string {\n return `mesh-ext-${sessionName}-${mockName}`.toLowerCase().replace(/[^a-z0-9_-]/g, \"-\");\n}\n\n/** A compose-mode external recorded in session state for teardown. */\nexport interface ComposeExternalRef {\n name: string;\n composeFile: string;\n project: string;\n}\n\n/** Wait until the external's published port accepts TCP (bounded). */\nasync function waitForPort(port: number, timeoutMs: number): Promise<boolean> {\n const deadline = Date.now() + timeoutMs;\n while (Date.now() < deadline) {\n if (await probeTcp(port, { timeoutMs: 2000 })) return true;\n await new Promise((r) => setTimeout(r, 2000));\n }\n return false;\n}\n\n/** What an existing realization of a compose external means for this run.\n * - `up` \u2192 nothing foreign in the way (or the realization is OURS):\n * normal idempotent `up`, ref recorded for `--kill`.\n * - `adopt-served` \u2192 ANOTHER realization of THIS compose file already serves\n * the declared port.\n * - `adopt-stopped` \u2192 a stopped container from another compose project holds\n * a `container_name` this file pins.\n * - `conflict` \u2192 the port is served by something this compose file does\n * not define (a native database, an unrelated container).\n * Never adopted: the app's credentials would be seeded\n * against the wrong service. */\nexport type ComposeExternalPlan =\n | { action: \"up\" }\n | { action: \"adopt-served\"; container: string }\n | { action: \"adopt-stopped\"; container: string }\n | { action: \"conflict\"; container?: string };\n\n/** The docker/network lookups `planComposeExternal` needs, injectable so the\n * branch table is unit-testable without a docker daemon. */\nexport interface ComposeExternalProbes {\n /** Is a container of OUR compose project already running for this file? */\n ownsRunning(composeFile: string, project: string): boolean;\n /** Does anything accept TCP on the declared port? */\n portServed(port: number): Promise<boolean>;\n /** A pinned `container_name` held by a DIFFERENT compose project. */\n foreignPinned(composeFile: string, project: string): string | undefined;\n /** Who publishes `port`, and is it a service THIS compose file defines?\n * Undefined when nothing in docker publishes it (a native process, or no\n * docker at all) \u2014 which is itself disqualifying for adoption. */\n portPublisher(\n composeFile: string,\n port: number,\n ): { container: string; definedByFile: boolean } | undefined;\n}\n\n/**\n * Decide how to realize a compose external, given what already exists.\n *\n * OWNERSHIP FIRST: a port probe can't tell WHO serves the port, and the\n * ordinary flow re-runs `mesh dev` against a session whose compose externals\n * are deliberately left running. Adopting there would drop the ref from\n * session state and permanently orphan the checkout's own container, so our\n * own realization takes the normal `up` path (idempotent, ref re-recorded)\n * and only a foreign one is adopted.\n *\n * IDENTITY SECOND: a foreign publisher is only adopted when it is a service\n * THIS compose file defines. Well-known ports (1433/5432/22) are exactly\n * where a native install or an unrelated container turns up, and adopting one\n * silently seeds the app's credentials against the wrong database.\n */\nexport async function planComposeExternal(\n composeFile: string,\n project: string,\n port: number,\n probes: ComposeExternalProbes,\n): Promise<ComposeExternalPlan> {\n if (probes.ownsRunning(composeFile, project)) return { action: \"up\" };\n if (await probes.portServed(port)) {\n const publisher = probes.portPublisher(composeFile, port);\n if (!publisher || !publisher.definedByFile) {\n return { action: \"conflict\", ...(publisher ? { container: publisher.container } : {}) };\n }\n return { action: \"adopt-served\", container: publisher.container };\n }\n // Pinned container_name held by a STOPPED container from another checkout/\n // project would make `up` fail with a name conflict \u2014 start that container\n // instead. (A RUNNING one is caught by the port probe above.)\n const foreign = probes.foreignPinned(composeFile, project);\n if (foreign) return { action: \"adopt-stopped\", container: foreign };\n return { action: \"up\" };\n}\n\n/** Real docker/network probes \u2014 replaced in tests. */\nconst dockerProbes: ComposeExternalProbes = {\n ownsRunning: ownsRunningRealization,\n portServed: (port) => probeTcp(port, { timeoutMs: 2000 }),\n foreignPinned: foreignPinnedContainer,\n portPublisher: composeFilePortPublisher,\n};\n\n/**\n * Bring a compose-mode external up (idempotent; waits for healthchecks \u2014\n * vendor DBs restoring seed data can take a while, hence the timeout).\n *\n * ADOPTION: the same external is often realized by more than one checkout \u2014\n * e.g. a vendor-DB compose ported from a legacy repo pins `container_name`\n * and a host port, so whichever checkout created it first blocks every other\n * `docker compose up` (name + port conflicts). An external service is by\n * definition outside the app, so if ANOTHER checkout already serves its\n * published port \u2014 or left a stopped container holding the pinned name \u2014\n * this ADOPTS that realization instead of failing: skip/`docker start`, wait\n * for the port, and return undefined so `--kill` never tears down what\n * another checkout owns. Our OWN realization is never adopted (see\n * `planComposeExternal`) \u2014 it stays recorded so `--kill` still cleans it up,\n * and a publisher this compose file doesn't define is never adopted either\n * (it would seed the app's credentials against the wrong service).\n */\nexport async function composeExternalUp(\n appRoot: string,\n sessionName: string,\n name: string,\n decl: LocalMockDecl,\n probes: ComposeExternalProbes = dockerProbes,\n): Promise<ComposeExternalRef | undefined> {\n const composeFile = path.resolve(appRoot, decl.compose!);\n if (!fs.existsSync(composeFile)) {\n throw new MeshCliError(`External '${name}': compose file not found at ${composeFile}.`, {\n remediation: { docs: 'package.json \u2192 \"mesh\": { \"externals\": { \u2026 } }' },\n });\n }\n const project = composeProjectName(sessionName, name);\n const plan = await planComposeExternal(composeFile, project, decl.port!, probes);\n if (plan.action === \"conflict\") {\n throw new MeshCliError(\n `External '${name}': localhost:${decl.port} is already served by ${\n plan.container ? `container '${plan.container}'` : \"a process outside docker\"\n }, which ${decl.compose} does not define \u2014 refusing to seed '${name}' credentials against it.`,\n {\n remediation: {\n command: `docker ps --filter publish=${decl.port} # stop it, or change the declared port`,\n },\n },\n );\n }\n if (plan.action === \"adopt-served\") {\n logInfo(\n `External '${name}' already served on localhost:${decl.port} by container '${plan.container}' \u2014 adopting it (started outside this checkout; \\`mesh dev --kill\\` won't touch it).`,\n );\n return undefined;\n }\n if (plan.action === \"adopt-stopped\") {\n const { container } = plan;\n logInfo(`External '${name}': container '${container}' exists from another checkout \u2014 starting and adopting it.`);\n execFileSync(\"docker\", [\"start\", container], { stdio: [\"ignore\", \"ignore\", \"inherit\"] });\n if (!(await waitForPort(decl.port!, 300_000))) {\n throw new MeshCliError(\n `External '${name}': adopted container '${container}' never served localhost:${decl.port}.`,\n { remediation: { command: `docker logs ${container} # then: docker rm -f ${container} and re-run` } },\n );\n }\n return undefined;\n }\n execFileSync(\n \"docker\",\n [\"compose\", \"-p\", project, \"-f\", composeFile, \"up\", \"-d\", \"--wait\", \"--wait-timeout\", \"300\"],\n { stdio: [\"ignore\", \"inherit\", \"inherit\"] },\n );\n return { name, composeFile, project };\n}\n\n/** Whether OUR compose project already has a container running for this file.\n * `docker compose ps` (running only) covers services WITHOUT a pinned\n * `container_name` too, which the label-inspect path below structurally\n * cannot \u2014 an unpinned service of ours is just `<project>-<svc>-1`. */\nfunction ownsRunningRealization(composeFile: string, project: string): boolean {\n try {\n const out = execFileSync(\n \"docker\",\n [\"compose\", \"-p\", project, \"-f\", composeFile, \"ps\", \"--format\", \"json\"],\n { encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] },\n ).trim();\n // Compose emits either a JSON array or NDJSON depending on version;\n // \"has any row\" is all we need from it.\n return out.length > 0 && out !== \"[]\";\n } catch {\n // ps hiccup \u2014 treat as \"not ours\" and let the normal paths decide.\n return false;\n }\n}\n\n/** The compose file's rendered service map, or undefined when it can't be\n * rendered (docker missing / bad file \u2014 callers decide what that means). */\nfunction composeServices(composeFile: string): Record<string, any> | undefined {\n try {\n const config = JSON.parse(\n execFileSync(\"docker\", [\"compose\", \"-f\", composeFile, \"config\", \"--format\", \"json\"], {\n encoding: \"utf-8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n }),\n );\n const services = config?.services;\n return services && typeof services === \"object\" ? services : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** The container publishing `port` on the host, with the compose identity\n * labels docker stamps on it. Undefined when nothing in docker publishes it\n * (a native process, or no docker at all). */\ninterface PortPublisher {\n container: string;\n image?: string;\n project?: string;\n service?: string;\n}\n\nfunction portPublisher(port: number): PortPublisher | undefined {\n try {\n const out = execFileSync(\n \"docker\",\n [\n \"ps\",\n \"--filter\",\n `publish=${port}`,\n \"--format\",\n '{{.Names}}\\t{{.Image}}\\t{{.Label \"com.docker.compose.project\"}}\\t{{.Label \"com.docker.compose.service\"}}',\n ],\n { encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] },\n );\n const line = out\n .split(\"\\n\")\n .map((l) => l.trim())\n .filter(Boolean)[0];\n if (!line) return undefined;\n const [container, image, project, service] = line.split(\"\\t\");\n if (!container) return undefined;\n return {\n container,\n image: image || undefined,\n project: project || undefined,\n service: service || undefined,\n };\n } catch {\n return undefined;\n }\n}\n\n/** Whether the container publishing the declared port is a service THIS\n * compose file defines \u2014 the evidence adoption requires. Matches on the\n * pinned `container_name`, or on the compose service name (cross-checked\n * against the image when both sides declare one, so an unrelated project's\n * service that happens to be called `db` isn't mistaken for ours). */\nfunction composeFileOwns(composeFile: string, publisher: PortPublisher | undefined): boolean {\n if (!publisher) return false;\n const services = composeServices(composeFile);\n if (!services) return false; // no identity evidence \u2014 don't adopt\n for (const [key, svc] of Object.entries<any>(services)) {\n if (svc?.container_name && svc.container_name === publisher.container) return true;\n if (publisher.service && publisher.service === key) {\n if (!svc?.image || !publisher.image || svc.image === publisher.image) return true;\n }\n }\n return false;\n}\n\n/** `ComposeExternalProbes.portPublisher` \u2014 who serves the declared port, and\n * whether this compose file defines it. Undefined when docker publishes\n * nothing there (a native process on the port, or no docker at all), which\n * `planComposeExternal` treats as a conflict rather than an adoption. */\nfunction composeFilePortPublisher(\n composeFile: string,\n port: number,\n): { container: string; definedByFile: boolean } | undefined {\n const publisher = portPublisher(port);\n if (!publisher) return undefined;\n return { container: publisher.container, definedByFile: composeFileOwns(composeFile, publisher) };\n}\n\n/** Find a container holding a `container_name` this compose file pins, when\n * it belongs to a different compose project (another checkout's realization\n * of the same external). Returns the container name, or undefined. */\nfunction foreignPinnedContainer(composeFile: string, project: string): string | undefined {\n // config render hiccup \u2014 fall through to the normal `up`, which reports\n // real errors itself.\n for (const svc of Object.values<any>(composeServices(composeFile) ?? {})) {\n const pinned = svc?.container_name;\n if (!pinned) continue;\n try {\n const owner = execFileSync(\n \"docker\",\n [\"inspect\", pinned, \"--format\", '{{ index .Config.Labels \"com.docker.compose.project\" }}'],\n { encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] },\n ).trim();\n if (owner !== project) return pinned;\n } catch {\n // no container by that name \u2014 nothing to adopt for this service\n }\n }\n return undefined;\n}\n\n/** Tear down compose-mode externals recorded in session state. Volumes are\n * kept (vendor DB data survives sessions, like the cloud external it\n * stands in for); best-effort per project. */\nexport function composeExternalsDown(refs: ComposeExternalRef[] | undefined): void {\n for (const ref of refs ?? []) {\n try {\n execFileSync(\"docker\", [\"compose\", \"-p\", ref.project, \"-f\", ref.composeFile, \"down\", \"--remove-orphans\"], {\n stdio: [\"ignore\", \"inherit\", \"inherit\"],\n });\n } catch {\n /* best effort \u2014 the file may be gone or docker down */\n }\n }\n}\n\n/** Same heuristic the schema seed uses: which credential keys are secret. */\nconst SECRET_KEY_RE = /key|secret|password|token/i;\n\n/** Read the app's declared externals (root package.json \u2192 mesh.externals;\n * legacy key mesh.mocks still read, externals wins on name collision). */\nexport function readLocalMocks(appRoot: string): Record<string, LocalMockDecl> {\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(appRoot, \"package.json\"), \"utf-8\"));\n const mocks = pkg?.mesh?.mocks;\n const externals = pkg?.mesh?.externals;\n return {\n ...(mocks && typeof mocks === \"object\" ? mocks : {}),\n ...(externals && typeof externals === \"object\" ? externals : {}),\n };\n } catch {\n return {};\n }\n}\n\nexport function externalSecretPath(tenant: string, external: string): string {\n return `mesh/${tenant}/${LOCAL_ENV}/external/${external}`;\n}\n\n/**\n * Pull an external service's credentials as configured in the app's tenant\n * environment (the CLOUD platform, not the local fabric) \u2014 secret\n * mesh/{tenant}/{env}/external/{name}, read with the developer's ambient\n * AWS credentials (AWS_PROFILE / SSO session for that tenant's account).\n * When the shell carries no AWS identity at all, `profile` (from the\n * declaration; MESH_AWS_PROFILE overrides) names the profile to use \u2014 the\n * same convention repo pull scripts follow.\n */\nexport async function fetchRemoteExternalCredentials(\n tenant: string,\n env: string,\n external: string,\n profile?: string,\n): Promise<Record<string, string>> {\n const secretId = `mesh/${tenant}/${env}/external/${external}`;\n const region = process.env.MESH_PLATFORM_REGION ?? process.env.AWS_REGION ?? \"us-east-2\";\n const fallbackProfile =\n !process.env.AWS_ACCESS_KEY_ID && !process.env.AWS_PROFILE\n ? (process.env.MESH_AWS_PROFILE ?? profile)\n : undefined;\n try {\n const { SecretsManagerClient, GetSecretValueCommand } = await import(\n \"@aws-sdk/client-secrets-manager\"\n );\n // The SDK's default chain reads AWS_PROFILE at first resolution \u2014 scope\n // the fallback to this client's call (no ambient identity to clobber).\n if (fallbackProfile) process.env.AWS_PROFILE = fallbackProfile;\n const sm = new SecretsManagerClient({ region });\n let res;\n try {\n res = await sm.send(new GetSecretValueCommand({ SecretId: secretId }));\n } finally {\n if (fallbackProfile) delete process.env.AWS_PROFILE;\n }\n const parsed: unknown = JSON.parse(res.SecretString ?? \"{}\");\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n throw new Error(\"secret value is not a JSON object\");\n }\n return Object.fromEntries(Object.entries(parsed).map(([key, v]) => [key, String(v)]));\n } catch (err) {\n throw new MeshCliError(\n `External '${external}': could not pull remote credentials from the ${env} environment (${secretId}, region ${region}): ${err instanceof Error ? err.message : err}`,\n {\n remediation: {\n command: `AWS_PROFILE=<${tenant}-${env} profile> mesh dev \u2026 # or set \"remote\": { \"profile\": \u2026 } on the declaration / MESH_AWS_PROFILE`,\n },\n },\n );\n }\n}\n\n/** The prober runs inside the stack: anything addressed at the developer\n * host must go through host.docker.internal. */\nfunction hostForProber(target: string): string {\n return target.replace(/\\b(?:localhost|127\\.0\\.0\\.1)\\b/, \"host.docker.internal\");\n}\n\n/**\n * A credential's endpoint value as an HTTP probe target, or undefined when\n * it isn't one. Credentials pulled from a tenant secret carry whatever the\n * vendor configured \u2014 a JDBC/ODBC connection string, an `sftp://` URL, or\n * an http URL with embedded userinfo. Anything non-http(s) falls through to\n * the tcp branch, and userinfo is stripped: the target is written to a\n * plaintext probe file and relabeled into Prometheus' `instance` label, so\n * a password in it would leak into every `probe_*` series.\n */\nfunction httpProbeUrl(raw: string | undefined): string | undefined {\n if (!raw) return undefined;\n let parsed: URL;\n try {\n parsed = new URL(raw);\n } catch {\n return undefined;\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") return undefined;\n parsed.username = \"\";\n parsed.password = \"\";\n return hostForProber(parsed.toString());\n}\n\n/** Reachability target for a non-HTTP integration, derived from its\n * credentials: a host/port pair (databases, SFTP) or an S3 bucket endpoint\n * (probed via TCP \u2014 no SDK or credentials needed, the technique CSP's\n * network lib used). */\nfunction derivedTcpTarget(creds: Record<string, string>): string | undefined {\n if (creds.host && creds.port) return `${hostForProber(creds.host)}:${creds.port}`;\n if (creds.bucket) return `${creds.bucket}.s3.${creds.region || \"us-east-1\"}.amazonaws.com:443`;\n return undefined;\n}\n\n/** The blackbox probe target for a realization, by mode: local/mock\n * stand-ins are probed on the host (the prober runs inside the stack \u2192\n * host.docker.internal); remote externals are probed at their REAL\n * endpoint, so the Hub shows actual vendor reachability. Every integration\n * gets AT LEAST a reachability check when its credentials expose a target:\n * an HTTP endpoint, a host(:port), or an S3 bucket (probed via TCP to the\n * bucket endpoint \u2014 no SDK/credentials needed, the same technique CSP's\n * network lib used). Undefined = nothing probeable in the credentials.\n *\n * Target and module come from ONE decision here so they can't drift: a\n * misrouted probe just reports probe_success 0, indistinguishable from a\n * genuinely down service. `tcp` yields host:port with no scheme/path and\n * routes the scrape to blackbox's tcp_connect (the scrape config's default\n * stays http_2xx); `http` GETs the target and expects 2xx. */\nexport function resolveProbeTarget(\n mode: ExternalMode,\n decl: LocalMockDecl,\n endpoint: MockEndpoint | undefined,\n creds: Record<string, string>,\n): { target: string; module?: \"tcp_connect\" } | undefined {\n if (mode === \"remote\") {\n // The endpoint comes from a TENANT SECRET, so it may be anything a\n // vendor credential carries \u2014 a JDBC/ODBC connection string, or a URL\n // with userinfo. Only a real http(s) URL is probeable, and its\n // credentials must never reach the probe file (world-readable) or the\n // Prometheus `instance` label.\n const url = httpProbeUrl(creds.endpoint ?? creds.url ?? creds.baseUrl);\n // Reachability fallbacks for non-HTTP integrations: host:port\n // (databases, SFTP) or the S3 bucket endpoint (object storage).\n const tcpTarget = derivedTcpTarget(creds);\n if (decl.probe === \"tcp\" || (!url && tcpTarget)) {\n if (!tcpTarget && decl.probe === \"tcp\") {\n logWarn(\n `External '${decl.external ?? \"\"}': probe: \"tcp\" declared but the remote credentials carry no host/port (or bucket) to probe \u2014 no uptime probe registered.`,\n );\n }\n return tcpTarget ? { target: tcpTarget, module: \"tcp_connect\" } : undefined;\n }\n // Remote endpoints rarely expose /health \u2014 default to the endpoint\n // root (Plaid's sandbox, for one, answers 200 there).\n return url ? { target: url.replace(/\\/+$/, \"\") + (decl.healthPath ?? \"\") } : undefined;\n }\n if (!endpoint) return undefined;\n if (decl.probe === \"tcp\") {\n return { target: `host.docker.internal:${endpoint.port}`, module: \"tcp_connect\" };\n }\n return {\n target: hostForProber(endpoint.url).replace(/\\/+$/, \"\") + (decl.healthPath ?? \"/health\"),\n };\n}\n\n/** Hub display name: explicit `displayName`, else the external name\n * title-cased on word boundaries (\"plaid-db\" \u2192 \"Plaid Db\"). */\nexport function externalDisplayName(name: string, decl: LocalMockDecl): string {\n return (\n decl.displayName ??\n name\n .split(/[-_\\s]+/)\n .filter(Boolean)\n .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n .join(\" \")\n );\n}\n\n/** Normalize the decl's `docs` (string URL or {url, provider}) for the meta. */\nexport function externalDocs(\n decl: LocalMockDecl,\n): { url: string; provider?: string } | undefined {\n if (!decl.docs) return undefined;\n return typeof decl.docs === \"string\" ? { url: decl.docs } : decl.docs;\n}\n\n/** Prometheus file_sd target file for one external's uptime probe. One\n * file per (tenant, app, external) so a session's probes can be removed on\n * `--kill` without touching another app's. */\nexport function localProbeFile(tenant: string, app: string, external: string): string {\n return path.join(localProbesDir(), `${tenant}-${app}-${external}.json`);\n}\n\n/** Drop probe target files (best effort). Called on `mesh dev --kill` so\n * Prometheus stops scraping stand-ins that no longer exist \u2014 otherwise the\n * Hub shows an integration down for a session that isn't running. */\nexport function localProbesRemove(files: string[] | undefined): void {\n for (const file of files ?? []) {\n try {\n fs.rmSync(file, { force: true });\n } catch {\n /* best effort \u2014 the cache dir may be gone */\n }\n }\n}\n\n/**\n * Wire a realization into the platform: seed the external-service\n * credential secret and register the vendor in the local Hub's\n * external-services view \u2014 the same shapes ExternalService exports in the\n * cloud. `endpoint` is the local stand-in's address (absent in remote\n * mode \u2014 the credentials carry the real endpoint).\n *\n * Returns the probe target file this realization registered, if any, so the\n * caller can record it in session state and remove it on `--kill`.\n */\nexport async function seedLocalMock(args: {\n tenant: string;\n app: string;\n name: string;\n decl: LocalMockDecl;\n endpoint?: MockEndpoint;\n}): Promise<string | undefined> {\n const { tenant, app, name, decl, endpoint } = args;\n if (!decl.external) return undefined;\n const mode = externalMode(name, decl);\n\n // Credential values by mode: local/mock interpolate the declared template\n // against the stand-in's endpoint; remote uses inline credentials or\n // pulls the tenant environment's configured secret.\n let value: Record<string, string>;\n if (mode === \"remote\") {\n const inline = remoteInlineCredentials(decl);\n value = inline\n ? Object.fromEntries(\n Object.entries(inline).map(([key, raw]) => [\n key,\n interpolateCredentialValue(raw, undefined),\n ]),\n )\n : await fetchRemoteExternalCredentials(\n tenant,\n decl.remote?.env ?? \"dev\",\n decl.external,\n decl.remote?.profile,\n );\n } else {\n if (!decl.credentials) return undefined;\n value = Object.fromEntries(\n Object.entries(decl.credentials).map(([key, raw]) => [\n key,\n interpolateCredentialValue(raw, endpoint),\n ]),\n );\n }\n\n const secretName = externalSecretPath(tenant, decl.external);\n await upsertLocalSecret(secretName, value);\n // The .config mirror \u2014 non-secret fields only. This is what the Hub's\n // credential views READ (the main secret is runtime-only by design), so\n // baseUrl etc. display in the Integrations detail page.\n await upsertLocalSecret(\n `${secretName}/.config`,\n Object.fromEntries(Object.entries(value).filter(([key]) => !SECRET_KEY_RE.test(key))),\n );\n\n // Hub discovery \u2014 the same per-stack shapes ExternalService exports in the\n // cloud (/apps/{app}/stacks/{stack}/external-services/{name}/\u2026).\n const { SSMClient, PutParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(LOCAL_AWS_CONFIG);\n const base = `/mesh-platform/${tenant}/${LOCAL_ENV}/apps/${app}/stacks/local/external-services/${decl.external}`;\n await ssm.send(\n new PutParameterCommand({\n Name: `${base}/meta`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({\n name: decl.external,\n // The integration keeps its REAL name in the Hub (\"Plaid\", not a\n // generated phrase); the description leads with it too, so display\n // heuristics that promote description heads still land on the name.\n displayName: externalDisplayName(name, decl),\n type: decl.type ?? \"other\",\n description:\n mode === \"local\"\n ? `${externalDisplayName(name, decl)} \u2014 local replica via mesh dev (docker compose)`\n : mode === \"remote\"\n ? remoteInlineCredentials(decl)\n ? `${externalDisplayName(name, decl)} \u2014 remote service (vendor credentials)`\n : `${externalDisplayName(name, decl)} \u2014 remote service (${decl.remote?.env ?? \"dev\"} environment credentials)`\n : `${externalDisplayName(name, decl)} \u2014 emulated by mesh dev (mock)`,\n secretPrefix: secretName,\n ...(externalDocs(decl) ? { docs: externalDocs(decl) } : {}),\n }),\n Description: `External service registration (local mock ${name})`,\n }),\n );\n await ssm.send(\n new PutParameterCommand({\n Name: `${base}/credentials`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({\n fields: Object.fromEntries(\n Object.keys(value).map((key) => [\n key,\n { type: \"field\", name: key, description: \"\", secret: SECRET_KEY_RE.test(key), optional: false },\n ]),\n ),\n keyedBy: null,\n }),\n Description: `External service credential schema (local mock ${name})`,\n }),\n );\n\n // Blackbox probe target (Prometheus file_sd) \u2014 the local analog of the\n // Probe CR ExternalService creates in the cloud, with the SAME label set,\n // so probe_success drives the Hub's health/uptime views unchanged.\n // Local/mock stand-ins are probed on the host; remote externals at their\n // real endpoint (host:port via the tcp_connect module for non-HTTP\n // externals \u2014 the __probe_module label routes the module through the\n // scrape config's relabeling, and \"__\"-prefixed labels drop after\n // relabeling so it never pollutes the stored series).\n const probe = resolveProbeTarget(mode, decl, endpoint, value);\n\n await ssm.send(\n new PutParameterCommand({\n Name: `${base}/healthCheck`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({ intervalSeconds: 30, timeoutSeconds: 10, hasCustomCheck: false, hasProbe: !!probe }),\n Description: `External service health check config (local mock ${name})`,\n }),\n );\n\n // The file and `hasProbe` must never disagree: a realization that no\n // longer resolves a probe target drops the file it wrote on a previous\n // run, or Prometheus keeps scraping the old target forever.\n const probeFile = localProbeFile(tenant, app, decl.external);\n if (probe) {\n fs.writeFileSync(\n probeFile,\n JSON.stringify(\n [\n {\n targets: [probe.target],\n labels: {\n type: \"external-service\",\n tenant,\n env: LOCAL_ENV,\n app,\n external_service: decl.external,\n target: decl.external,\n // \"__\"-prefixed labels drop after relabeling, so the module\n // selector never pollutes the stored series.\n ...(probe.module ? { __probe_module: probe.module } : {}),\n },\n },\n ],\n null,\n 2,\n ),\n );\n } else {\n localProbesRemove([probeFile]);\n }\n\n const endpointShown =\n endpoint?.url ??\n value.endpoint ??\n value.url ??\n value.baseUrl ??\n (value.host ? `${value.host}${value.port ? `:${value.port}` : \"\"}` : \"credentials\");\n logSuccess(\n `External '${name}' wired (${mode}): ${secretName} \u2192 ${endpointShown}` +\n ` (Hub registration${probe ? \" + uptime probe\" : \"\"})`,\n );\n return probe ? probeFile : undefined;\n}\n", "/**\n * @mesh-tech/api-registry/hub-roles \u2014 the Hub operator role vocabulary, declared\n * once (MESH-2504).\n *\n * This is a contract between three parties that cannot import each other:\n *\n * - `libs/infra-components` DECLARES the roles, by handing\n * `hubOperatorRoleKeys()` to the Hub's Zitadel project. A role that isn't\n * declared is not grantable.\n * - `apps/hub/api` ENFORCES them, in `middleware/authz.ts` `checkPolicy`.\n * - `apps/hub/ui` PRESENTS them, in `lib/auth.server.ts` (which affordances to\n * render) and `lib/api/types.ts` (`UserRole`).\n *\n * plus `libs/mesh-cli`, which seeds the same roles into the local Zitadel, and\n * `apps/hub/ai-assistant`, whose user-creation tools offer them as choices.\n *\n * Before this module the vocabulary was restated in each of those places and kept\n * in sync by comment. Every drift mode was silent: miss the declaration and the\n * role is never grantable; miss the enforcement and it silently cannot write; miss\n * the presentation and the UI reads it as no-role. Nothing threw and no build\n * failed \u2014 the roles claim just didn't contain what someone expected.\n *\n * This module carries no dependencies on purpose. It is reachable from Pulumi\n * (infra-components), from node (hub-api, mesh-cli) and from the browser (hub-ui)\n * because it is plain data plus two pure parsers, and it is a separate export path\n * from the package root so importing it never pulls zod in.\n *\n * ## The two classes\n *\n * Platform roles are GLOBAL: `ops` and `admin` run every tenant the Hub\n * administers, and are the only roles that may mutate.\n *\n * Tenant-scoped roles \u2014 `developer`, `auditor`, `vendor` \u2014 are read-only and are\n * granted per tenant as tenant-keyed role keys (`developer:<tenant>`,\n * `vendor:<tenant>`, `auditor:<tenant>`). hub-api's `checkPolicy` parses the key\n * and only allows reads on the named tenant. The bare keys stay declared so\n * pre-existing grants keep resolving, but a bare key grants nothing\n * tenant-scoped.\n *\n * `developer` moved from the global class to the tenant-scoped one on the Hub\n * RBAC project's direction that tenant access is a scope on the PERSON, applied\n * to every read-only role they hold \u2014 a developer confined to one tenant must\n * not see another tenant's apps or logs. A pre-existing bare `developer` grant\n * therefore reads nothing tenant-scoped until it is re-keyed to a tenant.\n *\n * The model is the MESH-2496 decision, enforced by MESH-2122.\n */\n\n/** Global roles; the only ones that may mutate. */\nexport const HUB_STAFF_ROLES = ['ops', 'admin'] as const;\n\n/** Granted per tenant as `<role>:<tenant>` keys; read-only, may never write. */\nexport const HUB_RESTRICTED_ROLES = ['developer', 'auditor', 'vendor'] as const;\n\nexport type HubStaffRole = (typeof HUB_STAFF_ROLES)[number];\nexport type HubRestrictedRole = (typeof HUB_RESTRICTED_ROLES)[number];\nexport type HubRole = HubStaffRole | HubRestrictedRole;\n\n/**\n * Every base role, staff first. This is the grantable vocabulary before tenant\n * keying \u2014 what the local Zitadel seed registers and what the UI offers when\n * creating a user.\n */\nexport const HUB_BASE_ROLES = [\n ...HUB_STAFF_ROLES,\n ...HUB_RESTRICTED_ROLES,\n] as const satisfies readonly HubRole[];\n\n/**\n * The roles allowed to mutate \u2014 a subset of the staff roles, since a restricted\n * role may never write. Asserted against {@link HUB_STAFF_ROLES} in the tests.\n */\nexport const HUB_WRITE_ROLES = ['ops', 'admin'] as const satisfies readonly HubStaffRole[];\n\n/**\n * Base roles ordered by privilege, highest first, for highest-privilege-wins\n * presentation in the UI.\n *\n * Precedence lives on the presentation side of the contract on purpose: it decides\n * which affordances to render, and the API deliberately returns the raw verified\n * vocabulary. It is declared here rather than in the UI only so that adding a role\n * without ranking it is a test failure instead of a role the UI silently ignores.\n */\nexport const HUB_ROLE_PRIORITY = [\n 'ops',\n 'admin',\n 'developer',\n 'auditor',\n 'vendor',\n] as const satisfies readonly HubRole[];\n\n/**\n * Separates a restricted role from the tenant it is granted on\n * (`vendor:acme`). Shared because the declaration side builds keys with it and\n * the enforcement side parses them with it \u2014 a divergence here would turn every\n * restricted grant into a no-op that still looks provisioned.\n */\nexport const HUB_ROLE_KEY_SEPARATOR = ':';\n\n/**\n * Separates the tenant from an optional app inside a keyed grant\n * (`vendor:acme/orders`). A tenant-scoped role that names an app reaches ONLY\n * that app's surfaces inside the tenant \u2014 the shape most vendors are handed,\n * since a vendor is engaged for one app, not an estate. A key with no app half\n * reaches the whole tenant, as before.\n */\nexport const HUB_ROLE_APP_SEPARATOR = '/';\n\nconst RESTRICTED = new Set<string>(HUB_RESTRICTED_ROLES);\n\n/**\n * The restricted base of a role key (`vendor:acme` \u2192 `vendor`), or null when the\n * key is not a restricted role at all.\n *\n * A bare `vendor`/`auditor` returns its own base: it IS a restricted role, it just\n * names no tenant. Callers that need the tenant ask {@link hubRoleKeyTenant}.\n */\nexport function restrictedRoleBase(roleKey: string): HubRestrictedRole | null {\n const sep = roleKey.indexOf(HUB_ROLE_KEY_SEPARATOR);\n const base = sep === -1 ? roleKey : roleKey.slice(0, sep);\n return RESTRICTED.has(base) ? (base as HubRestrictedRole) : null;\n}\n\n/**\n * The tenant a role key names, or null when the key carries none.\n *\n * Splits on the FIRST separator, so the tenant half is returned whole. That is\n * deliberate rather than incidental: it means a tenant name containing the\n * separator cannot silently read as a different tenant's grant \u2014 and\n * {@link hubOperatorRoleKeys} refuses to mint such a key in the first place.\n */\nexport function hubRoleKeyTenant(roleKey: string): string | null {\n const sep = roleKey.indexOf(HUB_ROLE_KEY_SEPARATOR);\n if (sep === -1) return null;\n const scope = roleKey.slice(sep + 1);\n const appSep = scope.indexOf(HUB_ROLE_APP_SEPARATOR);\n return appSep === -1 ? scope : scope.slice(0, appSep);\n}\n\n/**\n * The app a role key narrows to (`vendor:acme/orders` \u2192 `orders`), or null when\n * the key reaches its whole tenant. Split on the first app separator after the\n * tenant, so an app name is returned whole; {@link hubOperatorRoleKeys} refuses\n * tenant names that contain it, for the same reason it refuses the key separator.\n */\nexport function hubRoleKeyApp(roleKey: string): string | null {\n const sep = roleKey.indexOf(HUB_ROLE_KEY_SEPARATOR);\n if (sep === -1) return null;\n const scope = roleKey.slice(sep + 1);\n const appSep = scope.indexOf(HUB_ROLE_APP_SEPARATOR);\n return appSep === -1 || appSep === scope.length - 1 ? null : scope.slice(appSep + 1);\n}\n\n/**\n * The full set of role keys the Hub's Zitadel project declares: the base roles\n * plus a tenant-keyed pair per known tenant, deduped and in a stable order so the\n * Pulumi diff doesn't churn when tenant config reorders.\n */\nexport function hubOperatorRoleKeys(tenants: string[]): string[] {\n const cleaned = [...new Set(tenants.filter((t) => t !== ''))].sort();\n for (const tenant of cleaned) {\n for (const sep of [HUB_ROLE_KEY_SEPARATOR, HUB_ROLE_APP_SEPARATOR]) {\n if (tenant.includes(sep)) {\n throw new Error(\n `hubOperatorRoleKeys: tenant name \"${tenant}\" contains the role-key separator ` +\n `\"${sep}\" \u2014 it would parse as a different tenant's or app's grant`,\n );\n }\n }\n }\n return [\n ...HUB_BASE_ROLES,\n ...cleaned.flatMap((tenant) =>\n HUB_RESTRICTED_ROLES.map((role) => `${role}${HUB_ROLE_KEY_SEPARATOR}${tenant}`),\n ),\n ];\n}\n", "/**\n * @mesh-tech/api-registry \u2014 the shared contract between an Integration (producer,\n * infra-components) and a docs/discovery consumer (the hub). Types + path\n * helpers + the entry zod contract; no Pulumi, no Temporal, so either side can\n * import it.\n */\n\nimport { z } from 'zod';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Rate limiting \u2014 the declared half of the platform rate-limit lifecycle.\n// An integration DECLARES token-bucket defaults per request class here; the\n// deployment exports them to the registry entry (so the hub renders them\n// without a live call), app-kit's limiter enforces them, and per-subject\n// overrides ride the authz metadata payload\n// (`@mesh-tech/authz` `rate_limit_overrides` / `rateLimitOverrideFor`).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * One token-bucket rate: `rps` sustained refill per second, `burst` bucket\n * capacity (the largest instantaneous spike allowed). Both positive integers.\n *\n * @example\n * const readLimit: RateLimitSpec = { rps: 10, burst: 50 };\n */\nexport interface RateLimitSpec {\n /** Sustained requests per second (bucket refill rate). */\n rps: number;\n /** Bucket capacity \u2014 the largest instantaneous burst allowed. */\n burst: number;\n}\n\n/** Zod mirror of {@link RateLimitSpec}: both values must be positive integers. */\nexport const rateLimitSpecSchema = z.object({\n rps: z.number().int().min(1),\n burst: z.number().int().min(1),\n});\n\n/**\n * Declared rate-limit defaults keyed by request class. The canonical class\n * vocabulary is `reads` / `writes` (what the hub's override console offers);\n * the type is open so an integration can declare narrower classes its own\n * classifier produces. A class with no entry is not limited.\n *\n * @example\n * const rateLimits: RateLimitDefaults = {\n * reads: { rps: 10, burst: 50 },\n * writes: { rps: 2, burst: 5 },\n * };\n */\nexport type RateLimitDefaults = Record<string, RateLimitSpec>;\n\n/** Zod mirror of {@link RateLimitDefaults}. */\nexport const rateLimitDefaultsSchema = z.record(z.string().min(1), rateLimitSpecSchema);\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Integration health declaration \u2014 every integration states, in its\n// definition, whether its liveness can be measured. There is no third\n// answer: an integration that declares neither is a dependency nobody\n// decided not to measure, which is how one ships unwatched.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * How an integration's liveness is (or is not) measured.\n *\n * `{ op }` names a cheap, side-effect-free vendor operation the ops\n * listener's `GET /health` invokes with real credentials \u2014 the round-trip\n * that proves endpoint, auth, and identity together, so a credential\n * expiry reads as down rather than green.\n *\n * `{ unavailable }` states why no such operation exists. The reason is\n * rendered verbatim wherever this integration's health would appear, so\n * write it for the operator reading the tile, not for the compiler.\n *\n * @example\n * const health: IntegrationHealth = { op: \"Inquiry_Ping\" };\n *\n * @example\n * const health: IntegrationHealth = {\n * unavailable: \"The vendor exposes no read that does not bill per call.\",\n * };\n */\nexport type IntegrationHealth = { op: string } | { unavailable: string };\n\n/**\n * Narrow a health declaration to the probed case. Every layer that decides\n * whether to mount, wire, or sweep a probe branches on this one predicate.\n *\n * @example\n * if (isHealthProbed(definition.health)) mountHealthRoute(definition.health.op);\n */\nexport function isHealthProbed(health: IntegrationHealth): health is { op: string } {\n return 'op' in health;\n}\n\n/**\n * Zod mirror of {@link IntegrationHealth}. Both arms trim, then reject an\n * empty result: `min(1)` counts characters, so a whitespace-only reason\n * would otherwise satisfy every ingest point and publish a declaration that\n * renders as the blank this contract exists to replace. Trimming here fixes\n * it once for every layer instead of once per consumer.\n */\nexport const integrationHealthSchema = z.union([\n z.object({ op: z.string().trim().min(1) }),\n z.object({ unavailable: z.string().trim().min(1) }),\n]);\n\n/**\n * Validate a health declaration at ingest, where a definition crosses into\n * the platform (serve boot, registry publish). TypeScript alone cannot\n * refuse `{ unavailable: \"\" }` or a missing declaration from an untyped\n * JavaScript consumer, and an empty reason renders as a blank in the Hub \u2014\n * indistinguishable from the unwatched dependency this contract exists to\n * make impossible. Throws naming `context` so the failure points at the\n * integration, not at this function.\n *\n * @example\n * assertValidHealth(definition.health, `serveIntegration \"${definition.name}\"`);\n */\nexport function assertValidHealth(health: unknown, context: string): asserts health is IntegrationHealth {\n const result = integrationHealthSchema.safeParse(health);\n if (result.success) return;\n throw new Error(\n `${context}: invalid health declaration \u2014 every integration must declare ` +\n `health: { op: \"<cheap side-effect-free vendor op>\" } or ` +\n `health: { unavailable: \"<why this vendor cannot be probed>\" } ` +\n `(got ${JSON.stringify(health)})`,\n );\n}\n\n/** The transports an Integration projects. Only enabled surfaces appear. */\nexport interface ApiSurface {\n http?: { url: string };\n nexus?: { endpoint: string; taskQueue: string };\n}\n\n/** One per-owner registry record at `/mesh-platform/{tenant}/{env}/apis/<name>`. */\nexport interface ApiRegistryEntry {\n /** Contract version for forward-compat; absent = 1 (see `parseApiRegistryEntry`). */\n schemaVersion?: number;\n /** Unique key (the Integration name). */\n name: string;\n /** Family \u2014 groups variants (e.g. multiple SilverLake versions) in the hub. */\n provider?: string;\n /** Human version label + selector. */\n version?: string;\n title?: string;\n description?: string;\n surfaces: ApiSurface;\n /** Multi-instance axis: null/absent = single instance. */\n credentials?: { keyedBy?: string | null };\n /** Pointer to the FULL OpenAPI doc in the tenant artifacts bucket. */\n docs: { url: string; contentHash: string };\n /** The deployment's enabled operation subset (availability overlay source). */\n enabledOps: string[];\n /**\n * The in-cluster URL of the runtime's operator status payload \u2014 app-kit's\n * ops listener (`GET /status`, alongside `/health`). ClusterIP DNS, so it\n * is dialable only from inside the cluster; absent for non-K8s deploys.\n */\n status?: { url: string };\n /**\n * package.json version of the served directory, read at deploy time.\n * Best-effort: for a published-package wrapper deploy this is the wrapper's\n * version; the runtime `/status` payload reports the app's own.\n */\n appVersion?: string;\n /**\n * The deployment's DECLARED per-class rate-limit defaults, when the\n * integration declares any; the hub renders these as the baseline next to\n * per-subject overrides. Declared, not proven: enforcement is wired by the\n * serve entry (automatic on the generic dispatch surface; a custom router\n * mounts the middleware itself, and the nexus surface has no enforcement\n * point). Absent = nothing declared.\n */\n rateLimits?: RateLimitDefaults;\n /**\n * The integration's health declaration, copied from its definition \u2014 the\n * probed op, or the stated reason it cannot be probed. Optional on the\n * ENTRY (not on the definition): entries written before the declaration\n * became required predate it, and a consumer must tell \"wrote no\n * declaration\" from \"declared unavailable\" rather than reading the\n * silence as a reason.\n */\n health?: IntegrationHealth;\n /**\n * Rate classes the app's classifier PRODUCES beyond the built-ins, copied\n * from {@link IntegrationDefinition.producedRateClasses}. Consumers use it\n * to tell a wired custom class (validates clean) from a declared-only one\n * (published but never matched). Absent = the app does not publish the\n * contract; validation then falls back to the hedged declared-class note.\n */\n producedRateClasses?: string[];\n}\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Registry entry zod contract \u2014 consumers parse untrusted SSM JSON through\n// this so producer/consumer drift fails with a clear validation error rather\n// than propagating a malformed entry.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Zod mirror of {@link ApiSurface}. */\nexport const apiSurfaceSchema = z.object({\n http: z.object({ url: z.string().min(1) }).optional(),\n nexus: z.object({ endpoint: z.string().min(1), taskQueue: z.string().min(1) }).optional(),\n});\n\n/**\n * Zod mirror of {@link ApiRegistryEntry}. Unknown keys are stripped (a newer\n * producer may add fields); `schemaVersion` defaults to 1 when absent so\n * consumers can branch on it forward-compatibly.\n */\nexport const apiRegistryEntrySchema = z.object({\n schemaVersion: z.number().int().positive().default(1),\n name: z.string().min(1),\n provider: z.string().optional(),\n version: z.string().optional(),\n title: z.string().optional(),\n description: z.string().optional(),\n surfaces: apiSurfaceSchema,\n credentials: z.object({ keyedBy: z.string().nullish() }).optional(),\n docs: z.object({ url: z.string().min(1), contentHash: z.string().min(1) }),\n enabledOps: z.array(z.string()),\n // The consumer schema strips unknown keys, so a producer-side field that is\n // not mirrored here never reaches a consumer \u2014 mirror every addition.\n status: z.object({ url: z.string().min(1) }).optional(),\n appVersion: z.string().min(1).optional(),\n rateLimits: rateLimitDefaultsSchema.optional(),\n health: integrationHealthSchema.optional(),\n producedRateClasses: z.array(z.string().min(1)).optional(),\n});\n\n/** A parsed entry: {@link ApiRegistryEntry} with `schemaVersion` resolved. */\nexport type ParsedApiRegistryEntry = z.infer<typeof apiRegistryEntrySchema>;\n\n// Compile-time drift guard: the zod contract must stay assignable to the\n// hand-written interface (which keeps the per-field docs).\nconst _entryContractCheck: ApiRegistryEntry = {} as ParsedApiRegistryEntry;\nvoid _entryContractCheck;\n\n/**\n * Parse an untrusted registry entry (e.g. JSON from SSM). Throws a clear\n * validation error naming the offending fields on producer/consumer drift.\n */\nexport function parseApiRegistryEntry(value: unknown): ParsedApiRegistryEntry {\n const result = apiRegistryEntrySchema.safeParse(value);\n if (!result.success) {\n const detail = result.error.issues\n .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)\n .join('; ');\n throw new Error(`api-registry: invalid ApiRegistryEntry \u2014 ${detail}`);\n }\n return result.data;\n}\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Integration status \u2014 the operator payload app-kit's ops listener serves at\n// the registry entry's `status.url`. Owned here (not app-kit) because the\n// consumer side (the hub) depends on api-registry only, and untrusted JSON\n// from another pod needs a schema to parse through.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The versioned operator self-report served at `GET /status` on an\n * integration's ops listener. Names and flags only \u2014 no credential values,\n * no secret paths, nothing request-scoped.\n */\nexport interface IntegrationStatus {\n /** Payload contract version \u2014 bump when the shape changes incompatibly. */\n contract: 'v1';\n /** Integration identity, from the definition. */\n name: string;\n provider?: string;\n type?: string;\n title?: string;\n /** The definition's own version label (e.g. a vendor API generation). */\n definitionVersion?: string;\n /** The served application package's version (app-supplied wins over deploy-injected). */\n packageVersion?: string;\n tenant: string;\n /** Multi-instance selector, when the definition is keyed. */\n instanceKey?: string;\n /** Surfaces this process booted (from INTEGRATION_SURFACES). */\n surfaces: readonly ('http' | 'nexus')[];\n /** Whether the process talks to a real core or serves canned fixtures. */\n coreMode: 'mock' | 'live';\n /** The env variable that decided `coreMode: \"mock\"`, so an operator can see why. */\n coreModeSource?: string;\n /** The curated op set this deployment serves \u2014 same list /health reports. */\n enabledOps: readonly string[];\n /** True when platform-managed credentials are wired (secret prefix set). */\n credentialsWired: boolean;\n /**\n * The DECLARED per-class rate-limit defaults, when the definition declares\n * any and the serve entry has not disabled limiting. Declared, not proven:\n * a custom router that never mounts the middleware, or a nexus-only\n * process, declares without enforcing. Defaults only \u2014 per-subject\n * overrides live in the authz metadata payload and are not echoed here.\n */\n rateLimits?: RateLimitDefaults;\n /**\n * The health declaration this process booted with \u2014 the op it probes, or\n * the stated reason it cannot be probed. Optional for the same reason as\n * on the registry entry: a payload from an older app-kit omits it, and\n * absent must not read as a reason.\n */\n health?: IntegrationHealth;\n /** Process boot time (ISO 8601). */\n startedAt: string;\n}\n\n/** Zod mirror of {@link IntegrationStatus} \u2014 parse untrusted payloads through this. */\nexport const integrationStatusSchema = z.object({\n contract: z.literal('v1'),\n name: z.string().min(1),\n provider: z.string().optional(),\n type: z.string().optional(),\n title: z.string().optional(),\n definitionVersion: z.string().optional(),\n packageVersion: z.string().min(1).optional(),\n tenant: z.string().min(1),\n instanceKey: z.string().optional(),\n surfaces: z.array(z.enum(['http', 'nexus'])),\n coreMode: z.enum(['mock', 'live']),\n coreModeSource: z.string().optional(),\n enabledOps: z.array(z.string()),\n credentialsWired: z.boolean(),\n rateLimits: rateLimitDefaultsSchema.optional(),\n health: integrationHealthSchema.optional(),\n startedAt: z.string().min(1),\n});\n\n// Compile-time drift guard, same pattern as the registry entry above.\nconst _statusContractCheck: IntegrationStatus = {} as z.infer<typeof integrationStatusSchema>;\nvoid _statusContractCheck;\n\n/** Parse an untrusted status payload; throws naming the offending fields. */\nexport function parseIntegrationStatus(value: unknown): IntegrationStatus {\n const result = integrationStatusSchema.safeParse(value);\n if (!result.success) {\n const detail = result.error.issues\n .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)\n .join('; ');\n throw new Error(`api-registry: invalid IntegrationStatus \u2014 ${detail}`);\n }\n return result.data;\n}\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Credential schema \u2014 the shared shape for an integration's outbound creds.\n// Owned here so both infra-components (ExternalService secret) and an\n// IntegrationDefinition reference ONE schema type. Mirrors the secret JSON tree.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** A single credential field (a leaf in the schema tree). */\nexport interface CredentialField {\n /** Human-readable field name shown in CLI/UI. */\n name: string;\n description?: string;\n /** Mask in UI + encrypt at rest. */\n secret?: boolean;\n /** May be skipped. */\n optional?: boolean;\n /**\n * Multi-line value (e.g. a PEM block): editors MUST render a paste-faithful\n * multi-line input \u2014 a single-line input collapses the newlines and stores\n * a corrupted value that fails only at connection time.\n */\n multiline?: boolean;\n}\n\n/** A nested group of credential fields. */\nexport interface CredentialGroup {\n name: string;\n description?: string;\n fields: Record<string, CredentialField | CredentialGroup>;\n}\n\nexport type CredentialEntry = CredentialField | CredentialGroup;\n\n/** A credential schema: field definitions keyed by storage key (the secret JSON shape). */\nexport interface CredentialSchema {\n fields: Record<string, CredentialEntry>;\n}\n\n/** Type guard: is this entry a nested group (vs a leaf field)? */\nexport const isCredentialGroup = (e: CredentialEntry): e is CredentialGroup => 'fields' in e;\n\n/**\n * A reusable, package-exported description of how to integrate one third-party\n * service. The data (infra-safe) half \u2014 `mesh.apps.Integration` consumes this.\n * The runtime factories (`buildApi`/`nexusService`) are added by\n * `@mesh-tech/app-kit`'s `IntegrationRuntime`, which extends this.\n */\nexport interface IntegrationDefinition {\n /** Unique key \u2014 the integration's name. */\n name: string;\n /** Family label \u2014 groups variants (e.g. multiple versions) in the hub. */\n provider?: string;\n version?: string;\n title?: string;\n description?: string;\n /** ExternalService category for the Hub UI (e.g. \"banking_core\"). */\n type?: string;\n /** Outbound credential schema (the secret shape). */\n credentialSchema: CredentialSchema;\n /** Package whose built `dist/openapi.json` is the full OpenAPI doc. */\n docPackage?: string;\n /** Multi-instance key (per-FI); absent = single instance. */\n keyedBy?: string;\n /** Default operation subset; overridable per deployment via ENABLED_OPS. */\n defaultEnabledOps: string[];\n /**\n * Surface-mediated health declaration \u2014 required, and deliberately not\n * optional: an integration either names the vendor op that measures it or\n * says why none exists ({@link IntegrationHealth}).\n *\n * `serveIntegration` runs an always-on ops listener serving `GET /status`\n * for every integration; declaring `{ op }` additionally mounts\n * `GET /health` there (invoking the op against the vendor) and signals\n * `mesh.apps.Integration` to wire an in-cluster probe at that route.\n * Declaring `{ unavailable }` mounts nothing and publishes the reason, so\n * the Hub shows a stated \"not health-checked\" rather than a blank.\n */\n health: IntegrationHealth;\n /**\n * Declared per-class rate-limit defaults ({@link RateLimitDefaults}).\n * Declaring this makes the deployment rate-limited: `mesh.apps.Integration`\n * exports the defaults to the registry entry and app-kit's limiter enforces\n * them per subject, with per-subject overrides delivered live through the\n * authz metadata payload. Absent = no platform rate limiting.\n *\n * @example\n * rateLimits: { reads: { rps: 10, burst: 50 }, writes: { rps: 2, burst: 5 } }\n */\n rateLimits?: RateLimitDefaults;\n /**\n * Rate classes the serve entry's classifier actually PRODUCES beyond the\n * built-ins ({@link BUILTIN_RATE_CLASSES}) \u2014 the app's own promise that a\n * custom class in `rateLimits` is wired, not just declared. The classifier\n * (`classifyOp` / a custom router's `classify`) is a runtime function the\n * deploy cannot inspect, so this list is how deploy-time and console-time\n * validation learn what enforcement can really match: a class listed here\n * validates clean, a `rateLimits` class in neither set is one nothing ever\n * produces. Omit when the app uses only the built-ins.\n *\n * @example\n * producedRateClasses: ['exports']\n */\n producedRateClasses?: string[];\n}\n\n/**\n * The request classes app-kit's DEFAULT enforcement produces with no custom\n * classifier: `classifyByMethod` on HTTP routes and the dispatch surface's\n * built-in op classing. Every deployment can match these; anything beyond\n * them exists only when the app's classifier produces it (declared via\n * {@link IntegrationDefinition.producedRateClasses}).\n *\n * Mirrored (not imported) by `KNOWN_RATE_CLASSES` in `@mesh-tech/authz`,\n * which cannot depend on this package; `apps/hub/api` depends on both and\n * pins their equality, so drift fails a test rather than passing silently.\n *\n * @example\n * BUILTIN_RATE_CLASSES.includes('reads'); // true\n */\nexport const BUILTIN_RATE_CLASSES: readonly string[] = ['reads', 'writes'];\n\n/** Guard: an interpolated SSM path segment must be non-empty and slash-free. */\nfunction assertPathSegment(value: string, label: string): string {\n if (!value || value.includes('/')) {\n throw new Error(\n `api-registry: invalid ${label} ${JSON.stringify(value)} \u2014 ` +\n 'SSM path segments must be non-empty and must not contain \"/\"',\n );\n }\n return value;\n}\n\n/** SSM path of the apis registry root (for GetParametersByPath traversal). */\nexport function apisRegistryRoot(tenant: string, env: string): string {\n return `/mesh-platform/${assertPathSegment(tenant, 'tenant')}/${assertPathSegment(env, 'env')}/apis`;\n}\n\n/** SSM path of one Integration's per-owner entry. */\nexport function apiEntryPath(tenant: string, env: string, name: string): string {\n return `${apisRegistryRoot(tenant, env)}/${assertPathSegment(name, 'name')}`;\n}\n\n/**\n * Overlay availability onto doc entries: an entry is `available` iff its `id`\n * (the operationId) is in the deployment's enabled set. Pure; the full doc is\n * never filtered \u2014 availability is computed, not baked.\n */\nexport function markAvailability<T extends { id: string }>(\n entries: readonly T[],\n enabledOps: Iterable<string>,\n): Array<T & { available: boolean }> {\n const set = new Set(enabledOps);\n return entries.map((e) => ({ ...e, available: set.has(e.id) }));\n}\n\n// Re-exported at the root so consumers whose packaging only supports root\n// imports (the published mesh-cli bundles its workspace deps inline \u2014\n// build-for-publish aliases resolve the root entry file only) can still reach\n// the Hub role vocabulary. The subpath export stays; both are the same module.\nexport * from \"./hub-roles.js\";\n", "/**\n * Zitadel seeding for the `mesh start` local platform \u2014 the `mesh login local`\n * path (P1 critical path, design \u00A72.1 seeding).\n *\n * Mirrors what ZitadelPlatformIdentity provisions in the cloud, via the\n * management REST API using the first-instance machine user's PAT (written to\n * the zitadel_machinekey volume by the compose bootstrap). Org structure\n * follows the approved Zitadel tenant-auth pattern: everything here lives in\n * the PLATFORM org (`mesh` \u2014 platform-layer creds home, named by the\n * compose first-instance config); app tenants get their own orgs, created by\n * `mesh dev --local` (see auth-provision.ts):\n * - project \"mesh-cli\" (pattern naming: project per app \u2014 the CLI is a\n * platform-level client feature)\n * - native OIDC application \"cli\" inside it (PKCE + device code, no\n * secret \u2014 the same shape as ZitadelPlatformIdentity's cliApp, plus\n * devMode for http)\n * - two human test users (dev@local.mesh, ops@local.mesh)\n * - registry parity: /mesh-platform/local/dev/platform/zitadel in the local\n * AWS fabric ({endpoint, cliClientId} \u2014 what `mesh login` SSM discovery\n * reads) and temporal/test-users entries (what `mesh dev test-user` reads)\n * - the `local` CLI login context in ~/.config/mesh/config.json, so\n * `mesh login local` works with zero discovery.\n *\n * Idempotent: search-before-create for project/app, \"already exists\" is\n * success for users, SSM params overwrite.\n */\n\nimport * as fs from \"fs\";\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport { hubOperatorRoleKeys, hubRoleKeyTenant, restrictedRoleBase } from \"@mesh-tech/api-registry\";\nimport { logInfo, logSuccess, logWarn } from \"../../utils/log.js\";\nimport { MeshCliError } from \"../../utils/errors.js\";\nimport { writeContextConfig } from \"../login.js\";\nimport { compose, hubPort, DEFAULT_HUB_PORT } from \"./stack.js\";\nimport { LOCAL_TENANT, LOCAL_ENV } from \"./seed.js\";\nimport { meshCacheDir } from \"../../utils/cache-home.js\";\n\nexport const ZITADEL_ISSUER = \"http://localhost:8080\";\nexport const LOGIN_CONTEXT = \"local\";\n/** The platform org \u2014 home of platform-layer creds, same name as the cloud\n * platform tenant. Set by ZITADEL_FIRSTINSTANCE_ORG_NAME in the compose;\n * app tenants (incl. the default `local`) get their own orgs on demand. */\nexport const PLATFORM_ORG = \"mesh\";\n/**\n * Approved-pattern naming inside the platform org: project per app\n * (lowercase), application per service/client inside it. The CLI is a\n * platform-level client feature \u2192 project `mesh-cli`, application `cli`.\n * (The cloud's legacy catch-all \"Platform\" project is being retired by the\n * pattern doc's spec items; local starts pattern-true.)\n */\nexport const CLI_PROJECT_NAME = \"mesh-cli\";\nexport const CLI_APP_NAME = \"cli\";\nexport const CLI_REDIRECT_URI = \"http://localhost:9876/callback\";\n/**\n * The Hub's own auth config (pattern-true: the Hub is a platform app \u2192\n * project `hub` in the platform org, application per service). The `ui`\n * application backs the oauth2-proxy that fronts the Hub UI \u2014 the same\n * sidecar topology `mesh.apps.Service` deploys with `auth.provider: \"mesh\"`.\n * Roles ARE the Hub's vocabulary \u2014 imported from the module the deployed Hub's\n * infra declares them from, so a role that exists in the cloud is grantable\n * locally too (MESH-2504), including the tenant-keyed restricted keys\n * (`vendor:<tenant>`, `auditor:<tenant>`) that carry the only scope the policy\n * actually reads. The bare keys alone are not a usable local vocabulary: a bare\n * restricted grant names no tenant, so `checkPolicy` matches it against nothing\n * and it authorizes no tenant-scoped read.\n */\nexport const HUB_PROJECT_NAME = \"hub\";\nexport const HUB_APP_NAME = \"ui\";\n/** Registered on the seeded Hub app; the default-port URI is always included\n * so a later port switch never strands the app config. Zitadel's devMode only\n * relaxes the https requirement \u2014 every redirect URI must still be registered\n * exactly, so an existing app is RECONCILED (union-merged) on each seed run.\n * Functions, not constants: hubPort() must be read at call time so tests and\n * programmatic callers that set MESH_HUB_PORT after import see the override. */\nexport const hubRedirectUri = (): string => `http://localhost:${hubPort()}/oauth2/callback`;\nexport const HUB_DEFAULT_REDIRECT_URI = `http://localhost:${DEFAULT_HUB_PORT}/oauth2/callback`;\nexport const HUB_ROLES = hubOperatorRoleKeys([LOCAL_TENANT]);\nexport const ZITADEL_SSM_PARAM = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/platform/zitadel`;\nexport const TEST_USERS_SSM_PREFIX = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/temporal/test-users`;\n\nexport interface LocalTestUser {\n name: string;\n email: string;\n firstName: string;\n lastName: string;\n password: string;\n}\n\n/** Seeded human test users \u2014 plainly-labeled dev-grade credentials. */\nexport const TEST_USERS: LocalTestUser[] = [\n {\n name: \"dev\",\n email: \"dev@local.mesh\",\n firstName: \"Dev\",\n lastName: \"User\",\n password: \"LocalDev1!\",\n },\n {\n name: \"ops\",\n email: \"ops@local.mesh\",\n firstName: \"Ops\",\n lastName: \"User\",\n password: \"LocalDev1!\",\n },\n];\n\n/**\n * The CLI OIDC app payload \u2014 the same shape ZitadelPlatformIdentity creates in\n * the cloud (native, PKCE, device code, JWT access tokens, role assertions),\n * plus devMode so Zitadel accepts the http:// redirect locally.\n */\nexport function buildCliAppPayload() {\n return {\n name: CLI_APP_NAME,\n redirectUris: [CLI_REDIRECT_URI],\n responseTypes: [\"OIDC_RESPONSE_TYPE_CODE\"],\n grantTypes: [\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\", \"OIDC_GRANT_TYPE_DEVICE_CODE\"],\n appType: \"OIDC_APP_TYPE_NATIVE\",\n authMethodType: \"OIDC_AUTH_METHOD_TYPE_NONE\",\n accessTokenType: \"OIDC_TOKEN_TYPE_JWT\",\n accessTokenRoleAssertion: true,\n idTokenRoleAssertion: true,\n idTokenUserinfoAssertion: true,\n devMode: true,\n };\n}\n\n/** Read the seeder PAT the compose bootstrap wrote to the machinekey volume. */\nexport function readSeederPat(): string {\n const tmp = path.join(fs.mkdtempSync(path.join(os.tmpdir(), \"mesh-zitadel-\")), \"pat.txt\");\n try {\n compose([\"cp\", \"zitadel:/machinekey/pat.txt\", tmp]);\n const pat = fs.readFileSync(tmp, \"utf-8\").trim();\n if (!pat) throw new Error(\"empty PAT file\");\n return pat;\n } catch {\n throw new MeshCliError(\n \"Zitadel seeder PAT not found \u2014 the zitadel volume predates the bootstrap machine user (first-instance settings only apply on the first init).\",\n { remediation: { command: \"mesh stop --destroy && mesh start\" } },\n );\n } finally {\n fs.rmSync(path.dirname(tmp), { recursive: true, force: true });\n }\n}\n\n/** Call the local Zitadel REST API; `orgId` sets the org context header. */\nexport async function api(\n pat: string,\n method: string,\n apiPath: string,\n body?: unknown,\n orgId?: string,\n): Promise<any> {\n const res = await fetch(`${ZITADEL_ISSUER}${apiPath}`, {\n method,\n headers: {\n Authorization: `Bearer ${pat}`,\n \"Content-Type\": \"application/json\",\n ...(orgId ? { \"x-zitadel-orgid\": orgId } : {}),\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n signal: AbortSignal.timeout(15_000),\n });\n const text = await res.text();\n const data = text ? JSON.parse(text) : {};\n if (!res.ok) {\n const err: any = new Error(\n `Zitadel ${method} ${apiPath} \u2192 ${res.status}: ${data?.message ?? text}`,\n );\n err.status = res.status;\n err.zitadelCode = data?.code;\n throw err;\n }\n return data;\n}\n\nexport const isAlreadyExists = (err: any) =>\n err?.status === 409 || err?.zitadelCode === 6 || /already exists/i.test(err?.message ?? \"\");\n\n/**\n * Shared search-before-create for Zitadel projects: return the existing\n * project's id, else create it. Every ensure*Project in the local seeding\n * and auth-provisioning goes through this.\n */\nexport async function ensureZitadelProject(\n pat: string,\n name: string,\n opts: { orgId?: string; describe?: string; logExisting?: boolean } = {},\n): Promise<string> {\n const search = await api(\n pat,\n \"POST\",\n \"/management/v1/projects/_search\",\n { queries: [{ nameQuery: { name, method: \"TEXT_QUERY_METHOD_EQUALS\" } }] },\n opts.orgId,\n );\n const existing = search?.result?.[0]?.id;\n if (existing) {\n if (opts.logExisting) logInfo(`Zitadel project '${name}' already exists`);\n return existing;\n }\n const created = await api(\n pat,\n \"POST\",\n \"/management/v1/projects\",\n { name, projectRoleAssertion: true },\n opts.orgId,\n );\n logSuccess(`Created Zitadel project '${name}'${opts.describe ? ` (${opts.describe})` : \"\"}`);\n return created.id;\n}\n\n/** Shared name-equals application search; returns the raw app or undefined. */\nexport async function searchZitadelApp(\n pat: string,\n projectId: string,\n name: string,\n orgId?: string,\n): Promise<any | undefined> {\n const search = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/apps/_search`,\n { queries: [{ nameQuery: { name, method: \"TEXT_QUERY_METHOD_EQUALS\" } }] },\n orgId,\n );\n return search?.result?.[0];\n}\n\nconst ensureCliProject = (pat: string): Promise<string> =>\n ensureZitadelProject(pat, CLI_PROJECT_NAME, {\n describe: \"platform client feature\",\n logExisting: true,\n });\n\nasync function ensureCliApp(pat: string, projectId: string): Promise<string> {\n const existing = (await searchZitadelApp(pat, projectId, CLI_APP_NAME))?.oidcConfig?.clientId;\n if (existing) {\n logInfo(`Zitadel app '${CLI_APP_NAME}' already exists`);\n return existing;\n }\n const created = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/apps/oidc`,\n buildCliAppPayload(),\n );\n logSuccess(`Created Zitadel application '${CLI_APP_NAME}' (PKCE + device code)`);\n return created.clientId;\n}\n\nasync function ensureTestUsers(pat: string): Promise<void> {\n for (const user of TEST_USERS) {\n try {\n await api(pat, \"POST\", \"/management/v1/users/human/_import\", {\n userName: user.email,\n profile: {\n firstName: user.firstName,\n lastName: user.lastName,\n displayName: `${user.firstName} ${user.lastName}`,\n },\n email: { email: user.email, isEmailVerified: true },\n password: user.password,\n passwordChangeRequired: false,\n });\n logSuccess(`Created test user ${user.email}`);\n } catch (err) {\n if (isAlreadyExists(err)) {\n logInfo(`Test user ${user.email} already exists`);\n } else {\n throw err;\n }\n }\n }\n}\n\n// \u2500\u2500 Hub auth (oauth2-proxy client) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface HubAuthConfig {\n clientId: string;\n clientSecret: string;\n cookieSecret: string;\n /** The Hub's own Zitadel project \u2014 what its authz pointer names (\u00A77.1). */\n projectId: string;\n}\n\n/** Where the Hub's oauth2-proxy credentials persist between runs (the local\n * analog of the K8s Secret the Service component creates for the sidecar). */\nexport function hubAuthPath(): string {\n return path.join(meshCacheDir(\"mesh-local\"), \"hub-auth.json\");\n}\n\nexport function readHubAuth(): HubAuthConfig | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(hubAuthPath(), \"utf-8\"));\n if (parsed?.clientId && parsed?.clientSecret && parsed?.cookieSecret) return parsed;\n return null;\n } catch {\n return null;\n }\n}\n\nconst ensureHubProject = (pat: string): Promise<string> =>\n ensureZitadelProject(pat, HUB_PROJECT_NAME, { describe: \"Hub platform app\" });\n\n/**\n * What a role key is called in Zitadel's own UI and in the Hub's role picker.\n *\n * A tenant-keyed key reads as the base role plus the tenant it is scoped to, so\n * that `vendor:local` is not offered to an administrator as an opaque string\n * sitting next to a bare `vendor` that grants something entirely different.\n */\nfunction hubRoleDisplayName(roleKey: string): string {\n const tenant = hubRoleKeyTenant(roleKey);\n return tenant === null ? roleKey : `${restrictedRoleBase(roleKey) ?? roleKey} (${tenant})`;\n}\n\nasync function ensureHubRoles(pat: string, projectId: string): Promise<void> {\n for (const role of HUB_ROLES) {\n try {\n await api(pat, \"POST\", `/management/v1/projects/${projectId}/roles`, {\n roleKey: role,\n displayName: hubRoleDisplayName(role),\n });\n logSuccess(`Created Hub role '${role}'`);\n } catch (err) {\n if (!isAlreadyExists(err)) throw err;\n }\n }\n}\n\n/** The slice of Zitadel's OIDC app shape the reconcile below reads. */\ninterface HubOidcApp {\n id: string;\n oidcConfig?: {\n clientId?: string;\n redirectUris?: string[];\n postLogoutRedirectUris?: string[];\n responseTypes?: string[];\n grantTypes?: string[];\n accessTokenType?: string;\n accessTokenRoleAssertion?: boolean;\n idTokenRoleAssertion?: boolean;\n idTokenUserinfoAssertion?: boolean;\n devMode?: boolean;\n };\n}\n\n/** Union-merge the current-port redirect URIs into an existing Hub app's OIDC\n * config. Needed whenever the app was seeded under a different MESH_HUB_PORT:\n * devMode does NOT exempt an unregistered redirect_uri (Zitadel rejects the\n * authorize call with \"redirect_uri is missing in the client configuration\"),\n * it only permits http://. No-op when everything is already registered.\n * Exported for tests. */\nexport async function reconcileHubRedirectUris(\n pat: string,\n projectId: string,\n app: HubOidcApp,\n): Promise<void> {\n const cfg = app?.oidcConfig ?? {};\n const redirectUris = [\n ...new Set([...(cfg.redirectUris ?? []), hubRedirectUri(), HUB_DEFAULT_REDIRECT_URI]),\n ];\n // Both slash forms: the Hub's /logout sends `${origin}/` (trailing slash \u2014\n // matching the deployed platform, which registers `${baseUrl}/`), and\n // Zitadel exact-matches post_logout_redirect_uri. Seeding only the bare\n // form made local sign-out fail with \"post_logout_redirect_uri invalid\".\n const postLogoutRedirectUris = [\n ...new Set([\n ...(cfg.postLogoutRedirectUris ?? []),\n `http://localhost:${hubPort()}`,\n `http://localhost:${hubPort()}/`,\n `http://localhost:${DEFAULT_HUB_PORT}`,\n `http://localhost:${DEFAULT_HUB_PORT}/`,\n ]),\n ];\n if (\n redirectUris.length === (cfg.redirectUris ?? []).length &&\n postLogoutRedirectUris.length === (cfg.postLogoutRedirectUris ?? []).length\n ) {\n return;\n }\n // UpdateOIDCAppConfig replaces the whole config \u2014 carry the fields we seed\n // (falling back to the create-time shape; appType/authMethodType are\n // re-asserted to the seeded values). Fields we never seed \u2014 e.g.\n // additionalOrigins, clockSkew, backChannelLogoutUri \u2014 are NOT carried, so\n // anything set out-of-band in the Zitadel console is dropped here. Fine for\n // the local dev app; revisit before reusing against a shared instance.\n await api(pat, \"PUT\", `/management/v1/projects/${projectId}/apps/${app.id}/oidc_config`, {\n redirectUris,\n postLogoutRedirectUris,\n responseTypes: cfg.responseTypes ?? [\"OIDC_RESPONSE_TYPE_CODE\"],\n grantTypes: cfg.grantTypes ?? [\n \"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\",\n \"OIDC_GRANT_TYPE_REFRESH_TOKEN\",\n ],\n appType: \"OIDC_APP_TYPE_WEB\",\n authMethodType: \"OIDC_AUTH_METHOD_TYPE_BASIC\",\n accessTokenType: cfg.accessTokenType ?? \"OIDC_TOKEN_TYPE_JWT\",\n accessTokenRoleAssertion: cfg.accessTokenRoleAssertion ?? true,\n idTokenRoleAssertion: cfg.idTokenRoleAssertion ?? true,\n idTokenUserinfoAssertion: cfg.idTokenUserinfoAssertion ?? true,\n devMode: cfg.devMode ?? true,\n });\n logSuccess(\n `Registered Hub redirect URI http://localhost:${hubPort()}/oauth2/callback (MESH_HUB_PORT)`,\n );\n}\n\n/** Ensure the Hub `ui` web application; heals a lost client secret (Zitadel\n * returns it once \u2014 regenerate when the persisted copy is gone) and keeps the\n * redirect URIs in sync with the configured Hub port. */\nasync function ensureHubApp(\n pat: string,\n projectId: string,\n): Promise<{ clientId: string; clientSecret?: string }> {\n const existing = await searchZitadelApp(pat, projectId, HUB_APP_NAME);\n if (existing) {\n await reconcileHubRedirectUris(pat, projectId, existing);\n const clientId = existing?.oidcConfig?.clientId;\n const persisted = readHubAuth();\n if (persisted && persisted.clientId === clientId) return { clientId };\n const regenerated = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/apps/${existing.id}/oidc_config/_generate_client_secret`,\n {},\n );\n logSuccess(`Regenerated Hub UI client secret (local copy was missing)`);\n return { clientId, clientSecret: regenerated.clientSecret };\n }\n const created = await api(pat, \"POST\", `/management/v1/projects/${projectId}/apps/oidc`, {\n name: HUB_APP_NAME,\n redirectUris: [...new Set([hubRedirectUri(), HUB_DEFAULT_REDIRECT_URI])],\n postLogoutRedirectUris: [\n // Both slash forms \u2014 see reconcileHubRedirectUris; Zitadel exact-matches\n // and the Hub's /logout sends the trailing-slash form.\n ...new Set([\n `http://localhost:${hubPort()}`,\n `http://localhost:${hubPort()}/`,\n `http://localhost:${DEFAULT_HUB_PORT}`,\n `http://localhost:${DEFAULT_HUB_PORT}/`,\n ]),\n ],\n responseTypes: [\"OIDC_RESPONSE_TYPE_CODE\"],\n grantTypes: [\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\", \"OIDC_GRANT_TYPE_REFRESH_TOKEN\"],\n appType: \"OIDC_APP_TYPE_WEB\",\n authMethodType: \"OIDC_AUTH_METHOD_TYPE_BASIC\",\n accessTokenType: \"OIDC_TOKEN_TYPE_JWT\",\n accessTokenRoleAssertion: true,\n idTokenRoleAssertion: true,\n idTokenUserinfoAssertion: true,\n devMode: true,\n });\n logSuccess(`Created Zitadel application '${HUB_APP_NAME}' (Hub UI web client, code flow)`);\n return { clientId: created.clientId, clientSecret: created.clientSecret };\n}\n\n/** Grant Hub roles: admin@local.mesh + dev get `ops` (full dashboard),\n * ops@local.mesh gets `developer` \u2014 proves per-user role claims. */\nasync function ensureHubGrants(pat: string, projectId: string): Promise<void> {\n const grants: Array<{ email: string; roles: string[] }> = [\n { email: \"admin@local.mesh\", roles: [\"ops\"] },\n { email: \"dev@local.mesh\", roles: [\"ops\"] },\n { email: \"ops@local.mesh\", roles: [\"developer\"] },\n ];\n for (const grant of grants) {\n const search = await api(pat, \"POST\", \"/management/v1/users/_search\", {\n queries: [{ userNameQuery: { userName: grant.email, method: \"TEXT_QUERY_METHOD_EQUALS\" } }],\n });\n const userId = search?.result?.[0]?.id;\n if (!userId) continue;\n try {\n await api(pat, \"POST\", `/management/v1/users/${userId}/grants`, {\n projectId,\n roleKeys: grant.roles,\n });\n logSuccess(`Granted Hub role(s) ${grant.roles.join(\", \")} \u2192 ${grant.email}`);\n } catch (err) {\n if (!isAlreadyExists(err)) throw err;\n }\n }\n}\n\n/** Seed the Hub's auth config; persists oauth2-proxy credentials locally. */\nexport async function seedHubAuth(pat: string): Promise<HubAuthConfig> {\n const projectId = await ensureHubProject(pat);\n await ensureHubRoles(pat, projectId);\n const app = await ensureHubApp(pat, projectId);\n await ensureHubGrants(pat, projectId);\n\n const persisted = readHubAuth();\n // oauth2-proxy wants a RAW 16/24/32-byte cookie secret \u2014 the hosted\n // sidecar uses a 32-char RandomPassword; mirror that.\n const persistedCookie =\n persisted?.cookieSecret && [16, 24, 32].includes(persisted.cookieSecret.length)\n ? persisted.cookieSecret\n : undefined;\n const config: HubAuthConfig = {\n clientId: app.clientId,\n projectId,\n clientSecret: app.clientSecret ?? persisted?.clientSecret ?? \"\",\n cookieSecret: persistedCookie ?? (await import(\"crypto\")).randomBytes(16).toString(\"hex\"),\n };\n fs.writeFileSync(hubAuthPath(), JSON.stringify(config, null, 2), { mode: 0o600 });\n return config;\n}\n\n/**\n * Publish the Hub's OWN authz pointer \u2014 the local stand-in for what the platform\n * Pulumi program publishes (Hub RBAC design \u00A77.1).\n *\n * The Hub administers every app's identity through\n * `identity/apps/{app}/users`, which resolves the app's Zitadel project and org\n * from its published pointer. Without a pointer of its own the Hub is the one\n * app it cannot administer: its Users page has no project to create people in\n * and no role vocabulary to grant, which is why that page could only ever be a\n * mock. Locally there is no Pulumi run to publish it, so seeding does.\n *\n * Read-modify-write: the compiled-schema keys (`opsHubMetadataRef`, `mode`) are\n * published by the Hub itself and must survive this.\n */\nasync function publishHubAuthzPointer(\n projectId: string,\n orgId: string,\n awsConfig: object,\n): Promise<void> {\n const { SSMClient, GetParameterCommand, PutParameterCommand } =\n await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(awsConfig);\n const name = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/apps/hub/stacks/local/authz`;\n\n let current: Record<string, unknown> = {};\n try {\n const existing = await ssm.send(new GetParameterCommand({ Name: name }));\n current = JSON.parse(existing.Parameter?.Value ?? \"{}\") as Record<string, unknown>;\n } catch {\n // First run \u2014 nothing published yet.\n }\n\n const value = {\n spicedb: { instanceRefs: [] },\n ...current,\n zitadel: { projectId, orgId, issuer: ZITADEL_ISSUER },\n };\n await ssm.send(\n new PutParameterCommand({\n Name: name,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify(value),\n Description: \"Hub authz pointer (local analog of the platform Pulumi program)\",\n }),\n );\n logSuccess(`Hub authz pointer published \u2192 project ${projectId} in org ${orgId}`);\n}\n\n/** Registry parity: what SSM discovery and `mesh dev test-user` read in the cloud. */\nasync function writeRegistryParams(cliClientId: string, awsConfig: object): Promise<void> {\n const { SSMClient, PutParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(awsConfig);\n await ssm.send(\n new PutParameterCommand({\n Name: ZITADEL_SSM_PARAM,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({ endpoint: ZITADEL_ISSUER, cliClientId }),\n Description: \"Zitadel platform export (seeded by mesh start)\",\n }),\n );\n for (const user of TEST_USERS) {\n await ssm.send(\n new PutParameterCommand({\n Name: `${TEST_USERS_SSM_PREFIX}/${user.name}`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({ email: user.email, password: user.password, roles: [] }),\n Description: `Local test user '${user.name}' (dev-grade, seeded by mesh start)`,\n }),\n );\n }\n}\n\n/** Secrets Manager id the Hub reads its Zitadel admin key from (opsHubSecretName). */\nexport const OPS_HUB_SECRET_ID = \"mesh/local/dev/zitadel/ops-hub\";\n\n/**\n * Give the local Hub an ADMIN plane.\n *\n * The Hub verifies tokens with `ZITADEL_ISSUER`, but every WRITE \u2014 creating a\n * user, minting an API key \u2014 goes through a machine key it loads from Secrets\n * Manager. Without that key the local Hub silently offers no create affordance\n * and nothing reaches the IdP, which reads as \"the feature is broken\" rather\n * than \"the credential was never provisioned\".\n *\n * The key is a Zitadel machine key (private_key_jwt): `{ keyId, key, userId }`.\n * Idempotent \u2014 an existing user is reused, and a fresh key is minted only when\n * the secret is absent, because Zitadel returns the private half exactly once.\n */\nasync function ensureOpsHubAdmin(pat: string, awsConfig: object): Promise<void> {\n const {\n SecretsManagerClient,\n GetSecretValueCommand,\n CreateSecretCommand,\n PutSecretValueCommand,\n } = await import(\"@aws-sdk/client-secrets-manager\");\n const sm = new SecretsManagerClient(awsConfig);\n\n const existing = await sm\n .send(new GetSecretValueCommand({ SecretId: OPS_HUB_SECRET_ID }))\n .catch(() => null);\n if (existing?.SecretString) {\n logSuccess(\"Hub admin key already provisioned (Zitadel writes enabled)\");\n return;\n }\n\n const userName = \"hub-opshub\";\n const found = await api(pat, \"POST\", \"/management/v1/users/_search\", {\n queries: [{ userNameQuery: { userName, method: \"TEXT_QUERY_METHOD_EQUALS\" } }],\n });\n let userId: string | undefined = found?.result?.[0]?.id;\n if (!userId) {\n const created = await api(pat, \"POST\", \"/management/v1/users/machine\", {\n userName,\n name: \"Hub Ops Hub admin\",\n description: \"Local Hub Zitadel admin plane (seeded by mesh start)\",\n });\n userId = created?.userId;\n }\n if (!userId) throw new MeshCliError(\"could not create the Hub's Zitadel admin user\");\n\n // IAM_OWNER because the Hub administers every org on the instance \u2014 each app\n // tenant gets its own org, and a key is minted in the app's org, not the Hub's.\n await api(pat, \"POST\", \"/admin/v1/members\", { userId, roles: [\"IAM_OWNER\"] }).catch(\n () => undefined,\n );\n\n const key = await api(pat, \"POST\", `/management/v1/users/${userId}/keys`, {\n type: \"KEY_TYPE_JSON\",\n });\n if (!key?.keyDetails)\n throw new MeshCliError(\"Zitadel did not return a machine key for the Hub admin user\");\n const raw = JSON.parse(Buffer.from(key.keyDetails, \"base64\").toString(\"utf8\")) as {\n keyId: string;\n key: string;\n userId: string;\n };\n const secretString = JSON.stringify({ keyId: raw.keyId, key: raw.key, userId: raw.userId });\n await sm\n .send(new CreateSecretCommand({ Name: OPS_HUB_SECRET_ID, SecretString: secretString }))\n .catch(async () => {\n await sm.send(\n new PutSecretValueCommand({ SecretId: OPS_HUB_SECRET_ID, SecretString: secretString }),\n );\n });\n logSuccess(`Hub admin key provisioned \u2192 ${OPS_HUB_SECRET_ID} (Zitadel writes enabled)`);\n}\n\n/** Where the local stack's mail goes \u2014 the `mailpit` service in docker-compose. */\nconst LOCAL_SMTP = {\n host: \"mailpit:1025\",\n from: \"no-reply@local.mesh\",\n fromName: \"Mesh (local)\",\n} as const;\n\n/**\n * Point the instance's mail at the local mailbox, on every start.\n *\n * The compose file sets the same thing via `ZITADEL_DEFAULTINSTANCE_SMTP\u2026`,\n * but those apply at FIRST INIT only \u2014 so every stack that existed before the\n * mailbox was added would keep no SMTP config at all, and activation and\n * password-reset mail would go on silently going nowhere. Reconciling here\n * covers those without asking anyone to `mesh stop --destroy`.\n *\n * Idempotent: reads the configured providers first and only writes when the\n * host differs. Best-effort \u2014 a stack whose mail is misconfigured is worth a\n * warning, not a failed `mesh start`.\n */\nasync function ensureLocalSmtp(pat: string): Promise<void> {\n try {\n const existing = await api(pat, \"POST\", \"/admin/v1/smtp/_search\", {}).catch(() => null);\n const configs: Array<{ id?: string; senderAddress?: string; host?: string; state?: string }> =\n existing?.result ?? [];\n const match = configs.find((c) => c.host === LOCAL_SMTP.host);\n\n if (!match) {\n const created = await api(pat, \"POST\", \"/admin/v1/smtp\", {\n senderAddress: LOCAL_SMTP.from,\n senderName: LOCAL_SMTP.fromName,\n tls: false,\n host: LOCAL_SMTP.host,\n user: \"\",\n password: \"\",\n });\n // A provider that exists but is not active still sends nothing.\n if (created?.id) await api(pat, \"POST\", `/admin/v1/smtp/${created.id}/_activate`, {});\n logSuccess(\n `Local mailbox wired to Zitadel \u2192 ${LOCAL_SMTP.host} (view at http://localhost:8025)`,\n );\n return;\n }\n\n if (match.state !== \"SMTP_CONFIG_ACTIVE\" && match.id) {\n await api(pat, \"POST\", `/admin/v1/smtp/${match.id}/_activate`, {});\n }\n } catch (err) {\n logWarn(\n `Could not wire the local mailbox to Zitadel (${(err as Error).message}). ` +\n \"Activation and password-reset mail will not be delivered locally.\",\n );\n }\n}\n\nexport interface ZitadelSeedResult {\n projectId: string;\n cliClientId: string;\n hubAuth: HubAuthConfig;\n}\n\nexport async function seedZitadel(awsConfig: object): Promise<ZitadelSeedResult> {\n const pat = readSeederPat();\n const org = await api(pat, \"POST\", \"/admin/v1/orgs/_search\", {\n queries: [{ nameQuery: { name: PLATFORM_ORG, method: \"TEXT_QUERY_METHOD_EQUALS\" } }],\n });\n if (!org?.result?.[0]) {\n throw new MeshCliError(\n `Platform org '${PLATFORM_ORG}' not found \u2014 the zitadel volume predates the org naming.`,\n { remediation: { command: \"mesh stop --destroy && mesh start\" } },\n );\n }\n logSuccess(\n `Platform org '${PLATFORM_ORG}' ready (platform tenant root \u2014 holds platform-service auth config)`,\n );\n // Zitadel v4 defaults new instances to the login-v2 UI \u2014 a separate\n // container this stack doesn't run. Pin the built-in v1 login so the\n // authorize flow has a page to land on (idempotent; compose sets the\n // same default at first-instance time).\n try {\n await api(pat, \"PUT\", \"/v2/features/instance\", { loginV2: { required: false } });\n } catch {\n // pre-v4 instance or endpoint drift \u2014 v1 login is already the default\n }\n await ensureLocalSmtp(pat);\n const projectId = await ensureCliProject(pat);\n const cliClientId = await ensureCliApp(pat, projectId);\n await ensureTestUsers(pat);\n // Hub auth AFTER test users exist (role grants target them).\n const hubAuth = await seedHubAuth(pat);\n await publishHubAuthzPointer(hubAuth.projectId, org.result[0].id, awsConfig);\n await writeRegistryParams(cliClientId, awsConfig);\n await ensureOpsHubAdmin(pat, awsConfig);\n writeContextConfig(LOGIN_CONTEXT, { issuer: ZITADEL_ISSUER, clientId: cliClientId });\n logSuccess(`Login context '${LOGIN_CONTEXT}' configured \u2192 try: mesh login ${LOGIN_CONTEXT}`);\n return { projectId, cliClientId, hubAuth };\n}\n", "/**\n * App-tenant auth auto-provisioning for the local platform \u2014 the local analog\n * of what the Pulumi components do in the cloud, per the approved Zitadel\n * tenant-auth pattern (Org / Project / Application / Service User):\n *\n * Org = app tenant (ZitadelTenantIdentity parity)\n * Project = one per app, (ZitadelAppIdentity parity \u2014\n * name = authz schema namespace the app IS the authz boundary)\n * Application = one per service inside it (API app, Basic creds)\n * Secret = mesh/{tenant}/{env}/apps/{app}/zitadel/{service}\n * (ministack Secrets Manager \u2014 same canonical path as cloud)\n *\n * `mesh dev --local` calls this on startup so scaffolding + running an app\n * auto-creates its auth config \u2014 no Pulumi deploy, no manual Zitadel steps.\n * Idempotent: search-before-create everywhere; the credential secret is only\n * written when the application is first created (Zitadel returns the secret\n * once).\n */\n\nimport { logInfo, logSuccess } from \"../../utils/log.js\";\nimport { LOCAL_ENV, LOCAL_AWS_CONFIG, registerTenantEnv } from \"./seed.js\";\nimport { api, readSeederPat, PLATFORM_ORG, ensureZitadelProject, searchZitadelApp } from \"./seed-zitadel.js\";\nimport { upsertLocalSecret } from \"./helpers.js\";\n\nexport interface AppTenantAuthArgs {\n /** App tenant (org name), e.g. \"acme\" */\n tenant: string;\n /** App name = project name = authz schema namespace */\n app: string;\n /** Service names \u2192 one API application each (api, worker, \u2026) */\n services: string[];\n /**\n * App-declared project roles (package.json \u2192 mesh.auth.roles). Created on\n * the app's Zitadel project and granted to the local `{app}-m2m` caller so\n * role-scoped services (roles claim \u2192 scopes) accept its tokens.\n */\n roles?: string[];\n}\n\nexport interface ProvisionedAuth {\n orgId: string;\n projectId: string;\n /** Services whose application (and secret) was created this run */\n created: string[];\n}\n\nexport function authSecretPath(tenant: string, app: string, service: string): string {\n return `mesh/${tenant}/${LOCAL_ENV}/apps/${app}/zitadel/${service}`;\n}\n\nasync function ensureOrg(pat: string, name: string): Promise<string> {\n const search = await api(pat, \"POST\", \"/admin/v1/orgs/_search\", {\n queries: [{ nameQuery: { name, method: \"TEXT_QUERY_METHOD_EQUALS\" } }],\n });\n const existing = search?.result?.[0]?.id;\n if (existing) return existing;\n const created = await api(pat, \"POST\", \"/v2/organizations\", { name });\n logSuccess(`Created Zitadel org '${name}' (app tenant)`);\n return created.organizationId;\n}\n\nconst ensureProject = (pat: string, orgId: string, name: string): Promise<string> =>\n ensureZitadelProject(pat, name, { orgId, describe: \"app\" });\n\ntype ServiceAppResult =\n | { created: true; clientId: string; clientSecret: string }\n | { created: false; appId: string; clientId?: string };\n\n/** Ensure the service's API application exists; returns fresh credentials\n * when it was created this run. */\nasync function ensureServiceApp(\n pat: string,\n orgId: string,\n projectId: string,\n service: string,\n): Promise<ServiceAppResult> {\n const existing = await searchZitadelApp(pat, projectId, service, orgId);\n if (existing) {\n return { created: false, appId: existing.id, clientId: existing?.apiConfig?.clientId };\n }\n const created = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/apps/api`,\n { name: service, authMethodType: \"API_AUTH_METHOD_TYPE_BASIC\" },\n orgId,\n );\n logSuccess(`Created Zitadel application '${service}' (service)`);\n return { created: true, clientId: created.clientId, clientSecret: created.clientSecret };\n}\n\n/** Ensure the app-declared project roles exist (idempotent). */\nasync function ensureProjectRoles(\n pat: string,\n orgId: string,\n projectId: string,\n roles: string[],\n): Promise<void> {\n if (roles.length === 0) return;\n const existing = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/roles/_search`,\n {},\n orgId,\n );\n const have = new Set((existing?.result ?? []).map((r: { key: string }) => r.key));\n for (const key of roles) {\n if (have.has(key)) continue;\n await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/roles`,\n { roleKey: key, displayName: key },\n orgId,\n );\n logSuccess(`Created project role '${key}'`);\n }\n}\n\n/**\n * Ensure the app's LOCAL M2M caller: a machine user (`{app}-m2m`) with a\n * client-credentials secret, granted the app project (+ declared roles).\n * API applications are resource servers \u2014 they cannot obtain tokens \u2014 so\n * without this there is no way to mint a caller token against the local\n * Zitadel at all. Secret stored at mesh/{tenant}/dev/apps/{app}/zitadel/m2m.\n */\nasync function ensureM2mCaller(\n pat: string,\n tenant: string,\n app: string,\n orgId: string,\n projectId: string,\n roles: string[],\n): Promise<boolean> {\n const userName = `${app}-m2m`;\n const search = await api(\n pat,\n \"POST\",\n \"/management/v1/users/_search\",\n { queries: [{ userNameQuery: { userName, method: \"TEXT_QUERY_METHOD_EQUALS\" } }] },\n orgId,\n );\n let userId: string | undefined = search?.result?.[0]?.id;\n let createdUser = false;\n if (!userId) {\n const created = await api(\n pat,\n \"POST\",\n \"/management/v1/users/machine\",\n { userName, name: `${app} local M2M caller`, accessTokenType: \"ACCESS_TOKEN_TYPE_BEARER\" },\n orgId,\n );\n userId = created.userId as string;\n createdUser = true;\n logSuccess(`Created machine user '${userName}' (local M2M caller)`);\n }\n\n // Grant the project (+ declared roles); on re-runs update the existing\n // grant so newly-declared roles apply.\n try {\n await api(pat, \"POST\", `/management/v1/users/${userId}/grants`, { projectId, roleKeys: roles }, orgId);\n } catch (err) {\n const e = err as { status?: number; message?: string };\n if (e.status !== 409 && !/already exists/i.test(String(e.message))) throw err;\n const grants = await api(\n pat,\n \"POST\",\n \"/management/v1/users/grants/_search\",\n { queries: [{ userIdQuery: { userId } }, { projectIdQuery: { projectId } }] },\n orgId,\n );\n const grantId = grants?.result?.[0]?.id;\n if (grantId && roles.length > 0) {\n try {\n await api(pat, \"PUT\", `/management/v1/users/${userId}/grants/${grantId}`, { roleKeys: roles }, orgId);\n } catch (updateErr) {\n // Zitadel 400s a no-op update (\"User grant has not been changed\",\n // COMMAND-Rs8fy) \u2014 the grant already carries the declared roles.\n // Anything else is real. Must not throw here: aborting mid-provision\n // skips the secret (re-)mint below and leaves callers without creds.\n const ue = updateErr as { message?: string };\n if (!/has not been changed/i.test(String(ue.message))) throw updateErr;\n }\n }\n }\n\n if (createdUser || !(await authSecretExists(tenant, app, \"m2m\"))) {\n const secret = await api(pat, \"PUT\", `/management/v1/users/${userId}/secret`, {}, orgId);\n await writeAuthSecret(tenant, app, \"m2m\", {\n clientId: secret.clientId,\n clientSecret: secret.clientSecret,\n issuer: \"http://localhost:8080\",\n orgId,\n projectId,\n });\n return true;\n }\n return false;\n}\n\nasync function authSecretExists(tenant: string, app: string, service: string): Promise<boolean> {\n const { SecretsManagerClient, GetSecretValueCommand } = await import(\"@aws-sdk/client-secrets-manager\");\n const sm = new SecretsManagerClient(LOCAL_AWS_CONFIG);\n return sm.send(new GetSecretValueCommand({ SecretId: authSecretPath(tenant, app, service) })).then(\n () => true,\n () => false,\n );\n}\n\nasync function writeAuthSecret(\n tenant: string,\n app: string,\n service: string,\n value: Record<string, string>,\n): Promise<void> {\n const name = authSecretPath(tenant, app, service);\n await upsertLocalSecret(name, value);\n logSuccess(`Stored service credentials \u2192 ${name}`);\n}\n\n/**\n * Register the running app in the local platform registry \u2014 the same\n * self-registration path deployed apps use (/mesh-platform/{tenant}/{env}/\n * apps/{app}/meta), so StackPort (and the local Hub once its images land)\n * can visualize local apps.\n */\nexport async function registerLocalApp(\n args: AppTenantAuthArgs & {\n ports?: Record<string, number>;\n /** ExternalService names the app's services consume (ExternalService.link()\n * parity) \u2014 the Hub derives Integrations consumers + uptime from these. */\n links?: string[];\n /** The app's own version (its package.json), stamped like a deploy. */\n version?: string;\n },\n): Promise<string> {\n const { SSMClient, PutParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(LOCAL_AWS_CONFIG);\n const put = (name: string, value: unknown, description: string) =>\n ssm.send(\n new PutParameterCommand({ Name: name, Type: \"String\", Overwrite: true, Value: JSON.stringify(value), Description: description }),\n );\n\n // The app's tenant + env must be registered for the Hub to list the app.\n await registerTenantEnv(args.tenant);\n\n // Per-stack registration in the exact shape hub-api's SSM assembler reads\n // (stack \"local\" = the mesh dev --local runtime).\n const namespace = `${args.tenant}-${LOCAL_ENV}-${args.app}`;\n const base = `/mesh-platform/${args.tenant}/${LOCAL_ENV}/apps/${args.app}/stacks/local`;\n await put(\n base,\n {\n name: args.app,\n tenant: args.tenant,\n stack: \"local\",\n namespace,\n platform: { name: \"local\", env: LOCAL_ENV },\n },\n `Local app registration for ${args.tenant}/${args.app} (mesh dev --local)`,\n );\n for (const service of args.services) {\n const port = args.ports?.[service];\n const isMock = service.startsWith(\"mock-\");\n await put(\n `${base}/services/${service}`,\n {\n ...(port ? { url: `http://localhost:${port}`, port } : {}),\n replicas: 1,\n tier: \"core\",\n ...(!isMock && args.links?.length ? { links: args.links } : {}),\n },\n `Local service registration (${service})`,\n );\n }\n // Worker registration \u2014 the shape deployed TemporalWorker components\n // write; hub-api discovers a tenant's Temporal namespaces from these, so\n // the tenant-wide workflows view finds local runs without an explicit\n // namespace filter. Template convention: worker services are named so.\n for (const service of args.services) {\n if (!/worker/i.test(service)) continue;\n await put(\n `${base}/workers/${service}`,\n { temporalNamespace: namespace, taskQueue: args.app },\n `Local worker registration (${service})`,\n );\n }\n await put(\n `${base}/meta`,\n {\n runtime: \"mesh-dev-local\",\n services: args.services,\n ports: args.ports ?? {},\n // The same two fields a deployed stack stamps, so the Hub's app list and\n // its activity timeline read a local run exactly as they read a deploy \u2014\n // which version of this app is running here, and since when. Without them\n // a local app shows a blank version and never appears on the timeline,\n // and \"deployments aren't tracked\" is indistinguishable from \"no deploys\".\n ...(args.version ? { version: args.version } : {}),\n deployedAt: new Date().toISOString(),\n },\n \"Local run metadata (mesh dev --local)\",\n );\n logSuccess(`Registered app in the local registry \u2192 ${base}`);\n return base;\n}\n\n/**\n * Reconcile the platform registry from Zitadel \u2014 every app-tenant org (and\n * its projects = apps, applications = services) provisioned in Zitadel gets\n * a Hub-readable registration, even if the app hasn't been run since it was\n * provisioned. Zitadel is the auth source of truth; the registry mirrors it,\n * exactly like the cloud where ZitadelTenantIdentity/ZitadelAppIdentity and\n * the app's self-registration both flow from the same deploy. Idempotent:\n * existing per-stack registrations (which carry live ports/URLs from\n * `mesh dev --local`) are never overwritten.\n */\nexport async function reconcileRegistryFromZitadel(): Promise<{ tenants: string[]; apps: number }> {\n const pat = readSeederPat();\n const { SSMClient, GetParameterCommand, PutParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(LOCAL_AWS_CONFIG);\n\n const orgs = await api(pat, \"POST\", \"/admin/v1/orgs/_search\", { query: { limit: 200 } });\n const tenants: string[] = [];\n let apps = 0;\n for (const org of orgs?.result ?? []) {\n const tenant = org?.name;\n // The platform org holds platform-service auth config, not an app tenant.\n if (!tenant || tenant === PLATFORM_ORG) continue;\n await registerTenantEnv(tenant);\n tenants.push(tenant);\n\n const projects = await api(\n pat,\n \"POST\",\n \"/management/v1/projects/_search\",\n { query: { limit: 200 } },\n org.id,\n );\n for (const project of projects?.result ?? []) {\n const app = project?.name;\n if (!app) continue;\n apps++;\n const base = `/mesh-platform/${tenant}/${LOCAL_ENV}/apps/${app}/stacks/local`;\n const exists = await ssm.send(new GetParameterCommand({ Name: base })).then(\n () => true,\n () => false,\n );\n if (exists) continue;\n await ssm.send(\n new PutParameterCommand({\n Name: base,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({\n name: app,\n tenant,\n stack: \"local\",\n namespace: `${tenant}-${LOCAL_ENV}-${app}`,\n platform: { name: \"local\", env: LOCAL_ENV },\n }),\n Description: `App registration reconciled from Zitadel (${tenant}/${app})`,\n }),\n );\n const applications = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${project.id}/apps/_search`,\n { query: { limit: 100 } },\n org.id,\n );\n for (const application of applications?.result ?? []) {\n const service = application?.name;\n if (!service) continue;\n await ssm.send(\n new PutParameterCommand({\n Name: `${base}/services/${service}`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({ replicas: 1, tier: \"core\" }),\n Description: `Service registration reconciled from Zitadel (${service})`,\n }),\n );\n }\n }\n }\n return { tenants, apps };\n}\n\n/**\n * Ensure a service has a BROWSER sign-in, not just an API client.\n *\n * `ensureServiceApp` gives every service a Zitadel **API** application, which is\n * what a machine caller authenticates with. A person cannot sign in to one:\n * there is no authorization endpoint and no redirect URI, so the Hub's\n * Access \u2192 Sign-in tab reports the app has no browser login and refuses to\n * create a user \u2014 correctly, since the account would have nowhere to log in.\n *\n * An app declares which of its services carry a login in `package.json`:\n *\n * ```json\n * { \"mesh\": { \"auth\": { \"signIn\": [\"ui\"] } } }\n * ```\n *\n * which mirrors what the Pulumi program expresses as a `web` OIDC application\n * on `ZitadelAppIdentity`. Deployed, the redirect URI comes from the service's\n * public hostname; locally it is the port `mesh dev` allocated, so this runs\n * AFTER port allocation and re-asserts the URIs on every launch \u2014 a session\n * that lands on a different port must not leave the previous one registered as\n * the only valid callback.\n *\n * Idempotent, and best-effort at the call site: a missing sign-in app degrades\n * the Access tab, it does not stop the dev loop.\n */\nexport async function ensureSignInApp(args: {\n tenant: string;\n app: string;\n service: string;\n /** Origin the service is reachable at, e.g. `http://localhost:40881`. */\n baseUrl: string;\n}): Promise<void> {\n const pat = readSeederPat();\n const orgId = await ensureOrg(pat, args.tenant);\n const projectId = await ensureProject(pat, orgId, args.app);\n const name = `${args.service}-web`;\n const base = args.baseUrl.replace(/\\/+$/, \"\");\n // oauth2-proxy's callback, and both slash forms for post-logout \u2014 Zitadel\n // exact-matches the latter, and proxies differ on the trailing slash.\n const redirectUris = [`${base}/oauth2/callback`];\n const postLogoutRedirectUris = [base, `${base}/`];\n\n const config = {\n redirectUris,\n postLogoutRedirectUris,\n responseTypes: [\"OIDC_RESPONSE_TYPE_CODE\"],\n grantTypes: [\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\", \"OIDC_GRANT_TYPE_REFRESH_TOKEN\"],\n appType: \"OIDC_APP_TYPE_WEB\",\n authMethodType: \"OIDC_AUTH_METHOD_TYPE_BASIC\",\n // Roles must ride in the token: the app authorizes on them, and the Hub\n // shows which roles a person holds by reading the same grants.\n accessTokenType: \"OIDC_TOKEN_TYPE_JWT\",\n accessTokenRoleAssertion: true,\n idTokenRoleAssertion: true,\n idTokenUserinfoAssertion: true,\n // http:// callbacks are only permitted in dev mode.\n devMode: true,\n };\n\n const existing = await searchZitadelApp(pat, projectId, name, orgId);\n if (existing) {\n await api(\n pat,\n \"PUT\",\n `/management/v1/projects/${projectId}/apps/${existing.id}/oidc_config`,\n config,\n orgId,\n );\n return;\n }\n\n const created = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/apps/oidc`,\n { name, ...config },\n orgId,\n );\n await writeAuthSecret(args.tenant, args.app, name, {\n clientId: created.clientId,\n clientSecret: created.clientSecret,\n issuer: \"http://localhost:8080\",\n orgId,\n projectId,\n });\n logSuccess(`Created Zitadel application '${name}' (browser sign-in \u2192 ${base}/oauth2/callback)`);\n}\n\n/**\n * Ensure the app tenant's full auth config exists (org \u2192 project \u2192\n * application-per-service \u2192 credential secrets).\n */\nexport async function ensureAppTenantAuth(args: AppTenantAuthArgs): Promise<ProvisionedAuth> {\n const pat = readSeederPat();\n const orgId = await ensureOrg(pat, args.tenant);\n const projectId = await ensureProject(pat, orgId, args.app);\n const roles = args.roles ?? [];\n await ensureProjectRoles(pat, orgId, projectId, roles);\n const created: string[] = [];\n for (const service of args.services) {\n const result = await ensureServiceApp(pat, orgId, projectId, service);\n if (result.created) {\n await writeAuthSecret(args.tenant, args.app, service, {\n clientId: result.clientId,\n clientSecret: result.clientSecret,\n issuer: \"http://localhost:8080\",\n orgId,\n projectId,\n });\n created.push(service);\n } else if (!(await authSecretExists(args.tenant, args.app, service))) {\n // Zitadel persists across stack restarts but the local secret store is\n // in-memory \u2014 heal the gap by regenerating the application's secret\n // (Zitadel returns it only once, so a lost secret can't be re-read).\n const regenerated = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/apps/${result.appId}/api_config/_generate_client_secret`,\n {},\n orgId,\n );\n await writeAuthSecret(args.tenant, args.app, service, {\n clientId: result.clientId ?? \"\",\n clientSecret: regenerated.clientSecret,\n issuer: \"http://localhost:8080\",\n orgId,\n projectId,\n });\n logSuccess(`Regenerated credentials for '${service}' (local secret store was reset)`);\n created.push(service);\n }\n }\n if (await ensureM2mCaller(pat, args.tenant, args.app, orgId, projectId, roles)) {\n created.push(\"m2m\");\n }\n if (created.length === 0) {\n logInfo(`Auth config for ${args.tenant}/${args.app} already provisioned`);\n }\n return { orgId, projectId, created };\n}\n", "/**\n * Local DevOutput source for `mesh dev` (design \u00A75.2, AC C1/C3).\n *\n * Instead of reading a deployed Pulumi stack, synthesize the same DevOutput\n * shape from repo conventions and point every service at the running\n * `mesh start` platform. Zero AWS credentials, zero VPN, zero Pulumi state \u2014\n * the app's code never knows it's local:\n * - Temporal \u2192 localhost:7233, namespace local-dev (seeded)\n * - Postgres \u2192 the local `app` database\n * - Zitadel \u2192 http://localhost:8080 (seeded org/app/users)\n * - SpiceDB \u2192 localhost:50051 (preshared key)\n * - AWS SDK \u2192 ministack via AWS_ENDPOINT_URL (SSM/Secrets/S3 registry)\n *\n * Detection convention = what `mesh create-app` templates emit: immediate\n * subdirectories of the app root whose package.json has a `dev` script\n * (api/, worker/, web/, \u2026); a single-package app root with its own `dev`\n * script becomes one service.\n */\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport { isServiceMode, openapiMockCommand } from \"./mocks.js\";\nimport { probeTcp } from \"./helpers.js\";\nimport { MeshCliError } from \"../../utils/errors.js\";\nimport { localAwsEnv, LOCAL_ENV } from \"./seed.js\";\nimport { TEMPORAL_ADDRESS } from \"./seed.js\";\nimport { LOGIN_CONTEXT, ZITADEL_ISSUER } from \"./seed-zitadel.js\";\nimport { authSecretPath } from \"./auth-provision.js\";\nimport { findPackageRoot } from \"./stack.js\";\nimport { getContextConfig } from \"../login.js\";\n\n/** Matches dev.ts's DevEnvVar/DevService/DevOutput shapes (kept structural \u2014\n * dev.ts owns the canonical interfaces). */\ninterface LocalDevEnvVar {\n value: string;\n}\ninterface LocalDevService {\n src: string;\n port: number;\n command: string[];\n env?: Record<string, LocalDevEnvVar>;\n}\nexport interface LocalDevOutput {\n platform: { tenant: string; env: string };\n app?: string;\n stack: string;\n services: Record<string, LocalDevService>;\n tunnels: Record<string, never>;\n}\n\nexport const LOCAL_STACK_NAME = \"local\";\nconst BASE_PORT = 3000;\n\n/**\n * The app's namespace, exactly the name the cloud gives it in K8s AND\n * Temporal ({tenant}-{env}-{app}) \u2014 the Hub resolves both from this one\n * convention, so local runs are addressable with zero translation.\n */\nexport function localAppNamespace(tenant: string, appName: string): string {\n return `${tenant}-${LOCAL_ENV}-${appName}`;\n}\n\n/** The OTLP log shipper (assets/log-shipper.mjs) each local service's\n * launch pipeline pipes through \u2014 ships stdout to the stack's collector\n * with the hosted k8s_* attribution. */\nexport function logShipperPath(): string {\n return path.join(findPackageRoot(), \"assets\", \"log-shipper.mjs\");\n}\n\n/**\n * OTel SDK bootstrap \u2014 the local analog of the OTel Operator's pod\n * injection: when the service ships `@opentelemetry/auto-instrumentations-\n * node` (the templates declare it), the platform injects the `--import`\n * register hook + exporter env, and HTTP/fetch/gRPC spans flow to the\n * stack's collector \u2192 Tempo \u2192 the Hub's Traces views. Apps without the\n * package run untouched.\n */\nexport function autoInstrumentationEnv(appRoot: string, src: string): Record<string, LocalDevEnvVar> {\n const pkgDir = path.join(\"node_modules\", \"@opentelemetry\", \"auto-instrumentations-node\");\n const present =\n fs.existsSync(path.join(appRoot, src, pkgDir)) || fs.existsSync(path.join(appRoot, pkgDir));\n if (!present) return {};\n return {\n NODE_OPTIONS: { value: \"--import @opentelemetry/auto-instrumentations-node/register\" },\n OTEL_TRACES_EXPORTER: { value: \"otlp\" },\n // Logs ship via the launch pipeline; metrics locally come from\n // Prometheus scrapes \u2014 keep the SDK to traces.\n OTEL_METRICS_EXPORTER: { value: \"none\" },\n OTEL_LOGS_EXPORTER: { value: \"none\" },\n OTEL_NODE_DISABLED_INSTRUMENTATIONS: { value: \"fs,dns,net\" },\n };\n}\n\n/** Hosted-parity resource attribution for a local service \u2014 what the\n * in-cluster k8sattributes processor stamps. */\nexport function otelEnv(\n tenant: string,\n appName: string,\n serviceName: string,\n): Record<string, LocalDevEnvVar> {\n const namespace = localAppNamespace(tenant, appName);\n return {\n OTEL_EXPORTER_OTLP_ENDPOINT: { value: \"http://localhost:4318\" },\n OTEL_SERVICE_NAME: { value: serviceName },\n OTEL_RESOURCE_ATTRIBUTES: {\n value: `k8s.namespace.name=${namespace},k8s.deployment.name=${serviceName},k8s.container.name=${serviceName},service.namespace=${appName}`,\n },\n };\n}\n\n/** Endpoints of the running `mesh start` platform, as service env. */\nexport function localPlatformEnv(\n tenant: string,\n appName: string,\n serviceName: string,\n): Record<string, LocalDevEnvVar> {\n const namespace = localAppNamespace(tenant, appName);\n const env: Record<string, LocalDevEnvVar> = {\n TEMPORAL_ADDRESS: { value: TEMPORAL_ADDRESS },\n TEMPORAL_NAMESPACE: { value: namespace },\n TEMPORAL_TASK_QUEUE: { value: appName },\n // OTel wiring, two consumers: instrumented services ship traces/metrics\n // themselves, and the launch pipeline's log shipper reads these to ship\n // stdout logs \u2014 both with the in-cluster k8sattributes attribution.\n ...otelEnv(tenant, appName, serviceName),\n DATABASE_URL: { value: \"postgres://postgres:postgres@localhost:5433/app\" },\n ZITADEL_ISSUER: { value: ZITADEL_ISSUER },\n // The service's own Zitadel M2M credentials (created by ensureAppTenantAuth\n // on session start) \u2014 resolveCredentials(\"zitadel\") picks this up, so apps\n // can hydrate introspection/M2M config the same way they resolve\n // ExternalService links instead of hand-carrying client ids in env files.\n ZITADEL_SECRET_PREFIX: { value: authSecretPath(tenant, appName, serviceName) },\n SPICEDB_ENDPOINT: { value: \"localhost:50051\" },\n SPICEDB_HTTP_ENDPOINT: { value: \"http://localhost:8443\" },\n SPICEDB_PRESHARED_KEY: { value: \"local-dev-key\" },\n MEMCACHED_SERVERS: { value: \"localhost:11211\" },\n MESH_LOCAL: { value: \"1\" },\n MESH_SERVICE: { value: serviceName },\n };\n // Local tokens come from `mesh login local` (the seeded CLI app), so that\n // app's clientId is the audience local APIs should verify. In the cloud,\n // consumers request the API project's audience scope instead.\n const cliClientId = getContextConfig(LOGIN_CONTEXT)?.clientId;\n if (cliClientId) {\n env.AUTH_AUDIENCE = { value: cliClientId };\n }\n for (const [key, value] of Object.entries(localAwsEnv())) {\n env[key] = { value };\n }\n return env;\n}\n\nfunction hasDevScript(dir: string): boolean {\n const pkgPath = path.join(dir, \"package.json\");\n if (!fs.existsSync(pkgPath)) return false;\n try {\n const pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf-8\"));\n return typeof pkg?.scripts?.dev === \"string\";\n } catch {\n return false;\n }\n}\n\n/**\n * Detect runnable services by template convention: immediate subdirectories\n * with a package.json `dev` script; falls back to the app root itself when\n * it is a single-package app.\n */\nexport function detectLocalServices(appRoot: string): Record<string, { src: string }> {\n const services: Record<string, { src: string }> = {};\n for (const entry of fs.readdirSync(appRoot, { withFileTypes: true })) {\n if (!entry.isDirectory() || entry.name.startsWith(\".\") || entry.name === \"node_modules\") continue;\n if (hasDevScript(path.join(appRoot, entry.name))) {\n services[entry.name] = { src: entry.name };\n }\n }\n if (Object.keys(services).length === 0 && hasDevScript(appRoot)) {\n services[path.basename(appRoot)] = { src: \".\" };\n }\n return services;\n}\n\n/** Synthesize the DevOutput `mesh dev` would otherwise read from Pulumi.\n * `tenant` is the app tenant (detectLocalTenant) \u2014 it scopes the app's\n * namespace ({tenant}-{env}-{app}) for Temporal, logs, and traces.\n * `opts.mocks` (mesh dev --externals) appends declared mock services and injects\n * their ExternalService credential prefixes into every app service. */\nexport function buildLocalDevOutput(\n appRoot: string,\n tenant: string,\n opts: { mocks?: Record<string, import(\"./mocks.js\").LocalMockDecl> } = {},\n): LocalDevOutput {\n const detected = detectLocalServices(appRoot);\n const names = Object.keys(detected);\n if (names.length === 0) {\n throw new MeshCliError(\n `No runnable services found under ${appRoot} \u2014 expected subdirectories (api/, worker/, \u2026) with a package.json 'dev' script.`,\n { remediation: { command: \"mesh create-app\" } },\n );\n }\n // App name = project name = authz namespace. Pulumi.yaml `name:` wins\n // (matches getProjectName \u2014 a stack-backed app run with --local must keep\n // its deployed identity), then package.json name, then the basename.\n // Scoped package names (@scu-apps/plaid) are unusable as Temporal/K8s\n // namespaces \u2014 drop the scope.\n let appName = path.basename(appRoot);\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(appRoot, \"package.json\"), \"utf-8\"));\n if (typeof pkg?.name === \"string\" && pkg.name) appName = pkg.name.replace(/^@[^/]+\\//, \"\");\n } catch {\n // no root package.json \u2014 keep the basename\n }\n const pulumiYaml = path.join(appRoot, \"Pulumi.yaml\");\n if (fs.existsSync(pulumiYaml)) {\n const match = fs.readFileSync(pulumiYaml, \"utf-8\").match(/^name:\\s*(.+)$/m);\n if (match?.[1]?.trim()) appName = match[1].trim();\n }\n const services: Record<string, LocalDevService> = {};\n const mocks = opts.mocks ?? {};\n // Only process/openapi declarations run as mock-{name} services in this\n // orchestration; compose externals are docker-managed and sandbox\n // declarations spawn nothing (dev.ts seeds their credentials directly).\n const mockNames = Object.keys(mocks).filter((name) => isServiceMode(name, mocks[name]!));\n\n // The ExternalService link() contract, synthesized: every app service gets\n // {EXT}_SECRET_PREFIX for each mocked external, so resolveCredentials()\n // runs the exact deployed code path against the locally seeded secret.\n const externalEnv: Record<string, LocalDevEnvVar> = {};\n for (const decl of Object.values(mocks)) {\n if (!decl.external) continue;\n const prefix = decl.external.toUpperCase().replace(/-/g, \"_\");\n externalEnv[`${prefix}_SECRET_PREFIX`] = {\n value: `mesh/${tenant}/${LOCAL_ENV}/external/${decl.external}`,\n };\n }\n\n names.forEach((name, i) => {\n const port = BASE_PORT + i;\n services[name] = {\n src: detected[name]!.src,\n port,\n command: [\"pnpm\", \"dev\"],\n env: {\n // allocatePorts keeps service.port and env.PORT in sync on reallocation\n PORT: { value: String(port) },\n ...localPlatformEnv(tenant, appName, name),\n ...autoInstrumentationEnv(appRoot, detected[name]!.src),\n ...externalEnv,\n },\n };\n });\n\n // Sibling-service URLs \u2014 the local analog of the deployed cross-service\n // wiring (http://{svc}.{namespace}.svc.cluster.local:{port} in-cluster):\n // every app service gets SERVICE_{NAME}_URL for each app service, so code\n // can wire cross-service clients locally without knowing allocated ports.\n // allocatePorts' cross-service localhost:{port} rewrite keeps them in sync\n // when ports are reallocated.\n //\n // This is a PUBLIC, app-facing env contract \u2014 {NAME} is the service's\n // directory name upper-cased, non-[A-Z0-9] \u2192 \"_\". Apps that name their\n // link the same way (events.link({ environmentPrefix: \"SERVICE_EVENTS\" }))\n // read ONE variable deployed and locally, with no isLocal branch.\n // See docs/guides/mesh-dev-local-env.md.\n for (const name of names) {\n const key = `SERVICE_${name.toUpperCase().replace(/[^A-Z0-9]/g, \"_\")}_URL`;\n const url = `http://localhost:${services[name]!.port}`;\n for (const other of names) {\n services[other]!.env![key] = { value: url };\n }\n }\n\n // Declared mocks join the same orchestration (ports, env file, tmux\n // window, log pipeline) as first-class services named mock-{name}.\n mockNames.forEach((name, i) => {\n const decl = mocks[name]!;\n const serviceName = `mock-${name}`;\n const port = decl.port ?? BASE_PORT + names.length + i;\n services[serviceName] = {\n // openapi mode runs Prism from the app root against the spec path.\n src: decl.src ?? \".\",\n port,\n command: decl.openapi ? openapiMockCommand(decl) : (decl.command ?? [\"pnpm\", \"dev\"]),\n env: {\n PORT: { value: String(port) },\n ...otelEnv(tenant, appName, serviceName),\n ...Object.fromEntries(\n Object.entries(decl.env ?? {}).map(([key, value]) => [key, { value }]),\n ),\n },\n };\n });\n\n return {\n platform: { tenant: \"local\", env: \"dev\" },\n app: appName,\n stack: LOCAL_STACK_NAME,\n services,\n tunnels: {},\n };\n}\n\n/**\n * Whether the app is backed by a Pulumi program: its own (Pulumi.yaml or a\n * Pulumi.<stack>.yaml stack config in the app dir), or a monorepo ROOT\n * program above it (walk up to the repo boundary \u2014 .git/pnpm-workspace).\n * Apps like apps/hub deploy via a root program and have no per-app\n * Pulumi.yaml; they must NOT default into `mesh dev` local mode.\n */\nexport function hasStackBacking(appRoot: string): boolean {\n const hasOwn =\n fs.existsSync(path.join(appRoot, \"Pulumi.yaml\")) ||\n fs.readdirSync(appRoot).some((f) => /^Pulumi\\..+\\.yaml$/.test(f));\n if (hasOwn) return true;\n let dir = path.dirname(appRoot);\n while (true) {\n if (fs.existsSync(path.join(dir, \"Pulumi.yaml\"))) return true;\n if (fs.existsSync(path.join(dir, \".git\")) || fs.existsSync(path.join(dir, \"pnpm-workspace.yaml\"))) {\n return fs.existsSync(path.join(dir, \"Pulumi.yaml\"));\n }\n const parent = path.dirname(dir);\n if (parent === dir) return false;\n dir = parent;\n }\n}\n\n/**\n * The app's tenant, for auth provisioning: `mesh:tenant` from the app's\n * Pulumi stack config when present (templates write it), else \"local\".\n */\nexport function detectLocalTenant(appRoot: string): string {\n for (const file of fs.readdirSync(appRoot)) {\n if (!/^Pulumi\\..*\\.yaml$/.test(file)) continue;\n const match = fs\n .readFileSync(path.join(appRoot, file), \"utf-8\")\n .match(/^\\s*mesh:tenant:\\s*[\"']?([A-Za-z0-9-]+)[\"']?\\s*$/m);\n if (match) return match[1]!;\n }\n return \"local\";\n}\n\n/** The local platform must be up before `mesh dev` can wire services to it. */\nexport async function ensureLocalPlatformRunning(): Promise<void> {\n const [temporalUp, fabricUp] = await Promise.all([probeTcp(7233), probeTcp(4566)]);\n if (!temporalUp || !fabricUp) {\n throw new MeshCliError(\n \"The local Mesh platform is not running (mesh dev local mode wires services to it).\",\n { remediation: { command: \"mesh start\" } },\n );\n }\n}\n", "/**\n * Docker runner for `mesh dev --local` (design \u00A75.3, AC C2).\n *\n * The tmux runner stays the default on dev machines; `--runner docker` is\n * for environments where tmux isn't sensible \u2014 CI (the golden-path\n * workflow), headless agents, non-macOS boxes. It renders the SAME\n * synthesized DevOutput into a docker compose project:\n *\n * - one service per DevOutput service, `node:22-bookworm`, the app\n * bind-mounted at /workspace, `pnpm dev` in the service dir\n * - `network_mode: host` \u2014 services reach the `mesh start` platform on\n * localhost exactly like the tmux runner (Linux/CI semantics; on macOS\n * Docker Desktop host networking must be enabled, and host-installed\n * node_modules are platform-specific \u2014 tmux is the right runner there)\n * - per-service env files carrying the identical env contract\n * - logs ship through the same OTLP shipper (mounted from the CLI)\n *\n * Parity: `mesh dev --status|--kill|logs|restart` map to\n * `docker compose ps|down|logs|restart` via the session state's runner.\n */\n\nimport { execFileSync } from \"child_process\";\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport { logShipperPath } from \"./dev-local.js\";\nimport { meshCacheDir } from \"../../utils/cache-home.js\";\n\n// Digest-pinned so dev-runner containers are reproducible (matches the\n// pinning of the stack's third-party images).\nconst DEV_RUNNER_IMAGE =\n \"node:22-bookworm@sha256:5647be709086c696ff32edaaf1c70cd26d1da6ab2b39c32f3c7b4c4a31957e37\";\n\ninterface RunnerService {\n src: string;\n port: number;\n command: string[];\n env?: Record<string, { value: string }>;\n}\n\nfunction sessionDir(sessionName: string): string {\n return meshCacheDir(\"mesh-local\", \"dev\", sessionName);\n}\n\n/** docker env-file format: KEY=VALUE, no quoting, single-line values only. */\nfunction renderDockerEnvFile(env: Record<string, { value: string }>): string {\n const lines: string[] = [];\n for (const [key, entry] of Object.entries(env)) {\n if (entry.value.includes(\"\\n\")) continue; // multi-line values unsupported by env_file\n lines.push(`${key}=${entry.value}`);\n }\n return lines.join(\"\\n\") + \"\\n\";\n}\n\n/** Write the compose project for a dev session; returns the compose file path. */\nexport function writeDevCompose(\n sessionName: string,\n appRoot: string,\n services: Record<string, RunnerService>,\n): string {\n const dir = sessionDir(sessionName);\n const shipper = logShipperPath();\n\n const blocks: string[] = [\n `# Generated by \\`mesh dev --local --runner docker\\` \u2014 do not edit.`,\n `name: ${sessionName}`,\n `services:`,\n ];\n for (const [name, service] of Object.entries(services)) {\n const env = service.env ?? {};\n fs.writeFileSync(path.join(dir, `${name}.env`), renderDockerEnvFile(env), { mode: 0o600 });\n const workdir = path.posix.join(\"/workspace\", service.src === \".\" ? \"\" : service.src);\n const ship = env.OTEL_RESOURCE_ATTRIBUTES\n ? ` 2>&1 | NODE_OPTIONS= node /mesh-assets/log-shipper.mjs`\n : \"\";\n blocks.push(\n ` ${name}:`,\n ` image: ${DEV_RUNNER_IMAGE}`,\n ` network_mode: host`,\n ` working_dir: ${workdir}`,\n ` volumes:`,\n ` - '${appRoot}:/workspace'`,\n ` - '${path.dirname(shipper)}:/mesh-assets:ro'`,\n ` environment:`,\n // node_modules is the HOST's bind-mounted install \u2014 pnpm inside the\n // container must never \"verify\" it and try a purge/reinstall (pnpm\n // \u226510.9's verify-deps-before-run prompts and dies headless; worse, a\n // purge would clobber the host install).\n ` CI: 'true'`,\n ` npm_config_verify_deps_before_run: 'false'`,\n ` env_file:`,\n ` - './${name}.env'`,\n // pipefail: the container's exit code must be the SERVICE's, not the\n // log-shipper's \u2014 otherwise a crashed service reports success (CI).\n ` command: [\"bash\", \"-lc\", \"set -o pipefail; corepack enable >/dev/null 2>&1; { ${service.command.join(\" \")}; }${ship}\"]`,\n ` restart: unless-stopped`,\n );\n }\n const composePath = path.join(dir, \"compose.yml\");\n fs.writeFileSync(composePath, blocks.join(\"\\n\") + \"\\n\");\n return composePath;\n}\n\nfunction composeArgs(sessionName: string, args: string[]): string[] {\n return [\"compose\", \"-p\", sessionName, \"-f\", path.join(sessionDir(sessionName), \"compose.yml\"), ...args];\n}\n\nexport function dockerDevUp(sessionName: string): void {\n execFileSync(\"docker\", composeArgs(sessionName, [\"up\", \"-d\"]), {\n stdio: [\"ignore\", \"inherit\", \"inherit\"],\n });\n}\n\nexport function dockerDevDown(sessionName: string): void {\n execFileSync(\"docker\", composeArgs(sessionName, [\"down\", \"--remove-orphans\"]), {\n stdio: [\"ignore\", \"inherit\", \"inherit\"],\n });\n}\n\nexport function dockerDevPs(sessionName: string): string {\n return execFileSync(\"docker\", composeArgs(sessionName, [\"ps\", \"--format\", \"table {{.Service}}\\t{{.State}}\\t{{.Status}}\"]), {\n encoding: \"utf-8\",\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n }) as unknown as string;\n}\n\nexport function dockerDevLogs(sessionName: string, service: string, tail: number): string {\n return execFileSync(\"docker\", composeArgs(sessionName, [\"logs\", \"--tail\", String(tail), service]), {\n encoding: \"utf-8\",\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n }) as unknown as string;\n}\n\nexport function dockerDevRestart(sessionName: string, service: string): void {\n execFileSync(\"docker\", composeArgs(sessionName, [\"restart\", service]), {\n stdio: [\"ignore\", \"inherit\", \"inherit\"],\n });\n}\n", "/**\n * Stack-selection flag resolution (MESH-2154).\n *\n * `mesh` subcommands historically disagreed on the stack-selection flag:\n * `deploy`/`cluster` use `--stack` (Pulumi-native), while `dev`/`secrets` used\n * `--stage` (the SST-era term the platform has moved off). So `mesh kubectl\n * --stage dev` and `mesh dev --stack \u2026` each errored on muscle memory. We\n * consolidate on `--stack` everywhere and keep `--stage` as a HIDDEN, deprecated\n * alias that still works but warns \u2014 removable after a transition window.\n */\nimport { logWarn } from './log.js';\n\nexport interface StackSelector {\n /** Canonical Pulumi stack name. */\n stack?: string;\n /** @deprecated SST-era alias for {@link StackSelector.stack}; still accepted. */\n stage?: string;\n}\n\n/**\n * Resolve the stack selector, preferring the canonical `--stack` over the\n * deprecated `--stage` alias. Pure: reports whether the deprecated alias supplied\n * the value so the caller can warn. `--stack` wins when both are present.\n */\nexport function resolveStackSelector(opts: StackSelector): {\n stack: string | undefined;\n usedDeprecatedStage: boolean;\n} {\n if (opts.stack != null) return { stack: opts.stack, usedDeprecatedStage: false };\n if (opts.stage != null) return { stack: opts.stage, usedDeprecatedStage: true };\n return { stack: undefined, usedDeprecatedStage: false };\n}\n\nlet warnedOnce = false;\n\n/** Test-only: reset the once-per-process deprecation-warning latch. */\nexport function resetStackFlagWarning(): void {\n warnedOnce = false;\n}\n\n/**\n * Resolve `--stack`/`--stage` to the effective stack for use at a consumption\n * site, warning ONCE per process when the deprecated `--stage` alias supplied it.\n */\nexport function resolveStackOption(opts: StackSelector): string | undefined {\n const { stack, usedDeprecatedStage } = resolveStackSelector(opts);\n if (usedDeprecatedStage && !warnedOnce) {\n warnedOnce = true;\n logWarn(\n '--stage is deprecated; use --stack (the Pulumi-native name). --stage still works for now.',\n );\n }\n return stack;\n}\n", "/**\n * How peers address each other inside a `mesh dev` session (MESH-2597).\n *\n * `mesh dev` allocates local ports and rewrites `localhost:<oldPort>` references\n * across every service's env, which handles the PORT half of the question. The\n * HOST half was never handled: an env var derived from a `link()` renders as\n * `http://<svc>.<ns>.svc.cluster.local:8080`, and that survived untouched even\n * when the service it names was one of the ones just started on this laptop.\n *\n * Observed 2026-08-01: after a relaunch, `studio-ui` held\n * `AGENT_API_INTERNAL_URL=http://studio-api.fub-dev-studio.svc.cluster.local:8080`\n * while `studio-api` was running locally on :40161. The UI's proxy dialled the\n * cluster from the laptop, every request failed `getaddrinfo ENOTFOUND`, and the\n * whole app returned `502 upstream unavailable`.\n *\n * Its own module rather than more of `dev.ts` (already ~3.7k lines) so the\n * rewriting rule is testable without importing the launch path.\n */\n\n/** The subset of a dev service this module reads. */\nexport interface AddressableService {\n port: number;\n /** Empty for a DEPLOYED service \u2014 it is not running here. */\n command?: string[];\n env?: Record<string, { value: string }>;\n}\n\nexport interface AddressableOutput {\n services?: Record<string, AddressableService>;\n}\n\n/**\n * `http(s)://<name>.<namespace>.svc.cluster.local[:port]`.\n *\n * Anchored on the full service name so `studio-api` cannot match\n * `studio-api-worker`, and the name is escaped because it reaches this as data.\n */\nexport function clusterHostPattern(serviceName: string): RegExp {\n const name = serviceName.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n return new RegExp(`https?://${name}\\\\.[a-z0-9-]+\\\\.svc\\\\.cluster\\\\.local(?::\\\\d+)?`, \"g\");\n}\n\n/**\n * Point every reference to a LOCALLY-RUN service at its local port.\n *\n * Only services this session actually runs are rewritten. A deployed-only service\n * (no dev command) keeps its cluster address, because reaching that is the\n * tunnels' job, not this one's \u2014 rewriting it would break the one case that\n * currently works.\n *\n * The namespace is matched rather than resolved: the session already knows which\n * services it started and on which ports, and re-deriving the namespace here\n * would be a second source of truth for something the URL already carries.\n */\nexport function rewriteClusterHostsToLocal(devOutput: AddressableOutput): void {\n const localPorts = new Map<string, number>();\n for (const [name, service] of Object.entries(devOutput.services ?? {})) {\n if (!service.command || service.command.length === 0) continue;\n if (typeof service.port !== \"number\" || service.port <= 0) continue;\n localPorts.set(name, service.port);\n }\n if (localPorts.size === 0) return;\n\n for (const service of Object.values(devOutput.services ?? {})) {\n if (!service.env) continue;\n for (const envVar of Object.values(service.env)) {\n if (typeof envVar.value !== \"string\" || !envVar.value.includes(\".svc.cluster.local\")) continue;\n for (const [name, port] of localPorts) {\n envVar.value = envVar.value.replace(clusterHostPattern(name), `http://localhost:${port}`);\n }\n }\n }\n}\n", "/**\n * `mesh dev doctor` \u2014 preflight + on-demand diagnosis for `mesh dev`.\n *\n * Three layers kept deliberately separate so the bug-prone logic stays pure and\n * unit-testable without network/AWS:\n * 1. Engine + renderers (this section): `aggregateStatus`, `runChecks`,\n * `renderHuman`, `renderJson`.\n * 2. `DoctorContext` builder (resolves role/context/session once).\n * 3. The individual `Check`s (creds, tunnels, ports, \u2026), each returning data.\n *\n * Both `mesh dev doctor` and the `mesh dev` launch preflight call `runChecks`;\n * see MESH-2015 (docs/superpowers/specs/2026-07-08-mesh-dev-doctor-design.md).\n */\n\nimport { execFileSync } from \"node:child_process\";\nimport * as fs from \"node:fs\";\nimport * as net from \"node:net\";\nimport * as path from \"node:path\";\nimport type { SessionState } from \"./dev.js\";\nimport { isLinkedDependencyDir, isPortFree } from \"./dev.js\";\nimport type { ResolvedAwsCreds } from \"../utils/aws-auth.js\";\nimport {\n appUsesMeshPackages,\n probeRegistryToken,\n registryLoginFix,\n} from \"../utils/auth-preflight.js\";\nimport { probeCredentials, isRemoteEnvironment, type CredProbe } from \"./login.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport type CheckStatus = \"ok\" | \"warn\" | \"error\";\nexport type CheckPhase = \"preflight\" | \"ondemand\";\n\nexport interface CheckResult {\n status: CheckStatus;\n /** One line, present tense. */\n summary: string;\n /** Exact command that fixes it (rendered indented under the summary). */\n remediation?: string;\n /** Optional extra context (PID, TTL, path, \u2026). */\n detail?: string;\n}\n\n/**\n * One diagnostic. `C` is the context the check reads: `DoctorContext` for\n * `mesh dev doctor` (the default), `AppCheckContext` for `mesh app check` \u2014\n * the engine below is shared, only the context differs.\n */\nexport interface Check<C = DoctorContext> {\n /** Stable slug, e.g. `creds`, `tunnels`. */\n id: string;\n /** Human label. */\n title: string;\n /** Which contexts this check runs in. */\n phases: CheckPhase[];\n /** `phase` is the context of THIS run \u2014 checks may bound work tighter in\n * `preflight` (launch path) than in on-demand `mesh dev doctor`. */\n run(ctx: C, phase?: CheckPhase): Promise<CheckResult>;\n}\n\n/** What the renderers need from a check: its identity, not its `run`. */\nexport type CheckOutcome = { check: Pick<Check<unknown>, \"id\" | \"title\">; result: CheckResult };\n\nexport interface DoctorContext {\n appRoot: string;\n stack: string;\n sessionName: string;\n /** Resolved deployer role ARN (incl. admin selection), or null if none. */\n deployerRole: string | null;\n /** `derivePlatformContext()` result (the Zitadel context), or null. */\n platformContext: string | null;\n /** How creds were obtained at launch; null when the probe determines it. */\n credMethod: ResolvedAwsCreds[\"method\"] | \"ambient\" | null;\n /** Live session state (allocated ports, tunnels); null pre-launch. */\n sessionState: SessionState | null;\n}\n\n// ============================================================================\n// Engine + renderers (pure)\n// ============================================================================\n\n/** `error` if any error, else `warn` if any warn, else `ok` (empty \u2192 `ok`). */\nexport function aggregateStatus(results: CheckResult[]): CheckStatus {\n if (results.some((r) => r.status === \"error\")) return \"error\";\n if (results.some((r) => r.status === \"warn\")) return \"warn\";\n return \"ok\";\n}\n\n/**\n * Run every check whose `phases` includes `phase`, concurrently. A check that\n * throws is captured as an `error` result rather than aborting the run \u2014 the\n * whole point of doctor is to report, never to crash.\n */\nexport async function runChecks<C = DoctorContext>(\n ctx: C,\n phase: CheckPhase,\n checks: Check<C>[],\n): Promise<Array<{ check: Check<C>; result: CheckResult }>> {\n const applicable = checks.filter((c) => c.phases.includes(phase));\n return Promise.all(\n applicable.map(async (check) => {\n try {\n return { check, result: await check.run(ctx, phase) };\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n return {\n check,\n result: {\n status: \"error\" as const,\n summary: `check '${check.id}' threw: ${msg}`,\n },\n };\n }\n }),\n );\n}\n\nexport const ICONS: Record<CheckStatus, string> = { ok: \"\u2713\", warn: \"\u26A0\", error: \"\u2717\" };\n\n/** Human-readable: one line per check + indented remediation when present. */\nexport function renderHuman(results: CheckOutcome[]): string {\n const lines: string[] = [];\n for (const { check, result } of results) {\n lines.push(`${ICONS[result.status]} ${check.title} \u2014 ${result.summary}`);\n if (result.detail) lines.push(` ${result.detail}`);\n if (result.remediation) lines.push(` Fix: ${result.remediation}`);\n }\n return lines.join(\"\\n\");\n}\n\n/** The JSON report as data \u2014 `renderJson` serialises it; `mesh app check` nests one per app. */\nexport function jsonReport(results: CheckOutcome[]): {\n status: CheckStatus;\n checks: Array<{ id: string; status: CheckStatus; summary: string; remediation: string | null; detail: string | null }>;\n} {\n return {\n status: aggregateStatus(results.map((r) => r.result)),\n checks: results.map(({ check, result }) => ({\n id: check.id,\n status: result.status,\n summary: result.summary,\n // Stable keys for tooling: always present, `null` when absent\n // (JSON.stringify would otherwise drop `undefined`).\n remediation: result.remediation ?? null,\n detail: result.detail ?? null,\n })),\n };\n}\n\n/** Machine-readable: stable key shape for tooling / future CI. */\nexport function renderJson(results: CheckOutcome[]): string {\n return JSON.stringify(jsonReport(results), null, 2);\n}\n\n// ============================================================================\n// Check: credentials + TTL (creds)\n// ============================================================================\n\n/** Render a TTL in seconds as a compact human string (\"47m\", \"1h 10m\"). */\nfunction fmtTtl(seconds: number): string {\n if (seconds <= 0) return \"expired\";\n // Round to whole minutes first, then split \u2014 so 11h59.5m carries to \"12h\"\n // rather than rendering \"11h 60m\".\n const totalMin = Math.round(seconds / 60);\n const h = Math.floor(totalMin / 60);\n const m = totalMin % 60;\n if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;\n return `${m}m`;\n}\n\n/**\n * Pure mapper: a `CredProbe` \u2192 a `CheckResult` with the exact remediation.\n * `headless` selects the device-flow `mesh login` variant (browser flow times\n * out over SSH/tailscale \u2014 see the credential runbook).\n */\nexport function credProbeToResult(\n probe: CredProbe,\n context: string,\n headless: boolean,\n): CheckResult {\n const loginCmd = `mesh login ${context}${headless ? \" --device\" : \"\"}`;\n switch (probe.state) {\n case \"ok\":\n return {\n status: probe.ttlSeconds < 300 ? \"warn\" : \"ok\",\n summary: `deployer creds valid ${fmtTtl(probe.ttlSeconds)}${probe.email ? ` (${probe.email})` : \"\"}`,\n detail: `expires ${probe.expiresAt}`,\n };\n case \"no-session\":\n return {\n status: \"error\",\n summary: `no Zitadel session for ${context}`,\n remediation: loginCmd,\n };\n case \"expired-session\":\n return {\n status: \"error\",\n summary: `Zitadel session for ${context} expired and could not refresh`,\n remediation: loginCmd,\n };\n case \"assume-denied\":\n return {\n status: \"error\",\n summary: \"session valid but AssumeRole denied \u2014 wrong IAM role/policy\",\n detail: probe.detail,\n remediation: `check mesh:deployerRole in the stack config and the role's trust/permissions; re-login if role changed: ${loginCmd}`,\n };\n case \"stale-env-override\":\n return {\n status: \"error\",\n summary:\n \"stale AWS_* env vars would override the self-refreshing login profile\",\n remediation:\n \"unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN\",\n };\n }\n}\n\nexport const credsCheck: Check = {\n id: \"creds\",\n title: \"Credentials\",\n phases: [\"preflight\", \"ondemand\"],\n async run(ctx) {\n // When creds were already resolved (launch preflight passes the method),\n // trust that result \u2014 avoids a second AssumeRole call, and correctly\n // reports the SSO/environment paths that `probeCredentials` (Zitadel-only)\n // would otherwise misdiagnose.\n switch (ctx.credMethod) {\n case \"sso\":\n return {\n status: \"ok\",\n summary: \"using AWS SSO credentials (auto-refresh from SSO cache)\",\n };\n case \"environment\":\n return { status: \"ok\", summary: \"using AWS credentials from environment\" };\n case \"zitadel\":\n return {\n status: \"ok\",\n summary:\n \"using Zitadel deployer credentials (credential_process auto-refresh)\",\n };\n }\n // credMethod null (on-demand doctor, or a failed launch resolution) or\n // 'ambient': determine the real state by probing.\n if (!ctx.platformContext || !ctx.deployerRole) {\n return {\n status: \"warn\",\n summary:\n \"no deployer role/context resolved \u2014 using ambient AWS credentials\",\n detail:\n \"add mesh:deployerRole to the Pulumi stack config to use mesh login credentials\",\n };\n }\n const probe = await probeCredentials(ctx.platformContext, ctx.deployerRole);\n return credProbeToResult(probe, ctx.platformContext, isRemoteEnvironment());\n },\n};\n\n// ============================================================================\n// Check: CodeArtifact registry token (registry) \u2014 MESH-2479\n// ============================================================================\n\n/**\n * Verify the @mesh-tech CodeArtifact token actually works BEFORE long work \u2014\n * an expired token otherwise surfaces minutes later as npm's generic E401\n * (\"try npm login\", which cannot fix a CodeArtifact registry). Warn-level:\n * `mesh dev` itself runs installed code, so a stale token shouldn't block\n * launch \u2014 it should name the fix.\n */\nexport const registryCheck: Check = {\n id: \"registry\",\n title: \"Registry auth\",\n phases: [\"preflight\", \"ondemand\"],\n async run(ctx, phase) {\n // The other preflight checks are fast \u2014 don't let an unreachable\n // registry (offline / VPN off) hold the whole launch for the full\n // on-demand bound. The zero-access local loop must stay snappy.\n const probe = await probeRegistryToken(phase === \"preflight\" ? { timeoutMs: 3_000 } : undefined);\n switch (probe.state) {\n case \"fresh\":\n return { status: \"ok\", summary: \"CodeArtifact token accepted by the @mesh-tech registry\" };\n case \"expired\":\n return {\n status: \"warn\",\n summary:\n \"CodeArtifact token expired/rejected \u2014 installing @mesh-tech packages will fail with E401\",\n detail: probe.detail,\n remediation: registryLoginFix(ctx.platformContext ?? undefined),\n };\n case \"unreachable\":\n return {\n status: \"ok\",\n summary: \"registry unreachable \u2014 CodeArtifact token not verified (offline?)\",\n detail: probe.detail,\n };\n case \"missing\":\n return appUsesMeshPackages(ctx.appRoot)\n ? {\n status: \"warn\",\n summary:\n \"no CodeArtifact auth in ~/.npmrc, but this app depends on @mesh-tech packages \u2014 pnpm install will fail with E401\",\n remediation: registryLoginFix(ctx.platformContext ?? undefined),\n }\n : {\n status: \"ok\",\n summary: \"no CodeArtifact auth configured (needed only to install @mesh-tech packages)\",\n };\n }\n },\n};\n\n// ============================================================================\n// Checks: tmux, ports, worktree\n// ============================================================================\n\n/**\n * Parse `lsof -ti :PORT` + `ps -o comm= -p PID` output into the squatting\n * process. `lsof` may list several PIDs (one per line); we name the first.\n */\nexport function parsePortSquatter(\n lsofOut: string,\n psOut: string,\n): { pid: number; command: string } | null {\n const firstLine = lsofOut.trim().split(\"\\n\")[0]?.trim();\n const pid = Number(firstLine);\n if (!firstLine || !Number.isInteger(pid)) return null;\n const command = psOut.trim().split(\"\\n\")[0]?.trim() || \"unknown\";\n return { pid, command };\n}\n\n/** True when `serviceSrc` lives under (or equals) `repoRoot`. */\nexport function sharesWorktreeRoot(\n serviceSrc: string,\n repoRoot: string,\n): boolean {\n const src = path.resolve(serviceSrc);\n const root = path.resolve(repoRoot);\n return src === root || src.startsWith(root + path.sep);\n}\n\n/** How a service port relates to the live session that should own it. */\nexport type PortOwnership = \"owned\" | \"foreign\" | \"dead\";\n\n/**\n * Pure classification for the on-demand ports check. Given the PID currently\n * listening on a service port (`null` when nothing is) and the set of PIDs in\n * the live session's tmux process tree:\n * - `null` listener \u2192 `dead` (the service isn't running)\n * - listener in the session \u2192 `owned` (healthy \u2014 the session's own svc)\n * - listener outside it \u2192 `foreign` (a real squatter)\n * Mid-session the session's services are *supposed* to hold their ports, so a\n * naive \"occupied \u21D2 squatted\" test misreports a healthy session (MESH-2015).\n */\nexport function classifyPortListener(\n listenerPid: number | null,\n sessionPids: ReadonlySet<number>,\n): PortOwnership {\n if (listenerPid === null) return \"dead\";\n return sessionPids.has(listenerPid) ? \"owned\" : \"foreign\";\n}\n\n/**\n * Pure: expand tmux pane PIDs to the full set of PIDs in their process\n * subtrees, using the output of `ps -eo pid=,ppid=` (each line \"PID PPID\").\n * The roots themselves are included; unparseable lines are skipped.\n */\nexport function collectSessionPids(\n panePids: number[],\n psOut: string,\n): Set<number> {\n const children = new Map<number, number[]>();\n for (const line of psOut.trim().split(\"\\n\")) {\n const [pidStr, ppidStr] = line.trim().split(/\\s+/);\n const pid = Number(pidStr);\n const ppid = Number(ppidStr);\n if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;\n const kids = children.get(ppid) ?? [];\n kids.push(pid);\n children.set(ppid, kids);\n }\n const result = new Set<number>();\n const queue = panePids.filter((p) => Number.isInteger(p));\n while (queue.length > 0) {\n const pid = queue.shift() as number;\n if (result.has(pid)) continue;\n result.add(pid);\n for (const child of children.get(pid) ?? []) queue.push(child);\n }\n return result;\n}\n\n/** Identify the process squatting a port; null if it can't be determined. */\nfunction whoHasPort(port: number): { pid: number; command: string } | null {\n try {\n const lsof = execFileSync(\"lsof\", [\"-ti\", `:${port}`], {\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n const pid = lsof.trim().split(\"\\n\")[0]?.trim();\n if (!pid) return null;\n let ps = \"\";\n try {\n ps = execFileSync(\"ps\", [\"-o\", \"comm=\", \"-p\", pid], {\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n } catch {\n /* ps failed \u2014 parsePortSquatter falls back to \"unknown\" */\n }\n return parsePortSquatter(lsof, ps);\n } catch {\n return null;\n }\n}\n\n/**\n * Gather every PID in the live session's tmux process trees (pane PIDs plus\n * their descendants). Used to tell the session's own service processes apart\n * from foreign squatters. Returns an empty set when tmux/ps are unavailable or\n * the session has no panes \u2014 the caller then treats occupied ports as\n * unidentified rather than owned.\n */\nfunction sessionProcessTree(sessionName: string): Set<number> {\n let panePids: number[];\n try {\n const out = execFileSync(\n \"tmux\",\n [\"list-panes\", \"-s\", \"-t\", sessionName, \"-F\", \"#{pane_pid}\"],\n { encoding: \"utf8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] },\n );\n panePids = out\n .trim()\n .split(\"\\n\")\n .map((l) => Number(l.trim()))\n .filter((n) => Number.isInteger(n));\n } catch {\n return new Set();\n }\n if (panePids.length === 0) return new Set();\n let psOut = \"\";\n try {\n psOut = execFileSync(\"ps\", [\"-eo\", \"pid=,ppid=\"], {\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n } catch {\n // No process table \u2014 the pane PIDs themselves are still session-owned.\n return new Set(panePids);\n }\n return collectSessionPids(panePids, psOut);\n}\n\nexport const tmuxCheck: Check = {\n id: \"tmux\",\n title: \"tmux\",\n phases: [\"preflight\", \"ondemand\"],\n async run() {\n try {\n execFileSync(\"which\", [\"tmux\"], { stdio: \"ignore\" });\n return { status: \"ok\", summary: \"installed\" };\n } catch {\n return {\n status: \"error\",\n summary: \"tmux is not installed\",\n remediation: \"brew install tmux\",\n };\n }\n },\n};\n\nexport const portsCheck: Check = {\n id: \"ports\",\n title: \"Ports\",\n phases: [\"preflight\", \"ondemand\"],\n async run(ctx) {\n const services = ctx.sessionState?.devOutput.services;\n if (!services || Object.keys(services).length === 0) {\n // Preflight (no session): ports are validated at allocation/launch time.\n return {\n status: \"ok\",\n summary: \"no session \u2014 ports are checked at allocation time\",\n };\n }\n // Mid-session the session's OWN services should be listening on their\n // allocated ports. Classify each port against the session's process tree so\n // healthy self-owned ports don't read as squatters (MESH-2015).\n const sessionPids = sessionProcessTree(ctx.sessionName);\n const foreign: string[] = []; // held by a process outside the session\n const dead: string[] = []; // nothing listening \u2014 service died/isn't up\n const unknown: string[] = []; // occupied but the owner couldn't be resolved\n for (const [name, svc] of Object.entries(services)) {\n const free = await isPortFree(svc.port);\n const who = free ? null : whoHasPort(svc.port);\n const ownership = classifyPortListener(who?.pid ?? null, sessionPids);\n if (ownership === \"owned\") continue;\n if (ownership === \"foreign\") {\n foreign.push(`${name} :${svc.port} held by ${who!.command} (pid ${who!.pid})`);\n continue;\n }\n // `dead` from the helper (null listener pid): either the port is\n // genuinely free (service down) or it's occupied but the owner couldn't\n // be resolved via lsof.\n if (free) {\n dead.push(`${name} :${svc.port} not listening`);\n } else {\n unknown.push(`${name} :${svc.port} in use (owner unknown)`);\n }\n }\n const total = Object.keys(services).length;\n if (foreign.length === 0 && dead.length === 0 && unknown.length === 0) {\n return {\n status: \"ok\",\n summary: `all ${total} service port(s) owned by this session`,\n };\n }\n const parts: string[] = [];\n if (foreign.length) parts.push(`${foreign.length} squatted by another process`);\n if (dead.length) parts.push(`${dead.length} not listening (service down)`);\n if (unknown.length) parts.push(`${unknown.length} in use by an unidentified process`);\n const remediation =\n foreign.length > 0\n ? \"stop the squatting process (or its SSH tunnel), then `mesh dev restart <svc>`\"\n : dead.length > 0\n ? \"restart the stopped service: `mesh dev restart <svc>`\"\n : \"identify the port owner (`lsof -i :<port>`), then `mesh dev restart <svc>`\";\n return {\n // A real squatter or a down service is actionable \u2192 error; an\n // unidentifiable occupant alone is only a warning.\n status: foreign.length > 0 || dead.length > 0 ? \"error\" : \"warn\",\n summary: `service ports: ${parts.join(\", \")}`,\n detail: [...foreign, ...dead, ...unknown].join(\"; \"),\n remediation,\n };\n },\n};\n\n// ============================================================================\n// Check: config staleness (config-staleness)\n// ============================================================================\n\n/** True when the stack config file changed after the session was launched. */\nexport function isConfigStale(\n configMtimeMs: number,\n startedAtIso: string,\n): boolean {\n return configMtimeMs > Date.parse(startedAtIso);\n}\n\nexport const configStalenessCheck: Check = {\n // Preflight runs *at* launch, so nothing can be stale-relative-to-launch yet\n // \u2014 this only makes sense mid-session.\n id: \"config-staleness\",\n title: \"Config freshness\",\n phases: [\"ondemand\"],\n async run(ctx) {\n const state = ctx.sessionState;\n if (!state) {\n return { status: \"ok\", summary: \"no session \u2014 config read fresh at launch\" };\n }\n const configPath = path.join(ctx.appRoot, `Pulumi.${ctx.stack}.yaml`);\n if (!fs.existsSync(configPath)) {\n return {\n status: \"ok\",\n summary: `no Pulumi.${ctx.stack}.yaml to compare`,\n };\n }\n const mtimeMs = fs.statSync(configPath).mtimeMs;\n if (isConfigStale(mtimeMs, state.startedAt)) {\n return {\n status: \"warn\",\n summary: `Pulumi.${ctx.stack}.yaml changed since launch \u2014 a restart won't pick this up`,\n detail: `config mtime ${new Date(mtimeMs).toISOString()} > session start ${state.startedAt}`,\n remediation: `mesh deploy up --stack ${ctx.stack} && mesh dev`,\n };\n }\n return { status: \"ok\", summary: \"stack config unchanged since launch\" };\n },\n};\n\nexport const worktreeCheck: Check = {\n id: \"worktree\",\n title: \"Worktree\",\n phases: [\"preflight\", \"ondemand\"],\n async run(ctx) {\n const services = ctx.sessionState?.devOutput.services;\n if (!services) {\n return { status: \"ok\", summary: \"no session \u2014 worktree checked at launch\" };\n }\n let repoRoot: string;\n try {\n repoRoot = execFileSync(\"git\", [\"rev-parse\", \"--show-toplevel\"], {\n cwd: ctx.appRoot,\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n }).trim();\n } catch {\n return {\n status: \"warn\",\n summary: \"could not resolve git worktree root for the app\",\n };\n }\n // A dev-LINKED package is outside the worktree by construction and is what\n // the app runs either way \u2014 the same exemption `mesh dev` itself applies, so\n // the doctor does not warn about something the launcher deliberately allows.\n const foreign = Object.entries(services)\n .filter(\n ([, svc]) =>\n svc.src &&\n !sharesWorktreeRoot(svc.src, repoRoot) &&\n !isLinkedDependencyDir(svc.src, ctx.appRoot),\n )\n .map(([name, svc]) => `${name} \u2192 ${svc.src}`);\n if (foreign.length === 0) {\n return {\n status: \"ok\",\n summary: `all services resolve under ${repoRoot}`,\n };\n }\n return {\n status: \"warn\",\n summary: `${foreign.length} service(s) resolve outside this worktree \u2014 you may be running another worktree's code`,\n detail: foreign.join(\"; \"),\n remediation: \"relaunch from this worktree: `mesh dev` (re-reads stack output paths)\",\n };\n },\n};\n\n// ============================================================================\n// Checks: tunnels + temporal (connectivity)\n// ============================================================================\n\n/** Resolve `true` iff a TCP connection to host:port succeeds within `timeoutMs`. */\nexport function canConnect(\n host: string,\n port: number,\n timeoutMs = 800,\n): Promise<boolean> {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n let settled = false;\n const done = (ok: boolean) => {\n if (settled) return;\n settled = true;\n socket.destroy();\n resolve(ok);\n };\n socket.setTimeout(timeoutMs);\n socket.once(\"connect\", () => done(true));\n socket.once(\"timeout\", () => done(false));\n socket.once(\"error\", () => done(false));\n socket.connect(port, host);\n });\n}\n\nfunction isLocalHost(host: string): boolean {\n return host === \"localhost\" || host === \"127.0.0.1\" || host === \"::1\";\n}\n\nexport const tunnelsCheck: Check = {\n // On-demand only: pre-launch the tunnels don't exist yet, and dialing the\n // VPC endpoints they'll use would false-positive on an SSM-only machine.\n id: \"tunnels\",\n title: \"Tunnels\",\n phases: [\"ondemand\"],\n async run(ctx) {\n const tunnels = ctx.sessionState?.devOutput.tunnels;\n if (!tunnels || Object.keys(tunnels).length === 0) {\n return { status: \"ok\", summary: \"no tunnels in this session\" };\n }\n // Only dial the SSM-forwarded (localhost) tunnels \u2014 those are the ones that\n // die with \"broken pipe \u2192 ECONNREFUSED\". VPC-direct tunnels are reachable\n // only over VPN and would false-positive here.\n const local = Object.entries(tunnels).filter(([, t]) => isLocalHost(t.host));\n if (local.length === 0) {\n return {\n status: \"ok\",\n summary: `${Object.keys(tunnels).length} tunnel(s), all VPC-direct (not dialed)`,\n };\n }\n const dead: string[] = [];\n for (const [name, t] of local) {\n if (!(await canConnect(t.host, t.port))) dead.push(`${name} (${t.host}:${t.port})`);\n }\n if (dead.length === 0) {\n return { status: \"ok\", summary: `${local.length} SSM tunnel(s) live` };\n }\n return {\n status: \"error\",\n summary: `${dead.length} SSM tunnel(s) down`,\n detail: dead.join(\"; \"),\n remediation: \"relaunch to re-establish tunnels: `mesh dev`\",\n };\n },\n};\n\nexport const temporalCheck: Check = {\n // On-demand only: reachability depends on the session's tunnel being up.\n id: \"temporal\",\n title: \"Temporal\",\n phases: [\"ondemand\"],\n async run(ctx) {\n const state = ctx.sessionState;\n const t = state?.devOutput.tunnels[\"temporal\"];\n if (!state || !t) {\n return { status: \"ok\", summary: \"no temporal tunnel in this session\" };\n }\n const addr = `${t.host}:${t.port}`;\n if (!(await canConnect(t.host, t.port))) {\n return {\n status: \"error\",\n summary: `Temporal frontend unreachable at ${addr}`,\n remediation: \"tunnel likely down \u2014 relaunch: `mesh dev`\",\n };\n }\n const p = state.devOutput.platform;\n const app = state.devOutput.app;\n if (!p || !app) {\n return { status: \"ok\", summary: `frontend reachable at ${addr} (namespace unknown)` };\n }\n const namespace = `${p.tenant}-${p.env}-${app}`;\n try {\n const { Connection } = await import(\"@temporalio/client\");\n const connection = await Connection.connect({\n address: addr,\n connectTimeout: \"3s\",\n });\n try {\n await connection.workflowService.describeNamespace({ namespace });\n return { status: \"ok\", summary: `frontend reachable; namespace ${namespace} present` };\n } finally {\n await connection.close().catch(() => {});\n }\n } catch (err) {\n const code = (err as { code?: number }).code;\n // gRPC status: 5 NOT_FOUND, 7 PERMISSION_DENIED, 16 UNAUTHENTICATED.\n if (code === 5) {\n return {\n status: \"error\",\n summary: `namespace ${namespace} not found on the server`,\n remediation: `verify the app is deployed to this env (mesh deploy up --stack ${ctx.stack})`,\n };\n }\n if (code === 7 || code === 16) {\n return {\n status: \"ok\",\n summary: `frontend reachable; namespace ${namespace} auth-gated (not verified)`,\n };\n }\n return {\n status: \"warn\",\n summary: \"frontend reachable but namespace check errored\",\n detail: err instanceof Error ? err.message : String(err),\n };\n }\n },\n};\n\n// ============================================================================\n// Registry + runner\n// ============================================================================\n\n/** All checks, in display order. */\nexport const ALL_CHECKS: Check[] = [\n tmuxCheck,\n credsCheck,\n registryCheck,\n configStalenessCheck,\n portsCheck,\n worktreeCheck,\n tunnelsCheck,\n temporalCheck,\n];\n\n/**\n * Run the full on-demand doctor against a prebuilt context, print the report,\n * and return the aggregate status. The caller (the `mesh dev doctor`\n * subcommand) owns process exit.\n */\nexport async function runDoctor(\n ctx: DoctorContext,\n opts: { json: boolean },\n): Promise<CheckStatus> {\n const results = await runChecks(ctx, \"ondemand\", ALL_CHECKS);\n const status = aggregateStatus(results.map((r) => r.result));\n if (opts.json) {\n console.log(renderJson(results));\n } else {\n console.log(renderHuman(results));\n console.log(`\\n${ICONS[status]} overall: ${status}`);\n }\n return status;\n}\n", "/**\n * Per-worktree identity for collision-free concurrent `mesh dev` (MESH-2013).\n *\n * Running `mesh dev` in several git worktrees at once collides on ports, tmux\n * session names, tmpdir session state, and env-file dirs \u2014 because every one of\n * those derives from the app's Pulumi project name, which is identical across\n * worktrees. This module derives ONE deterministic token per worktree (from its\n * path) that the dev launcher threads through all of those so worktrees can't\n * step on each other.\n *\n * TASK QUEUES are unique by construction \u2014 `taskQueueSuffix` is `-${token}` and\n * the token carries the 4-hex path hash. SESSION NAMES are not: the tmux name is\n * `${projectName}-${slug}` (slug only), and the state file + env dir derive from\n * it, so two linked worktrees whose basenames sanitize to the same slug (e.g.\n * `.worktrees/MESH-2375` and `~/scratch/mesh-2375`) share all three. PORTS are\n * not either: the block is a hash bucketed into NUM_PORT_BLOCKS-1 slots and is\n * additionally scoped by app \u2014 see `withAppScopedPortBlock` below for what that\n * does and does not guarantee.\n *\n * The PRIMARY checkout (the repo's main working tree) keeps its historical\n * behavior \u2014 empty token, port block 0 (preferred ports), no session suffix \u2014\n * so existing muscle memory is preserved. Only LINKED worktrees get offsets.\n *\n * Pure helpers (`sanitizeSlug`, `worktreeHash`, `portBlockFor`, `blockBasePort`)\n * are git-free and unit-tested; `resolveWorktreeIdentity` shells to git via an\n * injectable runner so it can be tested without a real repo.\n */\nimport { execFileSync } from \"node:child_process\";\nimport * as crypto from \"node:crypto\";\nimport * as path from \"node:path\";\n\n/** Number of distinct port blocks (block 0 reserved for the primary checkout). */\nexport const NUM_PORT_BLOCKS = 64;\n/** First port of block 1; block 0 uses the app's preferred ports, not this range. */\nexport const PORT_BLOCK_BASE = 40000;\n/** Ports reserved per worktree block (split into service + tunnel sub-ranges). */\nexport const PORT_BLOCK_SIZE = 40;\n/** Service ports occupy `[base, base+SERVICE_SUBRANGE)`; tunnels take the rest. */\nexport const PORT_BLOCK_SERVICE_SUBRANGE = 24;\n\nexport interface WorktreeIdentity {\n /** Absolute path to this worktree's root (git top-level). */\n worktreeRoot: string;\n /** True for the repo's primary checkout; false for a linked `git worktree`. */\n isPrimary: boolean;\n /** Human-readable sanitized basename of the worktree root (e.g. \"design-registry\"). */\n slug: string;\n /** 4 hex chars of sha256(worktreeRoot) \u2014 disambiguates same-named worktrees. */\n hash: string;\n /** \"\" for the primary checkout; else `${slug}-${hash}`. */\n token: string;\n /** 0 for primary (preferred ports); 1..NUM_PORT_BLOCKS-1 for linked worktrees. */\n portBlock: number;\n /** \"\" for primary; else `-${token}` \u2014 appended to Temporal/Nexus task queues (Tier 2). */\n taskQueueSuffix: string;\n}\n\n/** Lowercase, hyphenate, strip non-alphanumerics; never empty. */\nexport function sanitizeSlug(name: string): string {\n const s = name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return s || \"wt\";\n}\n\n/** Stable 4-hex-char fingerprint of a worktree's absolute path. */\nexport function worktreeHash(worktreeRoot: string): string {\n return crypto.createHash(\"sha256\").update(worktreeRoot).digest(\"hex\").slice(0, 4);\n}\n\n/**\n * Map a worktree hash to a port block. Primary \u2192 0 (preferred ports).\n * Linked \u2192 deterministic 1..NUM_PORT_BLOCKS-1 (stable across runs for the same\n * worktree path, so ports don't drift between launches).\n */\nexport function portBlockFor(hash: string, isPrimary: boolean): number {\n if (isPrimary) return 0;\n const n = parseInt(hash, 16);\n return 1 + (n % (NUM_PORT_BLOCKS - 1));\n}\n\n/** First port of a block (block 0 has no dedicated range \u2014 callers use preferred ports). */\nexport function blockBasePort(block: number): number {\n return PORT_BLOCK_BASE + block * PORT_BLOCK_SIZE;\n}\n\n/**\n * Identity whose PORT BLOCK is derived from (worktree, app) \u2014 every non-port\n * field stays worktree-scoped. A repo can hold several apps whose `mesh dev`\n * sessions run CONCURRENTLY (e.g. scu-mesh-apps' plaid-token-manager +\n * plaid-reporting); with a per-worktree block they draw the same ports, and\n * the availability probe alone races services that are still booting and\n * haven't bound yet \u2014 both sessions allocated 41800 in the wild. Same\n * philosophy as the per-worktree blocks of MESH-2013.\n *\n * NOT disjoint by construction: `portBlockFor` buckets a hash into\n * NUM_PORT_BLOCKS-1 slots, so two (worktree, app) pairs CAN land on the same\n * block \u2014 and app-scoping raises the entity count from #worktrees to\n * #worktrees x #apps, which makes that likelier. Sibling apps in one worktree\n * (the case this fixes) are disjoint by default; the in-block probe and the\n * ephemeral fallback in `allocatePorts` remain the backstop for a collision.\n *\n * Primary checkouts keep preferred-port semantics (block 0) \u2014 sibling apps\n * there still share preferred ports and rely on that probe.\n */\nexport function withAppScopedPortBlock(wt: WorktreeIdentity, appRoot: string): WorktreeIdentity {\n if (wt.isPrimary) return wt;\n const appHash = worktreeHash(`${wt.worktreeRoot}#${path.resolve(appRoot)}`);\n return { ...wt, portBlock: portBlockFor(appHash, false) };\n}\n\n/**\n * Stack name for `mesh stack init --worktree` (MESH-2013 Slice 3): append the\n * worktree token so concurrent worktrees deploy to distinct stacks. The SSM\n * export paths (`apps/{app}/stacks/{stack}/...`), Temporal namespace\n * (`{tenant}-{stack}-{app}`), and Nexus endpoint name all derive from the stack\n * name, so one discriminator isolates all three. The primary checkout has an\n * empty token \u2192 the base name is returned unchanged (it IS the canonical stack).\n */\n/** Max chars of the worktree slug that reach a stack name. Deploy-time surfaces\n * (Temporal namespace `{tenant}-{stack}-{app}`, Nexus endpoint names) have\n * provider length limits, so we cap the human-readable slug here; the 4-hex hash\n * still guarantees uniqueness. (The local-only token \u2014 tmux/ports \u2014 is uncapped.) */\nexport const STACK_SLUG_MAX = 16;\n\nexport function worktreeStackName(baseName: string, wt: WorktreeIdentity): string {\n if (!wt.token) return baseName;\n const cappedSlug = wt.slug.slice(0, STACK_SLUG_MAX).replace(/-+$/, \"\");\n return `${baseName}-${cappedSlug}-${wt.hash}`;\n}\n\n/**\n * True when `stackName` would collide across worktrees: we're in a LINKED\n * worktree (non-empty token) but the stack name doesn't carry that token \u2014 so a\n * concurrent `pulumi up` from another worktree on the same name hits identical\n * SSM export paths / Temporal namespace. Drives the `mesh deploy` warning.\n */\nexport function stackNeedsWorktreeIsolation(stackName: string, wt: WorktreeIdentity): boolean {\n // The 4-hex hash is the stable discriminator present in every worktree stack\n // name (`worktreeStackName` ends with `-${hash}`), and survives the slug cap \u2014\n // so match on it rather than the full (uncapped) token.\n return !!wt.token && !stackName.endsWith(`-${wt.hash}`);\n}\n\ntype GitRunner = (args: string[], cwd: string) => string;\n\nconst defaultGitRunner: GitRunner = (args, cwd) =>\n execFileSync(\"git\", args, { cwd, encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] }).trim();\n\n/**\n * Resolve the worktree identity for a given cwd. Returns a primary-checkout\n * identity (token \"\", block 0) when not inside a git repo, so non-repo/edge\n * cases degrade to today's behavior rather than throwing.\n */\nexport function resolveWorktreeIdentity(\n cwd: string = process.cwd(),\n gitRunner: GitRunner = defaultGitRunner,\n): WorktreeIdentity {\n let worktreeRoot: string;\n let isPrimary = true;\n try {\n worktreeRoot = path.resolve(gitRunner([\"rev-parse\", \"--show-toplevel\"], cwd));\n // The common git dir is `<primary-worktree>/.git`; its parent is the primary\n // checkout. A linked worktree's top-level differs from that parent.\n const commonDir = gitRunner([\"rev-parse\", \"--git-common-dir\"], cwd);\n const primaryRoot = path.resolve(path.dirname(path.resolve(cwd, commonDir)));\n isPrimary = worktreeRoot === primaryRoot;\n } catch {\n // Not a git repo (or git unavailable) \u2014 behave as the primary checkout.\n return {\n worktreeRoot: path.resolve(cwd),\n isPrimary: true,\n slug: sanitizeSlug(path.basename(path.resolve(cwd))),\n hash: worktreeHash(path.resolve(cwd)),\n token: \"\",\n portBlock: 0,\n taskQueueSuffix: \"\",\n };\n }\n\n const slug = sanitizeSlug(path.basename(worktreeRoot));\n const hash = worktreeHash(worktreeRoot);\n const token = isPrimary ? \"\" : `${slug}-${hash}`;\n return {\n worktreeRoot,\n isPrimary,\n slug,\n hash,\n token,\n portBlock: portBlockFor(hash, isPrimary),\n taskQueueSuffix: token ? `-${token}` : \"\",\n };\n}\n", "/**\n * mesh dev \u2014 Pulumi-output-driven local development orchestrator\n *\n * Reads `pulumi stack output dev --json` to discover services and VPC endpoints,\n * then launches each service's dev command in a tmux window.\n * Requires VPN connection for direct access to VPC resources.\n *\n * Usage:\n * mesh dev Start all services, attach tmux\n * mesh dev --headless Start without attaching\n * mesh dev --kill Stop everything\n * mesh dev --status Show service health (--json for structured)\n * mesh dev logs <service> Tail a service's output\n * mesh dev restart <svc> Restart a single service\n */\n\nimport { execFileSync, spawn, spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as net from 'net';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { Command, Option } from 'commander';\nimport { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';\nimport {\n logInfo,\n logSuccess,\n logError,\n logWarn,\n isVpnConnected,\n getTailscaleInfo,\n headscaleDnsConfig,\n registerDnsRecords,\n unregisterDnsRecords,\n readDnsRecords,\n getPlatformBastionInfo,\n} from '../utils/index.js';\nimport type { DnsRecord, HeadscaleDnsConfig } from '../utils/index.js';\nimport {\n derivePlatformContext,\n renderCredentialProcessProfile,\n resolveAwsCredentials,\n resolveStableMeshBin,\n selectRoleForCaller,\n stripBareProfile,\n upsertManagedAwsConfigSection,\n type ResolvedAwsCreds,\n} from '../utils/aws-auth.js';\nimport { atomicWriteFileSync, readCredentials, getContextConfig, probeCredentials, getValidToken } from './login.js';\nimport { resolveVpnJoinBroker, mintPreAuthKey } from '../utils/vpn-join.js';\nimport { credProbeToPreflightError } from '../utils/pulumi-run.js';\nimport {\n startTailscaleTunnels,\n tailscaleAvailable,\n daemonState as tailscaleDaemonState,\n readTunnelState as readTailscaleState,\n readDaemonMeta as readTailscaleDaemonMeta,\n} from '../utils/tailscale.js';\nimport { pulumiStackOutput, readStackConfig } from '../utils/pulumi.js';\nimport {\n ensureKubeconfig,\n resolveHubPlatformName,\n sessionKubeconfigPath,\n} from '../utils/kubeconfig.js';\nimport { resolveTemporalAuth } from '../utils/temporal-auth.js';\nimport { probeTcpReachable, probeConnectionHolds } from '../utils/reachability.js';\nimport { fingerprintWorkflowSource } from '../utils/workflow-fingerprint.js';\nimport { buildLaunchCommand, envFileName, waitForPort, writeEnvFile } from './dev-launch.js';\nimport { buildLocalDevOutput, detectLocalTenant, ensureLocalPlatformRunning, hasStackBacking, localAppNamespace } from './local/dev-local.js';\nimport { ensureAppTenantAuth, ensureSignInApp, registerLocalApp } from './local/auth-provision.js';\nimport { localAwsEnv, ensureTemporalNamespace } from './local/seed.js';\nimport {\n composeExternalUp,\n composeExternalsDown,\n externalMode,\n localProbesRemove,\n parseExternalsSelection,\n readLocalMocks,\n seedLocalMock,\n type ComposeExternalRef,\n type ExternalMode,\n type LocalMockDecl,\n} from './local/mocks.js';\nimport { logShipperPath } from './local/dev-local.js';\nimport { writeAppServiceProbes } from './local/stack.js';\nimport { writeDevCompose, dockerDevUp, dockerDevDown, dockerDevPs, dockerDevLogs, dockerDevRestart } from './local/docker-runner.js';\nimport { MeshCliError } from '../utils/errors.js';\nimport { resolveStackOption } from '../utils/stack-flag.js';\nimport { rewriteClusterHostsToLocal } from './peer-addressing.js';\nimport {\n ALL_CHECKS,\n aggregateStatus,\n renderHuman,\n runChecks,\n runDoctor,\n type DoctorContext,\n} from './dev-doctor.js';\nimport {\n resolveWorktreeIdentity,\n withAppScopedPortBlock,\n blockBasePort,\n PORT_BLOCK_SERVICE_SUBRANGE,\n type WorktreeIdentity,\n} from '../utils/worktree-identity.js';\n\n// ============================================================================\n// Types\n// ============================================================================\n\ninterface DevEnvVar {\n value: string;\n /** If set, env var value uses the named tunnel's host:port */\n tunnel?: string;\n}\n\ninterface DevService {\n src: string;\n port: number;\n command: string[];\n env?: Record<string, DevEnvVar>;\n}\n\n/** VPC-accessible endpoint for VPN direct access */\ninterface DevTunnel {\n /** VPC hostname (RDS endpoint, NLB hostname, etc.) */\n host: string;\n /** Port number */\n port: number;\n}\n\ninterface DevSecret {\n /** Secrets Manager secret name */\n secretName: string;\n /** Maps secret JSON keys \u2192 env var names to inject into services */\n envMapping: Record<string, string>;\n}\n\nexport interface DevOutput {\n /**\n * Platform context. `tenant` is the APP tenant (Temporal namespace auth +\n * encoding key). `name` is the HUB platform name (bastion + headscale DNS,\n * which live in the hub for shared tenants); falls back to \"mesh\".\n */\n platform?: { tenant: string; name?: string; env: string };\n /** App name (e.g., \"hub\") */\n app?: string;\n /** Pulumi stack name (e.g., \"dev\", \"feature-xyz\") */\n stack?: string;\n services: Record<string, DevService>;\n tunnels: Record<string, DevTunnel>;\n /** Secrets to resolve from Secrets Manager at dev startup */\n secrets?: Record<string, DevSecret>;\n}\n\ninterface DevOptions {\n app?: string;\n stage?: string;\n headless?: boolean;\n kill?: boolean;\n status?: boolean;\n json?: boolean;\n session?: string;\n profile?: string;\n local?: boolean;\n /** true = all declared externals; string = comma-separated subset of\n * name[=mode] entries (mode \u2208 mock|local|remote overrides the decl). */\n mock?: boolean | string;\n /** Preferred spelling of --mock (same values). */\n externals?: boolean | string;\n /** local-mode process runner: 'tmux' (default) or 'docker' */\n runner?: string;\n dryRun?: boolean;\n transport?: string;\n force?: boolean;\n}\n\n/**\n * The app's own services, as blackbox probe targets, so local uptime is real.\n *\n * Mock stand-ins are excluded: `--externals` already registers each of those\n * under its own external-service labels, and probing them twice would show one\n * dependency as two rows \u2014 one of which disappears when the mock does.\n *\n * Best-effort. A probe file is monitoring, not the app: failing to write one\n * must never take down a dev session that is otherwise working.\n */\nexport function registerServiceProbes(\n devOutput: DevOutput,\n tenant: string,\n probeFiles: string[],\n): void {\n const services: Record<string, number> = {};\n for (const [name, service] of Object.entries(devOutput.services)) {\n if (name.startsWith(\"mock-\")) continue;\n services[name] = service.port;\n }\n try {\n const file = writeAppServiceProbes({\n tenant,\n env: devOutput.platform?.env ?? \"dev\",\n app: devOutput.app ?? \"\",\n services,\n });\n if (file) probeFiles.push(file);\n } catch (err) {\n logWarn(\n `Could not register uptime probes for this session (${err instanceof Error ? err.message : err}) \u2014 the Hub will show no uptime for these services.`,\n );\n }\n}\n\n/**\n * tmux session name for a dev launch. The primary checkout keeps the historical\n * `${projectName}-dev`; linked worktrees get a `${projectName}-${slug}` so two\n * worktrees of the same app don't share (and kill) each other's session, state,\n * and env-file dir (MESH-2013).\n */\nfunction deriveSessionName(projectName: string, wt: WorktreeIdentity): string {\n return wt.isPrimary ? `${projectName}-dev` : `${projectName}-${wt.slug}`;\n}\n\n/**\n * Render the resolved per-worktree plan (`mesh dev --dry-run`): identity,\n * session/state/env paths, and the port assignment \u2014 everything that must be\n * collision-free across concurrent worktrees, without launching anything.\n */\nfunction printDevPlan(\n sessionName: string,\n appRoot: string,\n wt: WorktreeIdentity,\n devOutput: DevOutput,\n): void {\n const kind = wt.isPrimary ? 'primary checkout' : `linked worktree (block ${wt.portBlock})`;\n console.log(`\\nmesh dev plan \u2014 ${kind}\\n`);\n console.log(` worktree root : ${wt.worktreeRoot}`);\n console.log(` app root : ${appRoot}`);\n console.log(` token : ${wt.token || '(none \u2014 primary)'}`);\n console.log(` tmux session : ${sessionName}`);\n console.log(` session state : ${getSessionStatePath(sessionName)}`);\n console.log(` env dir : ${getSessionEnvDir(sessionName)}`);\n const tq = wt.taskQueueSuffix\n ? `<app-task-queue>${wt.taskQueueSuffix}`\n : '<app-task-queue> (primary \u2014 unchanged)';\n console.log(` task queue : ${tq}`);\n console.log(`\\n services (port \u00B7 resolved source dir):`);\n const locals = Object.entries(devOutput.services).filter(\n ([, s]) => (s.port ?? 0) >= 0 && s.command && s.command.length > 0,\n );\n if (locals.length === 0) {\n console.log(` (none \u2014 all services are deployed/K8s)`);\n } else {\n const monorepoRoot = findMonorepoRoot();\n const wtWithSep = wt.worktreeRoot.endsWith(path.sep) ? wt.worktreeRoot : wt.worktreeRoot + path.sep;\n for (const [name, s] of locals) {\n const dir = path.resolve(appRoot, rebaseServiceSrc(s.src, monorepoRoot));\n const outside = dir !== wt.worktreeRoot && !dir.startsWith(wtWithSep);\n const port = s.port > 0 ? `:${s.port}` : '(no port)';\n console.log(` ${name.padEnd(18)} ${port.padEnd(7)} ${dir}${outside ? ' \u26A0 OUTSIDE worktree' : ''}`);\n }\n }\n console.log('');\n}\n\n// ============================================================================\n// Session state & port allocation\n// ============================================================================\n\nexport interface SessionState {\n appRoot: string;\n stack: string;\n devOutput: DevOutput;\n startedAt: string;\n /** 'tmux' (default) or 'docker' (mesh dev --runner docker, local mode) */\n runner?: 'tmux' | 'docker';\n /** Compose-mode external services (mesh dev --externals) this session brought\n * up \u2014 torn down (volumes kept) on `mesh dev --kill`. */\n composeExternals?: ComposeExternalRef[];\n /** Prometheus file_sd probe targets this session's externals registered \u2014\n * removed on `mesh dev --kill` so the Hub stops showing uptime for a\n * session that isn't running. Recorded as paths so `--kill --session`\n * works without resolving an app root. */\n externalProbeFiles?: string[];\n /**\n * Content fingerprint of the workflow source (agent-sdk / interpreter /\n * agent-contracts) at launch. Compared on relaunch/restart to warn before the\n * worker replays in-flight conversations against changed workflow code\n * (MESH-2017). Absent for sessions launched before this field existed.\n */\n workflowFingerprint?: string;\n}\n\nfunction getSessionStatePath(sessionName: string): string {\n const dir = path.join(os.tmpdir(), 'mesh-dev-sessions');\n if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });\n return path.join(dir, `${sessionName}.json`);\n}\n\nfunction saveSessionState(sessionName: string, state: SessionState): void {\n fs.writeFileSync(getSessionStatePath(sessionName), JSON.stringify(state, null, 2));\n}\n\nfunction loadSessionState(sessionName: string): SessionState | null {\n const filePath = getSessionStatePath(sessionName);\n if (!fs.existsSync(filePath)) return null;\n try {\n return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as SessionState;\n } catch {\n return null;\n }\n}\n\n/**\n * Relaunch safety (MESH-2017): if the workflow source changed since this session\n * launched, restarting the worker replays any in-flight conversations against the\n * NEW code \u2014 a replay-incompatible change strands them. Warn and require `--force`.\n *\n * Silent no-op when there's no prior session, no stored fingerprint (pre-feature\n * session), or nothing changed \u2014 so a restart of unchanged code never nags.\n * Returns `true` if the caller should ABORT (changed && !force).\n */\n/** Pure: did the workflow source change since launch (\u2192 risk of stranding)? */\nexport function workflowChanged(prior: string | undefined, current: string): boolean {\n return Boolean(prior) && Boolean(current) && current !== prior;\n}\n\nfunction workflowChangeWouldStrand(\n priorState: SessionState | null,\n appRoot: string,\n force: boolean,\n): boolean {\n const prior = priorState?.workflowFingerprint;\n if (!prior) return false;\n const current = fingerprintWorkflowSource(appRoot, priorState?.devOutput?.services);\n if (!workflowChanged(prior, current)) return false;\n logWarn(\n 'Workflow code changed since this session started.\\n' +\n ' Restarting the worker replays any in-flight conversations against the NEW code \u2014\\n' +\n ' a replay-incompatible change strands them (they get stuck and become unviewable).\\n' +\n ' Keep it replay-compatible: gate the change with wf.patched() and regenerate the replay\\n' +\n ' goldens (see the temporal-workflow-safety skill), or accept the risk.',\n );\n if (force) {\n logWarn(' Proceeding anyway (--force).');\n return false;\n }\n logError(' Refusing to restart the worker. Re-run with --force once the change is replay-safe.');\n return true;\n}\n\n/**\n * Per-session directory holding one sourceable env file per service\n * (`<service>.env.sh`, mode 0600). Written at launch; `mesh dev restart`\n * sources the same file so a restarted service gets a byte-identical env.\n */\nfunction getSessionEnvDir(sessionName: string): string {\n return path.join(os.tmpdir(), 'mesh-dev-sessions', sessionName);\n}\n\nfunction getServiceEnvFilePath(sessionName: string, serviceName: string): string {\n return path.join(getSessionEnvDir(sessionName), envFileName(serviceName));\n}\n\nfunction removeSessionState(sessionName: string): void {\n try {\n fs.unlinkSync(getSessionStatePath(sessionName));\n } catch {}\n try {\n fs.rmSync(getSessionEnvDir(sessionName), { recursive: true, force: true });\n } catch {}\n}\n\n/**\n * Check whether a TCP port is available \u2014 probing the way services actually bind.\n *\n * This used to bind `127.0.0.1` only, and that made it blind to exactly the\n * processes it exists to find. Node's `listen(port)` (and `@hono/node-server`,\n * which every agent-api uses) binds the WILDCARD address `::` in dual-stack mode,\n * holding the port on every interface \u2014 yet a probe bound to `127.0.0.1` still\n * succeeds against such a holder. So the check returned `true` for a port already\n * held by a prior `mesh dev` service, `allocatePorts` kept the preferred port, and\n * the service then died on `EADDRINUSE` at its real bind.\n *\n * Observed 2026-07-31: the `studio-api` pane died on `EADDRINUSE :::40161` while a\n * stale agent-api from an earlier boot kept serving on the same port, so the Studio\n * UI drove a process from a previous session's configuration and the conversation\n * list came back empty. Reproduced in isolation \u2014 holder on `::`, loopback probe\n * reports free, real bind fails (MESH-2590).\n *\n * Both addresses are probed: the wildcard catches all-interface holders, and the\n * loopback probe still catches a loopback-only squatter. A false \"busy\" is cheap \u2014\n * `allocatePorts` simply picks another port \u2014 while a false \"free\" crashes the\n * service, so this deliberately errs toward busy.\n */\nexport async function isPortFree(port: number): Promise<boolean> {\n const bindSucceeds = (host?: string): Promise<boolean> =>\n new Promise((resolve) => {\n const server = net.createServer();\n server.once('error', () => resolve(false));\n const onListening = () => server.close(() => resolve(true));\n if (host === undefined) server.listen(port, onListening);\n else server.listen(port, host, onListening);\n });\n\n // Wildcard first: it is how the services bind, so it is the probe that matters.\n if (!(await bindSucceeds())) return false;\n return bindSucceeds('127.0.0.1');\n}\n\n/**\n * Ask the OS for an ephemeral free port.\n */\nasync function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const server = net.createServer();\n server.once('error', reject);\n server.listen(0, '127.0.0.1', () => {\n const { port } = server.address() as net.AddressInfo;\n server.close(() => resolve(port));\n });\n });\n}\n\n/** Resolve true if something is accepting connections on 127.0.0.1:port. */\nfunction isPortListening(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const s = new net.Socket();\n let done = false;\n const fin = (ok: boolean) => {\n if (done) return;\n done = true;\n s.destroy();\n resolve(ok);\n };\n s.setTimeout(300);\n s.once('connect', () => fin(true));\n s.once('timeout', () => fin(false));\n s.once('error', () => fin(false));\n s.connect(port, '127.0.0.1');\n });\n}\n\n/**\n * Rewrite a service's OWN listen-port env var(s) from `oldPort` to `newPort`\n * when `allocatePorts` reassigns its port (e.g. a worktree block). Matches any\n * port-ish var (`PORT` or `*_PORT`, e.g. `VCS_PORT`/`AGENT_API_PORT`) whose value\n * is exactly the old port \u2014 so a service that reads its own env var still binds\n * the reassigned port instead of its deploy-time default (which would collide\n * with another worktree/session on that default). The name + exact-value match\n * keeps this from touching cross-service references or unrelated vars.\n * Mutates `service.env`. Pure otherwise \u2014 exported for unit testing. (MESH-2088)\n */\nexport function rewriteOwnServicePort(service: DevService, oldPort: number, newPort: number): void {\n for (const [key, spec] of Object.entries(service.env ?? {})) {\n if ((key === 'PORT' || key.endsWith('_PORT')) && spec.value === String(oldPort)) {\n spec.value = String(newPort);\n }\n }\n}\n\n/**\n * Clone devOutput and assign each service a local port.\n *\n * The PRIMARY checkout keeps the app's preferred ports (falling back to an\n * OS-ephemeral port only if one is busy). LINKED worktrees draw from a reserved\n * port block keyed on (worktree, app) \u2014 MESH-2013 for the worktree axis, #2889\n * for the app axis \u2014 so concurrent sessions don't collide, where the\n * `EADDRINUSE on 8080/8081/9000` class only happened because every worktree\n * reused the same preferred ports.\n *\n * Disjoint by DEFAULT, not by construction: the block is a hash bucket, so two\n * (worktree, app) pairs can share one. The in-block scan below plus the\n * ephemeral fallback are the backstop; MESH-2375 tracks making it deterministic.\n */\n\nasync function allocatePorts(devOutput: DevOutput, worktree: WorktreeIdentity): Promise<DevOutput> {\n const allocated = JSON.parse(JSON.stringify(devOutput)) as DevOutput;\n const usedPorts = new Set<number>();\n const portRemaps = new Map<number, number>();\n\n const blockBase = worktree.isPrimary ? 0 : blockBasePort(worktree.portBlock);\n let blockCursor = 0; // next unused index within the service sub-range\n\n for (const [name, service] of Object.entries(allocated.services)) {\n if (service.port <= 0) continue;\n // Skip deployed services (no dev command) \u2014 they run in K8s, no local port.\n if (!service.command || service.command.length === 0) continue;\n const preferred = service.port;\n\n let target = -1;\n if (worktree.isPrimary) {\n if (!usedPorts.has(preferred) && (await isPortFree(preferred))) {\n target = preferred;\n } else {\n target = await findFreePort();\n logWarn(`Port ${preferred} busy for service '${name}', using ${target}`);\n }\n } else {\n // Reserved block: next free port in [blockBase, blockBase+SERVICE_SUBRANGE).\n for (let i = blockCursor; i < PORT_BLOCK_SERVICE_SUBRANGE; i++) {\n const candidate = blockBase + i;\n if (!usedPorts.has(candidate) && (await isPortFree(candidate))) {\n target = candidate;\n blockCursor = i + 1;\n break;\n }\n }\n if (target < 0) {\n // Block exhausted or contended \u2014 degrade to an OS-ephemeral port.\n target = await findFreePort();\n logWarn(`Worktree port block exhausted for '${name}', using ${target}`);\n }\n }\n\n if (target !== preferred) {\n // Rewrite the service's OWN port env var(s) \u2014 not just a var literally\n // named `PORT`. Without this, a service reading `VCS_PORT`/`AGENT_API_PORT`\n // keeps its deploy-time default and collides across worktrees (MESH-2088).\n rewriteOwnServicePort(service, preferred, target);\n portRemaps.set(preferred, target);\n service.port = target;\n }\n usedPorts.add(target);\n }\n\n // Cross-service URL rewriting: when a service port is reassigned,\n // update any env var in other services that references the old port\n // (e.g., API_URL from a link() pointing to the API's original port).\n rewriteCrossServicePorts(allocated, portRemaps);\n rewriteClusterHostsToLocal(allocated);\n\n return allocated;\n}\n\n/**\n * Rewrite `localhost:${oldPort}` \u2192 `localhost:${newPort}` in EVERY service's env\n * values, for each `oldPort \u2192 newPort` entry in `portRemaps`. Mutates the given\n * `devOutput` in place \u2014 callers pass a clone they own, so this stays safe.\n *\n * Shared by `allocatePorts` (launch-time reallocation) and `mergePinnedPorts`\n * (refresh-time re-pinning) so both apply identical cross-service rewrites.\n */\nfunction rewriteCrossServicePorts(devOutput: DevOutput, portRemaps: Map<number, number>): void {\n if (portRemaps.size === 0) return;\n for (const service of Object.values(devOutput.services)) {\n if (!service.env) continue;\n for (const envVar of Object.values(service.env)) {\n for (const [oldPort, newPort] of portRemaps) {\n if (envVar.value.includes(`localhost:${oldPort}`)) {\n envVar.value = envVar.value.replace(`localhost:${oldPort}`, `localhost:${newPort}`);\n }\n }\n }\n }\n}\n\n/**\n * Merge a session's PINNED ports/tunnels into a freshly-read DevOutput.\n *\n * `mesh dev restart <svc> --refresh-env` re-reads `pulumi stack output` to pick\n * up config/deploy changes, but the running session already allocated local\n * ports (`allocatePorts`) and opened SSM tunnels. Re-reading the raw stack\n * output would carry the DEPLOYED (pre-allocation) ports, so writing those into\n * the env file would point the service \u2014 and its peers \u2014 at the wrong ports and\n * break the live tunnels.\n *\n * This returns `fresh` (so new/changed non-port env values win) but overrides:\n * - EVERY service present in both `fresh` and `pinned` keeps its pinned/\n * allocated PORT (both `service.port` and `service.env.PORT.value`) \u2014 not\n * just the refreshed one, so a regenerated service's peer references land on\n * the session-allocated ports, and\n * - every tunnel present in `pinned` with the pinned host:port (the live SSM\n * endpoints); tunnels that exist only in `fresh` survive untouched.\n *\n * It then re-applies BOTH of the peer-addressing rewrites `allocatePorts` does \u2014\n * there are two now, and naming only one here is how they drift apart:\n * - PORT: for each service whose port differs between `fresh` (preferred) and\n * `pinned` (allocated), `localhost:${freshPort}` \u2192 `localhost:${pinnedPort}`\n * across EVERY service's env (e.g. a `ui`'s `API_URL` pointing at `api`).\n * - HOST: an in-cluster address for a service running LOCALLY becomes its local\n * one. `pulumi stack output` carries cluster hosts verbatim, so a refresh\n * without this restores exactly the value MESH-2597 exists to remove.\n *\n * Pure: deep-clones and never mutates its arguments.\n */\nexport function mergePinnedPorts(fresh: DevOutput, pinned: DevOutput): DevOutput {\n const merged = JSON.parse(JSON.stringify(fresh)) as DevOutput;\n\n // Pinned tunnels win (live SSM/VPC endpoints); fresh-only tunnels survive.\n const pinnedTunnels = JSON.parse(JSON.stringify(pinned.tunnels ?? {})) as Record<\n string,\n DevTunnel\n >;\n merged.tunnels = { ...(merged.tunnels ?? {}), ...pinnedTunnels };\n\n // Pin EVERY service's allocated port (never re-allocate). Track fresh\u2192pinned\n // port remaps so peer references (localhost:<freshPort>) can be rewritten.\n const portRemaps = new Map<number, number>();\n for (const [name, mergedSvc] of Object.entries(merged.services ?? {})) {\n const pinnedSvc = pinned.services?.[name];\n if (!pinnedSvc || typeof pinnedSvc.port !== 'number') continue;\n const freshPort = mergedSvc.port;\n mergedSvc.port = pinnedSvc.port;\n if (mergedSvc.env?.PORT) {\n mergedSvc.env.PORT.value = String(pinnedSvc.port);\n }\n if (typeof freshPort === 'number' && freshPort > 0 && freshPort !== pinnedSvc.port) {\n portRemaps.set(freshPort, pinnedSvc.port);\n }\n }\n\n // Re-apply cross-service URL rewrite (mirrors allocatePorts): peer references\n // to a preferred port become references to the session-allocated port.\n rewriteCrossServicePorts(merged, portRemaps);\n // BOTH rewrites, or `--refresh-env` undoes the other one. This path re-reads\n // `pulumi stack output`, which carries in-cluster hosts verbatim, so without\n // this a refresh restores `AGENT_API_INTERNAL_URL` to\n // `http://studio-api.<ns>.svc.cluster.local:8080` for a service running locally\n // \u2014 reintroducing MESH-2597 on the command whose entire purpose is refreshing\n // env.\n rewriteClusterHostsToLocal(merged);\n\n return merged;\n}\n\n// ============================================================================\n// Pulumi helpers\n// ============================================================================\n\n/**\n * Find the monorepo root by walking up from cwd looking for the root\n * package.json (has \"workspaces\" or the well-known repo markers).\n */\nfunction findMonorepoRoot(): string | null {\n let dir = process.cwd();\n const root = path.parse(dir).root;\n\n while (dir !== root) {\n // Look for pnpm-workspace.yaml (pnpm monorepo) or .git (repo root)\n if (\n fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) ||\n fs.existsSync(path.join(dir, 'turbo.json'))\n ) {\n return dir;\n }\n // .git as fallback \u2014 but only if it's a directory (not a worktree file)\n const gitPath = path.join(dir, '.git');\n if (fs.existsSync(gitPath)) {\n return dir;\n }\n dir = path.dirname(dir);\n }\n return null;\n}\n\n/**\n * Discover Pulumi apps in the monorepo.\n * Scans:\n * {tenants,tests/tenants}/<tenant>/apps/<app>/Pulumi.yaml\n * apps/<app>/Pulumi.yaml (flat layout, tenant from stack config)\n */\nfunction discoverApps(monorepoRoot: string): Array<{\n name: string;\n tenant: string;\n appPath: string;\n relativePath: string;\n stacks: string[];\n}> {\n const apps: Array<{\n name: string;\n tenant: string;\n appPath: string;\n relativePath: string;\n stacks: string[];\n }> = [];\n\n // Helper: extract stacks and add an app entry\n function addApp(appDir: string, tenant: string) {\n const projectName = getProjectName(appDir);\n const stacks = fs\n .readdirSync(appDir)\n .filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml')\n .map((f) => f.replace(/^Pulumi\\./, '').replace(/\\.yaml$/, ''));\n\n apps.push({\n name: projectName,\n tenant,\n appPath: appDir,\n relativePath: path.relative(monorepoRoot, appDir),\n stacks,\n });\n }\n\n // 1. Tenant-scoped: {tenants,tests/tenants}/<tenant>/apps/<app>/\n const searchDirs = ['tenants', 'tests/tenants'];\n\n for (const searchDir of searchDirs) {\n const base = path.join(monorepoRoot, searchDir);\n if (!fs.existsSync(base)) continue;\n\n for (const tenant of fs.readdirSync(base)) {\n const appsDir = path.join(base, tenant, 'apps');\n if (!fs.existsSync(appsDir) || !fs.statSync(appsDir).isDirectory()) continue;\n\n for (const app of fs.readdirSync(appsDir)) {\n const appDir = path.join(appsDir, app);\n if (!fs.existsSync(path.join(appDir, 'Pulumi.yaml'))) continue;\n addApp(appDir, tenant);\n }\n }\n }\n\n // 2. Flat layout: apps/<app>/ (tenant read from first stack config)\n const flatAppsDir = path.join(monorepoRoot, 'apps');\n if (fs.existsSync(flatAppsDir) && fs.statSync(flatAppsDir).isDirectory()) {\n for (const app of fs.readdirSync(flatAppsDir)) {\n const appDir = path.join(flatAppsDir, app);\n if (!fs.statSync(appDir).isDirectory()) continue;\n if (!fs.existsSync(path.join(appDir, 'Pulumi.yaml'))) continue;\n\n // Read tenant from first stack config's mesh:tenant\n let tenant = 'unknown';\n const stackConfigs = fs\n .readdirSync(appDir)\n .filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml');\n if (stackConfigs.length > 0) {\n try {\n const content = fs.readFileSync(path.join(appDir, stackConfigs[0]!), 'utf-8');\n const tenantMatch = content.match(/mesh:tenant:\\s*(\\S+)/);\n if (tenantMatch) tenant = tenantMatch[1]!;\n } catch {}\n }\n\n addApp(appDir, tenant);\n }\n }\n\n return apps;\n}\n\n/**\n * Find the Pulumi app root by walking up from cwd looking for Pulumi.yaml.\n */\nfunction findAppRoot(appPath?: string): string {\n if (appPath) {\n // Resolve relative to cwd or monorepo root\n const resolved = path.resolve(appPath);\n if (fs.existsSync(path.join(resolved, 'Pulumi.yaml'))) return resolved;\n\n // Try relative to monorepo root\n const mono = findMonorepoRoot();\n if (mono) {\n const fromMono = path.resolve(mono, appPath);\n if (fs.existsSync(path.join(fromMono, 'Pulumi.yaml'))) return fromMono;\n }\n\n logError(`No Pulumi.yaml found at: ${appPath}`);\n process.exit(1);\n }\n\n let dir = process.cwd();\n const root = path.parse(dir).root;\n\n while (dir !== root) {\n if (fs.existsSync(path.join(dir, 'Pulumi.yaml'))) {\n return dir;\n }\n dir = path.dirname(dir);\n }\n\n return process.cwd();\n}\n\n/**\n * Read the Pulumi project name from Pulumi.yaml.\n */\nfunction getProjectName(appRoot: string): string {\n const yamlPath = path.join(appRoot, 'Pulumi.yaml');\n if (!fs.existsSync(yamlPath)) return path.basename(appRoot);\n\n const content = fs.readFileSync(yamlPath, 'utf-8');\n const match = content.match(/^name:\\s*(.+)$/m);\n return match?.[1]?.trim() ?? path.basename(appRoot);\n}\n\n/**\n * Detect the current Pulumi stack from:\n * 1. --stage flag\n * 2. MESH_STAGE env var\n * 3. `pulumi stack --show-name` (currently-selected stack)\n * 4. Pulumi.<stack>.yaml files in the app root (if exactly one, use it)\n */\nfunction detectStack(appRoot: string, stageArg?: string): string {\n if (stageArg) return stageArg;\n if (process.env.MESH_STAGE) return process.env.MESH_STAGE;\n\n try {\n const result = execFileSync('pulumi', ['stack', '--show-name'], {\n cwd: appRoot,\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'ignore'],\n }).trim();\n if (result) return result;\n } catch {\n // Fall through\n }\n\n // Infer from Pulumi.<stack>.yaml config files\n try {\n const files = fs\n .readdirSync(appRoot)\n .filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml');\n if (files.length === 1) {\n const match = files[0]!.match(/^Pulumi\\.(.+)\\.yaml$/);\n if (match?.[1]) {\n logInfo(`Auto-detected stack from ${files[0]}`);\n return match[1];\n }\n }\n } catch {\n // Fall through\n }\n\n return 'dev';\n}\n\n/**\n * Build the `--stack <name>` args for Pulumi commands.\n * Newer Pulumi versions require fully-qualified names (organization/project/stack)\n * with `--stack`. If the stack is already selected, we can omit the flag entirely.\n */\nfunction stackArgs(appRoot: string, stack: string): string[] {\n // Check if the requested stack is already the selected one \u2014 if so, skip --stack\n try {\n const selected = execFileSync('pulumi', ['stack', '--show-name'], {\n cwd: appRoot,\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'ignore'],\n }).trim();\n if (selected === stack) return [];\n } catch {\n // ignore\n }\n\n // Try selecting the stack first (works with short names), then omit --stack\n try {\n execFileSync('pulumi', ['stack', 'select', stack], {\n cwd: appRoot,\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'ignore'],\n });\n return [];\n } catch {\n // Fall through \u2014 pass --stack and hope the name is fully qualified\n }\n\n return ['--stack', stack];\n}\n\n/**\n * Read the dev output from Pulumi stack.\n * Tries `app` output first (new env.register() format), falls back to `dev`.\n */\n/**\n * Thrown by `getDevOutput` when no readable Pulumi stack output exists (usually a\n * fresh app that hasn't been `stack init` + `deploy`ed yet). Typed so each caller\n * presents it in its OWN context: only the launch path can conclude \"nothing to\n * run\" and print first-run guidance; the `--refresh-env`/restart handlers must\n * keep the running session's env untouched (MESH-2097). Carries the underlying\n * read error so an auth/network/JSON failure isn't misdiagnosed as \"fresh app\".\n */\nclass MissingStackOutputError extends Error {\n constructor(public readonly stackName: string, cause?: unknown) {\n super(`No Pulumi stack output for '${stackName}'`, { cause });\n this.name = 'MissingStackOutputError';\n }\n}\n\nfunction getDevOutput(appRoot: string, stack: string, awsEnv?: Record<string, string>): DevOutput {\n const sa = stackArgs(appRoot, stack);\n\n // Try new format: export const app = env.register() \u2192 { dev: { ... } }\n try {\n const result = pulumiStackOutput(appRoot, 'app', sa, awsEnv);\n const appOutput = JSON.parse(result) as { dev?: DevOutput };\n if (appOutput.dev) {\n return appOutput.dev;\n }\n } catch {\n // Fall through to legacy format\n }\n\n // Legacy format: export const dev = ...\n try {\n const result = pulumiStackOutput(appRoot, 'dev', sa, awsEnv);\n return JSON.parse(result) as DevOutput;\n } catch (err) {\n // No readable stack output. Throw a TYPED error so each caller decides how to\n // present it (MESH-2097): the launch path prints first-run guidance + exits;\n // the --refresh-env/restart handlers keep the running session untouched.\n // (process.exit() here previously hijacked control flow past those fail-closed\n // catches, and a bare `throw` escaped as an uncaught node stacktrace.)\n throw new MissingStackOutputError(stack, err);\n }\n}\n\n// ============================================================================\n// tmux helpers\n// ============================================================================\n\nfunction hasTmux(): boolean {\n try {\n execFileSync('which', ['tmux'], { stdio: 'ignore' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sessionExists(name: string): boolean {\n try {\n execFileSync('tmux', ['has-session', '-t', name], { stdio: 'ignore' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction killSession(name: string): boolean {\n try {\n execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' });\n return true;\n } catch {\n return false;\n }\n}\n\nconst AWS_STATIC_CREDENTIAL_ENV_KEYS = [\n 'AWS_ACCESS_KEY_ID',\n 'AWS_SECRET_ACCESS_KEY',\n 'AWS_SESSION_TOKEN',\n 'AWS_WEB_IDENTITY_TOKEN_FILE',\n 'AWS_ROLE_ARN',\n] as const;\n\nfunction applyAwsProfileOverride(profile: string): void {\n process.env.AWS_PROFILE = profile;\n for (const key of AWS_STATIC_CREDENTIAL_ENV_KEYS) {\n delete process.env[key];\n }\n}\n\n/**\n * Collect AWS credentials from the current environment.\n */\nfunction getAwsEnvVars(): Record<string, string> {\n const result: Record<string, string> = {};\n const vars = [\n 'AWS_PROFILE',\n 'AWS_ACCESS_KEY_ID',\n 'AWS_SECRET_ACCESS_KEY',\n 'AWS_SESSION_TOKEN',\n 'AWS_REGION',\n 'AWS_DEFAULT_REGION',\n ];\n\n for (const name of vars) {\n const value = process.env[name];\n if (value) result[name] = value;\n }\n return result;\n}\n\n/**\n * Build the AWS env vars to hand to long-lived child services (ui, api,\n * ai-assistant) and SSM tunnels.\n *\n * For the AWS SSO credential path, returns AWS_PROFILE + AWS_REGION only so\n * the AWS SDK's default provider chain can auto-refresh credentials from the\n * SSO cache (~8h session) as short-lived STS tokens expire. Without this,\n * child processes inherit a frozen STS triple and start throwing\n * ExpiredTokenException ~1h into a dev session.\n *\n * For the Zitadel web-identity path, when `opts` is supplied we write an AWS\n * config file whose `credential_process` re-invokes `mesh login \u2026 --credential-process`,\n * so the AWS SDK's default provider chain auto-refreshes the underlying STS\n * triple (the `mesh login` process re-issues the Zitadel token as needed).\n * This removes the ~1h forced `mesh dev` restart. When `opts` is absent we\n * fall back to the frozen STS triple (used by CI/`environment` and any caller\n * that doesn't thread the context/role through).\n *\n * For the remaining non-SSO paths (CI/CD env keys, or no deployer role),\n * returns the explicit STS triple \u2014 there is no profile to refresh from. The\n * user must restart `mesh dev` when those tokens expire.\n */\nexport function buildChildAwsEnv(\n awsEnv: Record<string, string>,\n method: ResolvedAwsCreds['method'] | 'ambient',\n profile: string | undefined,\n opts?: { context: string; roleArn: string; sessionScratchDir: string; meshBin: string },\n): Record<string, string> {\n const result: Record<string, string> = {};\n if (awsEnv.AWS_REGION) result.AWS_REGION = awsEnv.AWS_REGION;\n if (awsEnv.AWS_DEFAULT_REGION) result.AWS_DEFAULT_REGION = awsEnv.AWS_DEFAULT_REGION;\n\n if (method === 'sso' && profile) {\n result.AWS_PROFILE = profile;\n return result;\n }\n\n // Zitadel path with a credential_process profile: hand children a profile\n // that self-refreshes via `mesh login`, never a frozen triple.\n if (method === 'zitadel' && opts) {\n const sanitizedContext = opts.context.replace(/[^A-Za-z0-9_-]/g, '-');\n const profileName = `mesh-dev-${sanitizedContext}`;\n const configPath = path.join(opts.sessionScratchDir, 'aws-config');\n const region = awsEnv.AWS_REGION ?? awsEnv.AWS_DEFAULT_REGION ?? '';\n fs.mkdirSync(opts.sessionScratchDir, { recursive: true });\n const profileBlock = renderCredentialProcessProfile({\n profileName,\n context: opts.context,\n roleArn: opts.roleArn,\n region,\n meshBin: opts.meshBin,\n });\n // UPSERT a managed section; never rewrite the file. This file has a second\n // writer \u2014 `mesh login --export` (the worker-start recipe evals it) \u2014 and\n // the AWS SDK reads it ONCE per process, so a wholesale write here deletes\n // that profile under a running worker, which then fails every LLM call until\n // it is restarted. The key carries \"(mesh dev)\" so the two writers own two\n // sections rather than replacing each other's.\n const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';\n const next = upsertManagedAwsConfigSection(\n stripBareProfile(existing, profileName),\n `${opts.context} (mesh dev)`,\n profileBlock,\n );\n if (next !== existing) atomicWriteFileSync(configPath, next, 0o600);\n const out: Record<string, string> = {\n AWS_PROFILE: profileName,\n AWS_CONFIG_FILE: configPath,\n };\n if (region) out.AWS_REGION = region;\n return out;\n }\n\n for (const key of ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN'] as const) {\n if (awsEnv[key]) result[key] = awsEnv[key]!;\n }\n if (profile && !result.AWS_ACCESS_KEY_ID) {\n result.AWS_PROFILE = profile;\n }\n return result;\n}\n\n/**\n * Resolve a service's source dir onto the CURRENT worktree (MESH-2013).\n *\n * `mesh dev` reads the service `src` from `pulumi stack output`. Two shapes exist:\n * - New (post relative-src fix in infra-components): a monorepo-RELATIVE dir\n * like `packages/agent-sdk` \u2192 just resolve it against this worktree's root.\n * - Legacy: an ABSOLUTE path baked at the deploying `pulumi up`, e.g.\n * `/\u2026/mesh-platform/.worktrees/streaming-ui-builder/packages/agent-sdk`. The\n * old logic stripped only up to `/mesh-platform/`, keeping the\n * `.worktrees/<name>/` segment \u2014 so it re-rooted to a *sibling worktree* that\n * also exists, silently running the wrong worktree's code. We now reduce to\n * the monorepo-relative portion (dropping any `.worktrees/<name>/`) and\n * re-root here, so a stack deployed from any worktree runs from THIS one.\n */\nfunction rebaseServiceSrc(src: string, monorepoRoot: string | null): string {\n if (!monorepoRoot) return src;\n const rel = monorepoRelativeSrc(src);\n if (rel === null) return src;\n const rebased = path.join(monorepoRoot, rel);\n return fs.existsSync(rebased) ? rebased : src;\n}\n\n/**\n * Is `serviceDir` a package this app DEV-LINKED, rather than a stale sibling\n * worktree?\n *\n * Both look identical to the wrong-worktree guard \u2014 a source directory outside\n * the app's worktree \u2014 but only one is a mistake. A module that contributes a\n * dev service (`InfraContribution.devServices`) reports `resolvePackageRoot()`,\n * which is the package's REAL path; when that package is dev-linked from another\n * repo it legitimately lives there. Refusing it means a devService can run only\n * while its package is published \u2014 i.e. never while you are developing it, which\n * is the only time you need it.\n *\n * The test is proof, not a heuristic: read the directory's own package name and\n * ask whether THIS app resolves that name to THIS directory. A stale sibling\n * worktree cannot satisfy that \u2014 nothing in the app's `node_modules` points at\n * it \u2014 so the wrong-worktree guard keeps its teeth.\n *\n * @example\n * isLinkedDependencyDir(\"/repo/libs/infra-components/src/components/ai/redis\", \"/repo/apps/demo\");\n * // \u2192 true when the app resolves @mesh-tech/infra-components to that directory\n */\nexport function isLinkedDependencyDir(serviceDir: string, appRoot: string): boolean {\n // The service source is often INSIDE a package rather than its root \u2014 a redis\n // component ships as `<infra-components>/src/components/ai/redis`. So find the\n // enclosing package first (nearest ancestor with a named manifest), then ask\n // whether the app resolves THAT name to THAT directory.\n let pkgRoot: string | null = null;\n let name = '';\n for (let dir = serviceDir; ; dir = path.dirname(dir)) {\n try {\n const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));\n if (typeof manifest?.name === 'string' && manifest.name) {\n pkgRoot = dir;\n name = manifest.name;\n break;\n }\n } catch {\n /* no manifest here; keep walking up */\n }\n const parent = path.dirname(dir);\n if (parent === dir) break;\n }\n if (!pkgRoot) return false;\n\n // Resolve from the app outward, so a package linked for a DIFFERENT app in the\n // same repo does not vouch for this one.\n for (let dir = appRoot; ; dir = path.dirname(dir)) {\n try {\n if (fs.realpathSync(path.join(dir, 'node_modules', name)) === pkgRoot) return true;\n } catch {\n /* not resolvable here; keep walking up */\n }\n const parent = path.dirname(dir);\n if (parent === dir) return false;\n }\n}\n\n/**\n * Reduce a service `src` to its monorepo-relative form (pure; no filesystem).\n * - relative (`./packages/x` or `packages/x`) \u2192 normalized `packages/x`\n * - legacy absolute (`/\u2026/mesh-platform[/.worktrees/<name>]/packages/x`) \u2192\n * `packages/x` (drops the repo root AND any deploying-worktree segment)\n * Returns null when an absolute path has no recognizable monorepo marker.\n */\nexport function monorepoRelativeSrc(src: string): string | null {\n if (!path.isAbsolute(src)) return src.replace(/^\\.\\//, '') || '.';\n // Match the LAST `/mesh-platform` at a segment boundary \u2014 covers the repo root\n // itself (nothing follows \u2192 '.'), a nested `.worktrees/<name>/`, and normal\n // subpaths. A path ending at the repo root (e.g. a service whose src IS the\n // monorepo root) previously slipped through and stayed absolute (MESH-2069).\n const m = src.match(/^.*\\/mesh-platform(?:\\/(.*))?$/);\n if (!m) return null;\n const rest = (m[1] ?? '').replace(/^\\.worktrees\\/[^/]+(?:\\/|$)/, '');\n return rest === '' ? '.' : rest;\n}\n\n/**\n * Resolve env vars for a service, rewriting tunnel references to VPC endpoints.\n */\nfunction getServiceEnvVars(\n service: DevService,\n tunnels: Record<string, DevTunnel>,\n): Record<string, string> {\n const result: Record<string, string> = {};\n\n if (service.env) {\n for (const [key, spec] of Object.entries(service.env)) {\n let value = spec.value;\n\n if (spec.tunnel) {\n const tunnel = tunnels[spec.tunnel];\n if (tunnel) {\n // Rewrite to VPC endpoint (VPN provides direct access)\n try {\n const url = new URL(value);\n if (url.hostname) {\n url.hostname = tunnel.host;\n url.port = String(tunnel.port);\n value = url.toString();\n } else {\n value = `${tunnel.host}:${tunnel.port}`;\n }\n } catch {\n value = `${tunnel.host}:${tunnel.port}`;\n }\n }\n }\n\n result[key] = value;\n }\n }\n return result;\n}\n\n/**\n * Set env vars on a tmux session. New windows inherit them.\n *\n * Use for truly shared vars (AWS creds, secrets). For per-service\n * vars like PORT, use envPrefix() instead to avoid cross-service leaks.\n */\nfunction setTmuxEnv(sessionName: string, vars: Record<string, string>): void {\n for (const [key, value] of Object.entries(vars)) {\n execFileSync('tmux', ['set-environment', '-t', sessionName, key, value], { stdio: 'ignore' });\n }\n}\n\n/**\n * Build an `env K=V \u2026` prefix string for per-service env vars.\n *\n * This scopes variables to the process instead of polluting the\n * tmux session environment (where the last service's PORT would\n * leak to every other service on restart).\n */\nfunction envPrefix(vars: Record<string, string>): string {\n const entries = Object.entries(vars);\n if (entries.length === 0) return '';\n const parts = entries.map(([k, v]) => `${k}=${shellEscape(v)}`);\n return `env ${parts.join(' ')} `;\n}\n\n/** Escape a value for shell interpolation. */\nfunction shellEscape(s: string): string {\n // If the value is safe, return as-is\n if (/^[a-zA-Z0-9_./:@=+,-]+$/.test(s)) return s;\n // Otherwise single-quote it, escaping any embedded single quotes\n return `'${s.replace(/'/g, \"'\\\\''\")}'`;\n}\n\n// ============================================================================\n// Secret resolution\n// ============================================================================\n\n/**\n * Resolve secrets from Secrets Manager and return env var key\u2192value pairs.\n * VPN provides direct access \u2014 no URL rewriting needed.\n */\nasync function resolveSecrets(secrets: Record<string, DevSecret>): Promise<Record<string, string>> {\n const client = new SecretsManagerClient({});\n const resolved: Record<string, string> = {};\n\n for (const [name, secret] of Object.entries(secrets)) {\n try {\n const response = await client.send(\n new GetSecretValueCommand({ SecretId: secret.secretName }),\n );\n if (!response.SecretString) continue;\n\n const values = JSON.parse(response.SecretString) as Record<string, string>;\n\n for (const [secretKey, envVar] of Object.entries(secret.envMapping)) {\n const value = values[secretKey];\n if (value === undefined) continue;\n resolved[envVar] = value;\n }\n\n logSuccess(`Secret resolved: ${name} (${secret.secretName})`);\n } catch (err) {\n logWarn(\n `Could not resolve secret '${name}' (${secret.secretName}): ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n }\n\n return resolved;\n}\n\n\n/**\n * Resolve the Temporal encoding key from the K8s secret in the app namespace.\n *\n * The Pulumi-generated devEncodingKey is a placeholder that differs from the\n * deployed key, causing decryption failures when the local worker encounters\n * existing Temporal payloads. Reading the real key from K8s ensures local dev\n * can decrypt data created by deployed workers.\n *\n * Returns the encoding key value, or undefined if not available.\n */\nfunction resolveTemporalEncodingKey(\n tenant: string,\n env: string,\n appName: string,\n): string | undefined {\n const namespace = `${tenant}-${env}-${appName}`;\n const secretName = `${namespace}-temporal-encoding-key`;\n try {\n const b64 = execFileSync(\n 'kubectl',\n [\n 'get',\n 'secret',\n secretName,\n '-n',\n namespace,\n '-o',\n 'jsonpath={.data.TEMPORAL_ENCODING_KEY}',\n ],\n { encoding: 'utf-8', timeout: 10_000, stdio: ['pipe', 'pipe', 'pipe'] },\n ).trim();\n\n if (b64) {\n const key = Buffer.from(b64, 'base64').toString('utf-8');\n logSuccess(`Temporal encoding key resolved from K8s secret (${secretName})`);\n return key;\n }\n } catch {\n // Non-fatal \u2014 secret may not exist yet or kubectl may not have access\n }\n return undefined;\n}\n\n/**\n * Re-resolve the session-level Temporal auth vars the SAME way launch does\n * (see startServices): kubeconfig from SSM, M2M auth, encoding key from the\n * K8s secret, and TEMPORAL_ADDRESS from the (pinned) tunnel. Used by\n * `mesh dev restart --refresh-env` so a regenerated env file carries the live\n * resolved values, not the dev-output placeholders (which would desync payload\n * encryption between services).\n */\nasync function resolveTemporalAuthVars(\n sessionName: string,\n devOutput: DevOutput,\n): Promise<Record<string, string>> {\n const tenant = devOutput.platform?.tenant ?? 'mesh';\n const platformEnv = devOutput.platform?.env ?? 'dev';\n // Hub platform name \u2014 the EKS export lives under it, not under a hardcoded\n // \"mesh\" (MESH-2515); same axis as the bastion resolution.\n const platformName = resolveHubPlatformName(devOutput.platform);\n\n const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {\n onError: ({ parameter, error }) =>\n logWarn(\n `EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`,\n ),\n });\n if (kubeconfigPath) {\n process.env.KUBECONFIG = kubeconfigPath;\n setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });\n } else if (!process.env.KUBECONFIG) {\n // ensureKubeconfig couldn't re-resolve the cluster from SSM (transient /\n // stale creds). Fall back to the session kubeconfig written at launch so the\n // kubectl calls below don't default to ~/.kube/config \u2014 which macOS TCC\n // denies for shells without Full Disk Access (\"operation not permitted\").\n const existing = sessionKubeconfigPath(sessionName);\n if (fs.existsSync(existing)) {\n // Set only this process's env \u2014 deliberately NOT setTmuxEnv: this is a\n // best-effort fallback to a possibly-stale last-good kubeconfig, so we use\n // it for the kubectl calls below without persisting it into the session\n // (a later successful re-resolve is the one that should win the session).\n process.env.KUBECONFIG = existing;\n }\n }\n\n let temporalAuthVars: Record<string, string> = {};\n if (devOutput.tunnels['temporal']) {\n // Deliberately NOT `platformName` from above: the Temporal-auth fallback is\n // `?? tenant` (dedicated tenants may run their own Temporal), while the EKS\n // fallback is `?? 'mesh'`. Whether that difference is correct is MESH-2551;\n // don't \"fix\" either default to match the other without reading it.\n temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);\n\n const appName = devOutput.app ?? '';\n if (appName) {\n const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);\n if (encodingKey) {\n temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;\n }\n }\n\n const temporalTunnel = devOutput.tunnels['temporal'];\n temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);\n }\n return temporalAuthVars;\n}\n\n/**\n * Resolve the dev-user identity vars (DEV_USER_*) from cached `mesh login`\n * credentials for the platform context (e.g. 'mesh.dev'). Credentials are\n * stored under the platform context, not the tenant name \u2014 all tenants share\n * the same Zitadel instance.\n *\n * Services (e.g., Hub UI) use these as a fallback when the real oauth2-proxy\n * `X-Forwarded-*` request headers are absent:\n * DEV_USER_ID - subject from idToken, matches conversation ownership\n * DEV_USER_EMAIL - user email from idToken\n * DEV_USER_ID_TOKEN - full idToken JWT (profile claims, authorization hint)\n * DEV_USER_ACCESS_TOKEN - full accessToken JWT (has project:roles claim)\n *\n * Returned vars must be overlaid onto each service's env FILE (not just the\n * tmux session env): the dev output may carry a placeholder value (e.g. hub\n * ui's DEV_USER_ACCESS_TOKEN=\"dev-local\"), and the sourced per-service env\n * file wins over the session env, so a session-env-only injection gets\n * clobbered and every tenant-scoped Hub view silently 401s.\n */\n/**\n * The `mesh login` context that issues this app's dev-user identity.\n *\n * It is the PLATFORM's name and env \u2014 `trabian.dev`, `mesh.dev` \u2014 because the\n * platform owns the Zitadel the app's services verify against, so a token from\n * any other issuer is well-formed and rejected. Not every app runs on the `mesh`\n * platform: a shared tenant on the `trabian` platform whose dev user came from\n * `identity.dev.mesh-platform.trabian.com` would be verified against\n * `identity.dev.trabian.meshtech.io`.\n *\n * That failure is invisible at injection time and only surfaces at the far end\n * of a cross-app call, as `Missing or invalid Authorization header \u2014 expected\n * Bearer token`: the token is well-formed and unexpired, just from the wrong\n * issuer.\n *\n * `name` is optional on the dev output, so fall back to `mesh`, which keeps\n * mesh-platform apps on the context they already use.\n */\nfunction loginContextFor(platform: { name?: string; env: string }): string {\n return `${platform.name ?? 'mesh'}.${platform.env}`;\n}\n\nfunction resolveDevUserVars(devOutput: DevOutput): Record<string, string> {\n const credContext = devOutput.platform\n ? devOutput.platform.tenant === 'local'\n ? 'local'\n : loginContextFor(devOutput.platform)\n : null;\n if (!credContext) return {};\n const meshCreds = readCredentials(credContext);\n if (!meshCreds?.idToken) return {};\n const vars: Record<string, string> = { DEV_USER_ID_TOKEN: meshCreds.idToken };\n try {\n const payload = JSON.parse(\n Buffer.from(meshCreds.idToken.split('.')[1]!, 'base64url').toString(),\n );\n if (payload.sub) vars.DEV_USER_ID = payload.sub;\n if (payload.email) vars.DEV_USER_EMAIL = payload.email;\n } catch {\n /* ignore decode errors */\n }\n if (meshCreds.accessToken) {\n vars.DEV_USER_ACCESS_TOKEN = meshCreds.accessToken;\n }\n return vars;\n}\n\n// ============================================================================\n// SSM tunnel fallback (when VPN is not connected)\n// ============================================================================\n\n/**\n * Base port for SSM tunnels. Preferred ports use 20000 + original_port to\n * avoid collisions with dev services (1000-9999 range). If the preferred\n * port is already reserved or occupied, mesh dev walks upward until it finds\n * an available port.\n *\n * Examples:\n * PostgreSQL 5432 \u2192 25432\n * Temporal 7233 \u2192 27233\n * Temporal UI 8080 \u2192 28080\n */\nconst SSM_TUNNEL_BASE_PORT = 20000;\n\n/** How long to wait for an SSM tunnel's local port to accept TCP before failing. */\nconst TUNNEL_READY_TIMEOUT_MS = 60_000;\n\n/**\n * Build the client-facing `host:port` for an SSM tunnel.\n *\n * The `aws ssm start-session` port-forwarder binds IPv4 `127.0.0.1` only.\n * Advertising `localhost` lets Node/grpc-js resolve to IPv6 `::1` first, which\n * the tunnel isn't listening on \u2014 so e.g. the Temporal gRPC client fails with\n * `ECONNREFUSED ::1:<port>` and the Hub's Automations/workflow lists never load.\n * Maps the literal `localhost` (how tunnels are advertised today) to `127.0.0.1`\n * so clients hit the bound listener. Any other host \u2014 VPN-direct VPC endpoints,\n * or a different loopback literal like `::1`/`127.0.0.1` \u2014 passes through unchanged.\n */\nexport function tunnelClientAddress(tunnel: { host: string; port: number }): string {\n const host = tunnel.host === 'localhost' ? '127.0.0.1' : tunnel.host;\n return `${host}:${tunnel.port}`;\n}\n\n/** One tunnel's live health, resolved by a TCP dial of its client address. */\nexport interface TunnelHealth {\n name: string;\n /** Client-facing `host:port` (see {@link tunnelClientAddress}). */\n address: string;\n reachable: boolean;\n}\n\n/**\n * Render the Connections section (live tunnel health) as status-window lines.\n *\n * The status window refreshes every ~2s, so this is where a mid-session tunnel\n * death surfaces (MESH-2016): a dead tunnel shows `\u26A0 \u2026 DOWN` and a SINGLE\n * remediation line explains the `ECONNREFUSED` retry spam the dependent service\n * windows emit \u2014 instead of leaving the operator to decode that spam themselves.\n * Returns `[]` when there are no tunnels (nothing to show).\n */\nexport function formatTunnelHealth(health: TunnelHealth[]): string[] {\n if (health.length === 0) return [];\n const lines = ['Connections:'];\n for (const h of health) {\n const icon = h.reachable ? '\u2713' : '\u26A0';\n const suffix = h.reachable ? '' : ' DOWN';\n lines.push(` ${icon} ${h.name.padEnd(16)} ${h.address}${suffix}`);\n }\n const down = health.filter((h) => !h.reachable).map((h) => h.name);\n if (down.length > 0) {\n const noun = down.length === 1 ? 'tunnel' : 'tunnels';\n lines.push('');\n lines.push(\n ` \u26A0 ${down.length} ${noun} down (${down.join(', ')}) \u2014 dependent services are ` +\n `retrying (ECONNREFUSED spam is expected). Relaunch: mesh dev`,\n );\n }\n return lines;\n}\n\n/** Probe every tunnel's client address in parallel and resolve its health. */\nasync function probeTunnelHealth(\n tunnels: Record<string, { host: string; port: number }>,\n): Promise<TunnelHealth[]> {\n return Promise.all(\n Object.entries(tunnels).map(async ([name, tun]) => ({\n name,\n address: tunnelClientAddress(tun),\n // Dial the SAME address the client uses (localhost\u2192127.0.0.1) so the probe\n // sees exactly what a dependent service would. Use the connection-HOLDS\n // probe, not a bare accept: a forwarder binds its local port even when its\n // upstream leg is dead (a dropped SSM session, or a Tailscale SOCKS bridge\n // dialing a stale port \u2014 MESH-2169), so \"port accepts\" would report a dead\n // tunnel as healthy. Holding briefly catches the accept-then-close teardown.\n reachable: await probeConnectionHolds(\n tun.host === 'localhost' ? '127.0.0.1' : tun.host,\n tun.port,\n 800,\n ),\n })),\n );\n}\n\n/**\n * Probe the routing-critical tunnel of a (Tailscale-rewritten) devOutput and\n * return its name if the forwarder binds but does not ROUTE, else null.\n *\n * Only `temporal` is gated \u2014 it's the load-bearing VPC dependency (services die\n * on a gRPC deadline when it's dead), and keying on it mirrors the vpn-direct\n * reachability gate ({@link gateVpnDirectReachability}). A Tailscale forwarder\n * accepts its local port even when its SOCKS upstream is dead or the joined\n * tailnet doesn't route the VPC; such a forwarder tears the connection down\n * within the grace window, so it fails the connection-HOLDS probe that a bare\n * accept (the readiness gate's `waitForPort`) would pass (MESH-2169/MESH-2268).\n */\nexport async function firstUnroutableTunnel(\n tunnels: Record<string, { host: string; port: number }>,\n): Promise<string | null> {\n const temporal = tunnels['temporal'];\n if (!temporal || temporal.host !== 'localhost') return null;\n const holds = await probeConnectionHolds('127.0.0.1', temporal.port, 800);\n return holds ? null : 'temporal';\n}\n\n/** Compute the preferred local port for an SSM tunnel. */\n/** Tunnel backing for a dev session. */\nexport type Transport = \"ssm\" | \"tailscale\" | \"vpn-direct\";\n\n/** What `startServices` should do for VPC tunnels. */\nexport interface TunnelPlan {\n transport: Transport;\n tailscale?: { tenant: string; env: string; region: string; loginServer: string; socksPort: number; preAuthKey?: string };\n /** True when the user EXPLICITLY asked for this transport (`--transport <x>`),\n * vs an auto-resolved default. An explicit tailscale choice must NOT silently\n * fall back to SSM \u2014 surface the failure so the user can act (MESH-2098). */\n explicit?: boolean;\n}\n\n/**\n * Resolve which tunnel backing `mesh dev` should use.\n *\n * An explicit `--transport` flag wins. Both `auto` and the default (unset)\n * prefer Tailscale when it's available (brew `tailscaled` present), else fall\n * back to the VPN-direct/SSM choice: VPN-direct when the VPN is connected, else\n * SSM when the session-manager plugin is present. Where Tailscale isn't\n * available the behavior is unchanged from before this feature.\n */\nexport function resolveTransport(\n flag: string | undefined,\n ctx: { vpnConnected: boolean; hasSsmPlugin: boolean; tailscaleAvailable: boolean },\n): Transport {\n if (flag === \"tailscale\" || flag === \"ssm\") return flag;\n if (flag !== undefined && flag !== \"auto\") {\n throw new Error(`Invalid --transport '${flag}'. Valid values: auto (default), tailscale, ssm.`);\n }\n const legacy: Transport = ctx.vpnConnected ? \"vpn-direct\" : ctx.hasSsmPlugin ? \"ssm\" : \"vpn-direct\";\n return ctx.tailscaleAvailable ? \"tailscale\" : legacy;\n}\n\n/** Outcome of the reachability gate \u2014 `transport` is what to use, `reason` drives the log. */\nexport type ReachabilityDecision = {\n transport: Transport;\n reason: \"not-vpn-direct\" | \"reachable\" | \"fallback-ssm\" | \"unreachable-no-plugin\";\n};\n\n/**\n * Correct an auto-selected `vpn-direct` transport against ACTUAL reachability of\n * the Temporal VPC endpoint (MESH-2016). `resolveTransport` uses a presence check\n * (any Tailscale/VPN up) that's tailnet-blind \u2014 Tailscale-for-SSH looks connected\n * but doesn't route the mesh VPC. Only `vpn-direct` is reachability-gated (it's\n * never an explicit choice); every other transport passes through unchanged.\n *\n * Pure so the fallback logic is testable independently of the TCP probe.\n */\nexport function reachabilityFallbackTransport(\n transport: Transport,\n temporalReachable: boolean,\n hasSsmPlugin: boolean,\n): ReachabilityDecision {\n if (transport !== \"vpn-direct\") return { transport, reason: \"not-vpn-direct\" };\n if (temporalReachable) return { transport, reason: \"reachable\" };\n if (hasSsmPlugin) return { transport: \"ssm\", reason: \"fallback-ssm\" };\n return { transport, reason: \"unreachable-no-plugin\" };\n}\n\n/**\n * Apply the reachability gate to a (possibly auto-selected) transport: if it's\n * `vpn-direct`, TCP-dial the Temporal VPC endpoint and downgrade to SSM when it's\n * actually unreachable, logging the decision. Returns the effective transport;\n * a no-op for any non-`vpn-direct` transport.\n *\n * Called on BOTH auto-select paths \u2014 the normal resolve result AND the\n * Tailscale\u2192legacy fallback (when no mesh login config exists for the derived\n * context) \u2014 so a `vpn-direct` can never reach `startServices` unprobed\n * (MESH-2016).\n */\nasync function gateVpnDirectReachability(\n transport: Transport,\n devOutput: DevOutput,\n): Promise<Transport> {\n if (transport !== \"vpn-direct\") return transport;\n const temporalTunnel = devOutput.tunnels?.[\"temporal\"];\n if (!temporalTunnel) return transport;\n const { host, port } = temporalTunnel;\n const reachable = await probeTcpReachable(host, port, 1500);\n const decision = reachabilityFallbackTransport(transport, reachable, hasSessionManagerPlugin());\n if (decision.reason === \"reachable\") {\n logInfo(`Transport: VPN-direct \u2014 Temporal VPC endpoint ${host}:${port} is reachable.`);\n } else if (decision.reason === \"fallback-ssm\") {\n logWarn(\n `Transport: VPN reports connected but the Temporal VPC endpoint ${host}:${port} is unreachable ` +\n `(tailnet-blind presence check) \u2014 falling back to SSM tunnels (2XXXX port range; VPN-only features ` +\n `like in-cluster kubectl are unavailable). Pin with --transport=ssm to skip this probe.`,\n );\n } else if (decision.reason === \"unreachable-no-plugin\") {\n logWarn(\n `Transport: Temporal VPC endpoint ${host}:${port} is unreachable and the SSM session-manager ` +\n `plugin is missing \u2014 VPN-direct will likely fail. Fix VPN routing or install the plugin ` +\n `(see: mesh dev doctor).`,\n );\n }\n return decision.transport;\n}\n\n/**\n * Derive the Headscale login-server URL from a login context's issuer\n * (`identity.<env>.<domain>` \u2192 `vpn.<env>.<domain>`). Used for the Tailscale\n * transport's OIDC join. Returns null if the context has no cached login config.\n */\nfunction deriveLoginServer(context: string): string | null {\n const cfg = getContextConfig(context);\n if (!cfg?.issuer) return null;\n try {\n const u = new URL(cfg.issuer);\n const parts = u.hostname.split(\".\");\n parts[0] = \"vpn\";\n return `https://${parts.join(\".\")}`;\n } catch {\n return null;\n }\n}\n\nexport function preferredSsmLocalPort(remotePort: number): number {\n const preferred = SSM_TUNNEL_BASE_PORT + remotePort;\n if (preferred > 65535) {\n throw new Error(\n `Cannot allocate SSM tunnel port for remote port ${remotePort}: preferred local port ${preferred} exceeds 65535`,\n );\n }\n return preferred;\n}\n\n/**\n * Reserve a deterministic local-port candidate for an SSM tunnel.\n *\n * This prevents two tunnels with the same remote port (common for HTTPS\n * endpoints) from being assigned the same localhost port before the AWS CLI\n * has a chance to bind it.\n */\nexport function reserveSsmLocalPortCandidate(\n remotePort: number,\n reservedPorts: Set<number>,\n startAt = preferredSsmLocalPort(remotePort),\n): number {\n for (let port = startAt; port <= 65535; port += 1) {\n if (!reservedPorts.has(port)) {\n reservedPorts.add(port);\n return port;\n }\n }\n throw new Error(\n `Cannot allocate SSM tunnel port for remote port ${remotePort}: no free candidate ports remain`,\n );\n}\n\nasync function allocateSsmLocalPort(\n remotePort: number,\n reservedPorts: Set<number>,\n): Promise<number> {\n let nextCandidate = preferredSsmLocalPort(remotePort);\n\n while (nextCandidate <= 65535) {\n const candidate = reserveSsmLocalPortCandidate(remotePort, reservedPorts, nextCandidate);\n if (await isPortFree(candidate)) {\n return candidate;\n }\n nextCandidate = candidate + 1;\n }\n\n throw new Error(\n `Cannot allocate SSM tunnel port for remote port ${remotePort}: no local ports are available`,\n );\n}\n\n/**\n * Check if the session-manager-plugin is installed (required for SSM tunnels).\n */\nfunction hasSessionManagerPlugin(): boolean {\n try {\n execFileSync('which', ['session-manager-plugin'], { stdio: 'ignore' });\n return true;\n } catch {\n return false;\n }\n}\n\ninterface PlannedSsmTunnel {\n tunnelName: string;\n endpoint: DevTunnel;\n localPort: number;\n}\n\n/**\n * Start SSM port-forwarding tunnels as tmux windows within the dev session.\n *\n * For each tunnel defined in devOutput (e.g., temporal, rds), this:\n * 1. Reads the bastion instance ID and service endpoints from SSM\n * 2. Spawns `aws ssm start-session` in a dedicated tmux window\n * 3. Rewrites devOutput.tunnels to point at localhost:2XXXX\n *\n * Returns the rewritten devOutput with tunnels pointing to localhost.\n */\nasync function startSsmTunnels(sessionName: string, devOutput: DevOutput): Promise<DevOutput> {\n const platformEnv = devOutput.platform?.env ?? 'dev';\n // The bastion is a HUB platform resource (/mesh-platform/<hub>/<env>/platform-bastion),\n // not an app-tenant resource \u2014 devOutput.platform.tenant is the APP tenant (e.g. acme),\n // which for shared tenants has no bastion. Resolve under the platform/hub name; default\n // to \"mesh\" (matching how resolveTemporalAuth reads platform-level params).\n const bastionTenant = devOutput.platform?.name ?? 'mesh';\n\n // Read bastion info from SSM\n const bastion = await getPlatformBastionInfo(bastionTenant, platformEnv).catch(() => null);\n if (!bastion) {\n logError('Could not read platform bastion info from SSM.');\n logInfo('Make sure PlatformBastion is deployed in your platform stack.');\n logInfo(\"If you don't have a bastion, connect via VPN instead: mesh vpn connect\");\n throw new Error('SSM tunnel fallback unavailable: no bastion found');\n }\n\n const rewritten = JSON.parse(JSON.stringify(devOutput)) as DevOutput;\n const tunnelCount = Object.keys(devOutput.tunnels).length;\n\n if (tunnelCount === 0) {\n logInfo('No tunnels defined \u2014 SSM fallback not needed.');\n return rewritten;\n }\n\n logInfo(`Starting ${tunnelCount} SSM tunnel(s) via bastion ${bastion.instanceId}...`);\n\n // Map devOutput tunnel names to bastion service keys\n // devOutput uses names like \"temporal\", \"rds\"; bastion uses \"temporal-frontend\", \"rds\"\n const tunnelToBastionKey: Record<string, string> = {\n temporal: 'temporal-frontend',\n 'temporal-ui': 'temporal-ui',\n rds: 'rds',\n db: 'rds',\n database: 'rds',\n };\n\n // Observability tunnels are non-fatal under SSM fallback \u2014 skip with a warning\n // rather than aborting the dev loop when the bastion doesn't expose them.\n const OPTIONAL_TUNNEL_NAMES = new Set([\n 'loki',\n 'prometheus',\n 'tempo',\n 'pushgateway',\n 'grafana',\n ]);\n\n const missingEndpoints: string[] = [];\n const skippedOptional: string[] = [];\n const reservedPorts = new Set<number>();\n const plannedTunnels: PlannedSsmTunnel[] = [];\n\n for (const [tunnelName] of Object.entries(devOutput.tunnels)) {\n const bastionKey = tunnelToBastionKey[tunnelName] ?? tunnelName;\n const endpoint = bastion.services[bastionKey];\n\n if (!endpoint) {\n // Observability tunnels degrade gracefully under SSM fallback: without\n // them the consuming app's monitoring views (logs/metrics/traces) won't\n // work, but they must not block the core dev loop. Only critical tunnels\n // (temporal, rds, ...) are fatal when the bastion can't reach them.\n if (OPTIONAL_TUNNEL_NAMES.has(tunnelName)) {\n skippedOptional.push(`${tunnelName} (bastion key: ${bastionKey})`);\n } else {\n missingEndpoints.push(`${tunnelName} (bastion key: ${bastionKey})`);\n }\n continue;\n }\n\n plannedTunnels.push({\n tunnelName,\n endpoint,\n localPort: await allocateSsmLocalPort(endpoint.port, reservedPorts),\n });\n }\n\n if (skippedOptional.length > 0) {\n logWarn(\n 'Skipping optional observability tunnel(s) not exposed by the bastion (SSM fallback):',\n );\n for (const skipped of skippedOptional) {\n logInfo(` Skipped: ${skipped}`);\n }\n logInfo(\n ' Monitoring views (logs/metrics/traces) stay unavailable until VPN is connected.',\n );\n }\n\n if (missingEndpoints.length > 0) {\n logError('SSM tunnel fallback cannot cover every required dev tunnel.');\n for (const missing of missingEndpoints) {\n logInfo(` Missing: ${missing}`);\n }\n logInfo(\n ` Available bastion services: ${Object.keys(bastion.services).join(', ') || '(none)'}`,\n );\n logInfo(\n 'Connect VPN instead or deploy/update PlatformBastion with the missing service endpoints.',\n );\n throw new Error('SSM tunnel fallback unavailable: missing bastion endpoints');\n }\n\n for (const { tunnelName, endpoint, localPort } of plannedTunnels) {\n // Create a tmux window for this tunnel\n const windowName = `tunnel-${tunnelName}`;\n const ssmCmd = [\n 'aws',\n 'ssm',\n 'start-session',\n '--target',\n bastion.instanceId,\n '--document-name',\n 'AWS-StartPortForwardingSessionToRemoteHost',\n '--parameters',\n `'${JSON.stringify({\n host: [endpoint.host],\n portNumber: [String(endpoint.port)],\n localPortNumber: [String(localPort)],\n })}'`,\n ].join(' ');\n\n execFileSync('tmux', ['new-window', '-t', sessionName, '-n', windowName]);\n\n // Keep window alive so tunnel errors are visible\n execFileSync(\n 'tmux',\n ['set-option', '-t', `${sessionName}:${windowName}`, 'remain-on-exit', 'on'],\n { stdio: 'ignore' },\n );\n\n execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${windowName}`, ssmCmd, 'Enter']);\n\n // Rewrite the tunnel to point at localhost\n rewritten.tunnels[tunnelName] = {\n host: 'localhost',\n port: localPort,\n };\n\n logSuccess(\n ` ${tunnelName}: localhost:${localPort} \u2192 ${endpoint.host}:${endpoint.port} (via SSM)`,\n );\n }\n\n return rewritten;\n}\n\n/**\n * Update the DevBox host's Caddy reverse proxy config.\n *\n * Generates a Caddyfile so services are accessible at\n * http://{service}.{stack}.{app}.{tenant}-{env}.mesh.local\n * from the developer's Mac (via Tailscale + dnsmasq).\n */\nasync function updateDevboxProxy(devOutput: DevOutput, appRoot: string): Promise<void> {\n const appName = devOutput.app ?? path.basename(appRoot);\n const tenant = devOutput.platform?.tenant ?? 'mesh';\n const env = devOutput.platform?.env ?? 'dev';\n const stack = devOutput.stack ?? 'dev';\n const baseDomain = `${tenant}-${env}.mesh.local`;\n\n // Build hostname \u2192 port map\n const services: Record<string, number> = {};\n for (const [name, service] of Object.entries(devOutput.services)) {\n if (service.port) {\n services[`${name}.${stack}.${appName}.${baseDomain}`] = service.port;\n }\n }\n for (const [name, tunnel] of Object.entries(devOutput.tunnels)) {\n if (tunnel.port) {\n services[`${name}.${stack}.${appName}.${baseDomain}`] = tunnel.port;\n }\n }\n\n if (Object.keys(services).length === 0) return;\n\n // Generate Caddyfile\n const lines = [\n '# Auto-generated by mesh dev \u2014 do not edit manually.',\n '',\n ':8080 {',\n ' respond /health \"OK\" 200',\n '}',\n '',\n ];\n for (const [hostname, port] of Object.entries(services)) {\n lines.push(`http://${hostname} {`);\n lines.push(` reverse_proxy localhost:${port}`);\n lines.push('}');\n lines.push('');\n }\n\n const caddyfile = lines.join('\\n');\n\n // Write to host's /etc/caddy via a throwaway container with a host volume mount.\n // The Docker socket is the HOST's, so -v /etc/caddy mounts the HOST path.\n execFileSync(\n 'docker',\n [\n 'run',\n '--rm',\n '-v',\n '/etc/caddy:/etc/caddy',\n 'busybox',\n 'sh',\n '-c',\n `cat > /etc/caddy/Caddyfile << 'CADDYEOF'\\n${caddyfile}\\nCADDYEOF`,\n ],\n { stdio: 'pipe' },\n );\n\n // Reload Caddy on the host via nsenter into PID 1's mount namespace\n execFileSync(\n 'docker',\n [\n 'run',\n '--rm',\n '--pid=host',\n '--privileged',\n 'busybox',\n 'nsenter',\n '-t',\n '1',\n '-m',\n '--',\n 'caddy',\n 'reload',\n '--config',\n '/etc/caddy/Caddyfile',\n '--adapter',\n 'caddyfile',\n ],\n { stdio: 'pipe' },\n );\n\n logSuccess('DevBox proxy updated:');\n for (const [hostname, port] of Object.entries(services)) {\n logInfo(` http://${hostname} \u2192 localhost:${port}`);\n }\n}\n\n// ============================================================================\n// Subdomain routing (Headscale MagicDNS + Caddy)\n// ============================================================================\n\ninterface SubdomainContext {\n tsHostname: string;\n tsIp: string;\n baseDomain: string; // e.g., \"hub.dev.devcontainer-test.vpn.internal\"\n dnsConfig: HeadscaleDnsConfig;\n}\n\nasync function setupSubdomainRouting(\n devOutput: DevOutput,\n appRoot: string,\n): Promise<SubdomainContext | null> {\n // 1. Get Tailscale info\n const tsInfo = await getTailscaleInfo();\n if (!tsInfo) return null;\n\n const appName = devOutput.app ?? path.basename(appRoot);\n const stack = devOutput.stack ?? 'dev';\n const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;\n\n // 2. Derive Headscale DNS config from platform context (same convention as `mesh vpn`)\n const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };\n const dnsConfig = headscaleDnsConfig(platform.name ?? platform.tenant, platform.env);\n\n // 3. Build DNS records for all services + tunnels\n const records: DnsRecord[] = [];\n for (const [name, service] of Object.entries(devOutput.services)) {\n if (service.port) {\n records.push({ name: `${name}.${baseDomain}`, type: 'A', value: tsInfo.ip });\n }\n }\n for (const [name] of Object.entries(devOutput.tunnels)) {\n records.push({ name: `${name}.${baseDomain}`, type: 'A', value: tsInfo.ip });\n }\n\n // 4. Remove stale DNS records (best-effort)\n try {\n unregisterDnsRecords(dnsConfig, baseDomain);\n } catch {\n /* ignore */\n }\n\n // 5. Generate Caddyfile\n const caddyDir = '/tmp/mesh-dev-caddy';\n const caddyfile = path.join(caddyDir, 'Caddyfile');\n if (!fs.existsSync(caddyDir)) fs.mkdirSync(caddyDir, { recursive: true });\n\n // Build Caddy config \u2014 bind directly to the Tailscale IP on port 80.\n // In kernel tun mode, tailscale0 is a real interface so Caddy can listen\n // on it without any tailscale serve layer in between.\n const caddyLines: string[] = [`# mesh-dev: ${appName}/${stack} (${tsInfo.hostname})`, ''];\n for (const [name, service] of Object.entries(devOutput.services)) {\n if (service.port) {\n caddyLines.push(`http://${name}.${baseDomain} {`);\n caddyLines.push(` bind ${tsInfo.ip}`);\n caddyLines.push(` reverse_proxy localhost:${service.port}`);\n caddyLines.push('}');\n caddyLines.push('');\n }\n }\n for (const [name, tunnel] of Object.entries(devOutput.tunnels)) {\n caddyLines.push(`http://${name}.${baseDomain} {`);\n caddyLines.push(` bind ${tsInfo.ip}`);\n caddyLines.push(` reverse_proxy ${tunnel.host}:${tunnel.port}`);\n caddyLines.push('}');\n caddyLines.push('');\n }\n\n fs.writeFileSync(caddyfile, caddyLines.join('\\n'));\n\n // 6. Start or reload Caddy\n let caddyRunning = false;\n try {\n execFileSync('pgrep', ['-f', 'caddy run.*mesh-dev-caddy'], { stdio: 'pipe' });\n caddyRunning = true;\n } catch {\n /* not running */\n }\n\n if (caddyRunning) {\n try {\n execFileSync('caddy', ['reload', '--config', caddyfile, '--adapter', 'caddyfile'], {\n stdio: 'pipe',\n });\n } catch {\n logWarn('Caddy reload failed \u2014 check Caddyfile syntax');\n return null;\n }\n } else {\n try {\n execFileSync('which', ['caddy'], { stdio: 'pipe' });\n } catch {\n logWarn(\n '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',\n );\n return null;\n }\n const caddy = spawn('caddy', ['run', '--config', caddyfile, '--adapter', 'caddyfile'], {\n stdio: 'ignore',\n detached: true,\n cwd: caddyDir,\n });\n caddy.unref();\n }\n\n // 7. Register DNS records (after Caddy is confirmed running)\n try {\n const existing = readDnsRecords(dnsConfig);\n const allRecords = existing.filter((r) => !r.name.endsWith(baseDomain));\n allRecords.push(...records);\n registerDnsRecords(dnsConfig, allRecords);\n } catch {\n logWarn(\n 'DNS registration failed \u2014 Headscale pod may be unreachable. Dev routing may not work.',\n );\n return null;\n }\n\n return { tsHostname: tsInfo.hostname, tsIp: tsInfo.ip, baseDomain, dnsConfig };\n}\n\n// ============================================================================\n// Core actions\n// ============================================================================\n\n/**\n * Start all services in tmux windows.\n */\nasync function startServices(\n sessionName: string,\n appRoot: string,\n devOutput: DevOutput,\n headless: boolean,\n awsEnv: Record<string, string>,\n tunnelPlan: TunnelPlan = { transport: 'vpn-direct' },\n worktreeRoot?: string,\n taskQueueSuffix = '',\n): Promise<DevOutput> {\n if (!hasTmux()) {\n logError('tmux is required. Install with: brew install tmux');\n process.exit(1);\n }\n\n // Always kill any existing session and recreate for a clean start.\n // Tunnels (if any) are set up as part of this flow below.\n if (sessionExists(sessionName)) {\n logInfo(`Killing existing session: ${sessionName}`);\n killSession(sessionName);\n }\n\n const serviceNames = Object.keys(devOutput.services);\n if (serviceNames.length === 0) {\n logWarn('No services defined in dev output.');\n return devOutput;\n }\n\n // Create tmux session with a status window (always fresh).\n logInfo(`Creating tmux session: ${sessionName}`);\n execFileSync('tmux', ['new-session', '-d', '-s', sessionName, '-n', 'status', '-c', appRoot]);\n\n // Set AWS credentials on the session BEFORE starting anything that needs them\n // (SSM tunnels, services). tmux windows inherit the session environment.\n setTmuxEnv(sessionName, awsEnv);\n\n // Bring up VPC tunnels (rewriting devOutput.tunnels to localhost:PORT) via the\n // chosen backing. Tailscale (shared per-tenant userspace daemon): an explicit\n // `--transport tailscale` now hard-fails on any bring-up error (see below); only\n // an auto-resolved tailscale choice falls back to SSM. vpn-direct needs no tunnels.\n const hasTunnels = Object.keys(devOutput.tunnels).length > 0;\n let effectiveTransport = tunnelPlan.transport;\n if (hasTunnels && effectiveTransport === 'tailscale' && tunnelPlan.tailscale) {\n try {\n const rewritten = await startTailscaleTunnels(devOutput, tunnelPlan.tailscale);\n // Verify the forwarders actually ROUTE, not just bind (MESH-2268). Transport\n // selection picks tailscale on PRESENCE (a tailscaled binary exists), so an\n // auto-selected daemon can come up joined to a tailnet that doesn't route the\n // mesh VPC, or with a dead SOCKS upstream (MESH-2169) \u2014 the forwarder binds\n // its port either way and the bare-accept readiness gate below (waitForPort)\n // would pass it, letting services start and then die on a gRPC deadline.\n // Throw on a dead critical tunnel so the catch below applies the same\n // explicit-hard-fail-vs-auto-SSM-fallback policy as a bring-up failure \u2014 and\n // only commit the rewrite (reassign devOutput) once it holds, so an SSM\n // fallback runs startSsmTunnels on the ORIGINAL, un-rewritten tunnels.\n const dead = await firstUnroutableTunnel(rewritten.tunnels);\n if (dead) {\n throw new Error(\n `forwarder for '${dead}' bound but does not route the VPC ` +\n `(dead SOCKS upstream or non-routing tailnet)`,\n );\n }\n devOutput = rewritten;\n // Announce ONLY after the join + forwarders actually came up \u2014 the previous\n // pre-flight \"Using userspace-Tailscale tunnels\" printed before this even ran,\n // so a fallback still read as success (MESH-2098).\n logInfo('Connected: userspace-Tailscale tunnels for VPC resources (shared per tenant).');\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n if (tunnelPlan.explicit) {\n // The user explicitly asked for --transport=tailscale. Don't silently\n // override that with SSM \u2014 surface the real reason (often a pending VPN\n // node registration whose URL was printed above) and let them act.\n logError(`Tailscale transport failed: ${reason}`);\n logInfo(\n 'If a VPN registration URL was shown above, open it to authorize this machine, then re-run.',\n );\n logInfo('Or switch backing explicitly: mesh dev --transport ssm');\n // We already killed the prior session and created a fresh (empty) one\n // above; tear it down so an explicit-tailscale failure leaves no orphaned\n // half-session behind.\n killSession(sessionName);\n process.exit(1);\n }\n logWarn(`Tailscale transport unavailable (${reason}) \u2014 falling back to SSM tunnels.`);\n effectiveTransport = 'ssm';\n }\n }\n if (hasTunnels && effectiveTransport === 'ssm') {\n devOutput = await startSsmTunnels(sessionName, devOutput);\n }\n if (hasTunnels && effectiveTransport !== 'vpn-direct') {\n\n // Gate service start on tunnel readiness: poll each tunnel's local port\n // until it accepts TCP. Without this, Temporal-connected services (api,\n // worker, vcs) raced the SSM tunnel and died connection-refused at launch.\n for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {\n if (tunnel.host !== 'localhost') continue; // not SSM-rewritten\n logInfo(`Waiting for tunnel ${tName} (localhost:${tunnel.port})...`);\n const ready = await waitForPort('localhost', tunnel.port, TUNNEL_READY_TIMEOUT_MS);\n if (!ready) {\n logError(\n `Tunnel '${tName}' did not become ready on localhost:${tunnel.port} within ${TUNNEL_READY_TIMEOUT_MS / 1000}s.`,\n );\n logInfo(` Check the tunnel window for errors: tmux attach -t ${sessionName} (window tunnel-${tName})`);\n logInfo(' Common causes: expired AWS credentials, bastion stopped, session-manager-plugin errors.');\n throw new Error(\n `SSM tunnel '${tName}' not ready on localhost:${tunnel.port} after ${TUNNEL_READY_TIMEOUT_MS / 1000}s`,\n );\n }\n logSuccess(` Tunnel ready: ${tName} (localhost:${tunnel.port})`);\n }\n }\n\n const tunnelNames = Object.keys(devOutput.tunnels);\n\n // Set up subdomain routing if Tailscale is available\n const subdomainCtx = await setupSubdomainRouting(devOutput, appRoot);\n\n // Log VPC connections\n if (tunnelNames.length > 0) {\n const isSSM = Object.values(devOutput.tunnels).some(\n (t) => t.host === 'localhost' && t.port >= SSM_TUNNEL_BASE_PORT,\n );\n logSuccess(isSSM ? 'SSM tunnel connections:' : 'VPN direct connections:');\n for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {\n if (subdomainCtx) {\n logInfo(` ${tName} \\u2192 http://${tName}.${subdomainCtx.baseDomain}`);\n } else {\n logInfo(` ${tName} \\u2192 ${tunnel.host}:${tunnel.port}`);\n }\n }\n }\n\n // Resolve secrets from Secrets Manager\n let secretEnvVars: Record<string, string> = {};\n if (devOutput.secrets && Object.keys(devOutput.secrets).length > 0) {\n secretEnvVars = await resolveSecrets(devOutput.secrets);\n }\n\n // Resolve Temporal auth credentials from SSM when a temporal endpoint is configured.\n const tenant = devOutput.platform?.tenant ?? 'mesh';\n const platformEnv = devOutput.platform?.env ?? 'dev';\n\n // Write a session-scoped kubeconfig (aws eks get-token) so the kubectl calls\n // below (encoding-key read, temporal-pod env fallback) don't depend on an\n // ambient ~/.kube/config. Best-effort: continue without it if unresolved.\n // The local platform (`mesh start`) has no cluster \u2014 skip entirely.\n const isLocalPlatform = tenant === 'local';\n if (!isLocalPlatform) {\n // Hub platform name \u2014 the EKS export lives under it, not under a hardcoded\n // \"mesh\" (MESH-2515); same axis as the bastion resolution.\n const platformName = resolveHubPlatformName(devOutput.platform);\n const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {\n onError: ({ parameter, error }) =>\n logWarn(\n `EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`,\n ),\n });\n if (kubeconfigPath) {\n process.env.KUBECONFIG = kubeconfigPath;\n setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });\n logSuccess(`Kubeconfig resolved from SSM (hub ${platformName}/${platformEnv}) \u2192 ${kubeconfigPath}`);\n } else {\n logWarn('Could not resolve EKS kubeconfig from SSM; kubectl calls will use ambient config (if any).');\n }\n }\n\n let temporalAuthVars: Record<string, string> = {};\n if (devOutput.tunnels['temporal']) {\n // Deliberately NOT the `?? 'mesh'` fallback the EKS path above uses: the\n // Temporal-auth fallback is `?? tenant` (dedicated tenants may run their\n // own Temporal). Whether that difference is correct is MESH-2551.\n temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);\n\n // Resolve the encoding key from K8s so local dev can decrypt\n // payloads created by deployed workers.\n const appName = devOutput.app ?? '';\n if (appName) {\n const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);\n if (encodingKey) {\n temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;\n }\n }\n\n // Set TEMPORAL_ADDRESS to the VPC endpoint for direct VPN access (or the\n // pinned loopback address under SSM fallback \u2014 see tunnelClientAddress).\n const temporalTunnel = devOutput.tunnels['temporal'];\n temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);\n }\n\n // Set shared env vars (secrets + temporal auth) on the tmux session.\n // AWS creds were already set earlier (before SSM tunnels started).\n setTmuxEnv(sessionName, secretEnvVars);\n setTmuxEnv(sessionName, temporalAuthVars);\n\n // Inject dev user identity from mesh login credentials so services can\n // emulate the oauth2-proxy auth headers locally.\n //\n // The credentials are stored under the platform context (e.g. 'mesh.dev'),\n // not the tenant name. Use 'mesh' as the platform name since all tenants\n // share the same Zitadel instance.\n //\n // Services (e.g., Hub UI) use these env vars as a fallback when the real\n // oauth2-proxy `X-Forwarded-*` request headers are absent:\n // DEV_USER_ID - subject from idToken, matches conversation ownership\n // DEV_USER_EMAIL - user email from idToken\n // DEV_USER_ID_TOKEN - full idToken JWT (profile claims, authorization hint)\n // DEV_USER_ACCESS_TOKEN - full accessToken JWT (has project:roles claim)\n // Local platform creds live under the `local` context (mesh login local).\n const credContext = devOutput.platform\n ? isLocalPlatform\n ? 'local'\n : loginContextFor(devOutput.platform)\n : null;\n const devUserVars = resolveDevUserVars(devOutput);\n if (Object.keys(devUserVars).length > 0) {\n setTmuxEnv(sessionName, devUserVars);\n logSuccess(`Dev user injected from mesh login: ${credContext}`);\n\n // Serve a continuously-fresh dev-user token so services survive past the\n // ~1h Zitadel horizon without a restart (MESH-2039). The static vars above\n // remain the fallback when this URL is absent. App-agnostic: gated on the\n // platform context, not the app \u2014 any `mesh dev` app inherits it.\n const tokenPort = await findFreePort();\n const tokenUrl = `http://127.0.0.1:${tokenPort}`;\n setTmuxEnv(sessionName, { DEV_USER_TOKEN_URL: tokenUrl });\n execFileSync('tmux', ['new-window', '-t', sessionName, '-n', 'token-server']);\n execFileSync(\n 'tmux',\n ['set-option', '-t', `${sessionName}:token-server`, 'remain-on-exit', 'on'],\n { stdio: 'ignore' },\n );\n execFileSync('tmux', [\n 'send-keys',\n '-t',\n `${sessionName}:token-server`,\n `npx mesh dev __token-server ${tokenPort} ${credContext}`,\n 'Enter',\n ]);\n // Best-effort readiness so the first requests land (non-fatal).\n for (let i = 0; i < 15 && !(await isPortListening(tokenPort)); i++) {\n await new Promise((r) => setTimeout(r, 200));\n }\n logSuccess(`Dev-user token-server: ${tokenUrl} (context ${credContext})`);\n } else if (credContext) {\n logWarn(`No mesh login credentials found for '${credContext}'.`);\n logInfo(` Run: mesh login ${credContext}`);\n logInfo(' Hub UI will show unauthenticated state without valid credentials.');\n }\n\n const monorepoRoot = findMonorepoRoot();\n\n for (const [name, service] of Object.entries(devOutput.services)) {\n const cmd = service.command.join(' ');\n\n // Skip services with no dev command (e.g., deployed-only like ApiDocs)\n if (!cmd) {\n logInfo(`Skipped: ${name} (no dev command, deployed to K8s)`);\n continue;\n }\n\n const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, monorepoRoot));\n\n // Wrong-worktree guard (MESH-2013): if `rebaseServiceSrc` couldn't re-root the\n // deploy-time absolute path onto THIS worktree, `serviceDir` can point into a\n // DIFFERENT worktree \u2014 silently running stale code. Refuse rather than launch it.\n if (worktreeRoot && fs.existsSync(serviceDir)) {\n const wtRootWithSep = worktreeRoot.endsWith(path.sep) ? worktreeRoot : worktreeRoot + path.sep;\n const outside = serviceDir !== worktreeRoot && !serviceDir.startsWith(wtRootWithSep);\n // A dev-LINKED package is outside the worktree by construction, and is what\n // this app runs either way \u2014 see `isLinkedDependencyDir`. Only an\n // unexplained outside path is the wrong-worktree failure.\n if (outside && isLinkedDependencyDir(serviceDir, appRoot)) {\n logInfo(`${name}: source is a dev-linked package (${serviceDir})`);\n } else if (outside) {\n logError(\n `Refusing to launch '${name}': its source resolved to ${serviceDir}, ` +\n `outside this worktree (${worktreeRoot}), and it is not a package this ` +\n `app resolves there. The stack was likely deployed from another ` +\n `worktree. Re-run \\`mesh deploy up\\` here, or pass --app.`,\n );\n process.exit(1);\n }\n }\n\n // Skip services whose source directory doesn't exist in this worktree.\n // This happens when the Pulumi stack was deployed from a different branch\n // that added a service not yet present here (e.g., hub-worker added on\n // feature/hub-credential-management but absent from feature/app-detail-redesign).\n if (!fs.existsSync(serviceDir)) {\n logWarn(\n `Skipped: ${name} (source directory not found: ${serviceDir}` +\n `${serviceDir !== service.src ? ` \u2014 rebased from ${service.src}` : ''})`,\n );\n continue;\n }\n // Per-service env vars are written to a 0600 env file and `source`d by\n // the launch command, scoping them to the process (no cross-service\n // leaks through the tmux session environment) while keeping the\n // send-keys string short \u2014 inlining the full env used to intermittently\n // exceed what tmux send-keys delivers intact, silently truncating the\n // command so the service never started.\n //\n // Overlay the RESOLVED Temporal vars (real encoding key + tunnel address +\n // M2M auth, fetched from K8s/SSM) on top of the dev-output env. The dev\n // output carries placeholder values from the Agent's Temporal link (e.g.\n // a `dev-encoding-key-*` placeholder), and the per-service env would\n // otherwise shadow the session-level resolved values \u2014 desyncing services\n // that DO carry the placeholder (worker) from those that don't (api),\n // which breaks payload encryption (mismatched encoding keys \u2192 decode null).\n const serviceVars = {\n ...getServiceEnvVars(service, devOutput.tunnels),\n ...temporalAuthVars,\n // Overlay the resolved dev-user identity for the same reason as the\n // Temporal vars: the dev output may carry a placeholder (e.g. hub ui's\n // DEV_USER_ACCESS_TOKEN=\"dev-local\"), and since the sourced env file\n // wins over the tmux session env, the placeholder would clobber the\n // real token injected above \u2014 silently 401ing tenant-scoped Hub views.\n ...devUserVars,\n // Per-worktree Temporal task-queue isolation (MESH-2013 Slice 2): agent\n // workers/apps append this to their task-queue names so concurrent\n // worktrees don't steal each other's workflow tasks. Empty on the primary\n // checkout \u2192 omitted \u2192 unchanged historical behavior.\n ...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),\n // AWS, for the same reason as the Temporal and dev-user overlays above: the\n // sourced env file WINS over the tmux session env, so a service that reads this\n // file gets whatever the file says and nothing else.\n //\n // It matters beyond the service's own process. A module dev service can hand\n // credentials to something it starts \u2014 the workspaces manager gives each\n // workspace's sidecar an AWS profile so Claude Code can reach Bedrock \u2014 and with\n // no AWS_* here it silently has none to give. `buildChildAwsEnv` has already\n // chosen the SELF-REFRESHING shape (a profile with `credential_process`, not a\n // frozen STS triple), which is exactly what a long-lived child needs.\n ...awsEnv,\n };\n const envFilePath = getServiceEnvFilePath(sessionName, name);\n writeEnvFile(envFilePath, serviceVars);\n\n // Local mode ships the service's stdout to the platform's log pipeline\n // via OTLP (assets/log-shipper.mjs): the OTEL_* vars in the env file\n // carry the hosted k8s_* attribution, so the Hub's logs view covers\n // local runs with zero file plumbing.\n const launchCmd = buildLaunchCommand(\n envFilePath,\n serviceDir,\n cmd,\n service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined,\n );\n\n execFileSync('tmux', ['new-window', '-t', sessionName, '-n', name, '-c', serviceDir]);\n\n // Keep window alive on exit so errors are visible\n execFileSync('tmux', ['set-option', '-t', `${sessionName}:${name}`, 'remain-on-exit', 'on'], {\n stdio: 'ignore',\n });\n\n execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${name}`, launchCmd, 'Enter']);\n\n // Log the ACTUAL launch dir (`serviceDir`, after the worktree rebase), not the\n // raw deploy-time `service.src` \u2014 printing the pre-rebase path made a correctly\n // re-rooted service look like it was running from a different (stale) worktree.\n logSuccess(`Started: ${name} (${serviceDir}, port ${service.port})`);\n }\n\n // Generate reverse proxy config for DevBox hostname-based routing.\n if (process.env.DEVCONTAINER === '1') {\n try {\n await updateDevboxProxy(devOutput, appRoot);\n } catch (e) {\n logWarn(`Could not update devbox proxy: ${(e as Error).message}`);\n }\n }\n\n // Run live status updates in the status window\n const statusCmd = `watch -n2 -t npx mesh dev --status --session '${sessionName}'`;\n execFileSync('tmux', ['send-keys', '-t', `${sessionName}:status`, statusCmd, 'Enter']);\n\n console.log('');\n logSuccess(`Dev session started: ${sessionName}`);\n console.log('');\n\n if (tunnelNames.length > 0) {\n console.log('Connections (VPN direct):');\n for (const tName of tunnelNames) {\n const t = devOutput.tunnels[tName]!;\n if (subdomainCtx) {\n console.log(` ${tName.padEnd(20)} http://${tName}.${subdomainCtx.baseDomain}`);\n } else {\n console.log(` ${tName.padEnd(20)} ${t.host}:${t.port}`);\n }\n }\n console.log('');\n }\n\n console.log('Services:');\n for (const name of serviceNames) {\n const s = devOutput.services[name]!;\n const hasCmd = s.command && s.command.length > 0;\n if (hasCmd) {\n if (subdomainCtx && s.port) {\n console.log(` ${name.padEnd(20)} http://${name}.${subdomainCtx.baseDomain}`);\n } else {\n const addr = s.port ? `http://localhost:${s.port}` : '(no port)';\n console.log(` ${name.padEnd(20)} ${addr}`);\n }\n } else {\n console.log(` ${name.padEnd(20)} (deployed)`);\n }\n }\n\n console.log('');\n\n if (!headless) {\n logInfo('Attaching to tmux session...');\n if (process.env.TMUX) {\n spawnSync('tmux', ['switch-client', '-t', sessionName], { stdio: 'inherit' });\n } else {\n spawnSync('tmux', ['attach', '-t', sessionName], { stdio: 'inherit' });\n }\n } else {\n console.log(`Attach with: tmux attach -t ${sessionName}`);\n console.log(`Stop with: mesh dev --kill`);\n console.log('');\n }\n\n // Return the (possibly SSM-tunnel-rewritten) devOutput so the caller can\n // persist it in session state for --status / restart.\n return devOutput;\n}\n\n/**\n * Show status of running services.\n */\nasync function showStatus(\n sessionName: string,\n devOutput: DevOutput,\n asJson: boolean,\n): Promise<void> {\n if (!sessionExists(sessionName)) {\n if (asJson) {\n console.log(JSON.stringify({ running: false, session: sessionName }));\n } else {\n logInfo(`No active session: ${sessionName}`);\n }\n return;\n }\n\n // Get tmux window list\n let windows: string[] = [];\n try {\n const raw = execFileSync(\n 'tmux',\n ['list-windows', '-t', sessionName, '-F', '#{window_name} #{pane_dead}'],\n { encoding: 'utf-8' },\n );\n windows = raw.trim().split('\\n');\n } catch {\n // Session might have disappeared\n }\n\n const windowStatus: Record<string, string> = {};\n for (const line of windows) {\n const [name, dead] = line.split(' ');\n if (name) windowStatus[name] = dead === '1' ? 'exited' : 'running';\n }\n\n const tunnelNames = Object.keys(devOutput.tunnels);\n // Live-probe each tunnel so a mid-session tunnel death shows as DOWN here\n // (this render runs every ~2s in the status window) \u2014 MESH-2016.\n const tunnelHealth = await probeTunnelHealth(devOutput.tunnels);\n const healthByName = new Map(tunnelHealth.map((h) => [h.name, h]));\n\n if (asJson) {\n const services: Record<string, object> = {};\n for (const [name, svc] of Object.entries(devOutput.services)) {\n services[name] = {\n status: windowStatus[name] ?? 'unknown',\n port: svc.port,\n src: svc.src,\n };\n }\n const tunnels: Record<string, object> = {};\n for (const [name, tun] of Object.entries(devOutput.tunnels)) {\n tunnels[name] = {\n host: tun.host,\n port: tun.port,\n reachable: healthByName.get(name)?.reachable ?? false,\n };\n }\n console.log(\n JSON.stringify({ running: true, session: sessionName, services, tunnels }, null, 2),\n );\n } else {\n console.log('\\u2500\\u2500 mesh dev \\u2500\\u2500');\n console.log('');\n console.log(`Session: ${sessionName}`);\n\n if (tunnelNames.length > 0) {\n console.log('');\n for (const line of formatTunnelHealth(tunnelHealth)) console.log(line);\n }\n\n console.log('');\n console.log('Services:');\n for (const [name, svc] of Object.entries(devOutput.services)) {\n const hasCmd = svc.command && svc.command.length > 0;\n if (!hasCmd) {\n console.log(` \u2601 ${name.padEnd(16)} deployed ${svc.src}`);\n continue;\n }\n const status = windowStatus[name] ?? 'unknown';\n const icon = status === 'running' ? '\u25CF' : status === 'exited' ? '\u2717' : '?';\n const addr = svc.port ? `:${svc.port}` : '';\n console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(10)} ${addr.padEnd(8)} ${svc.src}`);\n }\n console.log('');\n console.log('Commands:');\n console.log(' mesh dev restart <s> Restart a service');\n console.log(' mesh dev logs <s> Tail service logs');\n console.log(' mesh dev --kill Stop everything');\n console.log('');\n console.log('tmux: Ctrl+b n/p switch windows, d detach');\n }\n}\n\n/**\n * Restart a single service window.\n */\nasync function restartService(\n sessionName: string,\n serviceName: string,\n appRoot: string,\n devOutput: DevOutput,\n awsEnv: Record<string, string>,\n opts?: { refreshEnv?: boolean; stack?: string; sessionState?: SessionState | null },\n): Promise<void> {\n let service = devOutput.services[serviceName];\n if (!service) {\n logError(`Unknown service: ${serviceName}`);\n logInfo(`Available: ${Object.keys(devOutput.services).join(', ')}`);\n process.exit(1);\n }\n\n if (!sessionExists(sessionName)) {\n logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);\n process.exit(1);\n }\n\n // --refresh-env: regenerate this service's env file from CURRENT stack/SSM\n // outputs BEFORE killing the running process, so a failed read leaves the\n // good env file (and the running service) untouched (fail-closed).\n if (opts?.refreshEnv) {\n if (!opts.stack) {\n logError('--refresh-env: no stack resolved; keeping existing env and NOT restarting.');\n return;\n }\n // The session's allocated devOutput pins the ports/tunnels to reuse.\n const pinned = opts.sessionState?.devOutput ?? devOutput;\n let fresh: DevOutput;\n try {\n fresh = getDevOutput(appRoot, opts.stack, awsEnv);\n } catch (err) {\n logError(\n `--refresh-env: failed to read stack output for '${opts.stack}'. Keeping the existing env file for '${serviceName}' and NOT restarting.`,\n );\n logInfo(String((err as Error)?.message ?? err));\n return;\n }\n const merged = mergePinnedPorts(fresh, pinned);\n const mergedService = merged.services[serviceName];\n if (!mergedService) {\n logError(\n `--refresh-env: service '${serviceName}' is absent from the fresh stack output. Keeping the existing env file and NOT restarting.`,\n );\n return;\n }\n const temporalAuthVars = await resolveTemporalAuthVars(sessionName, merged);\n // Recompute the per-worktree suffix (deterministic from the worktree path,\n // so it matches what launch wrote) \u2014 a restarted service must keep polling\n // this worktree's isolated task queue (MESH-2013 Slice 2).\n const { taskQueueSuffix } = resolveWorktreeIdentity(appRoot);\n const serviceVars = {\n ...getServiceEnvVars(mergedService, merged.tunnels),\n ...temporalAuthVars,\n // Same overlay as launch: without it, --refresh-env rewrites the env\n // file from dev output and its DEV_USER_* placeholders clobber the real\n // token from `mesh login` (the \"restart silently 401s the Hub\" trap).\n ...resolveDevUserVars(merged),\n ...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),\n // Same overlay as launch \u2014 a refresh that dropped these would take a working\n // service's credentials away on restart, which is the hardest kind of change to\n // attribute afterwards.\n ...awsEnv,\n };\n writeEnvFile(getServiceEnvFilePath(sessionName, serviceName), serviceVars);\n // Relaunch from the refreshed service definition (command/src may have changed).\n service = mergedService;\n logSuccess(`Regenerated env for '${serviceName}' from current stack/SSM outputs.`);\n }\n\n const target = `${sessionName}:${serviceName}`;\n\n // Kill the current pane process and respawn\n try {\n execFileSync('tmux', ['respawn-pane', '-k', '-t', target], { stdio: 'ignore' });\n } catch {\n // Window might not exist yet\n logError(`Window '${serviceName}' not found in session.`);\n process.exit(1);\n }\n\n // Refresh shared env (AWS creds may have rotated) on the session.\n setTmuxEnv(sessionName, awsEnv);\n\n const cmd = service.command.join(' ');\n\n // Source the SAME env file written at launch \u2014 it is the session's env\n // contract for this service, guaranteeing a byte-identical environment.\n // Reconstructing env here has historically diverged from launch (e.g.\n // TEMPORAL_ENCODING_KEY drift \u2192 \"cannot decrypt payload\" / missing key \u2192\n // \"Unknown encoding: binary/encrypted\").\n const envFilePath = getServiceEnvFilePath(sessionName, serviceName);\n let restartCmd: string;\n if (fs.existsSync(envFilePath)) {\n const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, findMonorepoRoot()));\n // Keep the local log pipeline attached across restarts.\n restartCmd = buildLaunchCommand(\n envFilePath,\n serviceDir,\n cmd,\n service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined,\n );\n } else {\n logWarn(\n `No launch env file for '${serviceName}' (${envFilePath}) \u2014 session predates env-file launches.`,\n );\n logWarn('Falling back to reconstructed env; restart `mesh dev` for a faithful environment.');\n const serviceVars = getServiceEnvVars(service, devOutput.tunnels);\n restartCmd = `${envPrefix(serviceVars)}${cmd}`;\n }\n execFileSync('tmux', ['send-keys', '-t', target, restartCmd, 'Enter']);\n\n logSuccess(`Restarted: ${serviceName}`);\n}\n\n/**\n * Capture and display logs from a service window.\n */\nfunction showLogs(sessionName: string, serviceName: string, tail: number): void {\n if (!sessionExists(sessionName)) {\n logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);\n process.exit(1);\n }\n\n const target = `${sessionName}:${serviceName}`;\n\n try {\n const result = execFileSync('tmux', ['capture-pane', '-t', target, '-p', '-S', `-${tail}`], {\n encoding: 'utf-8',\n });\n process.stdout.write(result);\n } catch {\n logError(`Could not capture logs for '${serviceName}'.`);\n logInfo('Is the service name correct? Check with: mesh dev --status');\n process.exit(1);\n }\n}\n\n// ============================================================================\n// Command registration\n// ============================================================================\n\n/**\n * Assemble a {@link DoctorContext} for the current app/stack/session, resolving\n * the deployer role the same way the `restart`/launch paths do (default role +\n * Zitadel-claim admin selection). Used by `mesh dev doctor`.\n */\nfunction buildDoctorContext(\n appRoot: string,\n stack: string,\n sessionName: string,\n): DoctorContext {\n const state = loadSessionState(sessionName);\n const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');\n const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');\n const platformContext = derivePlatformContext(appRoot, stack);\n let deployerRole = defaultDeployerRole;\n if (defaultDeployerRole) {\n const meshCreds = platformContext ? readCredentials(platformContext) : null;\n const idToken =\n meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;\n deployerRole = selectRoleForCaller(idToken, {\n defaultRole: defaultDeployerRole,\n adminRole: adminDeployerRole ?? undefined,\n });\n }\n return {\n appRoot,\n stack,\n sessionName,\n deployerRole,\n platformContext,\n credMethod: null,\n sessionState: state,\n };\n}\n\nexport function registerDevCommand(program: Command): void {\n const dev = program\n .command('dev')\n .description('Start local dev environment (reads Pulumi stack outputs)')\n .option('--app <path>', 'Path to a Pulumi app (relative to monorepo root or cwd)')\n .option('--stack <stack>', 'Pulumi stack name (default: auto-detect)')\n .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())\n .option('--headless', 'Start without attaching to tmux')\n .option('--kill', 'Kill existing dev session')\n .option('--status', 'Show service status')\n .option('--json', 'Output status as JSON (with --status)')\n .option(\n '--session <name>',\n 'Override the tmux session name (auto-derived per git worktree by default \u2014 omit to keep concurrent worktrees isolated)',\n )\n .option('--dry-run', 'Print the resolved worktree/session/port plan and exit without launching')\n .option('--force', 'Relaunch even if workflow code changed since launch (may strand in-flight conversations)')\n .option('--profile <name>', 'AWS SSO profile to use (e.g., mesh-dev)')\n .addOption(\n new Option(\n '--transport <mode>',\n 'Tunnel backing (default: auto \u2014 Tailscale when available, else SSM/VPN)',\n ).choices(['auto', 'ssm', 'tailscale']),\n )\n .option(\n '--local',\n '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.',\n )\n .option(\n '--externals [names]',\n \"Also realize the app's declared external services (package.json \u2192 mesh.externals): all of them, or a comma-separated subset of name[=mode] entries. Modes: mock (emulate \u2014 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 \u2014 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.\",\n )\n .option('--mock [names]', 'Alias for --externals.')\n .option(\n '--runner <runner>',\n \"Process runner for local mode: 'tmux' (default \u2014 dev machines) or 'docker' (CI/headless: services run as a docker compose project with host networking; Linux semantics).\",\n 'tmux',\n );\n\n // Hidden: the dev-user token-server (MESH-2039). Launched into its own tmux\n // window by the dev launcher; serves a continuously-fresh dev-user token.\n dev\n .command('__token-server <port> <context>', { hidden: true })\n .action(async (port: string, context: string) => {\n const { startTokenServer } = await import('./dev-token-server.js');\n await startTokenServer(Number(port), context);\n });\n\n // Default action: start or show status\n dev.action(async (options: DevOptions) => {\n // An explicit --profile must win over stale shell-exported STS credentials.\n // The AWS SDK/CLI prefer AWS_ACCESS_KEY_ID over AWS_PROFILE, so clear any\n // static credentials before resolving the deployer role.\n if (options.profile) {\n applyAwsProfileOverride(options.profile);\n }\n\n // Fast-path: --status/--kill with explicit --session can skip app root resolution\n if (options.session && (options.status || options.kill)) {\n const sessionName = options.session;\n if (loadSessionState(sessionName)?.runner === 'docker') {\n if (options.kill) {\n dockerDevDown(sessionName);\n composeExternalsDown(loadSessionState(sessionName)?.composeExternals);\n localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);\n removeSessionState(sessionName);\n logSuccess(`Killed docker dev session: ${sessionName}`);\n } else {\n console.log(dockerDevPs(sessionName));\n }\n return;\n }\n if (options.kill) {\n composeExternalsDown(loadSessionState(sessionName)?.composeExternals);\n localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);\n // Clean up DNS records for this app\n try {\n const devOutput = loadSessionState(sessionName)?.devOutput;\n if (devOutput) {\n const tsInfo = await getTailscaleInfo();\n if (tsInfo) {\n const appName = devOutput.app ?? sessionName.replace(/-dev$/, '');\n const stack = devOutput.stack ?? 'dev';\n const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;\n const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };\n unregisterDnsRecords(headscaleDnsConfig(platform.name ?? platform.tenant, platform.env), baseDomain);\n }\n }\n } catch {\n /* best effort */\n }\n\n if (sessionExists(sessionName)) {\n killSession(sessionName);\n logSuccess(`Killed session: ${sessionName}`);\n } else {\n logInfo(`No active session: ${sessionName}`);\n }\n removeSessionState(sessionName);\n return;\n }\n // --status with --session: use state file, no Pulumi needed\n const state = loadSessionState(sessionName);\n if (state) {\n await showStatus(sessionName, state.devOutput, !!options.json);\n return;\n }\n // No state \u2014 fall through to normal flow\n }\n\n const appRoot = findAppRoot(options.app);\n const projectName = getProjectName(appRoot);\n // Port block scoped to (worktree, app): concurrent sibling-app sessions\n // must draw disjoint ports (probing races slow boots). Scoped once here so\n // local and tethered modes resolve the SAME plan \u2014 every other field stays\n // worktree-scoped, so session naming and task queues are unaffected.\n const worktree = withAppScopedPortBlock(resolveWorktreeIdentity(appRoot), appRoot);\n const sessionName = options.session ?? deriveSessionName(projectName, worktree);\n\n // --kill\n if (options.kill) {\n composeExternalsDown(loadSessionState(sessionName)?.composeExternals);\n localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);\n if (loadSessionState(sessionName)?.runner === 'docker') {\n dockerDevDown(sessionName);\n removeSessionState(sessionName);\n logSuccess(`Killed docker dev session: ${sessionName}`);\n return;\n }\n // Tailscale VPN tunnels are machine-global + shared across sessions, so\n // they persist on purpose after a kill. Capture the tenant before state\n // is removed so we can point at the explicit stop below.\n const killTsTenant = loadSessionState(sessionName)?.devOutput.platform?.name ?? 'mesh';\n // Clean up DNS records for this app\n try {\n const devOutput = loadSessionState(sessionName)?.devOutput;\n if (devOutput) {\n const tsInfo = await getTailscaleInfo();\n if (tsInfo) {\n const appName = devOutput.app ?? projectName;\n const stack = devOutput.stack ?? 'dev';\n const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;\n const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };\n unregisterDnsRecords(headscaleDnsConfig(platform.name ?? platform.tenant, platform.env), baseDomain);\n }\n }\n } catch {\n /* best effort */\n }\n\n if (sessionExists(sessionName)) {\n killSession(sessionName);\n logSuccess(`Killed session: ${sessionName}`);\n } else {\n logInfo(`No active session: ${sessionName}`);\n }\n removeSessionState(sessionName);\n if (readTailscaleState(killTsTenant)) {\n logInfo(\n `VPN tunnels persist across sessions \u2014 stop them with: mesh vpn tunnel down --tenant ${killTsTenant}`,\n );\n }\n return;\n }\n\n // \u2500\u2500 Local mode (design \u00A75.2) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Explicit --local, or auto when the app has no Pulumi program at all\n // (hasStackBacking also checks the monorepo ROOT \u2014 apps like apps/hub\n // deploy via a root program and must keep the tethered default).\n // Synthesizes DevOutput from repo conventions and wires services to the\n // `mesh start` platform: no AWS credentials, no VPN, no Pulumi state.\n const localMode = !!options.local || !hasStackBacking(appRoot);\n if (localMode) {\n // Read-only status query: report and exit BEFORE any side effect\n // (platform boot, Zitadel provisioning, registry writes) \u2014 parity\n // with the tethered path's early --status short-circuit.\n if (options.status) {\n const state = loadSessionState(sessionName);\n if (state?.runner === 'docker') {\n console.log(dockerDevPs(sessionName));\n return;\n }\n const statusOutput =\n state?.devOutput ??\n (buildLocalDevOutput(appRoot, detectLocalTenant(appRoot), { mocks: {} }) as unknown as DevOutput);\n showStatus(sessionName, statusOutput, !!options.json);\n return;\n }\n\n const dockerRunner = options.runner === 'docker';\n if (!dockerRunner && !hasTmux()) {\n logError('tmux is not installed. Fix: brew install tmux (or use --runner docker)');\n process.exit(1);\n }\n await ensureLocalPlatformRunning();\n const localTenant = detectLocalTenant(appRoot);\n logInfo(`Project: ${projectName}, Stack: local (mesh start platform)`);\n\n // --externals (alias --mock): resolve the requested subset of the\n // app's declared externals (package.json \u2192 mesh.externals). Entries\n // are name[=mode] \u2014 an explicit mode overrides the declaration's\n // default for this run (materialized onto the decl so every\n // downstream externalMode() call agrees). Mock-mode externals join\n // the same orchestration as services named mock-{name}; all\n // realizations get their credential secret seeded after ports are\n // final.\n const externalsRequest = options.externals ?? options.mock;\n let selectedMocks: Record<string, LocalMockDecl> = {};\n // The externals the developer NAMED (empty for the bare flag, which\n // selects everything declared). Failing to realize one of these is\n // fatal; failing to realize one the developer never asked for by name\n // only costs that external.\n const requestedExplicitly = new Set<string>();\n if (externalsRequest) {\n const declared = readLocalMocks(appRoot);\n let requested: string[];\n let overrides = new Map<string, ExternalMode>();\n if (externalsRequest === true) {\n requested = Object.keys(declared);\n } else {\n ({ names: requested, overrides } = parseExternalsSelection(String(externalsRequest)));\n for (const name of requested) requestedExplicitly.add(name);\n }\n const unknown = requested.filter((name) => !declared[name]);\n if (unknown.length > 0) {\n throw new MeshCliError(\n `Unknown external(s): ${unknown.join(', ')} \u2014 declared in package.json mesh.externals: ${Object.keys(declared).join(', ') || '(none)'}`,\n { remediation: { docs: 'package.json \u2192 \"mesh\": { \"externals\": { \u2026 } }' } },\n );\n }\n if (requested.length === 0) {\n logWarn('No externals declared (package.json \u2192 mesh.externals) \u2014 continuing without.');\n }\n selectedMocks = Object.fromEntries(\n requested.map((name) => [\n name,\n overrides.has(name) ? { ...declared[name]!, mode: overrides.get(name)! } : declared[name]!,\n ]),\n );\n // Validate every declaration's resolved mode up front \u2014 a bad decl\n // (or an override the decl can't satisfy) should fail here, not\n // after half the orchestration is up.\n for (const [name, decl] of Object.entries(selectedMocks)) externalMode(name, decl);\n }\n\n const rawDevOutput = buildLocalDevOutput(appRoot, localTenant, {\n mocks: selectedMocks,\n }) as unknown as DevOutput;\n\n // --dry-run (worktree-plan parity with tethered mode): print the\n // resolved session/port plan and exit before any side effect \u2014\n // no Zitadel provisioning, no registry writes, no launch.\n if (options.dryRun) {\n printDevPlan(sessionName, appRoot, worktree, await allocatePorts(rawDevOutput, worktree));\n // Non-service realizations (local/remote) don't appear in the port\n // plan \u2014 list them so the dry run shows the whole picture.\n for (const [name, decl] of Object.entries(selectedMocks)) {\n const mode = externalMode(name, decl);\n if (mode === 'local') {\n console.log(` external ${name.padEnd(18)} local \u2014 docker compose (${decl.compose}) \u2192 localhost:${decl.port}`);\n } else if (mode === 'remote') {\n console.log(` external ${name.padEnd(18)} remote \u2014 actual service credentials (no local process)`);\n }\n }\n return;\n }\n\n let signInServices: string[] = [];\n /** The app's own version \u2014 stamped on the registration like a deploy. */\n let appVersion: string | undefined;\n // Auto-provision the app tenant's auth config (approved Zitadel\n // tenant-auth pattern: org per tenant \u2192 project per app \u2192 application\n // per service + credential secret). Best-effort: the dev loop still\n // works if Zitadel isn't seeded yet.\n try {\n const tenant = localTenant;\n const app = rawDevOutput.app ?? projectName;\n logInfo(`Provisioning auth config for tenant '${tenant}', app '${app}'\u2026`);\n const services = Object.keys(rawDevOutput.services);\n // Mocks are stand-ins, not services of the app \u2014 no Zitadel\n // application for them (the registry still lists them for the Hub).\n const authServices = services.filter((name) => !name.startsWith('mock-'));\n // App-declared project roles (package.json \u2192 mesh.auth.roles): created\n // on the project and granted to the local `{app}-m2m` caller, so\n // role-scoped services (roles claim \u2192 scopes) accept its tokens.\n let authRoles: string[] = [];\n // Services carrying a BROWSER login (package.json \u2192 mesh.auth.signIn).\n // Registered after ports are allocated \u2014 the redirect URI is the port.\n signInServices = [];\n try {\n const appPkg = JSON.parse(\n fs.readFileSync(path.join(appRoot, 'package.json'), 'utf-8'),\n );\n if (Array.isArray(appPkg?.mesh?.auth?.roles)) {\n authRoles = appPkg.mesh.auth.roles.filter((r: unknown) => typeof r === 'string');\n }\n if (Array.isArray(appPkg?.mesh?.auth?.signIn)) {\n signInServices = appPkg.mesh.auth.signIn.filter((r: unknown) => typeof r === 'string');\n }\n if (typeof appPkg?.version === 'string') appVersion = appPkg.version;\n } catch {\n // no package.json / unparsable \u2014 roles stay empty\n }\n await ensureAppTenantAuth({ tenant, app, services: authServices, roles: authRoles });\n // The app's own Temporal namespace ({tenant}-{env}-{app}) \u2014 the same\n // name the cloud provisioner creates and the Hub's per-app workflow\n // view resolves.\n await ensureTemporalNamespace(localAppNamespace(tenant, app));\n } catch (err) {\n logWarn(\n `Auth auto-provisioning skipped: ${err instanceof Error ? err.message : err}` +\n ` \u2014 if the local Zitadel predates seeding, run: mesh stop --destroy && mesh start`,\n );\n }\n\n const devOutput = await allocatePorts(rawDevOutput, worktree);\n\n // Browser sign-in AFTER port allocation: the callback URL is the port a\n // service actually listens on, and a stale URI is the difference between\n // a working login and an \"invalid redirect_uri\" nobody can debug from\n // the app side.\n for (const service of signInServices) {\n const port = devOutput.services[service]?.port;\n if (!port) {\n logWarn(`mesh.auth.signIn names '${service}', which this app does not run \u2014 no sign-in app registered.`);\n continue;\n }\n try {\n await ensureSignInApp({\n tenant: localTenant,\n app: devOutput.app ?? projectName,\n service,\n baseUrl: `http://localhost:${port}`,\n });\n } catch (err) {\n logWarn(\n `Could not register the browser sign-in for '${service}' (${err instanceof Error ? err.message : err})` +\n ` \u2014 the Hub's Access \u2192 Sign-in tab will report this app has no login.`,\n );\n }\n }\n\n // Register AFTER allocatePorts: the registry must carry the ports the\n // services actually listen on, or StackPort/Hub (and anything reading\n // /mesh-platform/\u2026/stacks/local) points at dead URLs.\n try {\n await registerLocalApp({\n tenant: localTenant,\n app: devOutput.app ?? projectName,\n version: appVersion,\n services: Object.keys(devOutput.services),\n ports: Object.fromEntries(\n Object.entries(devOutput.services).map(([name, svc]) => [name, svc.port]),\n ),\n // ExternalService.link() parity: consuming services carry the\n // external names, so the Hub shows consumers + per-app uptime.\n links: Object.values(selectedMocks)\n .map((decl) => decl.external)\n .filter((n): n is string => !!n),\n });\n } catch (err) {\n logWarn(`Local registry registration skipped: ${err instanceof Error ? err.message : err}`);\n }\n\n // Wire realizations AFTER ports are final: realize each declaration\n // by mode, then seed the external's credential secret and register it\n // in the Hub \u2014 the app's resolveCredentials() path runs unchanged.\n // mock \u2192 mock-{name} service URL (Prism / mock process)\n // local \u2192 docker compose up -d --wait, endpoint from decl.port\n // remote \u2192 actual-service credentials seeded (inline, or pulled\n // from the app's tenant environment)\n const composeExternals: ComposeExternalRef[] = [];\n const externalProbeFiles: string[] = [];\n for (const [name, decl] of Object.entries(selectedMocks)) {\n const mode = externalMode(name, decl);\n try {\n let probeFile: string | undefined;\n if (mode === 'local') {\n logInfo(`Starting docker external '${name}' (${decl.compose})\u2026`);\n // undefined = adopted a realization another checkout owns \u2014\n // not recorded, so --kill leaves it running.\n const composeRef = await composeExternalUp(appRoot, sessionName, name, decl);\n if (composeRef) composeExternals.push(composeRef);\n probeFile = await seedLocalMock({\n tenant: localTenant,\n app: devOutput.app ?? projectName,\n name,\n decl,\n endpoint: { url: `http://localhost:${decl.port}`, host: 'localhost', port: decl.port! },\n });\n } else if (mode === 'remote') {\n probeFile = await seedLocalMock({\n tenant: localTenant,\n app: devOutput.app ?? projectName,\n name,\n decl,\n });\n } else {\n const mockService = devOutput.services[`mock-${name}`];\n if (!mockService) continue;\n probeFile = await seedLocalMock({\n tenant: localTenant,\n app: devOutput.app ?? projectName,\n name,\n decl,\n endpoint: {\n url: `http://localhost:${mockService.port}`,\n host: 'localhost',\n port: mockService.port,\n },\n });\n }\n if (probeFile) externalProbeFiles.push(probeFile);\n } catch (err) {\n // A remote external the developer didn't name costs only itself:\n // the bare `--externals` selects everything declared, and a\n // machine with no AWS identity (or no vendor key in the shell)\n // must still get its mock and local externals \u2014 that's the\n // zero-access local loop. Naming it keeps the failure fatal.\n if (mode === 'remote' && !requestedExplicitly.has(name) && err instanceof MeshCliError) {\n logWarn(\n `External '${name}': skipped \u2014 ${err.message}` +\n ` (re-run with \\`mesh dev --externals ${name}=remote\\` to make this fatal).`,\n );\n continue;\n }\n if (err instanceof MeshCliError) throw err; // bad decl / missing env var / failed remote pull \u2014 hard stop\n if (mode === 'local') {\n // The app's services depend on this external \u2014 starting them\n // against a dead dependency helps nobody.\n throw new MeshCliError(\n `Docker external '${name}' failed to start: ${err instanceof Error ? err.message : err}`,\n { remediation: { command: `docker compose -f ${decl.compose} up # debug it directly` } },\n );\n }\n logWarn(`External '${name}' credential seeding failed: ${err instanceof Error ? err.message : err}`);\n }\n }\n\n if (dockerRunner) {\n // C2: render the same DevOutput as a docker compose project.\n const composePath = writeDevCompose(\n sessionName,\n appRoot,\n devOutput.services as Record<string, { src: string; port: number; command: string[]; env?: Record<string, { value: string }> }>,\n );\n logInfo(`Docker runner: ${composePath}`);\n dockerDevUp(sessionName);\n registerServiceProbes(devOutput, localTenant, externalProbeFiles);\n saveSessionState(sessionName, {\n appRoot,\n stack: 'local',\n devOutput,\n startedAt: new Date().toISOString(),\n runner: 'docker',\n composeExternals,\n externalProbeFiles,\n });\n console.log('');\n logSuccess(`Dev session started (docker): ${sessionName}`);\n console.log('');\n console.log('Services:');\n for (const [name, service] of Object.entries(devOutput.services)) {\n console.log(` ${name.padEnd(20)} http://localhost:${service.port}`);\n }\n console.log('');\n console.log(`Status with: mesh dev --status --session '${sessionName}'`);\n console.log('Stop with: mesh dev --kill');\n return;\n }\n\n const finalDevOutput = await startServices(\n sessionName,\n appRoot,\n devOutput,\n !!options.headless,\n localAwsEnv(), // ministack wiring \u2014 the only \"AWS\" children need\n { transport: 'vpn-direct' }, // local mode: no tunnels\n );\n registerServiceProbes(finalDevOutput, localTenant, externalProbeFiles);\n saveSessionState(sessionName, {\n appRoot,\n stack: 'local',\n devOutput: finalDevOutput,\n startedAt: new Date().toISOString(),\n runner: 'tmux',\n composeExternals,\n externalProbeFiles,\n });\n return;\n }\n\n if (options.mock || options.externals) {\n logWarn(\n '--externals/--mock is local-mode only for now (tethered dev outputs do not carry external declarations yet \u2014 MESH-1885). Continuing without them.',\n );\n }\n\n const stack = detectStack(appRoot, resolveStackOption(options));\n logInfo(`Project: ${projectName}, Stack: ${stack}`);\n\n // Detect if the user accidentally ran `mesh dev` from the monorepo root\n // instead of an app directory. The monorepo root has a Pulumi.yaml but\n // no services/apps to run. Standalone apps (create-app scaffolds) ARE\n // their own workspace root \u2014 an api/ or worker/ package right here means\n // this is an app, not a monorepo root.\n const monorepoRoot = findMonorepoRoot();\n const looksLikeStandaloneApp = ['api', 'worker', 'web'].some((dir) =>\n fs.existsSync(path.join(appRoot, dir, 'package.json')),\n );\n if (monorepoRoot && path.resolve(appRoot) === path.resolve(monorepoRoot) && !looksLikeStandaloneApp) {\n logError(`mesh dev should be run from an app directory, not the monorepo root.\\n`);\n logInfo('Try:');\n logInfo(' cd apps/hub && mesh dev');\n logInfo('');\n logInfo('Or specify the app path:');\n logInfo(' mesh dev --app apps/hub');\n logInfo('');\n logInfo('List available apps:');\n logInfo(' mesh dev list');\n process.exit(1);\n }\n\n // \u2500\u2500 Prerequisite checks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Check all prerequisites upfront so the user gets one clear list of\n // what needs fixing, rather than failing one step at a time.\n const issues: string[] = [];\n const profileHint = options.profile ?? process.env.AWS_PROFILE ?? 'mesh-dev';\n\n // AWS role + credential resolution.\n // The stack can declare two role ARNs:\n // - `mesh:deployerRole` \u2014 the default per-tenant deployer\n // - `mesh:adminDeployerRole` \u2014 optional admin variant assumed when the\n // caller's Zitadel JWT carries an admin-tier role (e.g.\n // `mesh.platform:admin`). selectRoleForCaller picks between them.\n // tmux + credential validation/diagnosis is handled below by the shared\n // doctor preflight so `mesh dev` and `mesh dev doctor` agree.\n const platformContext = derivePlatformContext(appRoot, stack);\n const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');\n const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');\n let deployerRole: string | null = defaultDeployerRole;\n if (defaultDeployerRole) {\n const meshCreds = platformContext ? readCredentials(platformContext) : null;\n const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;\n deployerRole = selectRoleForCaller(idToken, {\n defaultRole: defaultDeployerRole,\n adminRole: adminDeployerRole ?? undefined,\n });\n if (adminDeployerRole && deployerRole === adminDeployerRole) {\n logInfo(`Caller has admin Zitadel role \u2014 assuming ${deployerRole.split('/').pop()} (admin variant)`);\n }\n }\n let awsEnv: Record<string, string>;\n let credMethod: ResolvedAwsCreds['method'] | 'ambient' = 'ambient';\n // Method fed to the doctor context: the resolved method on success, or\n // `null` on a failed resolution so the `creds` check probes for the exact\n // reason (no-session / expired / assume-denied / stale-env-override).\n let credMethodForDoctor: ResolvedAwsCreds['method'] | 'ambient' | null = 'ambient';\n if (deployerRole) {\n const resolved = await resolveAwsCredentials(deployerRole, appRoot, stack);\n if (!resolved) {\n awsEnv = {};\n credMethodForDoctor = null;\n } else {\n awsEnv = resolved.env;\n credMethod = resolved.method;\n credMethodForDoctor = resolved.method;\n }\n } else {\n awsEnv = getAwsEnvVars();\n credMethodForDoctor = 'ambient';\n }\n\n // 3. VPN or SSM fallback\n const vpnConnected = await isVpnConnected();\n // `let`: the auto-selected `vpn-direct` may be downgraded to `ssm` below by a\n // reachability probe once devOutput's Temporal endpoint is known (MESH-2016).\n let transport = resolveTransport(options.transport, {\n vpnConnected,\n hasSsmPlugin: hasSessionManagerPlugin(),\n tailscaleAvailable: tailscaleAvailable(),\n });\n if (options.transport === 'tailscale' && !tailscaleAvailable()) {\n issues.push(\n ' \\u2718 --transport=tailscale but the tailscaled binary is missing.\\n' +\n ' Fix: brew install tailscale',\n );\n }\n if (transport === 'ssm') {\n if (hasSessionManagerPlugin()) {\n if (!vpnConnected) {\n logWarn('VPN not connected \\u2014 will use SSM port-forwarding tunnels for VPC resources.');\n }\n logInfo(\n ' Tunnels use the 2XXXX port range (e.g., PostgreSQL on 25432, Temporal on 27233).',\n );\n logInfo(' Limitations: no kubectl exec into pods, no internal ingress/ALB access.');\n logInfo(' For full VPC access, connect VPN: mesh vpn connect');\n } else {\n issues.push(\n ' \\u2718 VPN is not connected and session-manager-plugin is not installed.\\n' +\n ' Option 1 (VPN): mesh vpn connect\\n' +\n ' Option 2 (SSM): brew install --cask session-manager-plugin\\n' +\n ' Option 3 (Tailscale): mesh dev --transport=tailscale',\n );\n }\n } else if (transport === 'tailscale') {\n // The daemon bring-up + join happen in startServices; announce success there,\n // not here \u2014 otherwise a fallback to SSM still reads as \"using Tailscale\"\n // (MESH-2098). A pending node registration prints its own URL during the join.\n logInfo('Bringing up userspace-Tailscale tunnels for VPC resources (shared per tenant)\u2026');\n }\n\n // Shared doctor preflight: the same checks as `mesh dev doctor`, so launch\n // and on-demand diagnosis name failures identically. Errors block launch;\n // warnings (config/worktree) print and proceed.\n const preflightCtx: DoctorContext = {\n appRoot,\n stack,\n sessionName,\n deployerRole,\n platformContext,\n credMethod: credMethodForDoctor,\n sessionState: null,\n };\n const preflight = await runChecks(preflightCtx, 'preflight', ALL_CHECKS);\n const preflightReport = renderHuman(preflight);\n if (preflightReport) console.log(preflightReport);\n const preflightStatus = aggregateStatus(preflight.map((r) => r.result));\n if (preflightStatus === 'error' || issues.length > 0) {\n logError('\\nPrerequisites not met. Fix the above before running mesh dev, then retry: mesh dev');\n for (const issue of issues) console.log(issue);\n process.exit(1);\n }\n\n // \u2500\u2500 Credentials resolved, inject into environment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Ensure AWS_REGION is set from the Pulumi stack config. Without this, AWS\n // SDK clients (SSM, Secrets Manager) would default to us-east-1.\n const awsRegion = readStackConfig(appRoot, stack, 'aws:region');\n if (awsRegion && !awsEnv.AWS_REGION) {\n awsEnv.AWS_REGION = awsRegion;\n }\n Object.assign(process.env, awsEnv);\n\n // Build the env that long-lived child services (ui, api, ai-assistant) and\n // SSM tunnels will see. When credentials came from AWS SSO, prefer passing\n // AWS_PROFILE + AWS_REGION so the AWS SDK v3 default provider chain can\n // auto-refresh from the SSO cache (~8h session) as short-lived STS tokens\n // expire. Without this, the hub API starts throwing ExpiredTokenException\n // about an hour in and tenants disappear from the UI. For non-SSO paths\n // (Zitadel JWT, CI/CD env keys) there is no profile to fall back to, so\n // pass the STS keys directly and accept that the user must restart\n // `mesh dev` when they expire.\n // For the Zitadel deployer path, hand children a self-refreshing\n // credential_process profile (context + role from the resolution above,\n // written into this session's scratch dir) instead of a frozen STS triple.\n const zitadelOpts =\n credMethod === 'zitadel' && deployerRole\n ? (() => {\n const context = derivePlatformContext(appRoot, stack);\n return context\n ? {\n context,\n roleArn: deployerRole,\n sessionScratchDir: getSessionEnvDir(sessionName),\n meshBin: resolveStableMeshBin(process.argv[1]),\n }\n : undefined;\n })()\n : undefined;\n const childAwsEnv = buildChildAwsEnv(awsEnv, credMethod, profileHint, zitadelOpts);\n\n let rawDevOutput: DevOutput;\n try {\n rawDevOutput = getDevOutput(appRoot, stack, awsEnv);\n } catch (err) {\n if (err instanceof MissingStackOutputError) {\n // Only the launch path can conclude \"nothing to run\" \u2014 print the\n // forward-chaining first-run fix and exit cleanly (MESH-2097). The\n // suggested commands carry --stack because the bare forms error right\n // after `stack init` (matching stack.ts's next-step output).\n logError(`No Pulumi stack output for '${stack}' \u2014 mesh dev has nothing to run.`);\n logInfo('If this is a new app, initialize and materialize it first:');\n logInfo(' mesh stack init # create your personal dev stack');\n logInfo(` mesh deploy up --stack ${stack} --yes # produce the app output mesh dev reads`);\n logInfo(`Then: mesh dev --stage ${stack} (the stack must export an \\`app\\` or \\`dev\\` output.)`);\n logInfo('No deployed stack yet? Run against the local platform: mesh dev --local (needs `mesh start`).');\n // Surface the underlying read error so an auth/network/JSON failure isn't\n // silently misdiagnosed as \"fresh app\" with zero forensics.\n const cause = (err.cause as Error | undefined)?.message;\n if (cause) logInfo(`(underlying stack-read error: ${cause})`);\n process.exit(1);\n }\n throw err;\n }\n\n // --status \u2014 use persisted state (has allocated ports), fall back to raw\n if (options.status) {\n const state = loadSessionState(sessionName);\n await showStatus(sessionName, state?.devOutput ?? rawDevOutput, !!options.json);\n return;\n }\n\n // Allocate dynamic ports (avoid collisions with other running apps).\n // devOutput is passed by value and may be rewritten inside startServices\n // if SSM tunnels are used (to point tunnels at localhost:2XXXX).\n const devOutput = await allocatePorts(rawDevOutput, worktree);\n\n // Reachability gate (MESH-2016): the transport chosen above used a PRESENCE\n // check (isVpnConnected \u2192 \"is any Tailscale/VPN up\"), which is tailnet-blind \u2014\n // Tailscale-for-SSH looks connected but doesn't route the mesh VPC. Now that\n // devOutput is known, probe the real Temporal VPC endpoint and downgrade an\n // auto-selected vpn-direct to SSM when it's unreachable.\n transport = await gateVpnDirectReachability(transport, devOutput);\n\n // --dry-run: show the resolved worktree/session/port plan, launch nothing.\n if (options.dryRun) {\n printDevPlan(sessionName, appRoot, worktree, devOutput);\n return;\n }\n\n // Build the tunnel plan for the chosen transport. For Tailscale, resolve the\n // shared per-tenant daemon context (tenant = hub platform name, since the\n // bastion is a hub resource); reuse the daemon's SOCKS port if it's already up.\n let tunnelPlan: TunnelPlan = { transport };\n if (transport === 'tailscale') {\n const tsTenant = devOutput.platform?.name ?? 'mesh';\n const tsEnv = devOutput.platform?.env ?? 'dev';\n const tsContext = derivePlatformContext(appRoot, stack) ?? `${tsTenant}.${tsEnv}`;\n const loginServer = deriveLoginServer(tsContext);\n if (!loginServer) {\n // Implicit selection (auto/unset) must degrade to the legacy transport,\n // not dead-end. Only an explicit --transport=tailscale may hard-error.\n const msg = `No login config for context '${tsContext}' \u2014 run: mesh login ${tsContext}`;\n if (options.transport === 'tailscale') {\n logError(msg);\n process.exit(1);\n }\n const legacy: Transport = vpnConnected\n ? 'vpn-direct'\n : hasSessionManagerPlugin()\n ? 'ssm'\n : 'vpn-direct';\n // Reachability-gate this legacy fallback too (MESH-2016): otherwise a\n // machine with tailscaled (\u2192 auto picks tailscale) but no mesh login\n // config, with any tailnet up (vpnConnected), would land on an UNPROBED\n // vpn-direct and reproduce the exact ECONNREFUSED this feature fixes.\n const gatedLegacy = await gateVpnDirectReachability(legacy, devOutput);\n logWarn(`${msg} \u2014 falling back to ${gatedLegacy} transport.`);\n tunnelPlan = { transport: gatedLegacy };\n } else {\n const prior = readTailscaleState(tsTenant);\n const daemonUp = tailscaleDaemonState(tsTenant).backendState !== 'Down';\n // Reuse the running daemon's real SOCKS port (daemon.json survives\n // `down`); never mint a fresh one the daemon isn't listening on.\n const socksPort = daemonUp\n ? (readTailscaleDaemonMeta(tsTenant)?.socksPort ?? prior?.socksPort ?? (await findFreePort()))\n : await findFreePort();\n // Zero-touch VPN join (MESH-2099): try to mint a single-use pre-auth key\n // from the platform's VpnJoinBroker so `tailscale up --authkey` joins\n // silently. Any failure leaves preAuthKey undefined \u2192 the interactive\n // browser-relay join (MESH-2098) is used instead.\n let preAuthKey: string | undefined;\n const brokerUrl = resolveVpnJoinBroker(tsContext);\n if (brokerUrl) {\n const minted = await mintPreAuthKey(tsContext, brokerUrl, { getValidToken });\n if (minted?.authKey) {\n preAuthKey = minted.authKey;\n } else {\n logInfo('zero-touch VPN join unavailable, falling back to browser registration');\n }\n }\n tunnelPlan = {\n transport,\n explicit: options.transport === 'tailscale',\n tailscale: {\n tenant: tsTenant,\n env: tsEnv,\n region: awsRegion ?? 'us-east-2',\n loginServer,\n socksPort,\n preAuthKey,\n },\n };\n }\n }\n\n // Relaunch safety (MESH-2017): a relaunch recreates the worker onto current\n // code. If the workflow source changed since this session started, warn before\n // it replays in-flight conversations against it (require --force). Checked\n // BEFORE startServices, which kills+recreates the session.\n if (workflowChangeWouldStrand(loadSessionState(sessionName), appRoot, !!options.force)) {\n process.exit(1);\n }\n\n // Start services. startServices handles tmux session creation, tunnel setup\n // (SSM or Tailscale, per tunnelPlan), and service windows. It returns the\n // final devOutput with tunnels rewritten to localhost ports.\n const finalDevOutput = await startServices(\n sessionName,\n appRoot,\n devOutput,\n !!options.headless,\n childAwsEnv,\n tunnelPlan,\n worktree.worktreeRoot,\n worktree.taskQueueSuffix,\n );\n\n // Persist session state so --status and restart see allocated ports\n // and the final tunnel endpoints (localhost for SSM, VPC for VPN).\n saveSessionState(sessionName, {\n appRoot,\n stack,\n devOutput: finalDevOutput,\n startedAt: new Date().toISOString(),\n workflowFingerprint: fingerprintWorkflowSource(appRoot, finalDevOutput.services),\n });\n });\n\n // Subcommand: logs\n dev\n .command('logs <service>')\n .description('Show logs for a service')\n .option('--tail <lines>', 'Number of lines', '100')\n .action((service: string, opts: { tail: string }) => {\n // --session is on the parent dev command\n const sessionName = dev.opts().session as string | undefined;\n const resolvedSession = sessionName ?? `${getProjectName(findAppRoot())}-dev`;\n if (loadSessionState(resolvedSession)?.runner === 'docker') {\n console.log(dockerDevLogs(resolvedSession, service, parseInt(opts.tail, 10)));\n return;\n }\n if (sessionName) {\n showLogs(sessionName, service, parseInt(opts.tail, 10));\n } else {\n const appRoot = findAppRoot();\n const projectName = getProjectName(appRoot);\n showLogs(`${projectName}-dev`, service, parseInt(opts.tail, 10));\n }\n });\n\n // Subcommand: restart\n dev\n .command('restart <service>')\n .description('Restart a service')\n .option('--stack <stack>', 'Pulumi stack name')\n .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())\n .option(\n '--refresh-env',\n \"Regenerate this service's env from current stack/SSM outputs before restarting (for config/deploy changes)\",\n )\n .option('--force', 'Restart the worker even if workflow code changed since launch (may strand in-flight conversations)')\n .action(async (service: string, opts: { stage?: string; refreshEnv?: boolean; force?: boolean }) => {\n // Hoisted so the catch can name the right login context (not a hardcoded\n // one) even when a throw happens before cred resolution. MESH-2089.\n let ctx: string | null = null;\n try {\n // --session is on the parent dev command\n const parentSession = dev.opts().session as string | undefined;\n const parentProfile = dev.opts().profile as string | undefined;\n if (parentProfile) {\n applyAwsProfileOverride(parentProfile);\n }\n const appRoot = findAppRoot();\n const projectName = getProjectName(appRoot);\n const sessionName = parentSession ?? `${projectName}-dev`;\n const state = loadSessionState(sessionName);\n if (state?.runner === 'docker') {\n dockerDevRestart(sessionName, service);\n logSuccess(`Restarted: ${service}`);\n return;\n }\n // Relaunch safety (MESH-2017): restarting the WORKER onto changed workflow\n // code strands in-flight conversations; restarting other services (ui/api)\n // doesn't. Gate the worker restart on the workflow-source fingerprint.\n if (/worker/i.test(service) && workflowChangeWouldStrand(state, appRoot, !!opts.force)) {\n process.exit(1);\n }\n const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));\n ctx = derivePlatformContext(appRoot, stack);\n // Resolve credentials BEFORE reading any Pulumi/SSM/kubeconfig output\n // (MESH-2090). With ambient AWS_* cleared (the documented restart\n // hygiene), the parent-side AWS calls the restart path makes \u2014 the\n // getDevOutput fallback below, and under --refresh-env the\n // resolveTemporalAuthVars/ensureKubeconfig calls \u2014 read the default SDK\n // chain (process.env). Resolving first lets us thread the resolved creds\n // into this process's env, and lets the precise login preflight fire\n // before the devOutput fallback (which was the MESH-2089 crash site).\n const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');\n const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');\n let deployerRole = defaultDeployerRole;\n if (defaultDeployerRole) {\n const meshCreds = ctx ? readCredentials(ctx) : null;\n const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;\n deployerRole = selectRoleForCaller(idToken, {\n defaultRole: defaultDeployerRole,\n adminRole: adminDeployerRole ?? undefined,\n });\n }\n const resolved = deployerRole ? await resolveAwsCredentials(deployerRole, appRoot, stack) : null;\n // Login preflight (MESH-2089): if a deployer role is configured but creds\n // couldn't be resolved AND there are no ambient AWS creds to fall back on,\n // the restart would proceed with no credentials and fail downstream. Name\n // the real problem (e.g. \"run mesh login <ctx> --device\") instead.\n if (deployerRole && !resolved && !process.env.AWS_ACCESS_KEY_ID) {\n const pf = ctx ? credProbeToPreflightError(await probeCredentials(ctx, deployerRole), ctx) : null;\n logError(\n pf?.message ??\n `Couldn't resolve AWS credentials to restart '${service}'. Run: mesh login ${ctx ?? 'mesh.dev'} --device`,\n );\n process.exit(1);\n }\n const baseAwsEnv = resolved?.env ?? getAwsEnvVars();\n // Ensure AWS_REGION is set from stack config so parent-side SDK clients\n // (SSM, EKS) don't default to us-east-1. Mirrors the launch path.\n const awsRegion = readStackConfig(appRoot, stack, 'aws:region');\n if (awsRegion && !baseAwsEnv.AWS_REGION) {\n baseAwsEnv.AWS_REGION = awsRegion;\n }\n // Inject the resolved creds into THIS process's env so the parent-side\n // AWS calls below run credentialed even when ambient AWS_* was cleared\n // (MESH-2090). Mirrors the launch path's inject (see startServices).\n Object.assign(process.env, baseAwsEnv);\n const profileHint = parentProfile ?? process.env.AWS_PROFILE ?? 'mesh-dev';\n const zitadelOpts =\n resolved?.method === 'zitadel' && deployerRole && ctx\n ? {\n context: ctx,\n roleArn: deployerRole,\n sessionScratchDir: getSessionEnvDir(sessionName),\n meshBin: resolveStableMeshBin(process.argv[1]),\n }\n : undefined;\n const awsEnv = buildChildAwsEnv(baseAwsEnv, resolved?.method ?? 'ambient', profileHint, zitadelOpts);\n // Use persisted state (has allocated ports), fall back to raw Pulumi\n // output \u2014 now credentialed via the resolved env (MESH-2090).\n const devOutput = state?.devOutput ?? getDevOutput(appRoot, stack, baseAwsEnv);\n await restartService(sessionName, service, appRoot, devOutput, awsEnv, {\n refreshEnv: !!opts.refreshEnv,\n stack,\n sessionState: state,\n });\n // Re-baseline the workflow fingerprint after a worker restart (MESH-2017):\n // the worker now runs the current code, so a subsequent unchanged restart\n // must NOT nag \u2014 otherwise a good-citizen `--force` earns a session of\n // false warnings, training the reflex this guard exists to prevent.\n if (/worker/i.test(service) && state) {\n saveSessionState(sessionName, {\n ...state,\n workflowFingerprint: fingerprintWorkflowSource(appRoot, state.devOutput?.services),\n });\n }\n } catch (err) {\n // Never crash uncaught (MESH-2089): a cred/kubeconfig/tmux failure should\n // name itself, not dump a bare Node stack. The underlying error is logged\n // so the exact cause is pinned if this recurs.\n logError(`Failed to restart '${service}': ${(err as Error)?.message ?? String(err)}`);\n logInfo(\n `If this is a credential/kubeconfig issue, run \\`mesh login ${ctx ?? '<your platform context>'} --device\\`, ` +\n 'or `mesh dev` to relaunch the session cleanly.',\n );\n process.exit(1);\n }\n });\n\n // Subcommand: doctor \u2014 diagnose the dev session\n dev\n .command('doctor')\n .description(\n 'Diagnose the dev session (creds, tunnels, config, ports, worktree, Temporal) \u2014 names the fix',\n )\n .option('--stack <stack>', 'Pulumi stack name')\n .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())\n .option('--json', 'Machine-readable output')\n .action(async (opts: { stack?: string; stage?: string; json?: boolean }) => {\n const parentProfile = dev.opts().profile as string | undefined;\n if (parentProfile) {\n applyAwsProfileOverride(parentProfile);\n }\n const appRoot = findAppRoot();\n const projectName = getProjectName(appRoot);\n const sessionName = (dev.opts().session as string | undefined) ?? `${projectName}-dev`;\n const state = loadSessionState(sessionName);\n const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));\n // `--json` may land on either the subcommand or the parent `dev`\n // command (both declare it), so honor both.\n const asJson = !!opts.json || !!(dev.opts().json as boolean | undefined);\n const ctx = buildDoctorContext(appRoot, stack, sessionName);\n const status = await runDoctor(ctx, { json: asJson });\n process.exit(status === 'error' ? 1 : 0);\n });\n\n // Subcommand: list \u2014 discover apps in the monorepo\n dev\n .command('list')\n .description('List Pulumi apps in the monorepo')\n .action(() => {\n // --json is on the parent dev command\n const asJson = dev.opts().json;\n const mono = findMonorepoRoot();\n if (!mono) {\n logError('Could not find monorepo root (pnpm-workspace.yaml or .git)');\n process.exit(1);\n }\n\n const apps = discoverApps(mono);\n\n if (asJson) {\n // Include running state for each app\n const result = apps.map((app) => {\n const sessionName = `${app.name}-dev`;\n return {\n ...app,\n running: sessionExists(sessionName),\n session: sessionName,\n };\n });\n console.log(JSON.stringify(result, null, 2));\n } else {\n if (apps.length === 0) {\n logInfo('No Pulumi apps found.');\n return;\n }\n console.log('Apps in monorepo:');\n console.log('');\n for (const app of apps) {\n const sessionName = `${app.name}-dev`;\n const running = sessionExists(sessionName);\n const icon = running ? '\u25CF' : '\u25CB';\n const stacks = app.stacks.length > 0 ? ` (${app.stacks.join(', ')})` : '';\n console.log(\n ` ${icon} ${app.name.padEnd(24)} ${app.tenant.padEnd(12)} ${app.relativePath}${stacks}`,\n );\n }\n console.log('');\n }\n });\n\n // Subcommand: test-user \u2014 retrieve Temporal test user credentials\n dev\n .command('test-user [name]')\n .description('Get Temporal test user credentials (from Pulumi-managed test users)')\n .option('--tenant <tenant>', 'Tenant name', 'mesh')\n .option('--env <env>', 'Environment', 'dev')\n .option('--region <region>', 'AWS region', 'us-east-2')\n .action(\n async (name: string | undefined, opts: { tenant: string; env: string; region: string }) => {\n const { SSMClient, GetParameterCommand, GetParametersByPathCommand } =\n await import('@aws-sdk/client-ssm');\n const ssm = new SSMClient({ region: opts.region });\n const basePath = `/mesh-platform/${opts.tenant}/${opts.env}/temporal/test-users`;\n\n if (!name) {\n // List all test users\n try {\n const resp = await ssm.send(\n new GetParametersByPathCommand({\n Path: basePath,\n Recursive: true,\n WithDecryption: true,\n }),\n );\n\n if (!resp.Parameters || resp.Parameters.length === 0) {\n logWarn(`No test users found at ${basePath}`);\n logInfo(\n 'Test users are defined in Pulumi config under mesh:temporal.authorization.testUsers',\n );\n logInfo(\"They're only available on dev stacks.\");\n return;\n }\n\n console.log('Temporal test users:');\n console.log('');\n for (const param of resp.Parameters) {\n const userName = param.Name!.split('/').pop()!;\n const creds = JSON.parse(param.Value!) as {\n email: string;\n password: string;\n roles: string[];\n description?: string;\n };\n console.log(` ${userName}`);\n console.log(` Email: ${creds.email}`);\n console.log(` Password: ${creds.password}`);\n console.log(` Roles: ${creds.roles.join(', ')}`);\n if (creds.description) console.log(` Note: ${creds.description}`);\n console.log('');\n }\n logInfo('Login at: https://temporal.dev.mesh-platform.trabian.com');\n } catch (err) {\n logError(\n `Failed to list test users: ${err instanceof Error ? err.message : String(err)}`,\n );\n process.exit(1);\n }\n } else {\n // Get a specific test user\n try {\n const resp = await ssm.send(\n new GetParameterCommand({\n Name: `${basePath}/${name}`,\n WithDecryption: true,\n }),\n );\n\n if (!resp.Parameter?.Value) {\n logError(`Test user '${name}' not found at ${basePath}/${name}`);\n return;\n }\n\n const creds = JSON.parse(resp.Parameter.Value) as {\n email: string;\n password: string;\n roles: string[];\n description?: string;\n };\n // Check if --json is set on parent\n const asJson = dev.opts().json;\n if (asJson) {\n console.log(JSON.stringify(creds, null, 2));\n } else {\n console.log(`Email: ${creds.email}`);\n console.log(`Password: ${creds.password}`);\n console.log(`Roles: ${creds.roles.join(', ')}`);\n if (creds.description) console.log(`Note: ${creds.description}`);\n console.log('');\n logInfo('Login at: https://temporal.dev.mesh-platform.trabian.com');\n }\n } catch (err: any) {\n const msg = err.message ?? String(err);\n if (msg.includes('ParameterNotFound') || err.name === 'ParameterNotFound') {\n logError(`Test user '${name}' not found.`);\n logInfo(`List available users with: mesh dev test-user`);\n } else {\n logError(`Failed to get test user: ${msg}`);\n }\n process.exit(1);\n }\n }\n },\n );\n}\n", "/**\n * Resolve a tenant's VPC tunnel targets from the platform bastion's service\n * map \u2014 the same source `startSsmTunnels` reads \u2014 mapping bastion service keys\n * to the app's tunnel names, resolving each ELB/RDS hostname to its private IP,\n * and assigning collision-free localhost ports via the SSM port convention.\n * (MESH-2040 / Slice 1 of MESH-2014.)\n */\n\nimport { execFileSync } from \"node:child_process\";\nimport * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { reserveSsmLocalPortCandidate, preferredSsmLocalPort } from \"../commands/dev.js\";\nimport { CONFIG_DIR, atomicWriteFileSync } from \"../commands/login.js\";\n\n/** Local-port block width per tenant \u2014 wider than the canonical port span so\n * two tenants' port blocks never overlap. */\nexport const PORT_BLOCK = 3000;\n\n/**\n * Deterministic per-tenant index for local-port offsetting. Reads a machine-global\n * registry (`~/.config/mesh/tailscale/port-registry.json`), seeded `{ mesh: 0 }` so\n * the hub's canonical ports are grandfathered regardless of registration order.\n * Assigns the next index to an unseen tenant and persists it. `registryPath` is\n * injectable for tests.\n */\nexport function tenantPortIndex(tenant: string, registryPath?: string): number {\n const p = registryPath ?? path.join(CONFIG_DIR, \"tailscale\", \"port-registry.json\");\n let reg: Record<string, number>;\n try {\n reg = JSON.parse(fs.readFileSync(p, \"utf8\")) as Record<string, number>;\n } catch {\n reg = { mesh: 0 };\n }\n if (tenant in reg) return reg[tenant]!;\n const idx = Math.max(-1, ...Object.values(reg)) + 1;\n reg[tenant] = idx;\n fs.mkdirSync(path.dirname(p), { recursive: true });\n atomicWriteFileSync(p, JSON.stringify(reg, null, 2), 0o600);\n return idx;\n}\n\nexport interface TunnelTarget {\n /** App-facing tunnel name (e.g. \"temporal\"). */\n name: string;\n /** Local port the forwarder listens on (SSM convention: base + remotePort). */\n localPort: number;\n /** VPC hostname (internal ELB / RDS endpoint). */\n targetHost: string;\n /** Resolved private IP of `targetHost`. */\n targetIp: string;\n /** Port on the VPC service. */\n remotePort: number;\n}\n\n/**\n * Bastion service key \u2192 app tunnel name. Mirrors `tunnelToBastionKey` in dev.ts\n * (inverted): the bastion exposes `temporal-frontend`; the app calls it `temporal`.\n */\nexport const TUNNEL_NAME_BY_BASTION_KEY: Record<string, string> = {\n \"temporal-frontend\": \"temporal\",\n \"temporal-ui\": \"temporal-ui\",\n rds: \"rds\",\n};\n\n/**\n * Build the tunnel target set. `resolveIp` is injected for testability (the\n * command passes `resolveElbIp`). Services not in the name map are skipped;\n * services whose host can't be resolved are skipped (the forwarder needs an IP).\n */\nexport function resolveTunnelTargets(\n services: Record<string, { host: string; port: number }>,\n resolveIp: (host: string) => string | null,\n portOffset = 0,\n): TunnelTarget[] {\n const reserved = new Set<number>();\n const targets: TunnelTarget[] = [];\n for (const [key, svc] of Object.entries(services)) {\n const name = TUNNEL_NAME_BY_BASTION_KEY[key];\n if (!name) continue;\n const targetIp = resolveIp(svc.host);\n if (!targetIp) continue;\n // Per-tenant offset keeps concurrent platform tenants off each other's ports.\n const localPort = reserveSsmLocalPortCandidate(\n svc.port,\n reserved,\n preferredSsmLocalPort(svc.port) + portOffset,\n );\n targets.push({ name, localPort, targetHost: svc.host, targetIp, remotePort: svc.port });\n }\n return targets;\n}\n\n/**\n * Resolve an AWS-internal ELB/RDS hostname to its private IP via a public\n * resolver \u2014 these names publish their private (10.x) address to public DNS\n * but the host's default resolver may not answer for them. Returns null on\n * failure (caller skips that tunnel).\n */\nexport function resolveElbIp(host: string): string | null {\n try {\n const out = execFileSync(\"dig\", [\"+short\", host, \"@1.1.1.1\"], {\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n const ip = out\n .trim()\n .split(\"\\n\")\n .map((l) => l.trim())\n .find((l) => /^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(l));\n return ip ?? null;\n } catch {\n return null;\n }\n}\n", "/**\n * A minimal SOCKS5 CONNECT forwarder: listens on a local TCP port and pipes\n * each client connection through a SOCKS5 proxy (no-auth) to a fixed target.\n *\n * Presents the same plain `localhost:PORT` contract the SSM tunnels give today,\n * so an app (e.g. a Temporal gRPC client) connects with no proxy awareness \u2014\n * the SOCKS5 proxy is the userspace-Tailscale daemon (MESH-2040 / MESH-2036).\n */\n\nimport * as net from \"node:net\";\n\n/** `\"10.0.137.203\"` \u2192 `[10,0,137,203]`; throws on anything that isn't IPv4. */\nexport function ipv4ToBytes(ip: string): number[] {\n const parts = ip.split(\".\");\n if (parts.length !== 4) throw new Error(`not an IPv4 address: ${ip}`);\n return parts.map((p) => {\n const n = Number(p);\n if (!Number.isInteger(n) || n < 0 || n > 255 || !/^\\d+$/.test(p)) {\n throw new Error(`not an IPv4 address: ${ip}`);\n }\n return n;\n });\n}\n\n/** SOCKS5 CONNECT request for an IPv4 target (VER, CMD=CONNECT, RSV, ATYP=IPv4, addr, port). */\nexport function socks5ConnectRequest(ip: string, port: number): Buffer {\n return Buffer.from([0x05, 0x01, 0x00, 0x01, ...ipv4ToBytes(ip), (port >> 8) & 0xff, port & 0xff]);\n}\n\nconst SOCKS_REPLY_MESSAGES: Record<number, string> = {\n 0: \"succeeded\",\n 1: \"general SOCKS server failure\",\n 2: \"connection not allowed by ruleset\",\n 3: \"network unreachable\",\n 4: \"host unreachable\",\n 5: \"connection refused\",\n 6: \"TTL expired\",\n 7: \"command not supported\",\n 8: \"address type not supported\",\n};\n\n/** Interpret the SOCKS5 reply code (byte 1 of the CONNECT reply). */\nexport function interpretSocksReply(code: number): { ok: boolean; message: string } {\n return { ok: code === 0, message: SOCKS_REPLY_MESSAGES[code] ?? `unknown SOCKS reply ${code}` };\n}\n\nexport interface ForwarderOptions {\n /** Local TCP port to listen on (0 = ephemeral, useful in tests). */\n listenPort: number;\n socksHost?: string;\n socksPort: number;\n targetIp: string;\n targetPort: number;\n /**\n * Observes server errors \u2014 above all a bind failure (`EADDRINUSE`). The\n * forwarder still never crashes the process, but the error must not be\n * INVISIBLE: an unbound forwarder whose port is held by a foreign process is\n * the silent-dead-tunnel class (MESH-2407), so callers record it and the\n * ownership handshake reports the port as not-listening.\n */\n onError?: (err: NodeJS.ErrnoException) => void;\n}\n\n/**\n * Create (and start) a forwarder server. Each inbound client is spliced to\n * `targetIp:targetPort` via a SOCKS5 CONNECT through `socksHost:socksPort`.\n */\nexport function createForwarder(opts: ForwarderOptions): net.Server {\n const socksHost = opts.socksHost ?? \"127.0.0.1\";\n const server = net.createServer((client) => {\n const up = net.connect(opts.socksPort, socksHost);\n let buf = Buffer.alloc(0);\n let phase = 0; // 0=await method-select, 1=await connect reply, 2=streaming\n up.on(\"connect\", () => up.write(Buffer.from([0x05, 0x01, 0x00]))); // greet, no-auth\n up.on(\"data\", (d) => {\n if (phase === 2) return;\n buf = Buffer.concat([buf, d]);\n if (phase === 0) {\n if (buf.length < 2) return;\n buf = buf.subarray(2);\n phase = 1;\n up.write(socks5ConnectRequest(opts.targetIp, opts.targetPort));\n }\n if (phase === 1) {\n if (buf.length < 10) return;\n const reply = interpretSocksReply(buf[1]!);\n const leftover = buf.subarray(10);\n phase = 2;\n if (!reply.ok) {\n up.destroy();\n client.destroy();\n return;\n }\n if (leftover.length) client.write(leftover);\n client.pipe(up);\n up.pipe(client);\n }\n });\n client.on(\"error\", () => up.destroy());\n up.on(\"error\", () => client.destroy());\n // A cleanly-closed client (clean FIN, no \"error\") must still tear down the\n // upstream SOCKS\u2192target leg \u2014 otherwise a probe that connects then closes\n // strands a real VPC-target connection until the target times out.\n client.on(\"close\", () => up.destroy());\n up.on(\"close\", () => client.destroy());\n });\n // A bind failure (e.g. the port is already held by an SSM tunnel) must not\n // crash the process running this forwarder \u2014 but it must be OBSERVED, not\n // swallowed: the runner records it and ownership checks fail the port.\n // Attach before listen() so the error is caught.\n server.on(\"error\", (err) => opts.onError?.(err));\n server.listen(opts.listenPort, \"127.0.0.1\");\n return server;\n}\n", "/**\n * Ownership contract for the tunnel runner (MESH-2407).\n *\n * Three silent-dead-tunnel incidents in ten days (MESH-2169, MESH-2014, the\n * #2991 re-review) shared one enabler: readiness was inferred from \"something\n * accepts TCP on the port\". That conflates LIVENESS (a listener exists) with\n * OWNERSHIP (the listener is OUR runner, wired to OUR SOCKS upstream). This\n * module is the ownership half of the contract:\n *\n * - The runner that actually binds the forwarder ports writes a MANIFEST\n * (pid, process-group id, a per-run random token, a loopback control port)\n * and answers a one-shot control handshake with that token plus the ports\n * it is listening on \u2014 read from its in-process `net.Server`s, so the\n * answer can't be faked by a foreign listener and probing it opens no\n * SOCKS\u2192VPC data connection.\n * - Callers verify ownership (manifest \u2192 live pid \u2192 control handshake \u2192\n * token/socks-port/port-coverage match) instead of dialing ports. A port\n * that accepts TCP without passing the handshake is a SQUATTER and must be\n * a loud failure, never adopt-and-continue.\n * - Teardown signals process GROUPS recorded here (plus argv-discovered\n * runners), so no kill path can orphan a grandchild with ports still bound.\n */\n\nimport * as crypto from 'node:crypto';\nimport * as fs from 'node:fs';\nimport * as net from 'node:net';\nimport * as path from 'node:path';\nimport { execFileSync } from 'node:child_process';\n\n/** Written by the runner (the process that binds the ports) \u2014 never by callers. */\nexport interface RunnerManifest {\n tenant: string;\n /** Pid of the runner process itself (the port owner), NOT its launcher. */\n pid: number;\n /** Process-group id \u2014 all teardown paths signal `-pgid`, never a bare pid. */\n pgid: number;\n /** Per-run random token; the control handshake must echo it. */\n token: string;\n /** Loopback port answering the one-shot ownership handshake. */\n controlPort: number;\n /** SOCKS port the runner's forwarders dial (must match the live daemon's). */\n socksPort: number;\n /** Forwarder ports the runner is responsible for. */\n ports: number[];\n startedAt: string;\n}\n\n/** What the runner's control endpoint answers with. */\nexport interface ControlResponse {\n token: string;\n tenant: string;\n pid: number;\n socksPort: number;\n /** Ports whose in-process `net.Server` is currently listening. */\n listening: number[];\n /** port \u2192 error code for forwarders that failed to bind (e.g. EADDRINUSE). */\n bindErrors: Record<string, string>;\n}\n\nexport function mintRunnerToken(): string {\n return crypto.randomBytes(16).toString('hex');\n}\n\nexport function readRunnerManifest(file: string): RunnerManifest | null {\n if (!fs.existsSync(file)) return null;\n try {\n return JSON.parse(fs.readFileSync(file, 'utf8')) as RunnerManifest;\n } catch {\n return null;\n }\n}\n\nexport function writeRunnerManifest(file: string, manifest: RunnerManifest): void {\n fs.mkdirSync(path.dirname(file), { recursive: true });\n fs.writeFileSync(file, JSON.stringify(manifest, null, 2), { mode: 0o600 });\n}\n\n/**\n * Remove the manifest \u2014 but only if it is still OURS (`token` matches). A dying\n * runner must not clobber the manifest of a newer runner that already replaced it.\n * Omit `token` for teardown paths that own the whole tenant state.\n */\nexport function clearRunnerManifest(file: string, token?: string): void {\n if (token !== undefined && readRunnerManifest(file)?.token !== token) return;\n try {\n fs.unlinkSync(file);\n } catch {\n /* already gone */\n }\n}\n\n/** Process-group id of a pid via `ps` (macOS/BSD); null when undiscoverable. */\nexport function getProcessGroupId(pid: number): number | null {\n try {\n const out = execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], {\n encoding: 'utf8',\n stdio: ['ignore', 'pipe', 'ignore'],\n }).trim();\n const pgid = Number(out);\n return Number.isInteger(pgid) && pgid > 0 ? pgid : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Pids of tunnel-runner processes for a tenant, parsed from `ps` output\n * (`pid command` lines). Matches both `__run` and the legacy `__supervise`\n * argv, and skips `exclude` (the calling process, when it IS the runner).\n * Pure for unit tests; `discoverRunnerPids` feeds it live `ps` output.\n */\nexport function parseRunnerPidsFromPs(\n psOutput: string,\n tenant: string,\n exclude?: number,\n): number[] {\n const pids: number[] = [];\n for (const line of psOutput.split('\\n')) {\n if (!/vpn\\s+tunnel\\s+(?:__run|__supervise)\\s+/.test(line)) continue;\n const m = line.trim().match(/^(\\d+)\\s+(.*)$/);\n if (!m) continue;\n const argv = m[2]!.split(/\\s+/);\n // The tenant is the argument right after __run/__supervise \u2014 an exact\n // match, so tenant \"enc\" never matches a runner for tenant \"encore\".\n const i = argv.findIndex((a) => a === '__run' || a === '__supervise');\n if (i === -1 || argv[i + 1] !== tenant) continue;\n const pid = Number(m[1]);\n if (pid !== exclude) pids.push(pid);\n }\n return pids;\n}\n\n/** Live argv discovery of runner pids for a tenant (empty when `ps` fails). */\nexport function discoverRunnerPids(tenant: string, exclude?: number): number[] {\n try {\n const out = execFileSync('ps', ['ax', '-o', 'pid=,command='], {\n encoding: 'utf8',\n stdio: ['ignore', 'pipe', 'ignore'],\n });\n return parseRunnerPidsFromPs(out, tenant, exclude);\n } catch {\n return [];\n }\n}\n\n/**\n * Start the runner's one-shot control endpoint on a loopback ephemeral port.\n * Each connection gets one JSON line (the live payload) and is closed \u2014 no\n * protocol state, nothing routable, and probing it touches no data plane.\n */\nexport function startControlServer(\n payload: () => ControlResponse,\n): Promise<{ server: net.Server; port: number }> {\n return new Promise((resolve, reject) => {\n const server = net.createServer((socket) => {\n socket.on('error', () => {});\n try {\n socket.end(`${JSON.stringify(payload())}\\n`);\n } catch {\n socket.destroy();\n }\n });\n server.on('error', reject);\n server.listen(0, '127.0.0.1', () => {\n resolve({ server, port: (server.address() as net.AddressInfo).port });\n });\n });\n}\n\n/** Dial the control endpoint and parse its one-line JSON answer; null on any failure. */\nexport function queryControl(port: number, timeoutMs = 700): Promise<ControlResponse | null> {\n return new Promise((resolve) => {\n const s = new net.Socket();\n let buf = '';\n let done = false;\n const fin = (v: ControlResponse | null) => {\n if (done) return;\n done = true;\n s.destroy();\n resolve(v);\n };\n s.setTimeout(timeoutMs);\n s.once('timeout', () => fin(null));\n s.once('error', () => fin(null));\n s.on('data', (d) => {\n buf += d.toString();\n });\n s.once('close', () => {\n try {\n fin(JSON.parse(buf) as ControlResponse);\n } catch {\n fin(null);\n }\n });\n s.connect(port, '127.0.0.1');\n });\n}\n\nexport interface OwnershipCheck {\n ok: boolean;\n /** Machine-readable failure reason (\"\" when ok). */\n reason: string;\n /** The control answer, when the handshake got that far. */\n resp?: ControlResponse;\n}\n\nfunction defaultPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * The ownership handshake: manifest \u2192 live pid \u2192 control endpoint \u2192 token,\n * SOCKS port, and port coverage all match. Anything less is NOT ours \u2014 the\n * caller must treat an accepting-but-unowned port as a squatter, never reuse it.\n */\nexport async function verifyRunnerOwnership(args: {\n manifest: RunnerManifest | null;\n expectedSocksPort: number;\n expectedPorts: number[];\n isPidAlive?: (pid: number) => boolean;\n query?: (port: number) => Promise<ControlResponse | null>;\n}): Promise<OwnershipCheck> {\n const { manifest } = args;\n if (!manifest) return { ok: false, reason: 'no-manifest' };\n const alive = args.isPidAlive ?? defaultPidAlive;\n if (!alive(manifest.pid)) return { ok: false, reason: 'runner-dead' };\n const resp = await (args.query ?? queryControl)(manifest.controlPort);\n if (!resp) return { ok: false, reason: 'no-control-answer' };\n if (resp.token !== manifest.token) return { ok: false, reason: 'token-mismatch', resp };\n if (resp.socksPort !== args.expectedSocksPort) {\n return { ok: false, reason: 'socks-port-mismatch', resp };\n }\n const listening = new Set(resp.listening);\n const missing = args.expectedPorts.filter((p) => !listening.has(p));\n if (missing.length > 0) {\n return { ok: false, reason: `ports-not-listening:${missing.join(',')}`, resp };\n }\n return { ok: true, reason: '', resp };\n}\n\n/**\n * Poll an ownership check until it passes or `deadlineMs` of wall-clock elapses;\n * returns the last (failing) check otherwise. The deadline bounds TIME, not\n * rounds \u2014 each check can itself cost up to the control-query timeout, so a\n * round-counting loop would multiply the promised deadline several-fold.\n * Retrying also matters for correctness, not just latency: two failure reasons\n * are transient by design (`ports-not-listening:` while the runner's self-heal\n * loop rebinds a forwarder, `no-control-answer` under accept-backlog pressure),\n * and a single-shot sample of a healthy shared runner in one of those windows\n * would tear it down for every concurrent session on the tenant (MESH-2014).\n */\nexport async function pollOwnership(\n check: () => Promise<OwnershipCheck>,\n deadlineMs: number,\n): Promise<OwnershipCheck> {\n let last: OwnershipCheck = { ok: false, reason: 'not-checked' };\n const end = Date.now() + deadlineMs;\n do {\n last = await check();\n if (last.ok) return last;\n await new Promise((r) => setTimeout(r, 250));\n } while (Date.now() < end);\n return last;\n}\n\n/** Best-effort `lsof` description of what holds a port (\"cmd (pid N)\"); null if unknown. */\nexport function describePortOwner(port: number): string | null {\n try {\n const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fcp'], {\n encoding: 'utf8',\n stdio: ['ignore', 'pipe', 'ignore'],\n });\n // -F output: \"p<pid>\\nc<command>\\n\u2026\" per process.\n const pid = out.match(/^p(\\d+)$/m)?.[1];\n const cmd = out.match(/^c(.+)$/m)?.[1];\n if (!pid && !cmd) return null;\n return `${cmd ?? '?'} (pid ${pid ?? '?'})`;\n } catch {\n return null;\n }\n}\n\nexport interface SquattedPort {\n port: number;\n owner: string | null;\n}\n\n/**\n * Of `ports`, the ones something is accepting TCP on. Called AFTER ownership\n * failed / teardown ran, so every hit is a foreign or stale listener.\n */\nexport async function findSquattedPorts(\n ports: number[],\n accepts: (port: number) => Promise<boolean>,\n): Promise<SquattedPort[]> {\n const squatted: SquattedPort[] = [];\n for (const port of ports) {\n if (await accepts(port)) squatted.push({ port, owner: describePortOwner(port) });\n }\n return squatted;\n}\n\n/**\n * The loud-failure text for a squatted tunnel port (`mesh dev doctor` style:\n * what is wrong, why we refuse, exactly what to run). Never adopt-and-continue.\n */\nexport function buildSquatterError(\n tenant: string,\n squatted: SquattedPort[],\n detail?: string,\n): string {\n const lines = squatted.map(\n (s) => ` 127.0.0.1:${s.port} held by ${s.owner ?? 'an unidentified process'}`,\n );\n const first = squatted[0]?.port ?? '<port>';\n return [\n `Tunnel port(s) for tenant '${tenant}' are bound by a process mesh does not own${detail ? ` (${detail})` : ''}:`,\n ...lines,\n ` Refusing to reuse a foreign listener \u2014 traffic would silently flow to the wrong upstream.`,\n ` To fix:`,\n ` mesh vpn -t ${tenant} tunnel down --stop # tear down mesh-owned runners + daemon`,\n ` lsof -nP -iTCP:${first} -sTCP:LISTEN # identify what still holds the port`,\n ` then stop that process and re-run.`,\n // Port blocks are a 63-bucket hash (MESH-2375), so a DIFFERENT tenant's\n // runner can collide onto this block \u2014 in which case the `down` above is a\n // no-op and only lsof identifies the real owner.\n ` (If lsof names another mesh runner, it belongs to a different tenant \u2014 tear that one down instead.)`,\n ].join('\\n');\n}\n", "/**\n * Userspace-Tailscale daemon management + tunnel-session state for\n * `mesh vpn tunnel` (MESH-2040 / Slice 1 of MESH-2014).\n *\n * A per-tenant `tailscaled` runs in userspace-networking mode (brew formula,\n * NOT the GUI app) exposing a SOCKS5 proxy; forwarders bridge `localhost:PORT`\n * to VPC services through it. State lives under `~/.config/mesh/tailscale/<tenant>/`\n * (r+w-granted under safehouse \u2014 see the MESH-2036 spike).\n */\n\nimport * as fs from \"node:fs\";\nimport * as net from \"node:net\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { spawn, execFileSync } from \"node:child_process\";\nimport { CONFIG_DIR, atomicWriteFileSync } from \"../commands/login.js\";\nimport { resolveStableMeshBin } from \"./aws-auth.js\";\nimport { logInfo, logWarn } from \"./log.js\";\nimport { MeshCliError, renderErrorBody } from \"./errors.js\";\nimport { assertSignalablePid } from \"./pid.js\";\nimport type { PidSource } from \"./pid.js\";\nimport { getPlatformBastionInfo } from \"./bastion.js\";\nimport { resolveTunnelTargets, resolveElbIp, tenantPortIndex, PORT_BLOCK } from \"./tailscale-targets.js\";\nimport { createForwarder } from \"./socks-forward.js\";\nimport {\n mintRunnerToken,\n readRunnerManifest,\n writeRunnerManifest,\n clearRunnerManifest,\n getProcessGroupId,\n discoverRunnerPids,\n startControlServer,\n verifyRunnerOwnership,\n pollOwnership,\n findSquattedPorts,\n buildSquatterError,\n} from \"./tunnel-ownership.js\";\nimport type { ControlResponse, OwnershipCheck } from \"./tunnel-ownership.js\";\nimport type { TunnelTarget } from \"./tailscale-targets.js\";\nimport type { DevOutput } from \"../commands/dev.js\";\n\nconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\n// ============================================================================\n// Paths\n// ============================================================================\n\nexport function tenantStateDir(tenant: string): string {\n return path.join(CONFIG_DIR, \"tailscale\", tenant);\n}\nexport function socketPath(tenant: string): string {\n return path.join(tenantStateDir(tenant), \"tailscaled.sock\");\n}\nexport function logPath(tenant: string): string {\n return path.join(tenantStateDir(tenant), \"tailscaled.log\");\n}\nexport function statePath(tenant: string): string {\n return path.join(tenantStateDir(tenant), \"tunnel-state.json\");\n}\n/**\n * Daemon-scoped metadata, written by `ensureDaemon` when it actually spawns the\n * daemon. Unlike `tunnel-state.json` (which `down` clears), this survives `down`\n * so a later `up` reuses the RUNNING daemon's SOCKS port instead of minting a\n * fresh one the daemon isn't listening on (which would leave every forwarder\n * dialing a dead port). Also records the daemon pid so `stopDaemon` can kill it.\n */\nexport function daemonMetaPath(tenant: string): string {\n return path.join(tenantStateDir(tenant), \"daemon.json\");\n}\n/**\n * Ownership manifest written by the RUNNER (the process that binds the\n * forwarder ports) \u2014 pid, process-group id, per-run token, control port. The\n * ownership handshake (MESH-2407) verifies against it; teardown signals the\n * recorded process group.\n */\nexport function runnerManifestPath(tenant: string): string {\n return path.join(tenantStateDir(tenant), \"runner.json\");\n}\n\n// ============================================================================\n// Tunnel-session state\n// ============================================================================\n\nexport interface TunnelState {\n tenant: string;\n env: string;\n socksPort: number;\n loginServer: string;\n /** PID of the detached forwarder supervisor (null until spawned). */\n supervisorPid: number | null;\n /** How the runner is supervised. Defaults to \"detached\" when absent. */\n managed?: \"launchd\" | \"detached\";\n startedAt: string;\n tunnels: TunnelTarget[];\n}\n\nexport function readTunnelState(tenant: string): TunnelState | null {\n const p = statePath(tenant);\n if (!fs.existsSync(p)) return null;\n try {\n return JSON.parse(fs.readFileSync(p, \"utf8\")) as TunnelState;\n } catch {\n return null;\n }\n}\n\nexport function writeTunnelState(tenant: string, state: TunnelState): void {\n fs.mkdirSync(tenantStateDir(tenant), { recursive: true });\n atomicWriteFileSync(statePath(tenant), JSON.stringify(state, null, 2), 0o600);\n}\n\nexport function clearTunnelState(tenant: string): void {\n try {\n fs.unlinkSync(statePath(tenant));\n } catch {\n /* already gone */\n }\n}\n\nexport interface DaemonMeta {\n /** The `--socks5-server` port the running daemon actually listens on. */\n socksPort: number;\n /** PID of the detached `tailscaled` process (for `stopDaemon`). */\n pid: number;\n}\n\nexport function readDaemonMeta(tenant: string): DaemonMeta | null {\n const p = daemonMetaPath(tenant);\n if (!fs.existsSync(p)) return null;\n try {\n return JSON.parse(fs.readFileSync(p, \"utf8\")) as DaemonMeta;\n } catch {\n return null;\n }\n}\n\nexport function writeDaemonMeta(tenant: string, meta: DaemonMeta): void {\n fs.mkdirSync(tenantStateDir(tenant), { recursive: true });\n atomicWriteFileSync(daemonMetaPath(tenant), JSON.stringify(meta, null, 2), 0o600);\n}\n\nexport function clearDaemonMeta(tenant: string): void {\n try {\n fs.unlinkSync(daemonMetaPath(tenant));\n } catch {\n /* already gone */\n }\n}\n\n// ============================================================================\n// Daemon status parsing\n// ============================================================================\n\n/** Parse `tailscale status --json` \u2192 the fields we care about. */\nexport function parseDaemonState(statusJson: string): {\n backendState: string;\n selfName?: string;\n} {\n try {\n const j = JSON.parse(statusJson) as { BackendState?: string; Self?: { HostName?: string } };\n return { backendState: j.BackendState ?? \"Unknown\", selfName: j.Self?.HostName };\n } catch {\n return { backendState: \"Unknown\" };\n }\n}\n\n// ============================================================================\n// Binary resolution (macOS / brew \u2014 Slice 1)\n// ============================================================================\n\n/** The brew `tailscaled` (NOT the GUI app, which ships no standalone daemon). */\nexport function tailscaledBinPath(): string {\n return resolveBrewBin(\"tailscaled\");\n}\n/** The brew `tailscale` CLI (prefer it over the GUI shim, which aborts under sandbox). */\nexport function tailscaleBinPath(): string {\n return resolveBrewBin(\"tailscale\");\n}\nfunction resolveBrewBin(name: string): string {\n const brew = `/opt/homebrew/bin/${name}`;\n if (fs.existsSync(brew)) return brew;\n try {\n const p = execFileSync(\"which\", [name], { encoding: \"utf8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] }).trim();\n if (p) return p;\n } catch {\n /* fall through */\n }\n throw new Error(`${name} not found \u2014 install the standalone binary: brew install tailscale`);\n}\n\n// ============================================================================\n// Daemon lifecycle\n// ============================================================================\n\n/** Backend state via the tenant's control socket; \"Down\" when no daemon answers. */\nexport function daemonState(tenant: string): { backendState: string; selfName?: string } {\n if (!fs.existsSync(socketPath(tenant))) return { backendState: \"Down\" };\n try {\n const out = execFileSync(\n tailscaleBinPath(),\n [\"--socket\", socketPath(tenant), \"status\", \"--json\"],\n { encoding: \"utf8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] },\n );\n return parseDaemonState(out);\n } catch {\n return { backendState: \"Down\" };\n }\n}\n\n/**\n * Extract the `--socks5-server` port from a `tailscaled` command line. Pure so\n * it's unit-testable. Accepts both `--socks5-server=127.0.0.1:PORT` and the\n * space-separated form, and a `localhost` host literal. Returns null if absent\n * or malformed.\n */\nexport function parseSocksPortFromArgv(argv: string): number | null {\n const m = argv.match(/--socks5-server[=\\s]+(?:127\\.0\\.0\\.1|localhost):(\\d+)/);\n if (!m) return null;\n const port = Number(m[1]);\n return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;\n}\n\n/**\n * Find the running userspace `tailscaled` for a tenant by its unique\n * `--socket=<path>` argument, returning its pid and the SOCKS port it ACTUALLY\n * bound (parsed from argv). Authoritative when `daemon.json` is missing or stale\n * (e.g. a daemon left by an older build that predates the meta file). Returns\n * null when no matching process is found or `ps` is unavailable.\n */\nexport function findRunningDaemon(tenant: string): { pid: number; socksPort: number | null } | null {\n const sock = socketPath(tenant);\n let out: string;\n try {\n out = execFileSync(\"ps\", [\"ax\", \"-o\", \"pid=,command=\"], {\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n } catch {\n return null;\n }\n for (const line of out.split(\"\\n\")) {\n // Match this tenant's daemon by its unique --socket path (skips the ps line,\n // other tenants' daemons, and the GUI app).\n if (!line.includes(\"tailscaled\") || !line.includes(`--socket=${sock}`)) continue;\n const m = line.trim().match(/^(\\d+)\\s+(.*)$/);\n if (!m) continue;\n return { pid: Number(m[1]), socksPort: parseSocksPortFromArgv(m[2]!) };\n }\n return null;\n}\n\n/** True if something accepts TCP on 127.0.0.1:port within `timeoutMs`. */\nexport function portAccepts(port: number, timeoutMs = 400): Promise<boolean> {\n return new Promise((resolve) => {\n const s = new net.Socket();\n let done = false;\n const fin = (v: boolean) => {\n if (done) return;\n done = true;\n s.destroy();\n resolve(v);\n };\n s.setTimeout(timeoutMs);\n s.once(\"connect\", () => fin(true));\n s.once(\"timeout\", () => fin(false));\n s.once(\"error\", () => fin(false));\n s.connect(port, \"127.0.0.1\");\n });\n}\n\n/**\n * Ensure a userspace `tailscaled` is running for the tenant and return the SOCKS\n * port the forwarders should dial. Idempotent \u2014 reuses a live daemon, but\n * RECONCILES the port: a daemon that's up on a different port than requested\n * (older build with no `daemon.json`, or stale meta) would otherwise leave every\n * forwarder dialing a dead port (MESH-2169). Trust `opts.socksPort` only if it\n * truly accepts; else adopt the running daemon's real port; else restart fresh.\n */\nexport async function ensureDaemon(\n tenant: string,\n opts: { region: string; socksPort: number },\n): Promise<number> {\n if (daemonState(tenant).backendState !== \"Down\") {\n // The daemon's SOCKS proxy is only useful on the exact port the forwarders\n // will dial. If the requested port already accepts, record it and use it.\n if (await portAccepts(opts.socksPort)) {\n writeDaemonMeta(tenant, {\n socksPort: opts.socksPort,\n pid: readDaemonMeta(tenant)?.pid ?? findRunningDaemon(tenant)?.pid ?? 0,\n });\n return opts.socksPort;\n }\n // Requested port is dead \u2014 the daemon is on a different one. Discover its\n // real SOCKS port from argv and adopt it (no restart \u2192 no severing other\n // sessions), rewriting daemon.json so future runs read the right port.\n const running = findRunningDaemon(tenant);\n if (running?.socksPort && (await portAccepts(running.socksPort))) {\n writeDaemonMeta(tenant, { socksPort: running.socksPort, pid: running.pid });\n return running.socksPort;\n }\n // Can't discover/verify a working SOCKS port \u2014 the daemon is unusable to us.\n // Tear it down (state dir keeps the node identity \u2192 no re-login) and fall\n // through to a fresh start on opts.socksPort.\n stopDaemon(tenant);\n for (let i = 0; i < 12 && daemonState(tenant).backendState !== \"Down\"; i++) {\n await sleep(250);\n }\n }\n const dir = tenantStateDir(tenant);\n fs.mkdirSync(dir, { recursive: true });\n const log = fs.openSync(logPath(tenant), \"a\");\n try {\n const child = spawn(\n tailscaledBinPath(),\n [\n \"--tun=userspace-networking\",\n `--socket=${socketPath(tenant)}`,\n `--statedir=${dir}`,\n `--socks5-server=127.0.0.1:${opts.socksPort}`,\n \"--port=0\",\n ],\n {\n detached: true,\n stdio: [\"ignore\", log, log],\n env: { ...process.env, AWS_REGION: opts.region },\n },\n );\n child.unref();\n // Persist the socks port + pid so `down` \u2192 `up` reuses this daemon's real\n // port (not a fresh one it isn't listening on) and `stopDaemon` can kill it.\n if (child.pid) writeDaemonMeta(tenant, { socksPort: opts.socksPort, pid: child.pid });\n } finally {\n fs.closeSync(log);\n }\n for (let i = 0; i < 40; i++) {\n await sleep(250);\n if (daemonState(tenant).backendState !== \"Down\") return opts.socksPort;\n }\n throw new Error(`tailscaled did not start for tenant '${tenant}' \u2014 see ${logPath(tenant)}`);\n}\n\n/**\n * Stop the detached `tailscaled` for a tenant (SIGTERM its pid) and remove the\n * socket + daemon metadata. Leaves the Headscale node identity in `tailscaled.state`\n * so a later `up` re-spawns and rejoins without an interactive re-login \u2014 unless\n * the caller logged out first.\n *\n * Best-effort, and that includes a corrupt `daemon.json`: an unsignalable\n * recorded pid is REFUSED (never handed to `kill`) but only warns \u2014 the socket\n * and meta are still cleared. `ensureDaemon` calls this on the UP path to\n * recycle a daemon it can't verify, so throwing here would dead-end\n * `mesh vpn tunnel up` / `mesh dev` on the very file this function deletes.\n * Safe to call when nothing is running.\n */\nexport function stopDaemon(tenant: string): void {\n // Prefer the recorded pid; fall back to discovering it from argv so a daemon\n // left without a daemon.json (older build / cleared meta) can still be killed.\n const meta = readDaemonMeta(tenant) ?? (() => {\n const running = findRunningDaemon(tenant);\n return running ? { socksPort: running.socksPort ?? 0, pid: running.pid } : null;\n })();\n // `ensureDaemon` records `pid: 0` when it adopts a running daemon whose pid it\n // could not attribute, so absent-or-0 is this file's \"no pid known\" sentinel \u2014\n // skip it, don't signal and don't fail. EVERY other unsignalable value (1, a\n // negative, a fraction, a string from a truncated/hand-edited daemon.json) is\n // corruption and must be refused rather than handed to kill. See utils/pid.ts.\n if (meta?.pid) {\n try {\n assertSignalablePid(meta.pid, {\n what: `the VPN daemon pid recorded for tenant '${tenant}'`,\n file: daemonMetaPath(tenant),\n });\n process.kill(meta.pid, \"SIGTERM\");\n } catch (err) {\n // Refusing to SIGNAL is the whole point; refusing to CLEAN UP is not.\n // The unlink + clearDaemonMeta below are what make the corrupt file\n // self-healing, and `ensureDaemon` (the UP path) depends on them. Warn\n // with the `rm` remediation intact and fall through.\n if (err instanceof MeshCliError) logWarn(renderErrorBody(err));\n /* else: ESRCH/EPERM from process.kill \u2014 already gone, as before */\n }\n }\n for (const p of [socketPath(tenant)]) {\n try {\n fs.unlinkSync(p);\n } catch {\n /* already gone */\n }\n }\n clearDaemonMeta(tenant);\n}\n\n/**\n * Build the `tailscale up` argument vector. Pure so it can be unit-tested and so\n * the interactive (browser-register) and zero-touch (pre-auth key) forms differ\n * only by the trailing `--authkey`.\n */\nexport function buildTailscaleUpArgs(opts: {\n socket: string;\n loginServer: string;\n hostname: string;\n preAuthKey?: string;\n}): string[] {\n const args = [\n \"--socket\", opts.socket, \"up\",\n \"--login-server\", opts.loginServer,\n \"--accept-routes\",\n \"--hostname\", opts.hostname,\n ];\n if (opts.preAuthKey) args.push(\"--authkey\", opts.preAuthKey);\n return args;\n}\n\n/**\n * Join the tenant's Headscale tailnet via Zitadel OIDC if not already Running.\n * Prints the device-flow registration URL and polls until registered. When a\n * pre-auth key is supplied the join is non-interactive: no `/register/` URL is\n * emitted, so the scrape/relay is skipped and we just wait for Running.\n */\nexport async function joinHeadscale(\n tenant: string,\n loginServer: string,\n opts: { preAuthKey?: string } = {},\n): Promise<void> {\n if (daemonState(tenant).backendState === \"Running\") return;\n const hostname = `mesh-${tenant}-${os.hostname().split(\".\")[0]}`;\n const child = spawn(\n tailscaleBinPath(),\n buildTailscaleUpArgs({ socket: socketPath(tenant), loginServer, hostname, preAuthKey: opts.preAuthKey }),\n { stdio: [\"ignore\", \"pipe\", \"pipe\"] },\n );\n if (!opts.preAuthKey) {\n let buf = \"\";\n let urlShown = false;\n const capture = (d: Buffer) => {\n buf += d.toString();\n if (urlShown) return;\n const m = buf.match(/https?:\\/\\/\\S+\\/register\\/\\S+/);\n if (m) {\n urlShown = true;\n // Relay the registration URL for headless/SSH machines (there's no local\n // browser to pop). One-time per machine \u2014 the node registration persists.\n logInfo(\n `\\n This machine isn't registered with the platform VPN yet.\\n` +\n ` Open this URL in a browser to authorize it (one-time, sign in with Zitadel):\\n\\n` +\n ` ${m[0]}\\n\\n` +\n ` Waiting for approval (up to 5 min)\u2026`,\n );\n }\n };\n child.stdout.on(\"data\", capture);\n child.stderr.on(\"data\", capture);\n }\n // Poll up to ~5 min \u2014 the join is an interactive browser approval.\n for (let i = 0; i < 600; i++) {\n await sleep(500);\n if (daemonState(tenant).backendState === \"Running\") {\n child.kill();\n return;\n }\n }\n child.kill();\n throw new Error(\n `VPN join not completed for tenant '${tenant}' within 5 min. Re-run \\`mesh vpn tunnel up\\` to resume \u2014 the daemon is still up.`,\n );\n}\n\n/** Log the tenant daemon out of Headscale (disconnect). Daemon process persists. */\nexport function logout(tenant: string): void {\n try {\n execFileSync(tailscaleBinPath(), [\"--socket\", socketPath(tenant), \"logout\"], { stdio: \"ignore\" });\n } catch {\n /* best-effort */\n }\n}\n\n// ============================================================================\n// Forwarder supervisor (shared per tenant) + devOutput rewrite\n// ============================================================================\n\n/**\n * True iff two target sets are identical down to targetIp + remotePort. A live\n * runner pins each forwarder's target at startup, so if the ELB IP rotated (or a\n * port changed) the running forwarders point at a dead address and must be\n * respawned, not reused. Pid-agnostic (a launchd runner records no supervisor pid).\n */\nexport function tunnelTargetsEqual(a: TunnelTarget[], b: TunnelTarget[]): boolean {\n const key = (ts: TunnelTarget[]) =>\n ts.map((t) => `${t.name}:${t.localPort}:${t.targetIp}:${t.remotePort}`).sort().join(\",\");\n return key(a) === key(b);\n}\n\n/** True iff `state` has a supervisor pid and its target set equals `targets`. */\nexport function supervisorTargetsMatch(\n state: TunnelState | null,\n targets: TunnelTarget[],\n): boolean {\n if (!state || !state.supervisorPid) return false;\n return tunnelTargetsEqual(state.tunnels, targets);\n}\n\nfunction pidAlive(pid: number | null | undefined): boolean {\n if (!pid) return false;\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * SIGTERM the supervisor's whole process GROUP, not the bare pid.\n *\n * `state.supervisorPid` is the pid of the detached spawn \u2014 which, under the\n * canonical source launcher, is `bin/mesh.mjs`. That launcher blocks in\n * `execFileSync` around the *real* runner (a `node --import tsx \u2026 mesh.ts`\n * grandchild that actually binds the forwarder ports), and `execFileSync` does\n * not forward signals. So signalling the bare pid kills only the launcher and\n * ORPHANS the runner, which keeps its ports bound \u2014 the orphan then answers\n * `allForwardersListening`'s ownership-less liveness dial and silently pins\n * every tunnel to a stale target (the MESH-2169 / MESH-2014 failure class).\n *\n * The launcher is spawned `detached: true`, so it is a process-group leader\n * (`pgid === pid`); `process.kill(-pid, \u2026)` reaps the launcher and the runner\n * together. Falls back to the bare pid for a legacy state file whose pid was\n * not a group leader, and tolerates ESRCH (already gone).\n *\n * `pid` is always recovered \u2014 from `tunnel-state.json`, `runner.json`, or a\n * `ps` scan \u2014 never constructed here, so it is guarded first. The guard rejects\n * anything below 2, which is what makes the NEGATED form safe: `pid >= 2` \u21D2\n * `-pid <= -2`, a real process group, never `kill(0)` (this shell's own group)\n * or `kill(-1)` (broadcast to everything the user can signal). See utils/pid.ts.\n *\n * @throws {MeshCliError} when `pid` is unsignalable. Callers reaping SEVERAL\n * independently-recovered pids must catch per-pid (see `stopTunnelRunners`) \u2014\n * letting one refusal propagate would skip the other pids and leave a live\n * runner holding ports.\n */\nexport function killSupervisor(pid: number, source?: PidSource): void {\n // MUST be outside the try \u2014 both signals below are wrapped in catch-alls, so a\n // guard placed inside would have its refusal swallowed and fall through to the\n // bare-pid kill it was meant to prevent.\n assertSignalablePid(pid, source ?? { what: \"tunnel supervisor pid\" });\n try {\n process.kill(-pid, \"SIGTERM\"); // detached spawn \u21D2 pid is the group leader\n } catch {\n try {\n process.kill(pid, \"SIGTERM\"); // not a group leader (old state file) \u2014 best effort\n } catch {\n /* already gone (ESRCH) */\n }\n }\n}\n\n/**\n * The ownership handshake for a tenant's tunnel runner (MESH-2407): manifest \u2192\n * live pid \u2192 control endpoint \u2192 token + SOCKS port + port coverage. This is\n * the ONLY readiness/reuse signal \u2014 a bare TCP dial proves a listener exists,\n * not that it is ours, and adopting a foreign listener is exactly the\n * silent-dead-tunnel class (MESH-2169 / MESH-2014 / #2991). As a bonus the\n * handshake reads listening state from the runner's in-process servers, so it\n * opens no SOCKS\u2192VPC data connection the way port dials do.\n */\nexport async function verifyTunnelOwnership(\n tenant: string,\n expected: { socksPort: number; ports: number[] },\n): Promise<OwnershipCheck> {\n return verifyRunnerOwnership({\n manifest: readRunnerManifest(runnerManifestPath(tenant)),\n expectedSocksPort: expected.socksPort,\n expectedPorts: expected.ports,\n });\n}\n\n/**\n * Poll the ownership handshake until it passes or `deadlineMs` of WALL CLOCK\n * elapses (each check can cost up to the control-query timeout, so the bound\n * must be time, not rounds); returns the last check.\n */\nfunction awaitOwnership(\n tenant: string,\n expected: { socksPort: number; ports: number[] },\n deadlineMs: number,\n): Promise<OwnershipCheck> {\n return pollOwnership(() => verifyTunnelOwnership(tenant, expected), deadlineMs);\n}\n\n/**\n * Tear down EVERY runner we can attribute to the tenant: the recorded\n * supervisor pid (as a process group \u2014 see `killSupervisor`), the launchd\n * agent, the manifest's process group, and any `__run`/`__supervise` process\n * discovered from argv (a runner from an older build, or one whose state file\n * was lost). This is the single teardown path \u2014 the #2991 lesson generalized:\n * no kill path may leave a grandchild with ports still bound.\n */\nexport function stopTunnelRunners(tenant: string, prior: TunnelState | null): void {\n if (prior?.managed === \"launchd\") uninstallLaunchAgent(tenant);\n // Each pid below is RECOVERED (two state files and a ps scan), so each names\n // its own provenance \u2014 the refusal has to point at the exact file to delete,\n // or a corrupt state dir bricks the very command that would have cleared it.\n //\n // The three sources are INDEPENDENT, so a refusal on one must not skip the\n // others or `clearRunnerManifest`. Aborting would let one corrupt integer in\n // `tunnel-state.json` strand a live, uncorrupted runner from `runner.json`\n // with its forwarder ports still bound \u2014 the silent-squatter class this\n // teardown exists to prevent (\"no kill path may leave a grandchild with ports\n // still bound\", MESH-2407). Refuse the bad pid, reap the rest;\n // `assertTunnelPortsFree` is the backstop that still fails loudly if an\n // un-reaped runner really is holding ports.\n const killOrWarn = (pid: number, source: PidSource): void => {\n try {\n killSupervisor(pid, source);\n } catch (err) {\n if (!(err instanceof MeshCliError)) throw err;\n logWarn(renderErrorBody(err));\n }\n };\n if (prior?.supervisorPid)\n killOrWarn(prior.supervisorPid, {\n what: `the tunnel supervisor pid recorded for tenant '${tenant}'`,\n file: statePath(tenant),\n });\n const manifest = readRunnerManifest(runnerManifestPath(tenant));\n if (manifest)\n killOrWarn(manifest.pgid || manifest.pid, {\n what: `the tunnel runner process group recorded for tenant '${tenant}'`,\n file: runnerManifestPath(tenant),\n });\n for (const pid of discoverRunnerPids(tenant, process.pid))\n killOrWarn(pid, { what: `a tunnel runner pid discovered from 'ps' for tenant '${tenant}'` });\n clearRunnerManifest(runnerManifestPath(tenant));\n}\n\n/**\n * After teardown, wait for the tenant's forwarder ports to actually free. Any\n * port still accepting is held by a process we could not kill \u2014 a foreign\n * squatter \u2014 and spawning a runner over it would produce forwarders that\n * silently fail to bind (the false-green readiness gate). Fail LOUDLY instead.\n */\nasync function assertTunnelPortsFree(tenant: string, ports: number[]): Promise<void> {\n let held = ports;\n for (let i = 0; i < 8; i++) {\n const stillHeld: number[] = [];\n for (const p of held) {\n if (await portAccepts(p, 250)) stillHeld.push(p);\n }\n held = stillHeld;\n if (held.length === 0) return;\n await sleep(250);\n }\n const squatted = await findSquattedPorts(held, (p) => portAccepts(p, 250));\n throw new Error(buildSquatterError(tenant, squatted, \"still bound after teardown\"));\n}\n\n/**\n * Ensure the per-tenant forwarder supervisor is running for `state.tunnels`.\n * Idempotent: reuses a live runner ONLY when it passes the ownership handshake\n * (our manifest token, our SOCKS port, every target port listening in-process);\n * otherwise tears down every attributable runner, verifies the ports actually\n * freed (a still-held port is a foreign squatter \u2192 loud failure, never spawn\n * over it), and (re)spawns a fresh runner \u2014 whose readiness is again the\n * handshake, not a bare port dial. Persists the state and returns the pid.\n */\nexport async function ensureForwarders(state: TunnelState): Promise<number> {\n const prior = readTunnelState(state.tenant);\n const expected = { socksPort: state.socksPort, ports: state.tunnels.map((t) => t.localPort) };\n // Reuse-if-healthy must be mode-aware. A launchd runner records no supervisorPid\n // (KeepAlive owns liveness), so gating reuse on pidAlive would tear down + rebind\n // the shared tunnels on EVERY `up`/`mesh dev` \u2014 severing other concurrent sessions,\n // the exact failure MESH-2014 exists to kill. Treat the launchd agent as \"alive\"\n // when its plist is installed; keep the pid check for the detached path.\n const runnerAlive =\n prior?.managed === \"launchd\"\n ? fs.existsSync(launchAgentPath(state.tenant))\n : pidAlive(prior?.supervisorPid);\n if (\n prior &&\n runnerAlive &&\n tunnelTargetsEqual(prior.tunnels, state.tunnels) &&\n // A prior supervisor spawned for a DIFFERENT SOCKS port still binds the same\n // local ports but dials a dead upstream \u2014 reusing it silently strands every\n // tunnel (MESH-2169). The port must match, and the runner must PROVE it is\n // ours via the ownership handshake (liveness \u2260 ownership \u2014 a port answering\n // a dial can be another session's SSM tunnel or an orphaned runner, #2991).\n // Poll briefly rather than sample once: `ports-not-listening` during the\n // runner's own rebind window and `no-control-answer` under load are\n // transient, and a single miss here would tear down a healthy runner SHARED\n // by every concurrent session on the tenant (MESH-2014). This branch is\n // only reached when the runner is alive with matching targets, so the\n // retry costs nothing on cold-start or genuinely-dead paths.\n prior.socksPort === state.socksPort &&\n (await awaitOwnership(state.tenant, expected, 1000)).ok\n ) {\n state.supervisorPid = prior.supervisorPid;\n state.managed = prior.managed;\n writeTunnelState(state.tenant, state);\n return prior.supervisorPid ?? 0;\n }\n // Tear down every attributable runner (state pid group, launchd agent,\n // manifest process group, argv-discovered strays), then require the ports to\n // actually free \u2014 a still-held port is foreign and must fail loudly here,\n // not surface later as a forwarder that silently failed to bind.\n stopTunnelRunners(state.tenant, prior);\n await assertTunnelPortsFree(state.tenant, expected.ports);\n // Write state first so the runner reads the correct targets on start.\n state.supervisorPid = null;\n // Prefer a launchd-supervised runner (survives reboot/crash); fall back to a\n // detached runner where launchd isn't usable (e.g. sandboxed shells). Both run\n // the same self-healing `__run`.\n // argv[1] under the canonical source launcher is bin/mesh.ts, which plain\n // node cannot execute (same failure class as the credential_process fix in\n // aws-auth.ts) \u2014 resolve to the stable launcher, or the bare `mesh` fallback.\n const meshBin = resolveStableMeshBin(process.argv[1]);\n if (launchdAvailable()) {\n state.managed = \"launchd\";\n writeTunnelState(state.tenant, state);\n // launchd does no PATH lookup, so the bare `mesh` fallback can't back a\n // LaunchAgent (it would become `node mesh \u2026`, crash-looping under KeepAlive).\n // Require an absolute path; otherwise fall through to the detached runner,\n // where spawn *does* resolve `mesh` on PATH.\n if (path.isAbsolute(meshBin) && installLaunchAgent(state.tenant, meshBin)) {\n // Installing the agent proves nothing about the forwarders \u2014 wait for the\n // runner's ownership handshake (this path used to return success blind).\n if ((await awaitOwnership(state.tenant, expected, 8000)).ok) return 0;\n uninstallLaunchAgent(state.tenant);\n // `launchctl bootout` signals the job but doesn't wait for it \u2014 spawning\n // the detached runner immediately would race the dying launchd runner for\n // the ports (EADDRINUSE \u2192 a transient turned into a hard failure). Every\n // teardown path must be followed by the ports-actually-freed check.\n await assertTunnelPortsFree(state.tenant, expected.ports);\n logInfo(\"launchd runner did not take ownership of the tunnel ports \u2014 using a detached runner.\");\n } else {\n logInfo(\"launchd install failed \u2014 using a detached self-healing runner.\");\n }\n }\n state.managed = \"detached\";\n writeTunnelState(state.tenant, state);\n // meshBin is the stable launcher, or the bare `mesh` fallback which spawn\n // resolves on PATH here (no PATH-lookup ternary needed \u2014 that was a no-op).\n const child = spawn(meshBin, [\"vpn\", \"tunnel\", \"__run\", state.tenant], {\n detached: true,\n stdio: \"ignore\",\n });\n // ENOENT from the bare `mesh` fallback (mesh-cli not installed globally)\n // arrives asynchronously \u2014 without this handler an unhandled 'error' throws\n // an uncaught exception and takes down `mesh dev` instead of degrading.\n child.on(\"error\", (err) =>\n logWarn(`tunnel supervisor failed to start (${err.message}) \u2014 falling back to SSM.`),\n );\n child.unref();\n state.supervisorPid = child.pid ?? null;\n writeTunnelState(state.tenant, state);\n // Readiness = the ownership handshake, not a port dial: the fresh runner must\n // present its manifest token and report every forwarder listening from its\n // own in-process servers. A dial would also pass for another session's SSM\n // tunnel on the same 2XXXX port \u2014 the false-green this contract kills.\n // Throwing here lands in startServices' catch \u2192 clean SSM fallback.\n const last = await awaitOwnership(state.tenant, expected, 5000);\n if (last.ok) return child.pid ?? -1;\n // Diagnose before failing: a port that accepts TCP while ownership failed is\n // held by someone else \u2014 name it and say exactly what to do.\n const squatted = await findSquattedPorts(expected.ports, (p) => portAccepts(p, 250));\n if (squatted.length > 0) {\n throw new Error(buildSquatterError(state.tenant, squatted, `ownership check: ${last.reason}`));\n }\n const bindErrors = Object.entries(last.resp?.bindErrors ?? {})\n .map(([port, code]) => `port ${port}: ${code}`)\n .join(\", \");\n throw new Error(\n `Tailscale forwarders did not take ownership for tenant '${state.tenant}' ` +\n `(${last.reason}${bindErrors ? `; ${bindErrors}` : \"\"}) \u2014 see ${logPath(state.tenant)}.`,\n );\n}\n\n/**\n * Rewrite `devOutput.tunnels` so each resolved target points at its local\n * forwarder (`localhost:localPort`) \u2014 the same shape `startSsmTunnels`\n * produces. Unmatched tunnels are left untouched. Pure (no mutation of input).\n */\nexport function rewriteTunnelsForTargets(\n devOutput: DevOutput,\n targets: TunnelTarget[],\n): DevOutput {\n const tunnels = { ...devOutput.tunnels };\n for (const t of targets) {\n tunnels[t.name] = { host: \"localhost\", port: t.localPort };\n }\n return { ...devOutput, tunnels };\n}\n\n/** True when the brew `tailscaled` binary is resolvable (Tailscale transport is usable). */\nexport function tailscaleAvailable(): boolean {\n try {\n tailscaledBinPath();\n return true;\n } catch {\n return false;\n }\n}\n\n// ============================================================================\n// Shared engine: ensure/start Tailscale tunnels (used by the command + mesh dev)\n// ============================================================================\n\n/**\n * Ensure the tenant's Tailscale tunnels are fully up: the shared per-tenant\n * daemon (started + OIDC-joined if needed), the resolved VPC targets, and the\n * forwarder supervisor. Idempotent (shared across worktrees/invocations of the\n * same tenant). Returns the resolved targets. Used by both `mesh vpn tunnel up`\n * and `mesh dev --transport=tailscale`.\n */\nexport async function ensureTailscaleTunnels(\n tenant: string,\n env: string,\n opts: { region: string; loginServer: string; socksPort: number; preAuthKey?: string },\n): Promise<TunnelTarget[]> {\n // Reconcile the daemon's SOCKS port \u2014 an already-running daemon may be on a\n // different port than requested; the forwarders MUST dial the real one (MESH-2169).\n const socksPort = await ensureDaemon(tenant, { region: opts.region, socksPort: opts.socksPort });\n await joinHeadscale(tenant, opts.loginServer, { preAuthKey: opts.preAuthKey });\n // Pass the region explicitly rather than clobbering process.env.AWS_REGION \u2014\n // this runs mid `mesh dev` launch and must not mutate the global env.\n const bastion = await getPlatformBastionInfo(tenant, env, opts.region);\n if (!bastion) {\n throw new Error(`Could not read the platform bastion for ${tenant}/${env} (check AWS credentials).`);\n }\n const portOffset = tenantPortIndex(tenant) * PORT_BLOCK;\n const targets = resolveTunnelTargets(bastion.services, resolveElbIp, portOffset);\n if (targets.length === 0) {\n throw new Error(`No resolvable VPC tunnels found for ${tenant}/${env}.`);\n }\n const state: TunnelState = {\n tenant,\n env,\n socksPort,\n loginServer: opts.loginServer,\n supervisorPid: null,\n startedAt: new Date().toISOString(),\n tunnels: targets,\n };\n await ensureForwarders(state);\n return targets;\n}\n\n/**\n * `startSsmTunnels` drop-in for `mesh dev`: ensure the tenant's Tailscale\n * tunnels, then rewrite `devOutput.tunnels` to the local forwarder ports.\n */\nexport async function startTailscaleTunnels(\n devOutput: DevOutput,\n ctx: { tenant: string; env: string; region: string; loginServer: string; socksPort: number; preAuthKey?: string },\n): Promise<DevOutput> {\n const targets = await ensureTailscaleTunnels(ctx.tenant, ctx.env, {\n region: ctx.region,\n loginServer: ctx.loginServer,\n socksPort: ctx.socksPort,\n preAuthKey: ctx.preAuthKey,\n });\n return rewriteTunnelsForTargets(devOutput, targets);\n}\n\n// ============================================================================\n// launchd supervision + self-heal (Slice 2a)\n// ============================================================================\n\nexport function launchAgentLabel(tenant: string): string {\n return `tech.mesh.tunnel.${tenant}`;\n}\n\n/** Minimal XML-escape for plist string values (controlled inputs, but paths can vary). */\nfunction xmlEscape(s: string): string {\n return s\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\");\n}\n\n/** Render the per-tenant LaunchAgent plist (pure). */\nexport function renderLaunchAgentPlist(args: {\n tenant: string;\n meshBin: string;\n nodeBin: string;\n logPath: string;\n /** PATH for the agent \u2014 must include the dir of `tailscaled` so daemon reheal\n * works under launchd's minimal PATH (esp. Intel `/usr/local/bin`). */\n pathEnv?: string;\n}): string {\n const pathEnv = args.pathEnv ?? \"/usr/bin:/bin:/usr/sbin:/sbin\";\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n <key>Label</key>\n <string>${launchAgentLabel(args.tenant)}</string>\n <key>ProgramArguments</key>\n <array>\n <string>${xmlEscape(args.nodeBin)}</string>\n <string>${xmlEscape(args.meshBin)}</string>\n <string>vpn</string>\n <string>tunnel</string>\n <string>__run</string>\n <string>${xmlEscape(args.tenant)}</string>\n </array>\n <key>EnvironmentVariables</key>\n <dict>\n <key>PATH</key>\n <string>${xmlEscape(pathEnv)}</string>\n </dict>\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n </dict>\n <key>StandardOutPath</key>\n <string>${xmlEscape(args.logPath)}</string>\n <key>StandardErrorPath</key>\n <string>${xmlEscape(args.logPath)}</string>\n</dict>\n</plist>\n`;\n}\n\n/** Targets whose local forwarder port isn't currently accepting connections. */\nexport function forwardersNeedingRebind(\n targets: TunnelTarget[],\n listening: Set<number>,\n): TunnelTarget[] {\n return targets.filter((t) => !listening.has(t.localPort));\n}\n\nexport function launchAgentPath(tenant: string): string {\n return path.join(os.homedir(), \"Library\", \"LaunchAgents\", `${launchAgentLabel(tenant)}.plist`);\n}\n\nfunction guiDomain(): string {\n return `gui/${process.getuid?.() ?? 0}`;\n}\n\nlet _launchdAvailable: boolean | undefined;\n/** True when launchctl works for this user (blocked under the seatbelt sandbox \u2192 false). */\nexport function launchdAvailable(): boolean {\n if (_launchdAvailable !== undefined) return _launchdAvailable;\n try {\n execFileSync(\"which\", [\"launchctl\"], { stdio: \"ignore\" });\n execFileSync(\"launchctl\", [\"print\", guiDomain()], { stdio: \"ignore\" });\n _launchdAvailable = true;\n } catch {\n _launchdAvailable = false;\n }\n return _launchdAvailable;\n}\n\n/**\n * Write + (re)load the per-tenant LaunchAgent. Returns true iff it fully\n * succeeded \u2014 any failure (plist write blocked by the sandbox, `launchctl`\n * bootstrap denied, \u2026) returns false so the caller falls back to a detached\n * runner. Never throws.\n */\nexport function installLaunchAgent(tenant: string, meshBin: string): boolean {\n const plistPath = launchAgentPath(tenant);\n try {\n // Resolve symlinks so the plist embeds a stable path (a since-deleted\n // worktree symlink would leave a KeepAlive agent respawning a dead binary).\n let realMeshBin = meshBin;\n try {\n realMeshBin = fs.realpathSync(meshBin);\n } catch {\n /* keep the raw path */\n }\n // Bake a PATH that includes the tailscaled dir so the runner's daemon reheal\n // works under launchd's minimal PATH (Intel installs live in /usr/local/bin).\n let pathEnv = \"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin\";\n try {\n pathEnv = `${path.dirname(tailscaledBinPath())}:${pathEnv}`;\n } catch {\n /* fall back to the default PATH */\n }\n fs.mkdirSync(path.dirname(plistPath), { recursive: true });\n fs.writeFileSync(\n plistPath,\n renderLaunchAgentPlist({\n tenant,\n meshBin: realMeshBin,\n nodeBin: process.execPath,\n logPath: path.join(tenantStateDir(tenant), \"runner.log\"),\n pathEnv,\n }),\n );\n try {\n execFileSync(\"launchctl\", [\"bootout\", guiDomain(), plistPath], { stdio: \"ignore\" });\n } catch {\n /* not loaded \u2014 fine */\n }\n execFileSync(\"launchctl\", [\"bootstrap\", guiDomain(), plistPath], { stdio: \"ignore\" });\n return true;\n } catch {\n return false;\n }\n}\n\n/** Unload + remove the per-tenant LaunchAgent (best-effort). */\nexport function uninstallLaunchAgent(tenant: string): void {\n const plistPath = launchAgentPath(tenant);\n try {\n execFileSync(\"launchctl\", [\"bootout\", guiDomain(), plistPath], { stdio: \"ignore\" });\n } catch {\n /* not loaded */\n }\n try {\n fs.unlinkSync(plistPath);\n } catch {\n /* already gone */\n }\n}\n\n// ============================================================================\n// The runner (__run): forwarders + continuous self-heal\n// ============================================================================\n\n/**\n * Long-lived runner for a tenant's tunnels \u2014 used both under launchd and as the\n * detached fallback. Runs a forwarder per target and, every ~5s, self-heals:\n * respawns the daemon if its process died, and rebinds any forwarder whose port\n * stopped listening. As the process that actually binds the ports, it is the\n * sole author of the ownership manifest + control endpoint (MESH-2407) that\n * callers verify against. Needs no AWS (targets come from tunnel-state). Never\n * returns.\n */\nexport async function runTunnels(tenant: string): Promise<never> {\n const st = readTunnelState(tenant);\n if (!st) {\n // State is gone (torn down). Under launchd's KeepAlive a non-zero exit would\n // respawn every ~10s forever (surviving reboots) \u2014 so self-uninstall the\n // agent (no-op when detached), drop any manifest a previous incarnation\n // left (it describes a runner that no longer exists), and exit cleanly.\n uninstallLaunchAgent(tenant);\n clearRunnerManifest(runnerManifestPath(tenant));\n process.exit(0);\n }\n const token = mintRunnerToken();\n const servers = new Map<number, import(\"node:net\").Server>();\n // port \u2192 last bind error code. Surfaced through the control endpoint so a\n // failed bind (EADDRINUSE from a squatted port) is a visible, attributable\n // failure at the caller \u2014 never a silently-dead forwarder.\n const bindErrors = new Map<number, string>();\n const spawnFor = (t: TunnelTarget) => {\n servers.get(t.localPort)?.close();\n bindErrors.delete(t.localPort);\n const server = createForwarder({\n listenPort: t.localPort,\n socksPort: st.socksPort,\n targetIp: t.targetIp,\n targetPort: t.remotePort,\n onError: (err) => {\n bindErrors.set(t.localPort, err.code ?? err.message);\n logWarn(`forwarder ${t.name} failed on 127.0.0.1:${t.localPort}: ${err.code ?? err.message}`);\n },\n });\n server.on(\"listening\", () => bindErrors.delete(t.localPort));\n servers.set(t.localPort, server);\n };\n for (const t of st.tunnels) spawnFor(t);\n const control = await startControlServer(\n (): ControlResponse => ({\n token,\n tenant,\n pid: process.pid,\n socksPort: st.socksPort,\n listening: st.tunnels.filter((t) => servers.get(t.localPort)?.listening).map((t) => t.localPort),\n bindErrors: Object.fromEntries([...bindErrors].map(([p, c]) => [String(p), c])),\n }),\n );\n writeRunnerManifest(runnerManifestPath(tenant), {\n tenant,\n pid: process.pid,\n pgid: getProcessGroupId(process.pid) ?? process.pid,\n token,\n controlPort: control.port,\n socksPort: st.socksPort,\n ports: st.tunnels.map((t) => t.localPort),\n startedAt: new Date().toISOString(),\n });\n const shutdown = () => {\n control.server.close();\n for (const s of servers.values()) s.close();\n // Token-guarded: never clobber the manifest of a newer runner that already\n // replaced us (teardown kills groups, and signal delivery order is loose).\n clearRunnerManifest(runnerManifestPath(tenant), token);\n process.exit(0);\n };\n process.on(\"SIGTERM\", shutdown);\n process.on(\"SIGINT\", shutdown);\n for (;;) {\n await sleep(5000);\n try {\n if (daemonState(tenant).backendState === \"Down\") {\n await ensureDaemon(tenant, {\n region: process.env.AWS_REGION ?? \"us-east-2\",\n socksPort: st.socksPort,\n });\n }\n // Check the in-process net.Server directly \u2014 do NOT dial the forwarder.\n // A dial makes the forwarder open a real SOCKS\u2192VPC-target connection, and\n // the probe's clean FIN doesn't tear the upstream leg down, leaking a\n // connection to Temporal/RDS/etc. every 5s per target.\n const listening = new Set<number>();\n for (const t of st.tunnels) {\n if (servers.get(t.localPort)?.listening) listening.add(t.localPort);\n }\n for (const t of forwardersNeedingRebind(st.tunnels, listening)) spawnFor(t);\n } catch {\n /* keep the loop alive across transient errors */\n }\n }\n}\n", "/**\n * `mesh vpn tunnel` \u2014 userspace-Tailscale tunnels to a tenant's VPC services.\n *\n * Stands up the tenant's `localhost:PORT` set (drop-in for the SSM tunnels)\n * backed by a per-tenant userspace `tailscaled` + SOCKS5 forwarders. Detached\n * and managed: `up` starts everything and returns; `down`/`status` manage it.\n * (MESH-2040 / Slice 1 of MESH-2014; graduates the MESH-2036 spike.)\n */\n\nimport type { Command } from \"commander\";\nimport * as net from \"node:net\";\nimport { logInfo, logError, logSuccess } from \"../../utils/log.js\";\nimport { getContextConfig } from \"../login.js\";\nimport {\n ensureTailscaleTunnels,\n stopDaemon,\n logout,\n daemonState,\n readTunnelState,\n clearTunnelState,\n readDaemonMeta,\n runTunnels,\n stopTunnelRunners,\n verifyTunnelOwnership,\n} from \"../../utils/tailscale.js\";\n\ninterface VpnOpts {\n tenant: string;\n env: string;\n}\n\n/**\n * Derive the Headscale login-server URL from the login context's issuer\n * (`identity.<env>.<domain>` \u2192 `vpn.<env>.<domain>`). Mirrors\n * `vpnEndpointFromContext` in index.ts (kept local to avoid a circular import;\n * Slice 2 can unify).\n */\nfunction deriveLoginServer(context: string): string {\n const cfg = getContextConfig(context);\n if (!cfg?.issuer) {\n throw new Error(`No login config for context '${context}'. Run: mesh login ${context}`);\n }\n const u = new URL(cfg.issuer);\n const parts = u.hostname.split(\".\");\n parts[0] = \"vpn\";\n return `https://${parts.join(\".\")}`;\n}\n\nfunction findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const s = net.createServer();\n s.on(\"error\", reject);\n s.listen(0, \"127.0.0.1\", () => {\n const port = (s.address() as net.AddressInfo).port;\n s.close(() => resolve(port));\n });\n });\n}\n\nfunction canConnect(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const s = new net.Socket();\n let settled = false;\n const done = (ok: boolean) => {\n if (settled) return;\n settled = true;\n s.destroy();\n resolve(ok);\n };\n s.setTimeout(500);\n s.once(\"connect\", () => done(true));\n s.once(\"timeout\", () => done(false));\n s.once(\"error\", () => done(false));\n s.connect(port, \"127.0.0.1\");\n });\n}\n\n// \u2500\u2500 up \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function tunnelUp(vpn: VpnOpts, cmdOpts: { context?: string }): Promise<void> {\n const { tenant, env } = vpn;\n const context = cmdOpts.context ?? `${tenant}.${env}`;\n const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? \"us-east-2\";\n const loginServer = deriveLoginServer(context);\n\n const prior = readTunnelState(tenant);\n const daemonUp = daemonState(tenant).backendState !== \"Down\";\n // When the daemon is already up, reuse the SOCKS port IT is listening on\n // (from daemon.json, which `down` doesn't clear) \u2014 not `prior` (cleared by\n // `down`) and never a fresh port the daemon isn't bound to.\n const socksPort = daemonUp\n ? (readDaemonMeta(tenant)?.socksPort ?? prior?.socksPort ?? (await findFreePort()))\n : await findFreePort();\n\n logInfo(`Bringing up userspace VPN tunnels for tenant '${tenant}'...`);\n const targets = await ensureTailscaleTunnels(tenant, env, { region, loginServer, socksPort });\n\n logSuccess(`VPN tunnels up for '${tenant}' (${targets.length}):`);\n for (const t of targets) {\n logInfo(` ${t.name.padEnd(14)} 127.0.0.1:${t.localPort} \u2192 ${t.targetHost}:${t.remotePort}`);\n }\n}\n\n// The __run / __supervise runner lives in utils/tailscale.ts (runTunnels) \u2014\n// forwarders + a continuous self-heal loop, used under launchd or detached.\n\n// \u2500\u2500 down \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction tunnelDown(vpn: VpnOpts, cmdOpts: { logout?: boolean; stop?: boolean }): void {\n const { tenant } = vpn;\n const st = readTunnelState(tenant);\n // Nothing to tear down for the forwarders \u2014 but still honor an explicit\n // --stop/--logout so a leaked daemon (state file already gone) can be killed.\n if (!st && !cmdOpts.stop && !cmdOpts.logout) {\n logInfo(`No tunnels running for tenant '${tenant}'.`);\n return;\n }\n // One teardown path for every runner shape (state pid group, launchd agent,\n // manifest process group, argv-discovered strays) \u2014 no kill path may leave a\n // grandchild with ports still bound (MESH-2407).\n stopTunnelRunners(tenant, st);\n if (cmdOpts.logout) {\n logout(tenant);\n logInfo(\"Logged the VPN daemon out of Headscale.\");\n }\n // By default the daemon persists (join-once / reuse). --stop or --logout\n // (after which the daemon is useless) tear it down too.\n if (cmdOpts.stop || cmdOpts.logout) {\n stopDaemon(tenant);\n logInfo(\"Stopped the userspace VPN daemon.\");\n }\n clearTunnelState(tenant);\n logSuccess(`VPN tunnels down for '${tenant}'.`);\n}\n\n// \u2500\u2500 status \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function tunnelStatus(vpn: VpnOpts, cmdOpts: { json?: boolean }): Promise<void> {\n const { tenant } = vpn;\n const st = readTunnelState(tenant);\n const ds = daemonState(tenant);\n // Ownership handshake against the runner's manifest \u2014 status must distinguish\n // \"our runner owns the ports\" from \"something answers the port\" (MESH-2407).\n const owned = st\n ? await verifyTunnelOwnership(tenant, {\n socksPort: st.socksPort,\n ports: st.tunnels.map((t) => t.localPort),\n })\n : null;\n if (cmdOpts.json) {\n console.log(\n JSON.stringify(\n {\n tenant,\n daemon: ds,\n up: !!st,\n managed: st?.managed ?? null,\n owned: owned?.ok ?? false,\n ownership: owned ? (owned.ok ? \"owned\" : owned.reason) : \"no-state\",\n tunnels: st?.tunnels ?? [],\n },\n null,\n 2,\n ),\n );\n return;\n }\n logInfo(`Daemon: ${ds.backendState}${ds.selfName ? ` (${ds.selfName})` : \"\"}`);\n if (!st) {\n logInfo(`No tunnels running for tenant '${tenant}'.`);\n return;\n }\n logInfo(`Managed: ${st.managed ?? \"detached\"}`);\n logInfo(\n owned?.ok\n ? `Runner: owned (verified)`\n : `Runner: NOT OWNED (${owned?.reason}) \u2014 ports below may be foreign listeners`,\n );\n const listening = owned?.ok ? new Set(owned.resp?.listening ?? []) : null;\n for (const t of st.tunnels) {\n // When ownership holds, report from the runner's in-process listening set\n // (no data-plane dial); otherwise fall back to a dial, flagged above.\n const up = listening ? listening.has(t.localPort) : await canConnect(t.localPort);\n logInfo(` ${up ? \"\u2713\" : \"\u2717\"} ${t.name.padEnd(14)} 127.0.0.1:${t.localPort} \u2192 ${t.targetHost}:${t.remotePort}`);\n }\n}\n\n// \u2500\u2500 registration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Run an action, printing a clean error (not a stack trace) + exit 1 on failure. */\nasync function guard(fn: () => void | Promise<void>): Promise<void> {\n try {\n await fn();\n } catch (e) {\n logError(e instanceof Error ? e.message : String(e));\n process.exit(1);\n }\n}\n\nexport function registerTunnelSubcommands(vpn: Command): void {\n const tunnel = vpn\n .command(\"tunnel\")\n .description(\"Userspace-Tailscale tunnels to VPC services (localhost:PORT set)\");\n\n tunnel\n .command(\"up\", { isDefault: true })\n .description(\"Start the tenant's VPC tunnels over userspace Tailscale\")\n .option(\"--context <ctx>\", \"Login context for the VPN (default: <tenant>.<env>)\")\n .action((cmdOpts: { context?: string }) => guard(() => tunnelUp(vpn.opts() as VpnOpts, cmdOpts)));\n\n tunnel\n .command(\"down\")\n .description(\"Stop the tenant's VPC tunnels\")\n .option(\"--stop\", \"Also stop the userspace tailscaled daemon (keeps VPN login)\")\n .option(\"--logout\", \"Log the daemon out of Headscale and stop it\")\n .action((cmdOpts: { logout?: boolean; stop?: boolean }) =>\n guard(() => tunnelDown(vpn.opts() as VpnOpts, cmdOpts)),\n );\n\n tunnel\n .command(\"status\")\n .description(\"Show daemon + tunnel status\")\n .option(\"--json\", \"Machine-readable output\")\n .action((cmdOpts: { json?: boolean }) => guard(() => tunnelStatus(vpn.opts() as VpnOpts, cmdOpts)));\n\n tunnel\n .command(\"__run <tenant>\", { hidden: true })\n .action((tenant: string) => runTunnels(tenant));\n\n // Back-compat alias for any launchd/detached runner referencing the old name.\n tunnel\n .command(\"__supervise <tenant>\", { hidden: true })\n .action((tenant: string) => runTunnels(tenant));\n}\n", "/**\n * mesh vpn \u2014 Headscale VPN management commands\n *\n * Commands:\n * mesh vpn connect <context> Connect to VPN via Tailscale + OIDC\n * mesh vpn disconnect Disconnect from VPN\n * mesh vpn status Show VPN control plane status\n * mesh vpn api-key Create a Headscale API key\n * mesh vpn pre-auth-key Create a pre-authentication key for a user\n * mesh vpn users List registered VPN users\n *\n * All commands shell out to kubectl. The user must have kubeconfig\n * configured for the target cluster (e.g., via `aws eks update-kubeconfig`).\n */\n\nimport { Command } from \"commander\";\nimport { execFileSync, type ExecFileSyncOptions } from \"child_process\";\nimport * as net from \"node:net\";\nimport { logInfo, logSuccess, logError, logWarn } from \"../../utils/index.js\";\nimport { getContextConfig } from \"../login.js\";\nimport { registerTunnelSubcommands } from \"./tunnel.js\";\nimport {\n ensureDaemon,\n joinHeadscale,\n daemonState,\n stopDaemon,\n tailscaleAvailable,\n readDaemonMeta,\n findRunningDaemon,\n} from \"../../utils/tailscale.js\";\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Default namespace follows the convention: {tenant}-{env}-headscale */\nfunction defaultNamespace(tenant: string, env: string): string {\n return `${tenant}-${env}-headscale`;\n}\n\nconst HEADSCALE_POD = \"headscale-0\";\nconst HEADSCALE_CONTAINER = \"headscale\";\n\n// =============================================================================\n// Options\n// =============================================================================\n\ninterface VpnOptions {\n tenant: string;\n env: string;\n namespace?: string;\n}\n\ninterface PreAuthKeyOptions extends VpnOptions {\n user: string;\n expiration?: string;\n reusable?: boolean;\n ephemeral?: boolean;\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction resolveNamespace(options: VpnOptions): string {\n return options.namespace ?? defaultNamespace(options.tenant, options.env);\n}\n\n/**\n * Run a headscale CLI command inside the headscale pod.\n * Returns stdout as a string. Throws on non-zero exit.\n */\nfunction headscaleExec(\n namespace: string,\n args: string[],\n opts?: { json?: boolean },\n): string {\n const cmd = [\"headscale\", ...args];\n if (opts?.json) {\n cmd.push(\"--output\", \"json\");\n }\n\n const execOpts: ExecFileSyncOptions = {\n encoding: \"utf-8\" as BufferEncoding,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n };\n\n try {\n const result = execFileSync(\n \"kubectl\",\n [\n \"exec\",\n \"-n\", namespace,\n HEADSCALE_POD,\n \"-c\", HEADSCALE_CONTAINER,\n \"--\",\n ...cmd,\n ],\n execOpts,\n );\n return (result as string).trim();\n } catch (error: unknown) {\n const execError = error as { stderr?: Buffer | string; status?: number };\n const stderr = execError.stderr?.toString().trim() ?? \"\";\n if (stderr) {\n throw new Error(stderr);\n }\n throw error;\n }\n}\n\n/**\n * Derive the likely EKS cluster name from tenant/env convention.\n */\nfunction clusterName(tenant: string, env: string): string {\n return `${tenant}-${env}-eks`;\n}\n\n/**\n * Check that the headscale pod is running and reachable.\n */\nfunction assertPodReady(namespace: string, options: VpnOptions): void {\n try {\n const output = execFileSync(\n \"kubectl\",\n [\n \"get\", \"pod\", HEADSCALE_POD,\n \"-n\", namespace,\n \"-o\", \"jsonpath={.status.phase}\",\n ],\n { encoding: \"utf-8\", stdio: [\"pipe\", \"pipe\", \"pipe\"] },\n );\n if (output.trim() !== \"Running\") {\n logError(`Headscale pod is not running (status: ${output.trim()})`);\n process.exit(1);\n }\n } catch (err: unknown) {\n const stderr = (err as { stderr?: Buffer | string }).stderr?.toString() ?? \"\";\n logError(`Cannot reach headscale pod in namespace ${namespace}`);\n if (stderr.includes(\"expired\") || stderr.includes(\"token\")) {\n logInfo(\"Your AWS credentials appear to be expired. Refresh them and retry.\");\n } else {\n const cluster = clusterName(options.tenant, options.env);\n logInfo(\"Make sure your kubeconfig is configured for the target cluster:\");\n const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? \"us-east-2\";\n logInfo(` aws eks update-kubeconfig --name ${cluster} --region ${region}`);\n }\n process.exit(1);\n }\n}\n\n// =============================================================================\n// Commands\n// =============================================================================\n\nasync function vpnStatus(options: VpnOptions): Promise<void> {\n const ns = resolveNamespace(options);\n logInfo(`Checking Headscale in namespace ${ns}...`);\n\n assertPodReady(ns, options);\n\n // Get pod details\n const podJson = execFileSync(\n \"kubectl\",\n [\n \"get\", \"pod\", HEADSCALE_POD,\n \"-n\", ns,\n \"-o\", \"json\",\n ],\n { encoding: \"utf-8\", stdio: [\"pipe\", \"pipe\", \"pipe\"] },\n );\n const pod = JSON.parse(podJson);\n const container = pod.status?.containerStatuses?.find(\n (c: { name: string }) => c.name === HEADSCALE_CONTAINER,\n );\n const image = container?.image ?? \"unknown\";\n const ready = container?.ready ?? false;\n const restarts = container?.restartCount ?? 0;\n\n // Get the HTTPRoute hostname\n let endpoint = \"unknown\";\n try {\n endpoint = execFileSync(\n \"kubectl\",\n [\n \"get\", \"httproute\",\n \"-n\", ns,\n \"-o\", \"jsonpath={.items[0].spec.hostnames[0]}\",\n ],\n { encoding: \"utf-8\", stdio: [\"pipe\", \"pipe\", \"pipe\"] },\n ).trim();\n if (endpoint) {\n endpoint = `https://${endpoint}`;\n }\n } catch {\n // HTTPRoute may not exist\n }\n\n // List users\n let userCount = 0;\n try {\n const usersJson = headscaleExec(ns, [\"users\", \"list\"], { json: true });\n const users = JSON.parse(usersJson);\n userCount = Array.isArray(users) ? users.length : 0;\n } catch {\n // Users may not be queryable yet\n }\n\n // List nodes\n let nodeCount = 0;\n try {\n const nodesJson = headscaleExec(ns, [\"nodes\", \"list\"], { json: true });\n const nodes = JSON.parse(nodesJson);\n nodeCount = Array.isArray(nodes) ? nodes.length : 0;\n } catch {\n // Nodes may not be queryable\n }\n\n console.log(\"\");\n console.log(` Endpoint: ${endpoint}`);\n console.log(` Image: ${image}`);\n console.log(` Ready: ${ready ? \"yes\" : \"no\"}`);\n console.log(` Restarts: ${restarts}`);\n console.log(` Namespace: ${ns}`);\n console.log(` Users: ${userCount}`);\n console.log(` Nodes: ${nodeCount}`);\n console.log(\"\");\n\n if (ready) {\n logSuccess(\"Headscale is healthy\");\n } else {\n logWarn(\"Headscale pod is not ready\");\n }\n}\n\nasync function createApiKey(options: VpnOptions): Promise<void> {\n const ns = resolveNamespace(options);\n logInfo(`Creating Headscale API key in namespace ${ns}...`);\n\n assertPodReady(ns, options);\n\n try {\n const output = headscaleExec(ns, [\"apikeys\", \"create\"]);\n console.log(\"\");\n logSuccess(\"API key created:\");\n console.log(\"\");\n console.log(` ${output}`);\n console.log(\"\");\n logWarn(\"Store this key securely \u2014 it cannot be retrieved again.\");\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : String(error);\n logError(`Failed to create API key: ${message}`);\n process.exit(1);\n }\n}\n\nasync function createPreAuthKey(options: PreAuthKeyOptions): Promise<void> {\n const ns = resolveNamespace(options);\n const { user, expiration, reusable, ephemeral } = options;\n\n logInfo(`Creating pre-auth key for user \"${user}\" in namespace ${ns}...`);\n\n assertPodReady(ns, options);\n\n // Ensure the user exists\n try {\n headscaleExec(ns, [\"users\", \"create\", user]);\n logInfo(`Created user \"${user}\"`);\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : String(error);\n // \"already exists\" is fine\n if (!message.includes(\"already exists\")) {\n logError(`Failed to create user: ${message}`);\n process.exit(1);\n }\n logInfo(`User \"${user}\" already exists`);\n }\n\n const args = [\"preauthkeys\", \"create\", \"--user\", user];\n if (expiration) {\n args.push(\"--expiration\", expiration);\n }\n if (reusable) {\n args.push(\"--reusable\");\n }\n if (ephemeral) {\n args.push(\"--ephemeral\");\n }\n\n try {\n const output = headscaleExec(ns, args);\n console.log(\"\");\n logSuccess(\"Pre-authentication key created:\");\n console.log(\"\");\n console.log(` ${output}`);\n console.log(\"\");\n logInfo(\"Use this key to register a node:\");\n logInfo(` tailscale up --login-server <endpoint> --authkey ${output}`);\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : String(error);\n logError(`Failed to create pre-auth key: ${message}`);\n process.exit(1);\n }\n}\n\nasync function listUsers(options: VpnOptions): Promise<void> {\n const ns = resolveNamespace(options);\n logInfo(`Listing Headscale users in namespace ${ns}...`);\n\n assertPodReady(ns, options);\n\n try {\n const output = headscaleExec(ns, [\"users\", \"list\"]);\n console.log(\"\");\n console.log(output);\n console.log(\"\");\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : String(error);\n logError(`Failed to list users: ${message}`);\n process.exit(1);\n }\n}\n\n// =============================================================================\n// VPN Connect / Disconnect\n// =============================================================================\n\n/**\n * Derive the VPN (Headscale) endpoint from a mesh login context.\n *\n * The login config stores an issuer URL like:\n * https://identity.dev.mesh-platform.trabian.com\n * The VPN endpoint follows the same domain with \"vpn\" as the subdomain:\n * https://vpn.dev.mesh-platform.trabian.com\n */\nfunction vpnEndpointFromContext(context: string): string {\n const config = getContextConfig(context);\n if (!config) {\n throw new Error(\n `No configuration found for \"${context}\".\\n` +\n `Run: mesh login ${context} (or add the context to ~/.config/mesh/config.json)`\n );\n }\n\n const issuerUrl = new URL(config.issuer);\n // Replace the first subdomain (\"identity\") with \"vpn\"\n const hostParts = issuerUrl.hostname.split(\".\");\n if (hostParts.length < 3) {\n throw new Error(\n `Cannot derive VPN endpoint from issuer \"${config.issuer}\". ` +\n `Expected a subdomain like identity.<env>.<domain>.`\n );\n }\n hostParts[0] = \"vpn\";\n return `https://${hostParts.join(\".\")}`;\n}\n\n/** Split a platform context (\"mesh.dev\") into its tenant + env. Pure. */\nexport function parseContextTenantEnv(context: string): { tenant: string; env: string } {\n const [tenant, env] = context.split(\".\");\n return { tenant: tenant || \"mesh\", env: env || \"dev\" };\n}\n\n/** Ephemeral free TCP port on loopback for the userspace daemon's SOCKS proxy. */\nfunction findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const srv = net.createServer();\n srv.on(\"error\", reject);\n srv.listen(0, \"127.0.0.1\", () => {\n const addr = srv.address();\n const port = typeof addr === \"object\" && addr ? addr.port : 0;\n srv.close(() => resolve(port));\n });\n });\n}\n\n/**\n * Check whether the `tailscale` CLI is installed and return its path,\n * or null if not found.\n */\nfunction findTailscale(): string | null {\n try {\n return execFileSync(\"which\", [\"tailscale\"], {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n }).trim() || null;\n } catch {\n return null;\n }\n}\n\ninterface TailscaleStatus {\n BackendState?: string;\n /** Control server URL, e.g. \"https://vpn.dev.mesh-platform.trabian.com\" */\n ControlURL?: string;\n CurrentTailnet?: { Name?: string } | null;\n}\n\n/** Return Tailscale's full status, or null if tailscale is not running. */\nfunction tailscaleStatus(): TailscaleStatus | null {\n try {\n const json = execFileSync(\"tailscale\", [\"status\", \"--json\"], {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n return JSON.parse(json) as TailscaleStatus;\n } catch {\n return null;\n }\n}\n\n/** Return Tailscale's BackendState, or null if tailscale is not running. */\nexport function tailscaleBackendState(): string | null {\n return tailscaleStatus()?.BackendState ?? null;\n}\n\n/**\n * Normalize a control server URL for comparison.\n * Trims trailing slashes and lowercases the origin.\n */\nfunction normalizeControlUrl(url: string): string {\n try {\n const u = new URL(url);\n return `${u.protocol}//${u.host}`.toLowerCase();\n } catch {\n return url.replace(/\\/+$/, \"\").toLowerCase();\n }\n}\n\nasync function vpnConnect(context: string, opts: { system?: boolean } = {}): Promise<void> {\n const endpoint = vpnEndpointFromContext(context);\n if (opts.system) {\n // Whole-machine VPN via the GUI Tailscale.app daemon. Opt-in only: it needs\n // the GUI app installed/running and CANNOT run headless or under the safehouse\n // sandbox (dyld can't load the app bundle's Sparkle.framework \u2192 abort trap).\n await vpnConnectSystem(endpoint);\n return;\n }\n await vpnConnectUserspace(context, endpoint);\n}\n\n/**\n * Default path: bring up the per-tenant userspace `tailscaled` (brew formula, NOT\n * the GUI app) and register this machine with the platform Headscale. This is the\n * same daemon `mesh dev --transport tailscale` / `mesh vpn tunnel` already use, so\n * it runs headless and under the sandbox. It exposes a local SOCKS5 proxy \u2014 the\n * userspace analog of system-wide routing \u2014 instead of a system TUN; for\n * auto-forwarded VPC services (Temporal, RDS, \u2026) use `mesh vpn tunnel` / `mesh dev`.\n */\nasync function vpnConnectUserspace(context: string, loginServer: string): Promise<void> {\n if (!tailscaleAvailable()) {\n logError(\"Standalone tailscale/tailscaled not found.\");\n logInfo(\"Install it: brew install tailscale\");\n logInfo(\"(The GUI Tailscale.app is not used here \u2014 for whole-machine VPN see `mesh vpn connect --system`.)\");\n process.exit(1);\n }\n const { tenant } = parseContextTenantEnv(context);\n const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? \"us-east-2\";\n\n if (daemonState(tenant).backendState === \"Running\") {\n // Prefer daemon.json; recover the real bound port from argv if meta is missing\n // (older build) so a \"connected\" session always shows how to reach it.\n const port = readDaemonMeta(tenant)?.socksPort ?? findRunningDaemon(tenant)?.socksPort;\n logSuccess(`Already connected to the platform VPN for '${tenant}' (userspace).`);\n if (port) logInfo(` SOCKS5 proxy: 127.0.0.1:${port} (route a tool via ALL_PROXY=socks5://127.0.0.1:${port})`);\n return;\n }\n\n logInfo(`Connecting to the platform VPN for '${tenant}' (userspace tailscaled)\u2026`);\n const socksPort = readDaemonMeta(tenant)?.socksPort ?? (await findFreePort());\n const realPort = await ensureDaemon(tenant, { region, socksPort });\n // Interactive join \u2014 joinHeadscale relays the one-time registration URL for\n // headless/SSH machines (no local browser to pop) and polls up to ~5 min.\n await joinHeadscale(tenant, loginServer);\n logSuccess(`VPN connected (userspace) for '${tenant}'.`);\n logInfo(` SOCKS5 proxy: 127.0.0.1:${realPort} \u2014 route a tool via ALL_PROXY=socks5://127.0.0.1:${realPort}`);\n logInfo(\" For auto-forwarded VPC services (Temporal, RDS, \u2026): mesh vpn tunnel up (or mesh dev)\");\n}\n\n/** Opt-in whole-machine VPN via the GUI Tailscale.app daemon (`mesh vpn connect --system`). */\nasync function vpnConnectSystem(endpoint: string): Promise<void> {\n if (!findTailscale()) {\n logError(\"Tailscale CLI not found.\");\n logInfo(\"Install it: brew install tailscale (or see https://tailscale.com/download)\");\n process.exit(1);\n }\n\n // If already connected to *this* endpoint, short-circuit. But if connected\n // to a different control server, fall through and reconnect \u2014 otherwise the\n // user would silently stay on the wrong tailnet.\n const status = tailscaleStatus();\n if (status?.BackendState === \"Running\") {\n const current = status.ControlURL ? normalizeControlUrl(status.ControlURL) : null;\n const target = normalizeControlUrl(endpoint);\n if (current === target) {\n logSuccess(`Already connected to VPN at ${endpoint}.`);\n logInfo(\"Run: tailscale status to see connected nodes.\");\n return;\n }\n if (current) {\n logWarn(`Currently connected to ${current}. Reconnecting to ${target}...`);\n }\n }\n\n logInfo(`Connecting to VPN at ${endpoint}...`);\n logInfo(\"A browser window will open for Zitadel authentication.\");\n console.log(\"\");\n\n // tailscale up is interactive (opens browser), so we inherit stdio.\n // On macOS, Tailscale.app owns the daemon \u2014 `tailscale up` talks to it.\n // On Linux, tailscaled runs as root and `tailscale up` may need sudo.\n //\n // --reset: wipe prior prefs so this command is idempotent. Without it,\n // tailscale refuses to apply prefs if a previous session set non-default\n // flags that aren't re-specified here.\n // --accept-routes: accept subnet routes advertised by the Headscale\n // SubnetRouter so clients can reach in-VPC services (EKS, RDS, internal LBs).\n const upArgs = [\n \"up\",\n \"--reset\",\n \"--login-server\", endpoint,\n \"--accept-routes\",\n ];\n try {\n execFileSync(\"tailscale\", upArgs, { stdio: \"inherit\" });\n console.log(\"\");\n logSuccess(\"VPN connected.\");\n } catch (error: unknown) {\n const code = (error as { status?: number }).status;\n const isPermissionError = process.platform === \"linux\" && (code === 1 || code === 2);\n console.log(\"\");\n logError(`tailscale up failed (exit ${code}).`);\n if (isPermissionError) {\n logInfo(\"On Linux, tailscaled runs as root \u2014 you may need sudo:\");\n logInfo(` sudo tailscale ${upArgs.join(\" \")}`);\n }\n process.exit(1);\n }\n}\n\nasync function vpnDisconnect(opts: { tenant: string; system?: boolean }): Promise<void> {\n if (opts.system) {\n // Whole-machine GUI Tailscale down (opt-in, mirrors `connect --system`).\n if (!findTailscale()) {\n logError(\"Tailscale CLI not found.\");\n process.exit(1);\n }\n if (tailscaleBackendState() !== \"Running\") {\n logInfo(\"System VPN is not connected.\");\n return;\n }\n logInfo(\"Disconnecting system VPN\u2026\");\n try {\n execFileSync(\"tailscale\", [\"down\"], { stdio: \"inherit\" });\n logSuccess(\"VPN disconnected.\");\n } catch (error: unknown) {\n const code = (error as { status?: number }).status;\n logError(`tailscale down failed (exit ${code}). Try: sudo tailscale down`);\n process.exit(1);\n }\n return;\n }\n\n // Default: stop the tenant's userspace daemon (leaves the Headscale node\n // identity in the statedir so a later `connect` rejoins without re-login).\n const { tenant } = opts;\n if (daemonState(tenant).backendState === \"Down\") {\n logInfo(`No userspace VPN daemon running for '${tenant}'.`);\n return;\n }\n logInfo(`Disconnecting userspace VPN for '${tenant}'\u2026`);\n stopDaemon(tenant);\n logSuccess(`VPN disconnected for '${tenant}'.`);\n}\n\n// =============================================================================\n// Registration\n// =============================================================================\n\nexport function registerVpnCommands(program: Command): void {\n const vpn = program\n .command(\"vpn\")\n .description(\"Headscale VPN management\")\n .option(\"-t, --tenant <tenant>\", \"Platform tenant\", \"mesh\")\n .option(\"-e, --env <env>\", \"Platform environment\", \"dev\")\n .option(\"-n, --namespace <namespace>\", \"Override K8s namespace (default: {tenant}-{env}-headscale)\");\n\n vpn\n .command(\"status\")\n .description(\"Show VPN control plane status\")\n .action(async () => {\n const opts = vpn.opts() as VpnOptions;\n await vpnStatus(opts);\n });\n\n vpn\n .command(\"connect\")\n .description(\"Connect to the platform VPN (userspace tailscaled; opens browser for Zitadel auth)\")\n .argument(\"<context>\", 'Platform context (e.g., \"mesh.dev\")')\n .option(\n \"--system\",\n \"Use the whole-machine GUI Tailscale.app instead (system TUN; cannot run headless/sandboxed)\",\n )\n .action(async (context: string, cmdOpts: { system?: boolean }) => {\n await vpnConnect(context, cmdOpts);\n });\n\n vpn\n .command(\"disconnect\")\n .description(\n \"Disconnect from the platform VPN \u2014 stops the tenant's shared userspace daemon \" +\n \"(also ends any active `mesh vpn tunnel` / `mesh dev` forwards for it); --system for GUI Tailscale\",\n )\n .option(\"--system\", \"Disconnect the whole-machine GUI Tailscale instead of the userspace daemon\")\n .action(async (cmdOpts: { system?: boolean }) => {\n const { tenant } = vpn.opts() as VpnOptions;\n await vpnDisconnect({ tenant, system: cmdOpts.system });\n });\n\n vpn\n .command(\"api-key\")\n .description(\"Create a Headscale API key\")\n .action(async () => {\n const opts = vpn.opts() as VpnOptions;\n await createApiKey(opts);\n });\n\n vpn\n .command(\"pre-auth-key\")\n .description(\"Create a pre-authentication key for node registration\")\n .requiredOption(\"-u, --user <user>\", \"User/namespace to create the key for (e.g., matt@trabian.com)\")\n .option(\"--expiration <duration>\", \"Key expiration (e.g., 24h, 7d)\", \"24h\")\n .option(\"--reusable\", \"Allow key to be used multiple times\")\n .option(\"--ephemeral\", \"Nodes registered with this key are ephemeral\")\n .action(async (cmdOpts: { user: string; expiration?: string; reusable?: boolean; ephemeral?: boolean }) => {\n const parentOpts = vpn.opts() as VpnOptions;\n await createPreAuthKey({ ...parentOpts, ...cmdOpts });\n });\n\n vpn\n .command(\"users\")\n .description(\"List registered VPN users\")\n .action(async () => {\n const opts = vpn.opts() as VpnOptions;\n await listUsers(opts);\n });\n\n registerTunnelSubcommands(vpn);\n}\n", "/**\n * mesh login \u2014 Authenticate with Zitadel via OIDC PKCE or Device Code flow.\n *\n * Opens browser to Zitadel (PKCE) or prints a code to enter in the browser\n * (Device Code). Exchanges for tokens and caches at ~/.config/mesh/credentials.json.\n *\n * Config is read from ~/.config/mesh/config.json, keyed by context\n * (e.g., \"mesh.dev\"). After deploying the platform, copy the CLI client ID\n * from the Pulumi stack output into the config file.\n *\n * In containers/SSH sessions, device code flow is used automatically.\n *\n * Usage:\n * mesh login mesh.dev \u2014 authenticate to mesh dev\n * mesh login mesh.dev --device \u2014 force device code flow\n * mesh login mesh.dev --status \u2014 show current auth state (Zitadel session if\n * there is one, otherwise whatever the AWS\n * credential chain resolves \u2014 SSO or key pair)\n * mesh logout mesh.dev \u2014 clear cached credentials\n */\n\nimport { Command } from \"commander\";\nimport * as http from \"http\";\nimport * as crypto from \"crypto\";\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport { execFileSync } from \"child_process\";\nimport { logError, logInfo, logSuccess, logWarn } from \"../utils/log.js\";\nimport {\n assumeRoleCredentials,\n assumeRoleWithWebIdentity,\n describeAssumeFailure,\n probeAwsIdentity,\n renderCredentialProcessProfile,\n tokenIssuer,\n resolveStableMeshBin,\n selectRoleForCaller,\n toCredentialProcessJson,\n upsertManagedAwsConfigSection,\n type AwsIdentity,\n} from \"../utils/aws-auth.js\";\n\nimport { firstPartyDomainFor } from \"../utils/first-party-contexts.js\";\nimport { tailscaleBackendState } from \"./vpn/index.js\";\n// =============================================================================\n// Paths (XDG Base Directory Specification)\n// =============================================================================\n\nconst CONFIG_DIR = path.join(\n process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? \"~\", \".config\"),\n \"mesh\"\n);\nconst CONFIG_FILE = path.join(CONFIG_DIR, \"config.json\");\nconst CREDENTIALS_FILE = path.join(CONFIG_DIR, \"credentials.json\");\n\nconst REDIRECT_PORT = 9876;\nconst REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;\nconst SCOPES =\n \"openid email profile offline_access urn:zitadel:iam:org:project:id:zitadel:aud\";\n\n// =============================================================================\n// Config & Credentials\n// =============================================================================\n\ninterface ContextConfig {\n issuer: string;\n clientId: string;\n /**\n * Default IAM role ARN to assume for this context when no `--role` flag\n * and no `MESH_AWS_ROLE` env var are provided. Set automatically the first\n * time `mesh login ... --export --role <arn>` runs against this context;\n * subsequent `--role` values overwrite it.\n */\n defaultRole?: string;\n /**\n * Optional admin-tier IAM role for this context. When set,\n * `selectRoleForCaller` upgrades to this ARN automatically when the\n * caller's Zitadel JWT carries the admin role (default\n * `mesh.platform:admin`; override via adminClaimRoles).\n */\n adminRole?: string;\n /**\n * Zitadel role names that map a caller to `adminRole`. Defaults to\n * `[\"mesh.platform:admin\"]` when omitted.\n */\n adminClaimRoles?: string[];\n /** Headscale login server (published by the platform). */\n vpn?: string;\n /** Zero-touch pre-auth broker URL (published by the platform). */\n vpnJoinBroker?: string;\n /**\n * Zitadel-gated registry-auth broker URL (published by the platform).\n * With this set, `mesh registry login` needs no AWS credentials at all.\n */\n registryBroker?: string;\n}\n\ninterface CachedCredentials {\n idToken: string;\n accessToken: string;\n refreshToken?: string;\n expiresAt: string; // ISO date\n email?: string;\n sub?: string;\n}\n\ntype ConfigFile = Record<string, ContextConfig>;\ntype CredentialsFile = Record<string, CachedCredentials>;\n\nfunction readConfig(): ConfigFile {\n if (!fs.existsSync(CONFIG_FILE)) return {};\n try {\n return JSON.parse(fs.readFileSync(CONFIG_FILE, \"utf-8\"));\n } catch {\n return {};\n }\n}\n\nexport function writeContextConfig(context: string, config: ContextConfig): void {\n const existing = readConfig();\n existing[context] = config;\n fs.mkdirSync(CONFIG_DIR, { recursive: true });\n fs.writeFileSync(CONFIG_FILE, JSON.stringify(existing, null, 2));\n}\n\nfunction getContextConfig(context: string): ContextConfig | null {\n const config = readConfig();\n return config[context] ?? null;\n}\n\n/**\n * Parse a mesh context like \"mesh.dev\" or \"acme.uat\" into tenant + env.\n * Returns null if the context doesn't match the expected {tenant}.{env} pattern.\n */\nfunction parseTenantEnv(context: string): { tenant: string; env: string } | null {\n const parts = context.split(\".\");\n if (parts.length !== 2 || !parts[0] || !parts[1]) return null;\n return { tenant: parts[0], env: parts[1] };\n}\n\n/**\n * Attempt auto-discovery of platform config from SSM Parameter Store.\n *\n * Uses ambient AWS credentials to read the zitadel config export at\n * /mesh-platform/{tenant}/{env}/platform/zitadel. This is the preferred\n * discovery path for users who already have AWS SSO access to the platform.\n *\n * Returns null if AWS credentials are unavailable, SSM read fails, or the\n * parameter doesn't contain the required fields.\n */\nasync function discoverConfigFromSsm(context: string): Promise<ContextConfig | null> {\n const parsed = parseTenantEnv(context);\n if (!parsed) return null;\n\n const { tenant, env } = parsed;\n const ssmPath = `/mesh-platform/${tenant}/${env}/platform/zitadel`;\n logInfo(`Attempting SSM discovery from ${ssmPath}...`);\n\n try {\n const { SSMClient, GetParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? \"us-east-2\";\n const ssm = new SSMClient({ region });\n\n const resp = await ssm.send(new GetParameterCommand({ Name: ssmPath }));\n const raw = resp.Parameter?.Value;\n if (!raw) {\n logWarn(`SSM parameter ${ssmPath} has no value`);\n return null;\n }\n\n const data = JSON.parse(raw) as Record<string, unknown>;\n const issuer = data.endpoint;\n const clientId = data.cliClientId;\n\n if (typeof issuer !== \"string\" || !issuer) {\n logWarn(\"SSM zitadel entry missing 'endpoint' field\");\n return null;\n }\n if (typeof clientId !== \"string\" || !clientId) {\n logWarn(\n \"SSM zitadel entry missing 'cliClientId' field.\\n\" +\n \" The platform needs to be deployed with an updated ZitadelPlatformIdentity\\n\" +\n \" that exports cliClientId. Until then, contact your platform admin.\"\n );\n return null;\n }\n\n const config: ContextConfig = { issuer, clientId };\n if (typeof data.vpn === \"string\") config.vpn = data.vpn;\n if (typeof data.vpnJoinBroker === \"string\") config.vpnJoinBroker = data.vpnJoinBroker;\n if (typeof data.registryBroker === \"string\") config.registryBroker = data.registryBroker;\n writeContextConfig(context, config);\n logSuccess(`Discovered platform configuration for ${context} via SSM`);\n return config;\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n if (message.includes(\"ExpiredToken\") || message.includes(\"credentials\")) {\n logWarn(`SSM discovery failed: AWS credentials expired or unavailable.`);\n // Do NOT send someone to `aws sso login` when an anonymous path is about\n // to be tried \u2014 on the install journey that is the exact misdirection\n // this feature removes. Only mention AWS when nothing else can resolve.\n if (firstPartyDomainFor(context) || context.split(\".\").length - 1 >= 2) {\n logInfo(` Falling back to anonymous HTTPS discovery \u2014 no AWS needed.`);\n } else {\n logInfo(` If you have an AWS account: aws sso login --profile <profile>`);\n logInfo(` Otherwise use the platform's full domain, e.g. mesh login dev.<tenant>.meshtech.io`);\n }\n } else if (message.includes(\"ParameterNotFound\")) {\n logWarn(`SSM parameter not found: ${ssmPath}`);\n logInfo(\" This platform context may not be deployed.\");\n } else {\n logWarn(`SSM discovery failed: ${message}`);\n }\n return null;\n }\n}\n\n/**\n * Attempt auto-discovery of platform config from a well-known endpoint.\n * Returns null if discovery fails for any reason (network, timeout, bad data).\n *\n * `contextKey` is the name the resulting config is CACHED under, which is not\n * always the domain probed: a pinned first-party alias (`mesh.dev`) probes\n * `dev.platform.meshtech.io` but must be cached under `mesh.dev`, or the very\n * next command re-runs discovery and the alias buys nothing.\n */\nasync function discoverConfig(\n domain: string,\n contextKey: string = domain,\n): Promise<ContextConfig | null> {\n const url = `https://cli.${domain}/.well-known/mesh.json`;\n logInfo(`Attempting discovery from ${url}...`);\n\n try {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 5000);\n const resp = await fetch(url, { signal: controller.signal });\n clearTimeout(timeout);\n\n if (!resp.ok) {\n logWarn(`Discovery endpoint returned ${resp.status}`);\n return null;\n }\n\n const data = (await resp.json()) as Record<string, unknown>;\n const issuer = data.issuer;\n const clientId = data.clientId;\n\n if (typeof issuer !== \"string\" || !issuer || typeof clientId !== \"string\" || !clientId) {\n logWarn(\"Discovery endpoint returned invalid config (missing issuer or clientId)\");\n return null;\n }\n\n const config: ContextConfig = { issuer, clientId };\n if (typeof data.vpn === \"string\") config.vpn = data.vpn;\n if (typeof data.vpnJoinBroker === \"string\") config.vpnJoinBroker = data.vpnJoinBroker;\n if (typeof data.registryBroker === \"string\") config.registryBroker = data.registryBroker;\n writeContextConfig(contextKey, config);\n logSuccess(`Discovered platform configuration for ${contextKey}`);\n return config;\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n if (message.includes(\"abort\")) {\n logWarn(\"Discovery timed out\");\n } else {\n logWarn(`Discovery failed: ${message}`);\n }\n return null;\n }\n}\nfunction readAllCredentials(): CredentialsFile {\n if (!fs.existsSync(CREDENTIALS_FILE)) return {};\n try {\n return JSON.parse(fs.readFileSync(CREDENTIALS_FILE, \"utf-8\"));\n } catch {\n return {};\n }\n}\n\nfunction readCredentials(context: string): CachedCredentials | null {\n return readAllCredentials()[context] ?? null;\n}\n\n/** Monotonic counter mixed into temp-file names alongside the pid, so two\n * writes from the same process in the same tick (e.g. rapid retries) never\n * collide on a temp path. */\nlet atomicWriteCounter = 0;\n\n/**\n * Write `data` to `path` atomically: write to a temp file in the SAME\n * directory as `path` (so the subsequent rename is same-filesystem, hence\n * atomic), then `fs.renameSync` it into place. This guarantees concurrent\n * readers (e.g. other `credential_process` invocations racing a refresh)\n * only ever observe a fully-written file \u2014 never a truncated one from an\n * interleaved `fs.writeFileSync`.\n *\n * On any error, best-effort removes the temp file before rethrowing.\n *\n * Exported for unit testing.\n */\nexport function atomicWriteFileSync(path: string, data: string, mode: number): void {\n const tmpPath = `${path}.${process.pid}.${atomicWriteCounter++}.tmp`;\n try {\n fs.writeFileSync(tmpPath, data, { mode });\n fs.renameSync(tmpPath, path);\n } catch (err) {\n try {\n fs.unlinkSync(tmpPath);\n } catch {\n // best-effort cleanup \u2014 ignore (e.g. write itself never created the file)\n }\n throw err;\n }\n}\n\nfunction writeCredentials(context: string, creds: CachedCredentials): void {\n fs.mkdirSync(CONFIG_DIR, { recursive: true });\n const all = readAllCredentials();\n all[context] = creds;\n atomicWriteFileSync(CREDENTIALS_FILE, JSON.stringify(all, null, 2), 0o600);\n}\n\nfunction clearCredentials(context: string): void {\n const all = readAllCredentials();\n delete all[context];\n fs.mkdirSync(CONFIG_DIR, { recursive: true });\n fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(all, null, 2), {\n mode: 0o600,\n });\n}\n\n// =============================================================================\n// OIDC Helpers\n// =============================================================================\n\ninterface TokenResponse {\n access_token: string;\n id_token: string;\n refresh_token?: string;\n token_type: string;\n expires_in: number;\n}\n\nfunction base64url(buffer: Buffer): string {\n return buffer.toString(\"base64url\");\n}\n\nfunction generateCodeVerifier(): string {\n return base64url(crypto.randomBytes(32));\n}\n\nfunction generateCodeChallenge(verifier: string): string {\n return base64url(crypto.createHash(\"sha256\").update(verifier).digest());\n}\n\nfunction decodeJwtPayload(token: string): Record<string, unknown> {\n const parts = token.split(\".\");\n if (parts.length !== 3) throw new Error(\"Invalid JWT\");\n return JSON.parse(Buffer.from(parts[1]!, \"base64url\").toString());\n}\n\nasync function exchangeCode(\n issuer: string,\n clientId: string,\n code: string,\n codeVerifier: string\n): Promise<TokenResponse> {\n const body = new URLSearchParams({\n grant_type: \"authorization_code\",\n code,\n redirect_uri: REDIRECT_URI,\n client_id: clientId,\n code_verifier: codeVerifier,\n });\n\n const resp = await fetch(`${issuer}/oauth/v2/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: body.toString(),\n });\n\n if (!resp.ok) {\n const text = await resp.text();\n throw new Error(`Token exchange failed (${resp.status}): ${text}`);\n }\n\n return resp.json() as Promise<TokenResponse>;\n}\n\nasync function refreshTokens(\n issuer: string,\n clientId: string,\n refreshToken: string\n): Promise<TokenResponse> {\n const body = new URLSearchParams({\n grant_type: \"refresh_token\",\n refresh_token: refreshToken,\n client_id: clientId,\n });\n\n const resp = await fetch(`${issuer}/oauth/v2/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: body.toString(),\n });\n\n if (!resp.ok) {\n throw new Error(`Token refresh failed (${resp.status})`);\n }\n\n return resp.json() as Promise<TokenResponse>;\n}\n\n// =============================================================================\n// Login Flow\n// =============================================================================\n\nfunction login(context: string, config: ContextConfig): Promise<void> {\n return new Promise((resolve, reject) => {\n const codeVerifier = generateCodeVerifier();\n const codeChallenge = generateCodeChallenge(codeVerifier);\n const state = base64url(crypto.randomBytes(16));\n\n const authUrl = new URL(`${config.issuer}/oauth/v2/authorize`);\n authUrl.searchParams.set(\"client_id\", config.clientId);\n authUrl.searchParams.set(\"redirect_uri\", REDIRECT_URI);\n authUrl.searchParams.set(\"response_type\", \"code\");\n authUrl.searchParams.set(\"scope\", SCOPES);\n authUrl.searchParams.set(\"code_challenge\", codeChallenge);\n authUrl.searchParams.set(\"code_challenge_method\", \"S256\");\n authUrl.searchParams.set(\"state\", state);\n\n // Tracked teardown \u2014 without this `mesh login` hangs after success\n // because (a) the 2-minute timeout below kept the event loop alive,\n // and (b) the browser's keep-alive socket survived `server.close()`\n // (which only stops new connections, not active ones).\n let timeoutId: NodeJS.Timeout | undefined;\n\n const server = http.createServer(async (req, res) => {\n try {\n const url = new URL(req.url ?? \"/\", `http://localhost:${REDIRECT_PORT}`);\n if (url.pathname !== \"/callback\") {\n res.writeHead(404);\n res.end(\"Not found\");\n return;\n }\n\n const error = url.searchParams.get(\"error\");\n if (error) {\n const desc =\n url.searchParams.get(\"error_description\") ?? error;\n const safeDesc = desc.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\").replace(/\"/g, \"&quot;\");\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n `<html><body><h2>Login failed</h2><p>${safeDesc}</p></body></html>`\n );\n teardown();\n reject(new Error(desc));\n return;\n }\n\n const returnedState = url.searchParams.get(\"state\");\n if (returnedState !== state) {\n res.writeHead(400);\n res.end(\"State mismatch\");\n teardown();\n reject(new Error(\"State mismatch\"));\n return;\n }\n\n const code = url.searchParams.get(\"code\");\n if (!code) {\n res.writeHead(400);\n res.end(\"No code\");\n teardown();\n reject(new Error(\"No authorization code received\"));\n return;\n }\n\n // Exchange code for tokens\n const tokens = await exchangeCode(\n config.issuer,\n config.clientId,\n code,\n codeVerifier\n );\n const idPayload = decodeJwtPayload(tokens.id_token);\n const email =\n (idPayload.email as string) ??\n (idPayload.preferred_username as string) ??\n \"unknown\";\n const sub = idPayload.sub as string;\n\n // Parse tenants from access token\n // (the Zitadel Action enriches the access token with urn:mesh:tenants)\n let tenants: string[] = [];\n try {\n const accessPayload = decodeJwtPayload(tokens.access_token);\n tenants =\n (accessPayload[\"urn:mesh:tenants\"] as string[]) ?? [];\n } catch {\n // Access token may be opaque (non-JWT) in some configs\n }\n\n // Cache credentials\n writeCredentials(context, {\n idToken: tokens.id_token,\n accessToken: tokens.access_token,\n refreshToken: tokens.refresh_token,\n expiresAt: new Date(\n Date.now() + tokens.expires_in * 1000\n ).toISOString(),\n email,\n sub,\n });\n\n res.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n `<html><body><h2>&#x2705; Logged in to ${context}!</h2><p>You can close this tab.</p></body></html>`\n );\n\n logSuccess(`Logged in as ${email} (${context})`);\n if (tenants.length > 0) {\n logInfo(`Deployable tenants: ${tenants.join(\", \")}`);\n }\n hintVpnIfDisconnected(context);\n\n teardown();\n resolve();\n } catch (err) {\n teardown();\n reject(err);\n }\n });\n\n // Defined after `server` so the closure captures it; covers\n // success, error, and timeout paths.\n const teardown = () => {\n if (timeoutId) clearTimeout(timeoutId);\n server.closeAllConnections?.();\n server.close();\n };\n\n server.listen(REDIRECT_PORT, () => {\n logInfo(`Opening browser for authentication (${context})...`);\n\n const url = authUrl.toString();\n try {\n if (process.platform === \"darwin\") {\n execFileSync(\"open\", [url], { stdio: \"ignore\" });\n } else if (process.platform === \"linux\") {\n execFileSync(\"xdg-open\", [url], { stdio: \"ignore\" });\n } else {\n logInfo(`Open this URL in your browser:\\n${url}`);\n }\n } catch {\n logInfo(`Open this URL in your browser:\\n${url}`);\n }\n });\n\n // Timeout after 2 minutes. We keep a handle so success / error\n // paths can clear it via `teardown()` \u2014 otherwise the timer keeps\n // the Node event loop alive for 2 minutes after a successful\n // login and the CLI appears to hang.\n timeoutId = setTimeout(() => {\n teardown();\n reject(new Error(\"Login timed out (2 minutes)\"));\n }, 120_000);\n });\n}\n\n\n// =============================================================================\n// Device Code Flow (RFC 8628) \u2014 works in containers, SSH, headless envs\n// =============================================================================\n\ninterface DeviceAuthResponse {\n device_code: string;\n user_code: string;\n verification_uri: string;\n verification_uri_complete?: string;\n expires_in: number;\n interval: number;\n}\n\n/** Number of fresh device codes to issue before giving up. A code's TTL is\n * short (a couple of minutes), so if the user is slow we quietly issue another\n * rather than failing the whole login (we hit this repeatedly in practice). */\nconst MAX_DEVICE_CODES = 3;\n\n/**\n * Run device-code login, re-issuing a fresh code (up to `maxCodes`) if the user\n * doesn't finish before a code expires. `attempt` performs ONE code's\n * request+poll and resolves `\"success\"` or `\"expired\"`; it rejects only on a\n * hard error (denied / network). Extracted + injectable so the reissue loop is\n * unit-testable without real network calls.\n */\nexport async function deviceLoginWithReissue(\n attempt: (codeNumber: number) => Promise<\"success\" | \"expired\">,\n maxCodes: number = MAX_DEVICE_CODES,\n): Promise<void> {\n for (let n = 1; n <= maxCodes; n++) {\n if (n > 1) logWarn(`That code expired \u2014 issuing a fresh one (${n}/${maxCodes})\u2026`);\n if ((await attempt(n)) === \"success\") return;\n }\n throw new Error(\n `Device login not completed after ${maxCodes} codes. Re-run the command when you're ready to authorize.`,\n );\n}\n\nasync function deviceCodeLogin(context: string, config: ContextConfig): Promise<void> {\n await deviceLoginWithReissue(() => attemptDeviceCode(context, config));\n}\n\nasync function attemptDeviceCode(\n context: string,\n config: ContextConfig,\n): Promise<\"success\" | \"expired\"> {\n // Step 1: Request device authorization\n const resp = await fetch(`${config.issuer}/oauth/v2/device_authorization`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n client_id: config.clientId,\n scope: SCOPES,\n }).toString(),\n });\n\n if (!resp.ok) {\n const text = await resp.text();\n throw new Error(`Device authorization request failed (${resp.status}): ${text}`);\n }\n\n const deviceAuth = await resp.json() as DeviceAuthResponse;\n const openUrl = deviceAuth.verification_uri_complete ?? deviceAuth.verification_uri;\n\n // Step 2: Display instructions\n console.log();\n logInfo(`Open this URL in your browser:\\n`);\n logInfo(` ${openUrl}\\n`);\n logInfo(`Code: ${deviceAuth.user_code}`);\n console.log();\n logInfo(\"Waiting for authorization...\");\n\n // Try to open browser (works on host, harmless failure in container)\n try {\n if (process.platform === \"darwin\") {\n execFileSync(\"open\", [openUrl], { stdio: \"ignore\" });\n } else if (process.platform === \"linux\") {\n execFileSync(\"xdg-open\", [openUrl], { stdio: \"ignore\" });\n }\n } catch { /* ignore \u2014 user can open manually */ }\n\n // Step 3: Poll for token\n const deadline = Date.now() + deviceAuth.expires_in * 1000;\n let interval = deviceAuth.interval * 1000;\n\n while (Date.now() < deadline) {\n await new Promise((r) => setTimeout(r, interval));\n\n const tokenResp = await fetch(`${config.issuer}/oauth/v2/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n client_id: config.clientId,\n grant_type: \"urn:ietf:params:oauth:grant-type:device_code\",\n device_code: deviceAuth.device_code,\n }).toString(),\n });\n\n if (tokenResp.ok) {\n const tokens = await tokenResp.json() as TokenResponse;\n const idPayload = decodeJwtPayload(tokens.id_token);\n const email = (idPayload.email as string) ?? (idPayload.preferred_username as string) ?? \"unknown\";\n const sub = idPayload.sub as string;\n\n let tenants: string[] = [];\n try {\n const accessPayload = decodeJwtPayload(tokens.access_token);\n tenants = (accessPayload[\"urn:mesh:tenants\"] as string[]) ?? [];\n } catch { /* opaque access token */ }\n\n writeCredentials(context, {\n idToken: tokens.id_token,\n accessToken: tokens.access_token,\n refreshToken: tokens.refresh_token,\n expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),\n email,\n sub,\n });\n\n logSuccess(`Logged in as ${email} (${context})`);\n if (tenants.length > 0) {\n logInfo(`Deployable tenants: ${tenants.join(\", \")}`);\n }\n hintVpnIfDisconnected(context);\n return \"success\";\n }\n\n const error = await tokenResp.json() as { error: string; error_description?: string };\n switch (error.error) {\n case \"authorization_pending\":\n continue;\n case \"slow_down\":\n interval += 1000;\n continue;\n case \"expired_token\":\n return \"expired\"; // caller re-issues a fresh code\n case \"access_denied\":\n throw new Error(\"Authorization denied by user.\");\n default:\n throw new Error(`Token exchange failed: ${error.error} \u2014 ${error.error_description ?? \"\"}`);\n }\n }\n\n return \"expired\"; // deadline reached without authorization \u2014 re-issue\n}\n\n/**\n * Detect an environment where the PKCE browser-callback flow won't work \u2014 a\n * container, a remote/SSH session, or any non-interactive shell (piped output,\n * headless, agent/CI-run). In all of these the callback flow has no browser to\n * open and no interactive terminal, so it can only hang until the 2-minute\n * timeout. Device-code flow (print a URL + code to complete elsewhere) is the\n * correct fallback, so callers use it whenever this returns true.\n *\n * Exported for tests.\n */\nexport function isRemoteEnvironment(): boolean {\n // Docker / devcontainer\n if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) return true;\n if (fs.existsSync(\"/.dockerenv\")) return true;\n // SSH session\n if (process.env.SSH_CLIENT || process.env.SSH_TTY) return true;\n // No interactive terminal on stdin/stdout \u2014 `isTTY` is `undefined` (falsy)\n // when the stream is not a TTY. This is the case that bit us: an agent- or\n // script-driven shell over Tailscale has none of the env vars above set, so\n // the old check fell through to the browser flow and timed out. Device flow\n // still works when a browser IS available (just copy the URL+code), so\n // preferring it here is strictly safer than a guaranteed timeout.\n if (!process.stdout.isTTY || !process.stdin.isTTY) return true;\n return false;\n}\n\n/**\n * After a successful login, hint about VPN if Tailscale is not connected.\n * Non-blocking \u2014 never fails or delays the login flow.\n */\nfunction hintVpnIfDisconnected(context: string): void {\n try {\n const state = tailscaleBackendState();\n if (state !== \"Running\") {\n console.log(\"\");\n logInfo(`VPN not connected. To access dev services, run:`);\n logInfo(` mesh vpn connect ${context}`);\n }\n } catch {\n // Tailscale not installed or not queryable \u2014 skip hint\n }\n}\n\n// =============================================================================\n// Status\n// =============================================================================\n\n/**\n * What `--status` reports when there is no usable Zitadel session.\n *\n * Zitadel is one way to reach a platform's AWS account, not the only one. A\n * profile authenticated with SSO \u2014 or with a plain access-key/secret pair \u2014\n * is just as good, and everything downstream already accepts it: aws-auth's\n * path 1 (ambient env creds) and path 3 (AssumeRole via the current session)\n * never touch the OIDC cache. Reporting \"not logged in\" purely because that\n * cache is empty sent people off to run a device flow they did not need, and\n * failed the check for anyone deliberately using static credentials.\n *\n * So: fall through to the AWS credential chain and report what it finds.\n */\n/** One `--status` output line plus which logger it goes through. */\nexport interface StatusLine {\n level: \"success\" | \"info\" | \"warn\";\n text: string;\n}\n\n/**\n * The account id embedded in an IAM role ARN\n * (`arn:aws:iam::123456789012:role/...`), or undefined when there is no ARN\n * to read one from.\n */\nexport function accountIdFromRoleArn(\n roleArn: string | undefined\n): string | undefined {\n return roleArn?.match(/^arn:aws:iam::(\\d{12}):/)?.[1];\n}\n\n/**\n * Decide everything `--status` says (and its exit code) when there is no\n * usable Zitadel session \u2014 pure so the wording and the exit decision are\n * pinned by tests.\n *\n * The probe only proves \"some AWS identity works on this machine\"; it says\n * nothing about whether that identity belongs to `context`. So the claim is\n * scoped to the credentials \u2014 unless the context config carries a role ARN,\n * whose account id makes the context check free: verify when we can, say so\n * when we can't.\n */\nexport function renderAwsFallbackStatus(\n context: string,\n reason: string,\n identity: AwsIdentity | null,\n expectedAccountId?: string\n): { lines: StatusLine[]; exitCode: 0 | 1 } {\n if (!identity) {\n return {\n lines: [\n {\n level: \"warn\",\n text: `${reason}, and no working AWS credentials were found either.`,\n },\n { level: \"info\", text: \"Authenticate with whichever you use:\" },\n { level: \"info\", text: ` mesh login ${context} (Zitadel SSO)` },\n {\n level: \"info\",\n text: \" aws sso login --profile \u2026 (AWS SSO profile)\",\n },\n {\n level: \"info\",\n text: \" export AWS_PROFILE=\u2026 (profile with a key/secret pair)\",\n },\n ],\n exitCode: 1,\n };\n }\n\n const lines: StatusLine[] = [\n {\n level: \"success\",\n text: `AWS credentials are working (${identity.source})`,\n },\n { level: \"info\", text: `Identity: ${identity.arn}` },\n { level: \"info\", text: `Account: ${identity.accountId}` },\n ];\n\n if (expectedAccountId && identity.accountId !== expectedAccountId) {\n lines.push({\n level: \"warn\",\n text:\n `This account does not match ${context}'s configured role account ` +\n `(${expectedAccountId}) \u2014 commands against ${context} will likely ` +\n `fail with AccessDenied. Check AWS_PROFILE / your exported credentials.`,\n });\n } else if (expectedAccountId) {\n lines.push({\n level: \"info\",\n text: `Account matches ${context}'s configured role account.`,\n });\n } else {\n lines.push({\n level: \"info\",\n text:\n `This is a working AWS identity, not a verified connection to ` +\n `${context} \u2014 nothing here checked that this account is ${context}'s.`,\n });\n }\n\n lines.push({\n level: \"info\",\n text:\n `${reason} \u2014 that only matters for the commands that need a *user* ` +\n `identity (Hub SSO, \\`mesh dev\\` test users, zero-touch VPN join). ` +\n `Deploys, registry auth and secrets work off these credentials.`,\n });\n\n return { lines, exitCode: 0 };\n}\n\nasync function showAwsCredentialStatus(\n context: string,\n reason: string,\n config: ContextConfig\n): Promise<void> {\n const identity = await probeAwsIdentity();\n const { lines, exitCode } = renderAwsFallbackStatus(\n context,\n reason,\n identity,\n accountIdFromRoleArn(config.defaultRole ?? config.adminRole)\n );\n\n for (const line of lines) {\n if (line.level === \"success\") logSuccess(line.text);\n else if (line.level === \"warn\") logWarn(line.text);\n else logInfo(line.text);\n }\n if (exitCode !== 0) process.exit(exitCode);\n}\n\nasync function showStatus(\n context: string,\n config: ContextConfig\n): Promise<void> {\n const creds = readCredentials(context);\n if (!creds) {\n await showAwsCredentialStatus(context, \"No cached Zitadel session\", config);\n return;\n }\n\n const expired = new Date(creds.expiresAt) < new Date();\n\n if (expired && creds.refreshToken) {\n logInfo(\"Token expired, attempting refresh...\");\n try {\n const tokens = await refreshTokens(\n config.issuer,\n config.clientId,\n creds.refreshToken\n );\n const idPayload = decodeJwtPayload(tokens.id_token);\n writeCredentials(context, {\n ...creds,\n idToken: tokens.id_token,\n accessToken: tokens.access_token,\n refreshToken: tokens.refresh_token ?? creds.refreshToken,\n expiresAt: new Date(\n Date.now() + tokens.expires_in * 1000\n ).toISOString(),\n email: (idPayload.email as string) ?? creds.email,\n });\n logSuccess(`Token refreshed for ${creds.email ?? \"unknown\"}`);\n return;\n } catch {\n await showAwsCredentialStatus(\n context,\n \"Zitadel token expired and refresh failed\",\n config\n );\n return;\n }\n }\n\n if (expired) {\n await showAwsCredentialStatus(\n context,\n `Zitadel token expired at ${creds.expiresAt}`,\n config\n );\n return;\n }\n\n // Parse tenants from access token\n let tenants: string[] = [];\n try {\n const accessPayload = decodeJwtPayload(creds.accessToken);\n tenants = (accessPayload[\"urn:mesh:tenants\"] as string[]) ?? [];\n } catch {\n // Access token may be opaque\n }\n\n logSuccess(`Logged in as ${creds.email ?? \"unknown\"} (${context})`);\n logInfo(`Subject: ${creds.sub}`);\n logInfo(`Expires: ${creds.expiresAt}`);\n if (tenants.length > 0) {\n logInfo(`Deployable tenants: ${tenants.join(\", \")}`);\n }\n}\n// =============================================================================\n// Commands\n// =============================================================================\n\nexport function registerLoginCommand(program: Command): void {\n program\n .command(\"login\")\n .description(\"Authenticate with Zitadel (OIDC PKCE or Device Code)\")\n .argument(\"<context>\", 'Platform context (e.g., \"mesh.dev\")')\n .option(\n \"--status\",\n \"Show current authentication status \u2014 the Zitadel session if one is cached, otherwise the AWS identity the credential chain resolves (SSO profile or key pair). Exits non-zero only when neither works.\"\n )\n .option(\"--device\", \"Force device code flow (no callback server needed)\")\n .option(\n \"--export\",\n \"After login, print a self-refreshing AWS credential_process profile as shell `export` statements (use with `eval`). The resulting shell auto-refreshes credentials via `mesh login`. Requires --role or MESH_AWS_ROLE.\"\n )\n .option(\n \"--static\",\n \"With --export: print raw temporary AWS credentials (a frozen ~1h triple) instead of the default self-refreshing credential_process profile\"\n )\n .option(\n \"--credential-process\",\n \"Print AWS credential_process JSON (used by mesh dev's temp profile); refreshes the Zitadel token as needed. Requires --role or a cached defaultRole.\"\n )\n .option(\n \"--role <arn>\",\n \"IAM role ARN to assume via web identity (used with --export). Saved as the default for this context so future --export runs can omit it. Resolution order: --role > MESH_AWS_ROLE > cached defaultRole.\"\n )\n .option(\n \"--region <region>\",\n \"AWS region to include in the exported AWS_REGION. Defaults to AWS_REGION env or us-east-2.\"\n )\n .option(\n \"--no-registry\",\n \"Skip the CodeArtifact registry-auth step that runs automatically after login for {tenant}.{env} contexts\"\n )\n .action(async (context: string, opts: {\n status?: boolean;\n device?: boolean;\n export?: boolean;\n static?: boolean;\n credentialProcess?: boolean;\n role?: string;\n region?: string;\n registry?: boolean;\n }) => {\n let config = getContextConfig(context);\n if (!config) {\n // One chain, shared with `ensureLogin` \u2014 see discoverConfigGuarded.\n // Deliberately NOT re-implemented here: this call site and that one\n // drifted apart once already, and the difference was whether a clean\n // laptop could resolve a short context at all.\n config = await discoverConfigGuarded(context);\n\n if (!config) {\n const pinned = firstPartyDomainFor(context);\n logError(\n `No configuration found for \"${context}\".\\n\\n` +\n `Tried:\\n` +\n ` 1. SSM: /mesh-platform/${context.split(\".\")[0]}/${context.split(\".\")[1]}/platform/zitadel\\n` +\n ` (requires AWS SSO login + read access)\\n` +\n (pinned\n ? ` 2. HTTPS: https://cli.${pinned}/.well-known/mesh.json (pinned first-party domain)\\n\\n`\n : ` 2. HTTPS: https://cli.${context}/.well-known/mesh.json\\n` +\n ` (only attempted for a full domain, or a known first-party context)\\n\\n`) +\n `To fix:\\n` +\n ` - Use the platform's full domain: mesh login <env>.<tenant>.meshtech.io\\n` +\n ` - Or, if you have AWS access, ensure SSO is active: aws sso login --profile <profile>\\n` +\n ` - Or add config manually to ${CONFIG_FILE}:\\n\\n` +\n ` {\\n` +\n ` \"${context}\": {\\n` +\n ` \"issuer\": \"https://identity.<env>.<your-platform-domain>\",\\n` +\n ` \"clientId\": \"<cli-oidc-client-id>\"\\n` +\n ` }\\n` +\n ` }\\n`\n );\n process.exit(1);\n }\n }\n\n if (opts.status) {\n await showStatus(context, config);\n return;\n }\n\n if (opts.export) {\n await exportAwsCredentials(context, config, opts);\n return;\n }\n\n if (opts.credentialProcess) {\n await credentialProcessAwsCredentials(context, config, opts);\n return;\n }\n\n try {\n const useDevice = opts.device || isRemoteEnvironment();\n if (useDevice) {\n logInfo(\"Using device code flow\");\n await deviceCodeLogin(context, config);\n } else {\n await login(context, config);\n }\n } catch (err) {\n logError(`Login failed: ${(err as Error).message}`);\n process.exit(1);\n }\n\n // F2: login absorbs registry auth. CodeArtifact-backed contexts are the\n // {tenant}.{env} ones \u2014 the local platform ('local') has no registry.\n // Best-effort: a registry hiccup must not fail an otherwise good login.\n if (opts.registry !== false && parseTenantEnv(context)) {\n try {\n logInfo(\"Refreshing CodeArtifact registry auth (skip with --no-registry)\u2026\");\n const { runRegistryLogin } = await import(\"./registry.js\");\n await runRegistryLogin(context, {});\n } catch (err) {\n logWarn(\n `Registry auth failed (login itself succeeded): ${(err as Error).message}` +\n ` \u2014 retry with: mesh registry login ${context}`\n + ` (add --profile mesh-dev only if you have an AWS account and the broker is unavailable)`,\n );\n }\n }\n });\n\n program\n .command(\"logout\")\n .description(\"Clear cached Zitadel credentials\")\n .argument(\"<context>\", 'Platform context (e.g., \"mesh.dev\")')\n .action((context: string) => {\n clearCredentials(context);\n logSuccess(`Logged out of ${context}. Credentials cleared.`);\n });\n}\n\n// =============================================================================\n// Exports (for use by other mesh commands)\n// =============================================================================\n\n/**\n * Get a valid access token for the given context, refreshing if needed.\n *\n * Returns the id token (a JWT with `sub` claim) suitable for\n * Authorization: Bearer headers. Returns null if no credentials are\n * cached or refresh fails.\n */\n/**\n * True while a cached token is still valid, treating anything within `marginMs`\n * of expiry as already stale. The margin lets pollers (e.g. the dev-user\n * token-server) re-mint BEFORE expiry instead of handing out a token that dies\n * before the caller's next poll.\n */\nexport function tokenStillValid(expiresAt: string, marginMs = 0, now = Date.now()): boolean {\n return new Date(expiresAt).getTime() - marginMs > now;\n}\n\nasync function getValidToken(\n context: string,\n opts: { marginMs?: number } = {},\n): Promise<string | null> {\n const config = getContextConfig(context);\n if (!config) return null;\n\n const creds = readCredentials(context);\n if (!creds) return null;\n\n if (tokenStillValid(creds.expiresAt, opts.marginMs ?? 0)) {\n return creds.idToken;\n }\n\n // Attempt refresh\n if (!creds.refreshToken) return null;\n try {\n const tokens = await refreshTokens(config.issuer, config.clientId, creds.refreshToken);\n const idPayload = decodeJwtPayload(tokens.id_token);\n writeCredentials(context, {\n ...creds,\n idToken: tokens.id_token,\n accessToken: tokens.access_token,\n refreshToken: tokens.refresh_token ?? creds.refreshToken,\n expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),\n email: (idPayload.email as string) ?? creds.email,\n });\n return tokens.id_token;\n } catch {\n return null;\n }\n}\n\n// =============================================================================\n// Credential freshness probe (shared seam: mesh dev doctor + preflight)\n// =============================================================================\n\n/**\n * Structured diagnosis of the deployer-credential path for a context/role,\n * without mutating anything. The shared seam behind `mesh dev doctor`'s creds\n * check and the launch preflight (MESH-2015), reusing the same\n * `getValidToken` + `assumeRoleCredentials` path as `--credential-process`\n * (MESH-1965).\n */\nexport type CredProbe =\n | { state: \"ok\"; ttlSeconds: number; expiresAt: string; email?: string }\n | { state: \"no-session\" }\n | { state: \"expired-session\" }\n | { state: \"assume-denied\"; detail: string }\n | { state: \"stale-env-override\" };\n\n/**\n * Probe deployer creds for `context`/`roleArn`. Order matters:\n * 1. No cached Zitadel session \u2192 `no-session`.\n * 2. Cached session AND ambient `AWS_*` env keys present \u2192 `stale-env-override`\n * (the frozen triple would override the self-refreshing profile at\n * runtime \u2014 the exact `mesh dev` trap). Guarded on a cached session so\n * legitimate CI env-key credentials, which have no `mesh login` session,\n * are not misreported.\n * 3. Session can't be refreshed \u2192 `expired-session`.\n * 4. AssumeRoleWithWebIdentity fails \u2192 `assume-denied` (valid identity, wrong\n * IAM/role/policy).\n * 5. Otherwise \u2192 `ok` with the STS TTL.\n */\nexport async function probeCredentials(\n context: string,\n roleArn: string,\n): Promise<CredProbe> {\n const cached = readCredentials(context);\n if (!cached) return { state: \"no-session\" };\n\n if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SESSION_TOKEN) {\n return { state: \"stale-env-override\" };\n }\n\n const token = await getValidToken(context);\n if (!token) return { state: \"expired-session\" };\n\n const sessionName = (cached.email ?? \"mesh-cli-doctor\")\n .replace(/[^a-zA-Z0-9=,.@-]/g, \"_\")\n .slice(0, 64);\n const sts = await assumeRoleCredentials(roleArn, token, sessionName);\n if (!sts) {\n return {\n state: \"assume-denied\",\n detail: describeAssumeFailure({ roleArn, context, issuer: tokenIssuer(token) }, \"inline\"),\n };\n }\n const ttlSeconds = Math.max(\n 0,\n Math.round((Date.parse(sts.Expiration) - Date.now()) / 1000),\n );\n return { state: \"ok\", ttlSeconds, expiresAt: sts.Expiration, email: cached.email };\n}\n\n// =============================================================================\n// AWS credential export (--export flag)\n// =============================================================================\n\n/**\n * Single-quote a value for shell-safe inclusion in an `export` statement.\n * Single-quoted strings in POSIX shells don't expand `$` or other metachars;\n * embedded single-quotes are escaped via the `'\\''` trick.\n */\nfunction shellSingleQuote(value: string): string {\n return `'${value.replace(/'/g, \"'\\\\''\")}'`;\n}\n\n/**\n * Render the `--static` output: raw temporary AWS credentials as shell\n * `export` statements (the old frozen ~1h triple). Pure \u2014 no I/O. Includes a\n * trailing newline. Exported for unit testing.\n */\nexport function renderExportStaticLines(args: {\n accessKey: string;\n secretKey: string;\n sessionToken: string;\n region: string;\n}): string {\n return (\n `export AWS_ACCESS_KEY_ID=${shellSingleQuote(args.accessKey)}\\n` +\n `export AWS_SECRET_ACCESS_KEY=${shellSingleQuote(args.secretKey)}\\n` +\n `export AWS_SESSION_TOKEN=${shellSingleQuote(args.sessionToken)}\\n` +\n `export AWS_REGION=${shellSingleQuote(args.region)}\\n`\n );\n}\n\n/**\n * Render the default `--export` output: point the shell at a self-refreshing\n * `credential_process` profile. The leading `unset` clears any stale STS\n * triple already in the shell so it can't shadow the profile. Pure \u2014 no I/O.\n * Includes a trailing newline. Exported for unit testing.\n *\n * Deliberately does NOT export `AWS_CONFIG_FILE`: the profile lives in the\n * user's REAL AWS config file (managed marker section \u2014 see\n * `upsertManagedAwsConfigSection`), so their other profiles (e.g.\n * `AWS_PROFILE=mesh-dev` InfraAdmin) keep working in the same shell.\n * Redirecting AWS_CONFIG_FILE to a mesh-private file used to clobber\n * ~/.aws/config for every subsequent AWS command.\n */\nexport function renderExportProfileLines(args: {\n profileName: string;\n region: string;\n}): string {\n return (\n `unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN\\n` +\n `export AWS_PROFILE=${shellSingleQuote(args.profileName)}\\n` +\n `export AWS_REGION=${shellSingleQuote(args.region)}\\n`\n );\n}\n\n/**\n * The AWS config file the managed profile section is written into: the\n * user's `$AWS_CONFIG_FILE` when THEY have set one, else `~/.aws/config` \u2014\n * the same file `aws configure sso` manages. Exported for unit testing.\n */\nexport function resolveUserAwsConfigPath(\n env: Record<string, string | undefined> = process.env,\n): string {\n return env.AWS_CONFIG_FILE ?? path.join(env.HOME ?? \"~\", \".aws\", \"config\");\n}\n\n/** True if `dir` can be created (if needed) and written. Used to detect an\n * unwritable `~/.aws` (e.g. a sandbox with no `~/.aws` grant) without throwing. */\nfunction canWriteDir(dir: string): boolean {\n try {\n fs.mkdirSync(dir, { recursive: true });\n fs.accessSync(dir, fs.constants.W_OK);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Where to write the managed AWS profile, with a safehouse fallback. Normally\n * the user's real `~/.aws/config` (augmented in place, so their other tools see\n * the profile \u2014 hence `redirected: false`, we don't touch their `AWS_CONFIG_FILE`).\n * But when `~/.aws` is not writable (a sandbox without a `~/.aws` grant), fall\n * back to a mesh-managed file under `~/.config/mesh` we CAN write, and set\n * `redirected: true` so the caller also exports `AWS_CONFIG_FILE` (otherwise the\n * AWS SDK, looking only at `~/.aws/config`, would never find the profile). An\n * explicit `AWS_CONFIG_FILE` always wins and is never redirected.\n */\nexport function resolveAwsConfigTarget(\n env: Record<string, string | undefined> = process.env,\n): { configPath: string; redirected: boolean } {\n if (env.AWS_CONFIG_FILE) return { configPath: env.AWS_CONFIG_FILE, redirected: false };\n const real = path.join(env.HOME ?? \"~\", \".aws\", \"config\");\n if (canWriteDir(path.dirname(real))) return { configPath: real, redirected: false };\n return { configPath: path.join(CONFIG_DIR, \"aws-config\"), redirected: true };\n}\n\n/**\n * Resolve which IAM role ARN to use, honoring the same precedence in both\n * `--export` and `--credential-process`: explicit `--role` wins, then\n * `MESH_AWS_ROLE`, then the context's cached `defaultRole`. Pure aside from\n * reading `process.env.MESH_AWS_ROLE` \u2014 returns an explanatory error instead\n * of throwing so callers can route it to stderr and exit non-zero.\n *\n * Exported for unit testing.\n */\nexport function resolveRoleOrExplain(\n opts: { role?: string },\n config: { defaultRole?: string },\n): { role: string } | { error: string } {\n const role = opts.role ?? process.env.MESH_AWS_ROLE ?? config.defaultRole;\n if (!role) {\n return {\n error:\n \"No IAM role available: pass --role <arn>, set MESH_AWS_ROLE, or run \" +\n \"`mesh login <context> --export --role <arn>` once to cache a default role for this context.\",\n };\n }\n return { role };\n}\n\n/**\n * Ensure a valid Zitadel ID token is cached for the given context \u2014 refreshing\n * or running the full login flow as needed. Returns null on failure.\n */\nasync function ensureValidToken(\n context: string,\n config: ContextConfig,\n opts: { device?: boolean },\n): Promise<string | null> {\n let token = await getValidToken(context);\n if (token) return token;\n\n logInfo(`No valid Zitadel session for ${context} \u2014 running login flow`);\n try {\n const useDevice = opts.device || isRemoteEnvironment();\n if (useDevice) {\n logInfo(\"Using device code flow\");\n await deviceCodeLogin(context, config);\n } else {\n await login(context, config);\n }\n } catch (err) {\n logError(`Login failed: ${(err as Error).message}`);\n return null;\n }\n return await getValidToken(context);\n}\n\n/**\n * Ensure a valid login session for `context`, running the login flow (device\n * code in remote/non-TTY, browser callback otherwise) if none is cached. Returns\n * the cached credentials, or null if no config is available to log in with.\n *\n * For use by other commands (e.g. `secrets set`, the AWS-cred resolver) so they\n * can trigger login on demand instead of just failing with guidance.\n */\nexport async function ensureLogin(\n context: string,\n opts: { device?: boolean; interactive?: boolean } = {},\n): Promise<CachedCredentials | null> {\n const existing = readCredentials(context);\n if (existing && new Date(existing.expiresAt) > new Date()) return existing;\n\n // interactive: false \u2192 cached-session-or-nothing. Callers with their own\n // fallback chain (e.g. the AWS-cred resolver's SSO path) use this so an\n // expired cache doesn't stall headless runs in a device-code poll.\n if (opts.interactive === false) return null;\n\n // Same guarded discovery chain as the `login` command: SSM first, and the\n // well-known HTTPS endpoint only for domain-looking contexts (\u22652 dots).\n // Hitting `https://cli.<short-name>/.well-known/mesh.json` for an uncached\n // context like \"mesh.dev\" would let whoever registers that domain seed our\n // cached issuer/clientId (login phishing).\n const config = getContextConfig(context) ?? (await discoverConfigGuarded(context));\n if (!config) {\n logWarn(`No login config for \"${context}\". Run: mesh login ${context}`);\n return null;\n }\n const token = await ensureValidToken(context, config, opts);\n if (!token) return null;\n return readCredentials(context);\n}\n\n/**\n * The one discovery chain, used by both the `login` command and `ensureLogin`:\n *\n * 1. SSM, when the context parses as `{tenant}.{env}` \u2014 the primary path for\n * internal users who already have AWS SSO access.\n * 2. The anonymous HTTPS well-known endpoint at the PINNED domain for a known\n * first-party short context (`mesh.dev` \u2192 `dev.platform.meshtech.io`). This\n * is what makes `mesh login mesh.dev` work on a laptop with no AWS at all\n * \u2014 the P0 case. The domain comes from a compiled-in table, never from the\n * context string, so it is not a phishing surface.\n * 3. The anonymous HTTPS endpoint at the context itself, only when it looks\n * like a real domain (\u22652 dots).\n *\n * Never hits `https://cli.<short-name>/\u2026` for an UNKNOWN short context \u2014 that\n * guard is the anti-phishing property and step 2 is deliberately a pinned\n * allow-list rather than a relaxation of it.\n */\nasync function discoverConfigGuarded(context: string): Promise<ContextConfig | null> {\n let config: ContextConfig | null = null;\n if (parseTenantEnv(context)) {\n config = await discoverConfigFromSsm(context);\n }\n if (!config) {\n const pinned = firstPartyDomainFor(context);\n if (pinned) {\n logInfo(`\"${context}\" is a known Mesh platform \u2014 resolving via ${pinned} (no AWS needed)`);\n config = await discoverConfig(pinned, context);\n }\n }\n if (!config && context.split(\".\").length - 1 >= 2) {\n config = await discoverConfig(context);\n }\n return config;\n}\n\n/**\n * Resolve AWS credentials via Zitadel JWT \u2192 AssumeRoleWithWebIdentity, then\n * print them as `export` statements on stdout. All status/error output goes\n * to stderr so the caller can safely `eval \"$(mesh login ... --export ...)\"`.\n */\nasync function exportAwsCredentials(\n context: string,\n config: ContextConfig,\n opts: { role?: string; region?: string; device?: boolean; static?: boolean },\n): Promise<void> {\n const token = await ensureValidToken(context, config, opts);\n if (!token) {\n process.exit(1);\n }\n\n // Pick a role for the caller. `resolveRoleOrExplain` handles the\n // --role / MESH_AWS_ROLE / cached-defaultRole precedence (shared with\n // --credential-process); on top of that, when we fell back to the cached\n // default we ask `selectRoleForCaller` to upgrade to the context's\n // `adminRole` when the Zitadel JWT carries an admin claim.\n const resolvedRole = resolveRoleOrExplain(opts, config);\n if (\"error\" in resolvedRole) {\n logError(\n \"--export requires --role <arn> (or MESH_AWS_ROLE env var, or a cached defaultRole).\\n\" +\n \" Example: mesh login mesh.dev --export --role arn:aws:iam::123456789012:role/mesh-developer\\n\" +\n \" After the first run, the role is saved to ~/.config/mesh/config.json and --role can be omitted.\"\n );\n process.exit(1);\n }\n let roleArn = resolvedRole.role;\n if (!opts.role && !process.env.MESH_AWS_ROLE && config.defaultRole) {\n roleArn = selectRoleForCaller(token, {\n defaultRole: config.defaultRole,\n adminRole: config.adminRole,\n adminClaimRoles: config.adminClaimRoles,\n });\n }\n if (config.adminRole && roleArn === config.adminRole && !opts.role) {\n logInfo(\n `Caller has admin Zitadel role \u2014 assuming ${roleArn.split(\"/\").pop()} (admin variant)`,\n );\n }\n\n // Persist --role so subsequent runs can omit it.\n if (opts.role && opts.role !== config.defaultRole) {\n writeContextConfig(context, { ...config, defaultRole: opts.role });\n logInfo(`Saved default role for ${context}`);\n }\n\n const region =\n opts.region ??\n process.env.AWS_REGION ??\n process.env.AWS_DEFAULT_REGION ??\n \"us-east-2\";\n\n const roleName = roleArn.split(\"/\").pop() ?? roleArn;\n\n // --static: resolve a frozen STS triple now (the old behavior). Consumers\n // that need raw credentials (no `mesh` binary on refresh) opt into this.\n if (opts.static) {\n const creds = readCredentials(context);\n const sessionName = (creds?.email ?? \"mesh-cli-export\")\n .replace(/[^a-zA-Z0-9=,.@-]/g, \"_\")\n .slice(0, 64);\n\n const env = await assumeRoleWithWebIdentity(roleArn, token, sessionName);\n if (!env) {\n logError(\n describeAssumeFailure({ roleArn, context, issuer: tokenIssuer(token) }),\n );\n process.exit(1);\n }\n\n const accessKey = env.AWS_ACCESS_KEY_ID;\n const secretKey = env.AWS_SECRET_ACCESS_KEY;\n const sessionToken = env.AWS_SESSION_TOKEN;\n if (!accessKey || !secretKey || !sessionToken) {\n logError(\"AWS STS returned incomplete credentials\");\n process.exit(1);\n }\n\n process.stdout.write(\n renderExportStaticLines({ accessKey, secretKey, sessionToken, region }),\n );\n logSuccess(`Exported static AWS credentials for ${roleName} in ${region}`);\n return;\n }\n\n // Default: manage a `[profile mesh-<context>]` block inside the user's REAL\n // AWS config file ($AWS_CONFIG_FILE if they set one, else ~/.aws/config) as\n // an idempotent marker-delimited section \u2014 the same file `aws configure sso`\n // manages \u2014 and export only AWS_PROFILE. The profile's `credential_process`\n // re-invokes `mesh login \u2026 --credential-process`, so `eval`-ed shells\n // auto-refresh instead of dying ~1h in. We must NOT export AWS_CONFIG_FILE\n // pointing at a mesh-private file: that hides ~/.aws/config from the whole\n // shell and breaks the user's other profiles (e.g. AWS_PROFILE=mesh-dev).\n const meshBin = resolveStableMeshBin(process.argv[1]);\n const sanitized = context.replace(/[^A-Za-z0-9_-]/g, \"-\");\n const profileName = `mesh-${sanitized}`;\n // Safehouse-friendly: falls back to a mesh-managed config when ~/.aws is not\n // writable (and then `redirected` tells us to export AWS_CONFIG_FILE too).\n const { configPath, redirected } = resolveAwsConfigTarget();\n fs.mkdirSync(path.dirname(configPath), { recursive: true });\n // Read \u2192 build \u2192 atomic-write back-to-back (all sync) so the lost-update\n // window against concurrent writers (another `mesh login --export`, the\n // user's own editor, `aws configure`) is as small as possible; the temp+\n // rename write means concurrent READERS never observe a truncated file.\n const existedBefore = fs.existsSync(configPath);\n const existing = existedBefore ? fs.readFileSync(configPath, \"utf-8\") : \"\";\n // Preserve the user's existing mode (rename replaces the inode, so the\n // temp file's mode wins) \u2014 never chmod their config out from under them.\n // Restrict to 0600 only when WE create the file.\n const mode = existedBefore ? fs.statSync(configPath).mode & 0o777 : 0o600;\n atomicWriteFileSync(\n configPath,\n upsertManagedAwsConfigSection(\n existing,\n context,\n renderCredentialProcessProfile({\n profileName,\n context,\n roleArn,\n region,\n meshBin,\n }),\n ),\n mode,\n );\n\n process.stdout.write(renderExportProfileLines({ profileName, region }));\n // When we redirected away from an unwritable ~/.aws, the profile lives in a\n // mesh-managed file \u2014 export AWS_CONFIG_FILE so the AWS SDK finds it.\n if (redirected) {\n process.stdout.write(`export AWS_CONFIG_FILE=${shellSingleQuote(configPath)}\\n`);\n }\n logSuccess(\n `Exported self-refreshing AWS profile ${profileName} for ${roleName} in ${region} ` +\n `(managed section in ${configPath})`,\n );\n}\n\n// =============================================================================\n// AWS credential_process (--credential-process flag)\n// =============================================================================\n\n/**\n * Resolve AWS credentials via Zitadel JWT \u2192 AssumeRoleWithWebIdentity, then\n * print them as AWS `credential_process` JSON on stdout \u2014 nothing else may\n * reach stdout in this mode, since the AWS SDK parses it directly. All\n * status/error output goes to stderr.\n *\n * Unlike `--export`, this does NOT run an interactive login flow when no\n * session is cached (there's no terminal/browser to drive it from a child\n * process) \u2014 it only refreshes an existing session via its refresh token\n * (`getValidToken`). Callers must `mesh login <context>` at least once.\n */\nasync function credentialProcessAwsCredentials(\n context: string,\n config: ContextConfig,\n opts: { role?: string },\n): Promise<void> {\n const token = await getValidToken(context);\n if (!token) {\n logError(\n `No valid Zitadel session for ${context} (missing, expired, or refresh failed). Run: mesh login ${context}`\n );\n process.exit(1);\n }\n\n const resolvedRole = resolveRoleOrExplain(opts, config);\n if (\"error\" in resolvedRole) {\n logError(resolvedRole.error);\n process.exit(1);\n }\n\n const creds = readCredentials(context);\n const sessionName = (creds?.email ?? \"mesh-cli-credential-process\")\n .replace(/[^a-zA-Z0-9=,.@-]/g, \"_\")\n .slice(0, 64);\n\n const stsCreds = await assumeRoleCredentials(resolvedRole.role, token, sessionName);\n if (!stsCreds) {\n logError(\n describeAssumeFailure({\n roleArn: resolvedRole.role,\n context,\n issuer: tokenIssuer(token),\n }),\n );\n process.exit(1);\n }\n\n process.stdout.write(toCredentialProcessJson(stsCreds) + \"\\n\");\n}\n\nexport { CONFIG_DIR, CONFIG_FILE, CREDENTIALS_FILE };\nexport type { ContextConfig, CachedCredentials, CredentialsFile };\n/** Every cached context config, keyed by context name. */\nexport function readAllContextConfigs(): ConfigFile {\n return readConfig();\n}\n\n/**\n * The discovery chain on its own, without the browser flow that follows it.\n *\n * Exported so the clean-laptop bootstrap \u2014 SSM \u2192 pinned first-party alias \u2192\n * full-domain, and the anti-phishing refusal for an unknown short context \u2014 can\n * be exercised end-to-end against the live well-known endpoint. The `login`\n * command's own path is otherwise only reachable by completing an interactive\n * sign-in, which is why this leg had no coverage above the unit tier.\n */\nexport async function discoverConfigForContext(context: string): Promise<ContextConfig | null> {\n return getContextConfig(context) ?? (await discoverConfigGuarded(context));\n}\n\nexport { readCredentials, readAllCredentials, getContextConfig, decodeJwtPayload, getValidToken };", "/**\n * Shared AWS credential resolution for mesh CLI commands.\n *\n * Three-tier resolution:\n * 1. Existing AWS credentials in environment (CI/CD) \u2192 use directly\n * 2. Zitadel JWT via `mesh login` \u2192 AssumeRoleWithWebIdentity\n * 3. Fallback \u2192 AssumeRole via current AWS session (SSO)\n *\n * Uses @aws-sdk/client-sts directly \u2014 does NOT shell out to the `aws` CLI\n * binary, so the binary is not a runtime requirement.\n */\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport {\n AssumeRoleCommand,\n AssumeRoleWithWebIdentityCommand,\n GetCallerIdentityCommand,\n STSClient,\n} from \"@aws-sdk/client-sts\";\nimport { logInfo, logSuccess, logWarn } from \"./log.js\";\nimport { isExpiredAwsTokenMessage, SSO_LOGIN_FIX } from \"./auth-preflight.js\";\nimport { ensureLogin } from \"../commands/login.js\";\n\nconst DEFAULT_REGION =\n process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? \"us-east-2\";\n\n/**\n * Derive the platform context key (e.g., \"mesh.dev\") from a Pulumi stack config.\n *\n * Reads `mesh:platform` from Pulumi.<stack>.yaml. Supports both formats:\n * mesh:platform: '{\"env\":\"dev\",\"name\":\"mesh\"}' \u2192 \"mesh.dev\"\n * mesh:platform:\n * name: mesh\n * env: dev \u2192 \"mesh.dev\"\n */\nexport function derivePlatformContext(appRoot: string, stack: string): string | null {\n const configFile = path.join(appRoot, `Pulumi.${stack}.yaml`);\n if (!fs.existsSync(configFile)) return null;\n\n const content = fs.readFileSync(configFile, \"utf-8\");\n\n // Try JSON-encoded string first\n const jsonMatch = content.match(/mesh:platform:\\s*'(\\{[^']+\\})'/);\n if (jsonMatch) {\n try {\n const parsed = JSON.parse(jsonMatch[1]!);\n if (parsed.name && parsed.env) return `${parsed.name}.${parsed.env}`;\n } catch {\n // fall through\n }\n }\n\n // Try YAML block format\n const nameMatch = content.match(/mesh:platform:[\\s\\S]*?name:\\s*(\\S+)/);\n const envMatch = content.match(/mesh:platform:[\\s\\S]*?env:\\s*(\\S+)/);\n if (nameMatch && envMatch) {\n return `${nameMatch[1]}.${envMatch[1]}`;\n }\n\n return null;\n}\n\n/**\n * Quote a `credential_process` command token for AWS config-file syntax: the\n * AWS SDK/CLI split the value on spaces, honoring double quotes \u2014 so any\n * token containing whitespace (e.g. a mesh binary under \"Application\n * Support\") must be wrapped in double quotes or the command is mis-split.\n * Tokens without whitespace are left bare (byte-identical to the historic\n * output).\n */\nfunction quoteCredProcessToken(token: string): string {\n return /\\s/.test(token) ? `\"${token}\"` : token;\n}\n\n/**\n * Resolve a STABLE absolute path to the running `mesh` entrypoint for\n * embedding in a `credential_process` line \u2014 or fall back to plain `mesh`\n * (PATH lookup) when `argv1` is missing, doesn't exist on disk, or lives in\n * a transient location (`npx` cache / OS temp dir): a profile pointing at a\n * path that evaporates breaks every later AWS call with a cryptic\n * credential_process error. The fallback is announced via `logWarn` so the\n * user knows to install mesh-cli globally.\n */\nexport function resolveStableMeshBin(\n argv1: string | undefined,\n deps: { exists?: (p: string) => boolean; warn?: (msg: string) => void } = {},\n): string {\n const exists = deps.exists ?? fs.existsSync;\n const warn = deps.warn ?? logWarn;\n let resolved = argv1 ? path.resolve(argv1) : undefined;\n // Under the source launcher the CLI process's argv[1] is a TypeScript entry\n // (bin/mesh.ts), which plain node cannot execute (type-stripped .js-suffixed\n // imports fail with ERR_MODULE_NOT_FOUND). Point the profile at the sibling\n // .mjs launcher instead \u2014 it bootstraps tsx itself, from any cwd/env. Keyed on\n // the `.ts` extension + a derived sibling (not the literal `mesh.ts`), so a\n // rename of the source entry doesn't silently revert to a broken value. Assign\n // unconditionally: if the sibling is absent the existing !exists guard below\n // reports it by name (rather than collapsing into the \"argv1 missing\" branch).\n if (resolved && /\\.ts$/.test(resolved)) {\n resolved = path.join(path.dirname(resolved), path.basename(resolved, \".ts\") + \".mjs\");\n }\n const transient =\n resolved !== undefined &&\n (/[\\\\/]_npx[\\\\/]/.test(resolved) || /^\\/(?:private\\/)?(?:tmp|var\\/folders)\\//.test(resolved));\n if (!resolved || !exists(resolved) || transient) {\n warn(\n `credential_process cannot reference a stable mesh binary` +\n (resolved ? ` (${resolved} is transient or missing)` : \"\") +\n ` \u2014 falling back to \\`mesh\\` on PATH. Install mesh-cli globally so the profile keeps refreshing.`,\n );\n return \"mesh\";\n }\n return resolved;\n}\n\n/**\n * Render an AWS config-file `[profile \u2026]` block whose `credential_process`\n * re-invokes `mesh login \u2026 --credential-process`, so the AWS SDK's default\n * provider chain auto-refreshes the underlying STS triple (the `mesh login`\n * process re-issues the Zitadel token as needed).\n *\n * Shared by `mesh dev` (child services) and `mesh login --export` so both\n * produce byte-identical profiles. Pure \u2014 no I/O. The `region` line is\n * emitted only when `region` is a non-empty string. Includes a trailing\n * newline. The binary path and role ARN are double-quoted when they contain\n * whitespace (AWS config command-splitting rules).\n */\nexport function renderCredentialProcessProfile(args: {\n profileName: string;\n context: string;\n roleArn: string;\n region?: string;\n meshBin: string;\n}): string {\n const lines = [\n `[profile ${args.profileName}]`,\n `credential_process = ${quoteCredProcessToken(args.meshBin)} login ${quoteCredProcessToken(args.context)} --credential-process --role ${quoteCredProcessToken(args.roleArn)}`,\n ];\n if (args.region) lines.push(`region = ${args.region}`);\n return lines.join(\"\\n\") + \"\\n\";\n}\n\n// Marker lines delimiting the mesh-managed section of the user's AWS config\n// file. Follows the `conda init` / `nvm` managed-block convention so the\n// section is discoverable and safely re-writable. The START marker carries\n// the context (one managed section per context); the END marker is shared.\nconst MANAGED_START_PREFIX = `# >>> mesh-cli managed profile `;\nconst MANAGED_START = (context: string) => `${MANAGED_START_PREFIX}${context} >>>`;\nconst MANAGED_END = `# <<< mesh-cli managed <<<`;\n\n/**\n * Given a managed START marker for OUR context whose matching END is missing\n * (orphaned \u2014 e.g. the user deleted the END line), find the index of the first\n * character we should NOT reclaim. Conservative by construction: because\n * `MANAGED_END` is a shared literal across contexts, an END found further down\n * may belong to ANOTHER context's healthy section \u2014 so we never scan to a\n * distant END or to EOF. We only reclaim the lines the mesh CLI provably\n * wrote (see `renderCredentialProcessProfile`): one `[profile \u2026]` header\n * followed by contiguous `key = value` lines, stopping at the first blank\n * line, comment/marker, second profile header, or `boundIdx` (the next\n * managed START of any context), whichever comes first.\n *\n * @param afterStart index just past OUR START marker text (before its newline)\n * @param boundIdx index of the next managed START marker after ours, or -1\n */\nfunction orphanedSectionTail(existing: string, afterStart: number, boundIdx: number): number {\n const limit = boundIdx === -1 ? existing.length : boundIdx;\n let idx = afterStart;\n if (idx >= limit || existing[idx] !== \"\\n\") return Math.min(idx, limit);\n idx++; // consume the newline terminating our START marker line\n let claimedHeader = false;\n while (idx < limit) {\n const nl = existing.indexOf(\"\\n\", idx);\n const lineEnd = nl === -1 || nl > limit ? limit : nl;\n const line = existing.slice(idx, lineEnd);\n const ours = claimedHeader\n ? /^[A-Za-z_][A-Za-z0-9_]*\\s*=/.test(line) // key = value continuation\n : /^\\[profile [^\\]]+\\]\\s*$/.test(line); // exactly one profile header\n if (!ours) return idx;\n claimedHeader = true;\n idx = lineEnd >= limit ? limit : lineEnd + 1;\n }\n return limit;\n}\n\n/**\n * Insert or replace the mesh-managed `[profile \u2026]` section for `context` in\n * the user's AWS config content, preserving ALL other content byte-for-byte\n * (this is the user's real ~/.aws/config \u2014 clobbering it breaks their other\n * profiles). Pure \u2014 no I/O; exported for unit testing.\n *\n * - Existing managed section for this context \u2192 replaced in place.\n * - Orphaned START (matching END lost) \u2192 only the contiguous profile block\n * that is provably ours is reclaimed; other contexts' sections and user\n * content below are preserved (see `orphanedSectionTail`).\n * - No section \u2192 appended, separated by a single blank line (and a trailing\n * newline is added to the existing content if it lacks one).\n */\nexport function upsertManagedAwsConfigSection(\n existing: string,\n context: string,\n profileBlock: string,\n): string {\n const body = profileBlock.endsWith(\"\\n\") ? profileBlock : profileBlock + \"\\n\";\n const section = `${MANAGED_START(context)}\\n${body}${MANAGED_END}\\n`;\n\n const startIdx = existing.indexOf(MANAGED_START(context));\n if (startIdx !== -1) {\n const afterStart = startIdx + MANAGED_START(context).length;\n const endIdx = existing.indexOf(MANAGED_END, afterStart);\n // MANAGED_END is a shared literal across contexts, so the first END below\n // our START is only OURS if no other context's START sits in between \u2014\n // otherwise that END closes the OTHER section and scanning to it would\n // swallow that section whole.\n const foreignIdx = existing.indexOf(MANAGED_START_PREFIX, afterStart);\n let tail: number;\n if (endIdx !== -1 && (foreignIdx === -1 || endIdx < foreignIdx)) {\n // Healthy section: replace START..END. Swallow a single trailing\n // newline of the old section so replacement is idempotent (the new\n // section brings its own).\n const tailIdx = endIdx + MANAGED_END.length;\n tail = existing[tailIdx] === \"\\n\" ? tailIdx + 1 : tailIdx;\n } else {\n // Orphaned START (our END missing). Reclaim only the contiguous\n // profile block that is provably ours \u2014 never foreign sections, user\n // profiles below, or everything to EOF.\n tail = orphanedSectionTail(existing, afterStart, foreignIdx);\n }\n return existing.slice(0, startIdx) + section + existing.slice(tail);\n }\n\n if (existing.length === 0) return section;\n const sep = existing.endsWith(\"\\n\\n\") ? \"\" : existing.endsWith(\"\\n\") ? \"\\n\" : \"\\n\\n\";\n return existing + sep + section;\n}\n\n/**\n * Remove a BARE `[profile name]` block \u2014 one outside any managed section \u2014 from\n * AWS config content. A file written before the managed-section format carries\n * exactly one such block, and migrating it must not leave that block behind as a\n * duplicate profile. Pure; exported for unit testing.\n *\n * @example\n * stripBareProfile(\"[profile acme]\\nregion = us-east-2\\n\", \"acme\"); // \u2192 \"\"\n */\nexport function stripBareProfile(existing: string, profileName: string): string {\n const header = `[profile ${profileName}]`;\n const lines = existing.split(\"\\n\");\n const out: string[] = [];\n let managed = false;\n let dropping = false;\n for (const line of lines) {\n if (line.startsWith(MANAGED_START_PREFIX)) managed = true;\n if (managed) {\n out.push(line);\n if (line === MANAGED_END) managed = false;\n continue;\n }\n if (line.trim() === header) {\n dropping = true;\n continue;\n }\n if (dropping && line.startsWith(\"[\")) dropping = false;\n if (!dropping) out.push(line);\n }\n return out.join(\"\\n\");\n}\n\nfunction newStsClient(): STSClient {\n return new STSClient({ region: DEFAULT_REGION });\n}\n\n/** Pull AWS env vars out of an STS Credentials struct; null if any field is missing. */\nfunction toEnv(credentials: {\n AccessKeyId?: string;\n SecretAccessKey?: string;\n SessionToken?: string;\n}): Record<string, string> | null {\n const { AccessKeyId, SecretAccessKey, SessionToken } = credentials;\n if (!AccessKeyId || !SecretAccessKey || !SessionToken) return null;\n return {\n AWS_ACCESS_KEY_ID: AccessKeyId,\n AWS_SECRET_ACCESS_KEY: SecretAccessKey,\n AWS_SESSION_TOKEN: SessionToken,\n };\n}\n\n/**\n * IAM rejects a DurationSeconds higher than the role's MaxSessionDuration with\n * a ValidationError. We detect that and fall back to a one-hour session.\n */\nfunction isMaxSessionDurationError(err: unknown): boolean {\n if (!(err instanceof Error)) return false;\n const message = err.message ?? \"\";\n return (\n message.includes(\"MaxSessionDuration\") ||\n message.includes(\"DurationSeconds\")\n );\n}\n\n/** Assume an IAM role via current AWS session and return credentials as env vars. */\nexport async function assumeRole(\n roleArn: string,\n sessionName: string = \"mesh-cli\",\n durationSeconds: number = 43200,\n): Promise<Record<string, string> | null> {\n const sts = newStsClient();\n for (const duration of [durationSeconds, 3600]) {\n try {\n const resp = await sts.send(\n new AssumeRoleCommand({\n RoleArn: roleArn,\n RoleSessionName: sessionName,\n DurationSeconds: duration,\n }),\n );\n if (!resp.Credentials) return null;\n return toEnv(resp.Credentials);\n } catch (err) {\n if (!isMaxSessionDurationError(err) || duration === 3600) {\n const message = err instanceof Error ? err.message : String(err);\n logWarn(`AssumeRole failed: ${message}`);\n // Name the SSO fix when the ambient session is the problem \u2014 the raw\n // STS error never mentions `aws sso login` (MESH-2479).\n if (isExpiredAwsTokenMessage(message)) {\n logWarn(` Your AWS SSO session looks expired or missing \u2014 refresh with: ${SSO_LOGIN_FIX}`);\n }\n return null;\n }\n // Retry with shorter duration\n }\n }\n return null;\n}\n\n/** STS credentials for a Zitadel-JWT-assumed role, including expiry. */\nexport interface StsCreds {\n AccessKeyId: string;\n SecretAccessKey: string;\n SessionToken: string;\n /** ISO-8601 expiration timestamp. */\n Expiration: string;\n}\n\n/**\n * Assume role using a Zitadel JWT via AssumeRoleWithWebIdentity, returning\n * the full STS credential struct (including `Expiration`) rather than the\n * env-var shape. Used by callers that need to track/refresh expiry (e.g.\n * `credential_process`).\n */\nexport async function assumeRoleCredentials(\n roleArn: string,\n idToken: string,\n sessionName: string,\n durationSeconds: number = 43200,\n): Promise<StsCreds | null> {\n // STS WebIdentity does not require ambient AWS credentials \u2014 the JWT proves identity.\n const sts = newStsClient();\n for (const duration of [durationSeconds, 3600]) {\n try {\n const resp = await sts.send(\n new AssumeRoleWithWebIdentityCommand({\n RoleArn: roleArn,\n RoleSessionName: sessionName,\n WebIdentityToken: idToken,\n DurationSeconds: duration,\n }),\n );\n if (!resp.Credentials) return null;\n const env = toEnv(resp.Credentials);\n if (!env) return null;\n return {\n // `env` is a `Record<string, string>` (index-signature typed), so\n // `noUncheckedIndexedAccess` widens these to `string | undefined`\n // even though `toEnv` guarantees they're set \u2014 assert accordingly.\n AccessKeyId: env.AWS_ACCESS_KEY_ID!,\n SecretAccessKey: env.AWS_SECRET_ACCESS_KEY!,\n SessionToken: env.AWS_SESSION_TOKEN!,\n Expiration: resp.Credentials.Expiration!.toISOString(),\n };\n } catch (err) {\n if (!isMaxSessionDurationError(err) || duration === 3600) {\n logWarn(\n `AssumeRoleWithWebIdentity failed: ${err instanceof Error ? err.message : String(err)}`,\n );\n return null;\n }\n // Retry with shorter duration\n }\n }\n return null;\n}\n\n/** Assume role using a Zitadel JWT via AssumeRoleWithWebIdentity. */\nexport async function assumeRoleWithWebIdentity(\n roleArn: string,\n idToken: string,\n sessionName: string,\n durationSeconds: number = 43200,\n): Promise<Record<string, string> | null> {\n const creds = await assumeRoleCredentials(roleArn, idToken, sessionName, durationSeconds);\n if (!creds) return null;\n return toEnv(creds);\n}\n\n/**\n * Extract the `iss` claim from a JWT without ever throwing \u2014 malformed or\n * corrupt tokens (e.g. a damaged `~/.config/mesh/credentials.json`) yield\n * `undefined`. The failure-diagnostic paths use this; a decode that throws\n * there would crash before the diagnostic prints, which is exactly the\n * failure class MESH-2515 removes.\n */\nexport function tokenIssuer(token: string): string | undefined {\n const iss = decodeJwtPayload(token)?.iss;\n return typeof iss === \"string\" && iss ? iss : undefined;\n}\n\n/**\n * Explain an AssumeRoleWithWebIdentity failure in platform terms: which login\n * session (context + token issuer) tried to assume which role, in which AWS\n * account. STS's \"web identity token could not be validated\" reads like an\n * IAM/expiry problem, but the usual cause is a context whose issuer the role's\n * account simply doesn't trust \u2014 e.g. a `mesh.dev` session against a role in a\n * `trabian`-platform account (MESH-2515). stderr only \u2014 callers in\n * `--credential-process` mode must keep stdout JSON-clean.\n *\n * `format: \"inline\"` renders the same facts as one line, for detail fields\n * that get interpolated mid-sentence (the doctor/preflight cred probe). Both\n * forms keep the `AssumeRoleWithWebIdentity failed` prefix the doctor plan\n * documents as the detail capture.\n */\nexport function describeAssumeFailure(\n opts: {\n roleArn: string;\n context?: string;\n issuer?: string;\n },\n format: \"block\" | \"inline\" = \"block\",\n): string {\n const account = /^arn:aws:iam::(\\d+):/.exec(opts.roleArn)?.[1];\n const head =\n `AssumeRoleWithWebIdentity failed for ${opts.roleArn}` +\n (account ? ` (account ${account})` : \"\");\n const session = opts.context\n ? `mesh login context '${opts.context}'` + (opts.issuer ? ` (issuer ${opts.issuer})` : \"\")\n : opts.issuer\n ? `issuer ${opts.issuer}`\n : undefined;\n if (format === \"inline\") {\n return (\n head +\n (session ? ` via ${session}` : \"\") +\n \" \u2014 if the role's account doesn't trust this issuer, this is a context mismatch\" +\n \" (wrong `mesh:platform`), not an IAM permission problem\"\n );\n }\n const lines = [head];\n if (session) lines.push(` Session: ${session}`);\n lines.push(\n \" If the role's account does not trust this issuer, you are logged into the\",\n \" wrong context for this stack \u2014 check `mesh:platform` in the app's Pulumi\",\n \" config and `mesh login <platform>.<env>` accordingly. (This is a context\",\n \" mismatch, not an IAM permission problem.)\",\n );\n return lines.join(\"\\n\");\n}\n\n/** Format STS credentials as AWS `credential_process` JSON (Version 1). */\nexport function toCredentialProcessJson(creds: StsCreds): string {\n return JSON.stringify({\n Version: 1,\n AccessKeyId: creds.AccessKeyId,\n SecretAccessKey: creds.SecretAccessKey,\n SessionToken: creds.SessionToken,\n Expiration: creds.Expiration,\n });\n}\n\nexport interface ResolvedAwsCreds {\n /** AWS credential env vars (ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN) */\n env: Record<string, string>;\n /** How the credentials were obtained */\n method: \"environment\" | \"zitadel\" | \"sso\";\n}\n\nexport interface AwsIdentity {\n /** Full caller ARN, e.g. arn:aws:iam::159923586610:user/CI-CD_User */\n arn: string;\n accountId: string;\n /** Where the SDK's default chain found the credentials, for the human reading it. */\n source: \"environment\" | \"profile\";\n}\n\n/**\n * Ask \"is there a working AWS connection right now?\" \u2014 nothing more.\n *\n * Deliberately NOT `resolveAwsCredentials`: that one answers \"get me credentials\n * for role X\" and has an opinion about how they were obtained. This one runs\n * GetCallerIdentity through the SDK's default provider chain and reports whatever\n * it finds, so a static key/secret pair in `~/.aws/credentials` counts exactly as\n * much as an SSO session or a Zitadel-minted role. Returns null when the chain\n * yields nothing usable.\n */\nexport async function probeAwsIdentity(): Promise<AwsIdentity | null> {\n try {\n const out = await newStsClient().send(new GetCallerIdentityCommand({}));\n if (!out.Arn || !out.Account) return null;\n return {\n arn: out.Arn,\n accountId: out.Account,\n source: process.env.AWS_ACCESS_KEY_ID ? \"environment\" : \"profile\",\n };\n } catch {\n return null;\n }\n}\n\n/**\n * Decode a Zitadel-issued JWT payload (no signature check \u2014 we only\n * use it for role-selection hints in mesh-cli; the actual STS calls\n * still verify against the OIDC provider). Returns null on malformed\n * input.\n */\nfunction decodeJwtPayload(token: string): Record<string, unknown> | null {\n const parts = token.split(\".\");\n if (parts.length !== 3 || !parts[1]) return null;\n try {\n return JSON.parse(Buffer.from(parts[1], \"base64url\").toString());\n } catch {\n return null;\n }\n}\n\n/**\n * Pick the AWS role ARN to assume based on Zitadel claims.\n *\n * Zitadel emits project roles under\n * `urn:zitadel:iam:org:project:<projectId>:roles` as a nested object\n * keyed by role name. We treat the presence of `mesh.platform:admin`\n * (or any role listed in `meshcli.adminRoles` in config) as the\n * signal to assume the broader admin role when the caller has\n * configured one.\n *\n * Returns `defaultRole` when no admin role is configured, no admin\n * claim is present, or the JWT can't be parsed. This keeps existing\n * deployments working without a config change.\n *\n * The admin AWS role's IAM trust policy is expected to gate\n * assumption on the same Zitadel claim in production (so the CLI\n * preference can't be spoofed). That setup is its own change \u2014 see\n * TODO(mesh-zitadel-aws-role-mapping).\n */\nexport function selectRoleForCaller(\n idToken: string | null,\n options: {\n defaultRole: string;\n adminRole?: string;\n /**\n * Zitadel role names that should map to `adminRole`. Defaults to\n * a single `mesh.platform:admin` role; callers can override via\n * config to introduce role tiers later.\n */\n adminClaimRoles?: string[];\n },\n): string {\n if (!options.adminRole || !idToken) return options.defaultRole;\n const adminRoles = options.adminClaimRoles ?? [\"mesh.platform:admin\"];\n const claims = decodeJwtPayload(idToken);\n if (!claims) return options.defaultRole;\n\n // Find the project-roles claim \u2014 key shape:\n // urn:zitadel:iam:org:project:<projectId>:roles\n // Multiple projects can emit roles; we walk all matching keys.\n for (const [key, value] of Object.entries(claims)) {\n if (!key.startsWith(\"urn:zitadel:iam:org:project:\")) continue;\n if (!key.endsWith(\":roles\")) continue;\n if (!value || typeof value !== \"object\") continue;\n const claimedRoles = Object.keys(value as Record<string, unknown>);\n for (const target of adminRoles) {\n if (claimedRoles.includes(target)) return options.adminRole;\n }\n }\n return options.defaultRole;\n}\n\n/**\n * Resolve AWS credentials using the three-tier strategy.\n *\n * @param roleArn The IAM role to assume (from mesh:deployerRole)\n * @param appRoot Pulumi app root directory\n * @param stack Pulumi stack name\n * @returns Resolved credentials, or null if all methods fail\n *\n * To inherit a broader role based on Zitadel claims, call\n * `selectRoleForCaller` first and pass the result as `roleArn`.\n */\nexport async function resolveAwsCredentials(\n roleArn: string,\n appRoot: string,\n stack: string,\n): Promise<ResolvedAwsCreds | null> {\n const roleName = roleArn.split(\"/\").pop() ?? roleArn;\n\n // Path 1: Existing AWS credentials in environment (CI/CD)\n if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SESSION_TOKEN) {\n logInfo(\"Using existing AWS credentials from environment\");\n return {\n env: {\n AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,\n AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY ?? \"\",\n AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN,\n },\n method: \"environment\",\n };\n }\n\n // Path 2: Zitadel JWT via mesh login \u2192 AssumeRoleWithWebIdentity.\n // Non-interactive here: only a valid CACHED session is used. An interactive\n // device-flow before the SSO fallback would stall headless runs (CI,\n // `mesh dev` with a working AWS_PROFILE) for minutes polling device codes\n // nobody sees \u2014 SSO gets its chance first; interactive login is Path 4.\n const platformContext = derivePlatformContext(appRoot, stack);\n if (platformContext) {\n const meshCreds = await ensureLogin(platformContext, { interactive: false });\n if (meshCreds) {\n const sessionName = (meshCreds.email ?? \"mesh-cli\")\n .replace(/[^a-zA-Z0-9=,.@-]/g, \"_\")\n .slice(0, 64);\n logInfo(`Assuming ${roleName} via Zitadel (${meshCreds.email ?? platformContext})`);\n\n const creds = await assumeRoleWithWebIdentity(roleArn, meshCreds.idToken, sessionName);\n if (creds) {\n logSuccess(`Assumed ${roleName} via Zitadel`);\n return { env: creds, method: \"zitadel\" };\n }\n\n logWarn(\n \"Zitadel JWT auth failed \u2014 falling back to AWS SSO.\\n\" +\n ` (Try: mesh login ${platformContext})`,\n );\n }\n }\n\n // Path 3: Fallback \u2014 AssumeRole via current AWS session (SSO)\n logInfo(`Assuming ${roleName} via AWS SSO`);\n const creds = await assumeRole(roleArn, \"mesh-cli\");\n if (creds) {\n logSuccess(`Assumed ${roleName}`);\n return { env: creds, method: \"sso\" };\n }\n\n // Path 4: Last resort \u2014 interactive Zitadel login (device/browser). Only\n // reached when there's no env cred, no cached session, and no working SSO,\n // i.e. a human at a terminal who genuinely needs to log in.\n if (platformContext && process.stdout.isTTY) {\n const meshCreds = await ensureLogin(platformContext);\n if (meshCreds) {\n const sessionName = (meshCreds.email ?? \"mesh-cli\")\n .replace(/[^a-zA-Z0-9=,.@-]/g, \"_\")\n .slice(0, 64);\n logInfo(`Assuming ${roleName} via Zitadel (${meshCreds.email ?? platformContext})`);\n const zCreds = await assumeRoleWithWebIdentity(roleArn, meshCreds.idToken, sessionName);\n if (zCreds) {\n logSuccess(`Assumed ${roleName} via Zitadel`);\n return { env: zCreds, method: \"zitadel\" };\n }\n }\n }\n\n return null;\n}\n", "/**\n * Serialize a fetched Temporal workflow history into the replay-fixture JSON\n * shape \u2014 the same canonical proto3-JSON `{ events: [...] }` that\n * `@mesh-tech/agent-sdk`'s `replay/history-io.ts` `historyToFixture` produces,\n * so a captured file replays via `replayFixture` (which rehydrates it with\n * `historyFromJSON`) for LOCAL debugging. Captured PRODUCTION histories are\n * never committed (they contain real, decrypted customer/conversation data \u2014\n * see the CLI's do-not-commit warning); only synthetic goldens live in\n * `packages/agent-sdk/src/replay/fixtures/`.\n *\n * We reuse `@temporalio/common`'s `historyToJSON` directly (the exact helper\n * agent-sdk's history-io uses) rather than importing `@mesh-tech/agent-sdk`:\n * the CLI has no reason to pull in the whole worker/replay stack, and keeping\n * the dependency pointed at the shared Temporal library guarantees the output\n * round-trips through `historyFromJSON`.\n *\n * The one thing a *real* history needs that a synthetic golden fixture doesn't:\n * its payloads are ENCRYPTED (the worker's AES-GCM codec). `historyToJSON`\n * would just base64 the ciphertext, which `replayFixture`'s plaintext converter\n * can't decode. So before serializing we walk every Payload in the history and\n * replace each encrypted one with its decrypted inner Payload \u2014 yielding a\n * plaintext fixture identical in shape to a codec-free golden capture.\n */\n\n// Explicit `.js` \u2014 this package publishes native ESM, so the compiled dist\n// must use a fully-specified specifier or Node throws ERR_MODULE_NOT_FOUND at\n// runtime (MESH-2262). tsx tolerates the extensionless form; node does not.\nimport { historyToJSON } from \"@temporalio/common/lib/proto-utils.js\";\n\nimport type { RawPayload, RawPayloadDecrypter } from \"./temporal-codec.js\";\n\n/**\n * A Temporal Payload as it appears on a raw history event (proto-decoded):\n * `metadata` is a map of header name \u2192 bytes, `data` is the (possibly\n * encrypted) body bytes. Matches `temporal.api.common.v1.Payload`.\n */\ninterface HistoryPayload {\n metadata?: Record<string, Uint8Array | null | undefined> | null;\n data?: Uint8Array | null;\n}\n\nfunction isPayload(value: unknown): value is HistoryPayload {\n return (\n value != null &&\n typeof value === \"object\" &&\n \"metadata\" in value &&\n \"data\" in value\n );\n}\n\n/**\n * Walk an arbitrary history-event tree and decrypt every encrypted Payload in\n * place, using `decrypt` to turn an encrypted Payload into its plaintext inner\n * Payload. Payloads the decrypter reports as non-encrypted (`null`) are left\n * untouched. Returns the number of payloads that were decrypted.\n *\n * The `{ metadata, data }` heuristic mirrors Temporal's own `fixPayloads` in\n * `@temporalio/common/lib/proto-utils`, so it identifies exactly the nodes\n * proto3-JSON treats as payloads. Once a node is a Payload we do NOT recurse\n * into it (its metadata values are bytes, not nested payloads).\n */\nexport async function decryptHistoryPayloads(\n node: unknown,\n decrypt: RawPayloadDecrypter,\n): Promise<number> {\n if (node == null || typeof node !== \"object\") return 0;\n\n if (isPayload(node)) {\n const inner = await decrypt(node as RawPayload);\n if (inner === null) return 0; // not encrypted \u2014 leave as-is\n // Replace the encrypted envelope with the decrypted inner payload's bytes.\n node.metadata = inner.metadata ?? {};\n node.data = inner.data ?? new Uint8Array(0);\n return 1;\n }\n\n let count = 0;\n if (Array.isArray(node)) {\n for (const item of node) count += await decryptHistoryPayloads(item, decrypt);\n } else {\n for (const value of Object.values(node as Record<string, unknown>)) {\n count += await decryptHistoryPayloads(value, decrypt);\n }\n }\n return count;\n}\n\n/** Result of {@link serializeHistoryToFixture}. */\nexport interface CaptureResult {\n /** The parsed proto3-JSON fixture object (drop-in for `replayFixture`). */\n fixture: unknown;\n /** Total events serialized. */\n eventCount: number;\n /** How many encrypted payloads were decrypted (0 when no decrypter given). */\n decryptedPayloads: number;\n}\n\n/**\n * Turn an ordered list of raw history events into a replay fixture object.\n *\n * @param events - Raw proto-decoded history events, in order (as yielded by the\n * CLI's `iterHistoryEvents` over `getWorkflowExecutionHistory`).\n * @param decrypter - Optional raw payload decrypter. When provided, every\n * encrypted payload in the history is decrypted in place before serialization\n * so the fixture is replayable with a plaintext converter. When omitted, the\n * payloads are serialized as-is (ciphertext, base64-encoded).\n *\n * `historyToJSON` runs its input through protobuf `History.fromObject`, so a\n * plain `{ events }` object of raw events is accepted directly; it returns a\n * proto3-JSON *string* which we parse back to a structured object (the same\n * `JSON.parse(historyToJSON(...))` round-trip `historyToFixture` performs).\n */\nexport async function serializeHistoryToFixture(\n events: unknown[],\n decrypter?: RawPayloadDecrypter,\n): Promise<CaptureResult> {\n let decryptedPayloads = 0;\n if (decrypter) {\n for (const event of events) {\n decryptedPayloads += await decryptHistoryPayloads(event, decrypter);\n }\n }\n\n const fixture = JSON.parse(historyToJSON({ events } as never)) as unknown;\n return { fixture, eventCount: events.length, decryptedPayloads };\n}\n", "/**\n * mesh temporal \u2014 inspect Temporal workflows for the current app/stack.\n *\n * Commands:\n * mesh temporal describe <workflowId> [runId] Show workflow status and metadata\n * mesh temporal history <workflowId> [runId] Show workflow event history\n *\n * Connection info is resolved from the app's Pulumi stack output (same as mesh dev).\n * Uses the raw gRPC API so we can render events without a codec proxy; when\n * `--show-payloads` is set we resolve the app's `TEMPORAL_ENCODING_KEY` from\n * its Kubernetes secret and decrypt inline via the shared codec helper.\n */\n\nimport { spawnSync } from \"node:child_process\";\nimport { writeFileSync, mkdirSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { Command } from \"commander\";\nimport { logError, logInfo, logSuccess, logWarn } from \"../utils/log.js\";\nimport {\n classifyHistoryEvents,\n extractSnapshotMessages,\n reconstructTranscript,\n renderTranscriptMarkdown,\n} from \"../utils/recover-conversation.js\";\nimport {\n findAppRoot,\n findStackConfigs,\n getCurrentStack,\n pulumiStackOutput,\n readStackConfig,\n} from \"../utils/pulumi.js\";\nimport { resolveAwsCredentials } from \"../utils/aws-auth.js\";\nimport { resolveTemporalAuth, acquireTemporalBearer } from \"../utils/temporal-auth.js\";\nimport {\n buildDecrypterForNamespace,\n buildRawDecrypterForNamespace,\n type PayloadDecrypter,\n} from \"../utils/temporal-codec.js\";\n\n// \u2500\u2500 Connection resolution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\ninterface TemporalConnectionInfo {\n address: string;\n namespace: string;\n /**\n * Context for minting a Zitadel Bearer for auth-enforcing servers. Absent when\n * connecting via explicit --address/env (then auth falls back to ambient env).\n */\n auth?: { tenant: string; env: string; platformName: string };\n}\n\n/**\n * Resolve Temporal address and namespace from the current app's stack output.\n * Falls back to env vars if stack output isn't available.\n */\nasync function resolveConnection(\n options: { stack?: string; address?: string; namespace?: string },\n): Promise<TemporalConnectionInfo> {\n // Explicit overrides take precedence\n if (options.address && options.namespace) {\n return { address: options.address, namespace: options.namespace };\n }\n\n // Try from environment (set by mesh dev)\n if (!options.address && process.env.TEMPORAL_ADDRESS) {\n const address = process.env.TEMPORAL_ADDRESS;\n const namespace = options.namespace ?? process.env.TEMPORAL_NAMESPACE;\n if (namespace) return { address, namespace };\n }\n\n // Resolve from Pulumi stack output\n const appRoot = findAppRoot(process.cwd());\n if (!appRoot) {\n throw new Error(\n \"Not in a Mesh app directory (no Pulumi.yaml found).\\n\" +\n \"Run from an app directory, or provide --address and --namespace.\",\n );\n }\n\n const stack = options.stack ?? getCurrentStack(appRoot);\n if (!stack) {\n const stacks = findStackConfigs(appRoot);\n throw new Error(\n `No Pulumi stack selected.\\n` +\n (stacks.length > 0\n ? `Available: ${stacks.join(\", \")}\\nUse: mesh temporal --stack <name> ...`\n : \"No stack configs found in this directory.\"),\n );\n }\n\n const sa = stack ? [\"--stack\", stack] : [];\n\n // Resolve AWS credentials the same way mesh dev does\n const deployerRole = readStackConfig(appRoot, stack, \"mesh:deployerRole\");\n const awsCreds = deployerRole\n ? (await resolveAwsCredentials(deployerRole, appRoot, stack))?.env\n : undefined;\n\n // Pulumi's `app` output from env.register() is a struct containing a\n // secret-tainted `dev` field (secret because service env vars include values\n // like TEMPORAL_ENCODING_KEY). pulumiStackOutput transparently retries with\n // --show-secrets when it sees a `\"[secret]\"` placeholder, so we can reach\n // the non-sensitive tunnel/namespace fields nested inside.\n let devOutput: any;\n let appOutput: any;\n try {\n const result = pulumiStackOutput(appRoot, \"app\", sa, awsCreds);\n appOutput = JSON.parse(result);\n devOutput = appOutput.dev ?? appOutput;\n } catch {\n // Legacy stacks that exported `dev` directly (pre-env.register() format).\n try {\n const result = pulumiStackOutput(appRoot, \"dev\", sa, awsCreds);\n devOutput = JSON.parse(result);\n appOutput = devOutput;\n } catch {\n throw new Error(\n `Could not read stack output for '${stack}'.\\n` +\n \"Ensure you've run: mesh deploy up\\n\" +\n \"Or provide --address and --namespace explicitly.\",\n );\n }\n }\n\n const tunnel = devOutput?.tunnels?.temporal;\n // namespace lives at the app output level, not inside dev\n const namespace = options.namespace ?? appOutput?.namespace ?? devOutput?.namespace;\n\n if (!tunnel) {\n throw new Error(\"No temporal tunnel found in stack output.\");\n }\n if (!namespace) {\n throw new Error(\"No temporal namespace found in stack output.\");\n }\n\n // Auth context for minting a Bearer, mirroring resolveTemporalAuthVars in dev.ts:\n // tenant/env/name come from the PLATFORM block (platformName is the platform's\n // name, not the app tenant \u2014 it keys the Zitadel issuer lookup).\n const platform = (devOutput?.platform ?? appOutput?.platform) as\n | { tenant?: string; env?: string; name?: string }\n | undefined;\n const auth = platform\n ? {\n tenant: platform.tenant ?? \"mesh\",\n env: platform.env ?? \"dev\",\n platformName: platform.name ?? platform.tenant ?? \"mesh\",\n }\n : undefined;\n\n return {\n address: options.address ?? `${tunnel.host}:${tunnel.port}`,\n namespace,\n auth,\n };\n}\n\n/**\n * Mint a Zitadel Bearer for the connection, or return undefined to connect\n * unauthenticated (correct for local/non-enforcing Temporal). Prefers ambient\n * env creds (e.g. exported by `mesh dev`); otherwise resolves the app's M2M\n * credentials from SSM via the auth context.\n */\nasync function resolveBearerToken(\n auth?: { tenant: string; env: string; platformName: string },\n): Promise<string | undefined> {\n try {\n const haveEnvCreds =\n !!process.env.TEMPORAL_AUTH_CLIENT_ID && !!process.env.ZITADEL_ISSUER;\n const vars =\n !haveEnvCreds && auth\n ? await resolveTemporalAuth(auth.tenant, auth.env, auth.platformName)\n : {};\n return await acquireTemporalBearer(vars);\n } catch (err) {\n logWarn(\n `Temporal auth unavailable (${err instanceof Error ? err.message : String(err)}); ` +\n `connecting unauthenticated`,\n );\n return undefined;\n }\n}\n\n// \u2500\u2500 Event type names \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n// Temporal event type enum \u2192 human-readable name\nconst EVENT_TYPE_NAMES: Record<number, string> = {\n 1: \"WorkflowExecutionStarted\",\n 2: \"WorkflowExecutionCompleted\",\n 3: \"WorkflowExecutionFailed\",\n 4: \"WorkflowExecutionTimedOut\",\n 5: \"WorkflowTaskScheduled\",\n 6: \"WorkflowTaskStarted\",\n 7: \"WorkflowTaskCompleted\",\n 8: \"WorkflowTaskTimedOut\",\n 9: \"WorkflowTaskFailed\",\n 10: \"ActivityTaskScheduled\",\n 11: \"ActivityTaskStarted\",\n 12: \"ActivityTaskCompleted\",\n 13: \"ActivityTaskFailed\",\n 14: \"ActivityTaskTimedOut\",\n 15: \"ActivityTaskCancelRequested\",\n 16: \"ActivityTaskCanceled\",\n 17: \"TimerStarted\",\n 18: \"TimerFired\",\n 19: \"TimerCanceled\",\n 20: \"WorkflowExecutionCancelRequested\",\n 21: \"WorkflowExecutionCanceled\",\n 24: \"WorkflowExecutionSignaled\", // not 22/23 which were removed\n 25: \"WorkflowExecutionTerminated\",\n 26: \"WorkflowExecutionSignaled\",\n 29: \"WorkflowPropertiesModified\",\n 40: \"ChildWorkflowExecutionStarted\",\n 41: \"ChildWorkflowExecutionCompleted\",\n 42: \"ChildWorkflowExecutionFailed\",\n 43: \"ChildWorkflowExecutionCanceled\",\n 44: \"ChildWorkflowExecutionTimedOut\",\n 45: \"ChildWorkflowExecutionTerminated\",\n 46: \"WorkflowPropertiesModified\",\n 48: \"NexusOperationScheduled\",\n 49: \"NexusOperationStarted\",\n 50: \"NexusOperationCompleted\",\n 51: \"NexusOperationFailed\",\n 52: \"NexusOperationCanceled\",\n 53: \"NexusOperationTimedOut\",\n};\n\n// Workflow status enum \u2192 name\nconst STATUS_NAMES: Record<number, string> = {\n 0: \"UNSPECIFIED\",\n 1: \"RUNNING\",\n 2: \"COMPLETED\",\n 3: \"FAILED\",\n 4: \"CANCELED\",\n 5: \"TERMINATED\",\n 6: \"CONTINUED_AS_NEW\",\n 7: \"TIMED_OUT\",\n};\n\n// \u2500\u2500 Commands \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function describeWorkflow(\n workflowId: string,\n runId: string | undefined,\n options: { stack?: string; address?: string; namespace?: string },\n): Promise<void> {\n const conn = await connect(options);\n try {\n const desc = await conn.connection.workflowService.describeWorkflowExecution({\n namespace: conn.namespace,\n execution: { workflowId, runId },\n });\n\n const info = desc.workflowExecutionInfo;\n if (!info) {\n logError(\"No workflow execution info returned.\");\n return;\n }\n\n console.log(`Workflow: ${workflowId}`);\n console.log(`Run ID: ${info.execution?.runId ?? runId ?? \"?\"}`);\n console.log(`Type: ${info.type?.name ?? \"?\"}`);\n console.log(`Status: ${STATUS_NAMES[info.status ?? 0] ?? info.status}`);\n console.log(`Task Queue: ${info.taskQueue ?? \"?\"}`);\n console.log(`Namespace: ${conn.namespace}`);\n\n // Pending activities\n const pending = desc.pendingActivities ?? [];\n if (pending.length > 0) {\n console.log(`\\nPending Activities (${pending.length}):`);\n for (const pa of pending) {\n console.log(` - ${pa.activityType?.name ?? \"?\"} (attempt ${pa.attempt}, state: ${pa.state})`);\n }\n }\n\n // Pending nexus operations\n const pendingNexus = desc.pendingNexusOperations ?? [];\n if (pendingNexus.length > 0) {\n console.log(`\\nPending Nexus Operations (${pendingNexus.length}):`);\n for (const pn of pendingNexus) {\n console.log(` - ${pn.operation ?? \"?\"} \u2192 ${pn.endpoint ?? \"?\"} (state: ${pn.state})`);\n }\n }\n } finally {\n conn.connection.close();\n }\n}\n\n/**\n * Page through a workflow's execution history, yielding each event in order.\n * Shared by `history` (existing-history phase) and `recover-conversation` so the\n * pagination logic lives in one place. Follow-mode long-polling is separate\n * (it uses `waitNewEvent` + terminal detection) and stays in `showHistory`.\n */\nasync function* iterHistoryEvents(\n workflowService: any,\n namespace: string,\n workflowId: string,\n runId: string | undefined,\n maximumPageSize = 100,\n): AsyncGenerator<any> {\n let nextPageToken: Uint8Array | undefined;\n do {\n const resp = await workflowService.getWorkflowExecutionHistory({\n namespace,\n execution: { workflowId, runId },\n maximumPageSize,\n nextPageToken,\n });\n\n for (const event of resp.history?.events ?? []) {\n yield event;\n }\n\n nextPageToken = resp.nextPageToken?.length ? resp.nextPageToken : undefined;\n } while (nextPageToken);\n}\n\nasync function showHistory(\n workflowId: string,\n runId: string | undefined,\n options: {\n stack?: string;\n address?: string;\n namespace?: string;\n limit?: string;\n compact?: boolean;\n follow?: boolean;\n showPayloads?: boolean;\n },\n): Promise<void> {\n const conn = await connect(options);\n const maxEvents = options.follow ? Infinity : parseInt(options.limit ?? \"200\", 10);\n\n // Compact mode skips WorkflowTask* events (scheduling noise)\n const skipTypes = new Set([5, 6, 7, 8, 9]); // WorkflowTask*\n\n // Resolve the per-app encoding key lazily \u2014 only when the caller asks for\n // payload bodies. Failure to resolve is non-fatal: we print the history\n // without payloads and the warning from buildDecrypterForNamespace tells\n // the user why.\n const decrypter: PayloadDecrypter | undefined = options.showPayloads\n ? await buildDecrypterForNamespace(conn.namespace)\n : undefined;\n\n try {\n let totalShown = 0;\n let lastEventId = 0;\n\n console.log(`History for ${workflowId} (namespace: ${conn.namespace})${options.follow ? \" [following]\" : \"\"}\\n`);\n\n // Fetch existing history first\n for await (const event of iterHistoryEvents(\n conn.connection.workflowService,\n conn.namespace,\n workflowId,\n runId,\n )) {\n lastEventId = Number(event.eventId);\n const eventType = event.eventType ?? 0;\n if (options.compact !== false && skipTypes.has(eventType)) continue;\n await printEvent(event, decrypter);\n totalShown++;\n if (totalShown >= maxEvents) break;\n }\n\n if (!options.follow) {\n console.log(`\\n${totalShown} events shown.`);\n return;\n }\n\n // Follow mode: long-poll for new events until workflow completes\n const terminalTypes = new Set([2, 3, 4, 21, 27, 28]); // Completed, Failed, TimedOut, Canceled, Terminated, ContinuedAsNew\n let done = false;\n\n while (!done) {\n try {\n const resp = await conn.connection.workflowService.getWorkflowExecutionHistory({\n namespace: conn.namespace,\n execution: { workflowId, runId },\n maximumPageSize: 100,\n waitNewEvent: true,\n // Start after the last event we've seen\n ...(lastEventId > 0 ? { skipArchival: true } : {}),\n });\n\n for (const event of resp.history?.events ?? []) {\n const eid = Number(event.eventId);\n if (eid <= lastEventId) continue; // already shown\n lastEventId = eid;\n\n const eventType = event.eventType ?? 0;\n if (options.compact !== false && skipTypes.has(eventType)) continue;\n await printEvent(event, decrypter);\n totalShown++;\n\n // Check if workflow reached a terminal state\n if (terminalTypes.has(eventType)) {\n done = true;\n }\n }\n } catch (err: any) {\n // Connection reset or workflow completed between polls\n if (err?.code === 5 /* NOT_FOUND */ || err?.message?.includes(\"not found\")) {\n console.log(\"\\nWorkflow completed or not found.\");\n done = true;\n } else {\n throw err;\n }\n }\n }\n\n console.log(`\\n${totalShown} events shown (workflow ended).`);\n } finally {\n conn.connection.close();\n }\n}\n\nasync function printEvent(\n event: any,\n decrypter: PayloadDecrypter | undefined,\n): Promise<void> {\n const eventType = event.eventType ?? 0;\n const typeName = EVENT_TYPE_NAMES[eventType] ?? `Unknown(${eventType})`;\n const id = String(event.eventId).padStart(4);\n const detail = extractEventDetail(event);\n const detailStr = detail ? ` \u2014 ${detail}` : \"\";\n console.log(`${id} ${typeName}${detailStr}`);\n\n if (!decrypter) return;\n\n // Print any named payload groups attached to this event \u2014 activity input/\n // output, signal input, workflow start args, etc. \u2014 decrypted and pretty-\n // printed under the header line with a 6-space indent so they align under\n // the event ID column.\n for (const group of extractEventPayloads(event)) {\n const rendered = await renderPayloads(group.payloads, decrypter);\n if (rendered === null) continue;\n console.log(` ${group.label}:`);\n for (const line of rendered.split(\"\\n\")) {\n console.log(` ${line}`);\n }\n }\n}\n\ninterface PayloadGroup {\n label: string;\n payloads: unknown[];\n}\n\n/**\n * Pick out the payload-bearing fields on a history event. Returns an empty\n * array for events that carry no interesting data (workflow task lifecycle,\n * timers, property modifications, etc.).\n */\nfunction extractEventPayloads(event: any): PayloadGroup[] {\n const groups: PayloadGroup[] = [];\n const pushIfAny = (label: string, payloads: unknown[] | undefined | null) => {\n if (payloads && payloads.length > 0) groups.push({ label, payloads });\n };\n\n if (event.workflowExecutionStartedEventAttributes) {\n pushIfAny(\"input\", event.workflowExecutionStartedEventAttributes.input?.payloads);\n }\n if (event.workflowExecutionCompletedEventAttributes) {\n pushIfAny(\"result\", event.workflowExecutionCompletedEventAttributes.result?.payloads);\n }\n if (event.workflowExecutionFailedEventAttributes) {\n const failure = event.workflowExecutionFailedEventAttributes.failure;\n if (failure?.message) groups.push({ label: \"failure\", payloads: [{ __failure: failure }] });\n }\n if (event.workflowExecutionSignaledEventAttributes) {\n pushIfAny(\"signal input\", event.workflowExecutionSignaledEventAttributes.input?.payloads);\n }\n if (event.activityTaskScheduledEventAttributes) {\n pushIfAny(\"input\", event.activityTaskScheduledEventAttributes.input?.payloads);\n }\n if (event.activityTaskCompletedEventAttributes) {\n pushIfAny(\"result\", event.activityTaskCompletedEventAttributes.result?.payloads);\n }\n if (event.activityTaskFailedEventAttributes) {\n const failure = event.activityTaskFailedEventAttributes.failure;\n if (failure?.message) groups.push({ label: \"failure\", payloads: [{ __failure: failure }] });\n }\n if (event.nexusOperationScheduledEventAttributes) {\n const input = event.nexusOperationScheduledEventAttributes.input;\n if (input) pushIfAny(\"input\", [input]);\n }\n if (event.nexusOperationCompletedEventAttributes) {\n const result = event.nexusOperationCompletedEventAttributes.result;\n if (result) pushIfAny(\"result\", [result]);\n }\n if (event.nexusOperationFailedEventAttributes) {\n const failure = event.nexusOperationFailedEventAttributes.failure;\n if (failure?.message) groups.push({ label: \"failure\", payloads: [{ __failure: failure }] });\n }\n return groups;\n}\n\n/**\n * Render one or more payloads into a multi-line string, or return `null`\n * when nothing rendered (e.g. all payloads were non-encrypted and empty).\n *\n * Failures (`{ __failure: ... }`) are rendered specially since they're a\n * structured error, not an encrypted payload.\n */\nasync function renderPayloads(\n payloads: unknown[],\n decrypter: PayloadDecrypter,\n): Promise<string | null> {\n const parts: string[] = [];\n for (const payload of payloads) {\n if (payload && typeof payload === \"object\" && \"__failure\" in payload) {\n const failure = (payload as { __failure: { message?: string; stackTrace?: string; cause?: unknown } }).__failure;\n parts.push(renderFailure(failure));\n continue;\n }\n const decoded = await decrypter(payload as Parameters<PayloadDecrypter>[0]);\n if (decoded === null) {\n parts.push(\"[non-encrypted payload]\");\n } else {\n parts.push(decoded);\n }\n }\n return parts.length > 0 ? parts.join(\"\\n---\\n\") : null;\n}\n\nfunction renderFailure(failure: { message?: string; stackTrace?: string; cause?: unknown }): string {\n const lines = [failure.message ?? \"(no message)\"];\n if (failure.stackTrace) lines.push(failure.stackTrace);\n return lines.join(\"\\n\");\n}\n\n/** Extract a human-readable detail string from event attributes. */\nfunction extractEventDetail(event: any): string | null {\n // Activity scheduled\n if (event.activityTaskScheduledEventAttributes) {\n const a = event.activityTaskScheduledEventAttributes;\n return `activity: ${a.activityType?.name ?? \"?\"}, queue: ${a.taskQueue?.name ?? \"?\"}`;\n }\n // Activity completed\n if (event.activityTaskCompletedEventAttributes) {\n return `scheduledId: ${event.activityTaskCompletedEventAttributes.scheduledEventId}`;\n }\n // Activity failed\n if (event.activityTaskFailedEventAttributes) {\n const a = event.activityTaskFailedEventAttributes;\n const msg = a.failure?.message ?? \"\";\n return `scheduledId: ${a.scheduledEventId}${msg ? `, error: ${msg.slice(0, 120)}` : \"\"}`;\n }\n // Activity timed out\n if (event.activityTaskTimedOutEventAttributes) {\n return `scheduledId: ${event.activityTaskTimedOutEventAttributes.scheduledEventId}`;\n }\n // Nexus scheduled\n if (event.nexusOperationScheduledEventAttributes) {\n const a = event.nexusOperationScheduledEventAttributes;\n return `endpoint: ${a.endpoint ?? \"?\"}, operation: ${a.operation ?? \"?\"}, service: ${a.service ?? \"?\"}`;\n }\n // Nexus completed/failed/timed out\n if (event.nexusOperationCompletedEventAttributes) {\n return `scheduledId: ${event.nexusOperationCompletedEventAttributes.scheduledEventId}`;\n }\n if (event.nexusOperationFailedEventAttributes) {\n const a = event.nexusOperationFailedEventAttributes;\n return `scheduledId: ${a.scheduledEventId}, error: ${a.failure?.message?.slice(0, 120) ?? \"?\"}`;\n }\n if (event.nexusOperationTimedOutEventAttributes) {\n return `scheduledId: ${event.nexusOperationTimedOutEventAttributes.scheduledEventId}`;\n }\n // Signal\n if (event.workflowExecutionSignaledEventAttributes) {\n return `signal: ${event.workflowExecutionSignaledEventAttributes.signalName ?? \"?\"}`;\n }\n // Workflow started\n if (event.workflowExecutionStartedEventAttributes) {\n const a = event.workflowExecutionStartedEventAttributes;\n return `type: ${a.workflowType?.name ?? \"?\"}, queue: ${a.taskQueue?.name ?? \"?\"}`;\n }\n // Workflow completed/failed\n if (event.workflowExecutionCompletedEventAttributes) return null;\n if (event.workflowExecutionFailedEventAttributes) {\n return `error: ${event.workflowExecutionFailedEventAttributes.failure?.message?.slice(0, 120) ?? \"?\"}`;\n }\n // Timer\n if (event.timerStartedEventAttributes) {\n const dur = event.timerStartedEventAttributes.startToFireTimeout;\n return `timerId: ${event.timerStartedEventAttributes.timerId}, duration: ${dur?.seconds ?? \"?\"}s`;\n }\n if (event.timerFiredEventAttributes) {\n return `timerId: ${event.timerFiredEventAttributes.timerId}`;\n }\n return null;\n}\n\n/**\n * Reconstruct an agent conversation's transcript from its durable Temporal\n * history \u2014 for when the worker can no longer replay it (code drift) but the\n * history is intact. Fetches the full history (same client/decrypt path as\n * `history`), classifies the `submit_user_message` signals (human turns) and\n * `callLLM` activity completions (assistant turns), decrypts their payloads,\n * and renders an ordered transcript.\n */\nasync function recoverConversation(\n workflowId: string,\n runId: string | undefined,\n options: {\n stack?: string;\n address?: string;\n namespace?: string;\n out?: string;\n json?: boolean;\n snapshot?: boolean;\n },\n): Promise<void> {\n const conn = await connect(options);\n\n // The history is encrypted \u2014 without the key there's nothing to reconstruct,\n // so (unlike `history`) a missing key is fatal here.\n const decrypter = await buildDecrypterForNamespace(conn.namespace);\n if (!decrypter) {\n conn.connection.close();\n throw new Error(\n \"Cannot decrypt conversation payloads: no encoding key available.\\n\" +\n \"Set TEMPORAL_ENCODING_KEY (e.g. from a `mesh dev` worker env) or configure \" +\n \"kubectl namespace read access, then retry.\",\n );\n }\n\n try {\n // Decode one payload group (signal input / activity result) into a plain\n // object. The decrypter yields pretty-printed JSON; parse it back. Any\n // failure returns undefined so the pure reconstructor emits a placeholder.\n const decodePayload = async (\n payloads: unknown[] | undefined | null,\n ): Promise<unknown> => {\n const first = payloads?.[0];\n if (first === undefined) return undefined;\n const decoded = await decrypter(first as Parameters<typeof decrypter>[0]);\n if (decoded === null) return undefined;\n try {\n return JSON.parse(decoded);\n } catch {\n return undefined;\n }\n };\n\n // Collect the full history in order, then classify it (which activity\n // completions were `callLLM`, which signals were `submit_user_message`)\n // via the shared pure helper.\n const rawEvents = [];\n for await (const event of iterHistoryEvents(\n conn.connection.workflowService,\n conn.namespace,\n workflowId,\n runId,\n )) {\n rawEvents.push(event);\n }\n\n // --snapshot: emit the store-ready conversation blob with STRUCTURED\n // ModelMessage[] (tool-call parts intact) for a full-fidelity backfill,\n // rather than the flattened text turns. artifacts/focus are left empty \u2014\n // the artifact index isn't reconstructable from history here, and the store\n // defaults them.\n if (options.snapshot) {\n const messages = await extractSnapshotMessages(rawEvents, decodePayload);\n const blob = {\n conversationId: workflowId,\n messages,\n artifacts: [] as unknown[],\n focus: null,\n };\n const output = JSON.stringify(blob, null, 2) + \"\\n\";\n if (options.out) {\n writeFileSync(options.out, output);\n logSuccess(\n `Wrote snapshot (${messages.length} messages) to ${options.out}`,\n );\n } else {\n process.stdout.write(output);\n }\n return;\n }\n\n const events = await classifyHistoryEvents(rawEvents, decodePayload);\n const turns = reconstructTranscript(events);\n\n const output = options.json\n ? JSON.stringify(\n {\n turns: turns.map((t) => ({\n role: t.role,\n text: t.text,\n ...(t.toolCalls ? { toolCalls: t.toolCalls } : {}),\n })),\n },\n null,\n 2,\n ) + \"\\n\"\n : renderTranscriptMarkdown(turns);\n\n if (options.out) {\n writeFileSync(options.out, output);\n logSuccess(`Wrote ${turns.length} turns to ${options.out}`);\n } else {\n process.stdout.write(output);\n }\n } finally {\n conn.connection.close();\n }\n}\n\n/**\n * Capture a workflow's FULL durable Temporal history to a replay fixture JSON\n * file \u2014 for LOCAL replay debugging of a specific production break. This is the\n * real-history counterpart of the CI gate's synthetic golden fixtures: the\n * output is the same proto3-JSON `{ events }` shape `@mesh-tech/agent-sdk`'s\n * `historyToFixture` produces, so it runs through `replayFixture` locally. It\n * is never committed to the replay fixtures dir \u2014 those are synthetic goldens;\n * this file holds real decrypted production data (see warning below).\n *\n * Payloads are DECRYPTED (via the same codec `history --show-payloads` uses) so\n * the fixture replays under agent-sdk's plaintext converter. Because that means\n * the file contains real customer/conversation data, the default output path is\n * `~/.mesh/replay-histories/` \u2014 outside any git repo \u2014 and a loud warning is\n * printed. An explicit `--out` inside a git repo is checked with\n * `git check-ignore`; a non-ignored path gets a loud warning (but is not\n * blocked). Like `recover-conversation`, a missing encoding key is fatal \u2014 an\n * encrypted fixture can't be replayed.\n */\nasync function captureHistory(\n workflowId: string,\n runId: string | undefined,\n options: {\n stack?: string;\n address?: string;\n namespace?: string;\n out?: string;\n },\n): Promise<void> {\n const conn = await connect(options);\n\n // A fixture whose payloads stay encrypted can't be replayed (the replayer\n // uses a codec-free converter), so \u2014 like recover-conversation \u2014 treat a\n // missing key as fatal rather than emitting a useless file.\n const decrypter = await buildRawDecrypterForNamespace(conn.namespace);\n if (!decrypter) {\n conn.connection.close();\n throw new Error(\n \"Cannot decrypt history payloads: no encoding key available.\\n\" +\n \"A replay fixture needs decrypted payloads. Set TEMPORAL_ENCODING_KEY \" +\n \"(e.g. from a `mesh dev` worker env) or configure kubectl namespace read \" +\n \"access, then retry.\",\n );\n }\n\n try {\n const events: unknown[] = [];\n for await (const event of iterHistoryEvents(\n conn.connection.workflowService,\n conn.namespace,\n workflowId,\n runId,\n )) {\n events.push(event);\n }\n\n if (events.length === 0) {\n throw new Error(\n `No history events found for workflow '${workflowId}'` +\n (runId ? ` (run ${runId})` : \"\") +\n `. Check the workflow id and namespace (${conn.namespace}).`,\n );\n }\n\n // Lazy import \u2014 pulls in @temporalio/common only when actually capturing,\n // so the CLI still loads without the Temporal SDK installed.\n const { serializeHistoryToFixture } = await import(\n \"../utils/capture-history.js\"\n );\n const { fixture, eventCount, decryptedPayloads } =\n await serializeHistoryToFixture(events, decrypter);\n\n const outPath =\n options.out ??\n join(homedir(), \".mesh\", \"replay-histories\", `${sanitizeFileId(workflowId)}.json`);\n mkdirSync(dirname(outPath), { recursive: true });\n if (options.out) warnIfNotGitIgnored(options.out);\n writeFileSync(outPath, JSON.stringify(fixture, null, 2) + \"\\n\");\n\n logSuccess(\n `Wrote replay history (${eventCount} events, ${decryptedPayloads} payloads decrypted) to ${outPath}`,\n );\n logWarn(\n \"\u26A0 This file contains DECRYPTED payloads \u2014 real customer/conversation \" +\n \"data.\\n\" +\n \" For LOCAL replay debugging ONLY. Do NOT commit it. (CI golden \" +\n \"fixtures are synthetic; this is the debugging counterpart.)\",\n );\n } finally {\n conn.connection.close();\n }\n}\n\n/** Make a workflow id safe to use as a filename (slashes, spaces, etc.). */\nfunction sanitizeFileId(id: string): string {\n return id.replace(/[^A-Za-z0-9._-]/g, \"_\");\n}\n\n/**\n * A replay fixture holds real DECRYPTED production data, so when the user\n * points `--out` at a path inside a git repo we check whether that path is\n * git-ignored (`git check-ignore`) and print a LOUD warning when it is not \u2014\n * we warn, we don't block. Best-effort: silent when git is unavailable or the\n * path is outside any repo (exit code 128), since there is nothing to commit\n * it into.\n */\nfunction warnIfNotGitIgnored(outPath: string): void {\n const abs = resolve(outPath);\n try {\n // Exit 0 = ignored, 1 = in a repo but NOT ignored, 128 = not a repo.\n const res = spawnSync(\"git\", [\"-C\", dirname(abs), \"check-ignore\", \"-q\", abs], {\n stdio: \"ignore\",\n });\n if (res.status !== 1) return;\n } catch {\n return; // git missing/unusable \u2014 can't check, stay quiet\n }\n logWarn(\n \"\u26A0\u26A0\u26A0 OUTPUT PATH IS INSIDE A GIT REPO AND IS *NOT* GITIGNORED \u26A0\u26A0\u26A0\\n\" +\n ` ${abs}\\n` +\n \" This fixture contains DECRYPTED production payloads. Add it to \" +\n \".gitignore or move it outside the repo \u2014 do NOT commit it.\",\n );\n}\n\nasync function terminateWorkflow(\n workflowId: string,\n runId: string | undefined,\n options: { stack?: string; address?: string; namespace?: string; reason?: string },\n): Promise<void> {\n const conn = await connect(options);\n try {\n await conn.connection.workflowService.terminateWorkflowExecution({\n namespace: conn.namespace,\n workflowExecution: { workflowId, runId },\n reason: options.reason ?? \"terminated via mesh temporal\",\n identity: \"mesh-cli\",\n });\n logSuccess(\n `Terminated ${workflowId}${runId ? ` (run ${runId})` : \"\"} in namespace ${conn.namespace}`,\n );\n } finally {\n conn.connection.close();\n }\n}\n\n/**\n * Open a raw Temporal client connection for this app's stack, with the Zitadel\n * Bearer attached when the server enforces auth.\n *\n * Exported (`@mesh-tech/mesh-cli/temporal`) for CLIs built beside this one, such as mesh-studio's\n * `studio workspace`, which reads and signals the workspace environment\n * entity over the same connection and the same auth resolution \u2014 one spelling of\n * \"how the CLI reaches this app's Temporal\", not two.\n *\n * @example\n * const { connection, namespace } = await connect({ stack: \"dev-trabianmatt\" });\n * try {\n * // \u2026use `connection` with @temporalio/client\u2026\n * } finally {\n * connection.close();\n * }\n */\nexport async function connect(\n options: { stack?: string; address?: string; namespace?: string },\n) {\n const info = await resolveConnection(options);\n logInfo(`Connecting to ${info.address} (namespace: ${info.namespace})`);\n\n // Dynamic import \u2014 @temporalio/client is only available in workspaces\n // that have it installed. The mesh-cli uses it from the monorepo.\n const { Connection } = await import(\"@temporalio/client\");\n\n // Attach a Zitadel Bearer when the server enforces auth. Without it the\n // MeshAuthorizer denies calls with \"Request unauthorized\" (e.g. terminate).\n const token = await resolveBearerToken(info.auth);\n const metadata = token ? { authorization: `Bearer ${token}` } : undefined;\n // Raise the gRPC receive cap well above the 4 MiB default. Every command here\n // is a whole-history read, and the histories worth reading are the big ones:\n // `capture-history` and `recover-conversation` exist precisely for stuck or\n // dead conversations, which are stuck or dead BECAUSE their history grew. At\n // the default cap a conversation Temporal itself terminated for \"history size\n // exceeds limit\" cannot be recovered by the tool built to recover it \u2014\n // `8 RESOURCE_EXHAUSTED: Received message larger than max (5014664 vs 4194304)`.\n // 256 MiB comfortably exceeds Temporal's own per-workflow history size limit,\n // so the cap is not the thing that stops a recovery.\n const connection = await Connection.connect({\n address: info.address,\n metadata,\n channelArgs: { \"grpc.max_receive_message_length\": 256 * 1024 * 1024 },\n });\n\n return { connection, namespace: info.namespace };\n}\n\n// \u2500\u2500 Registration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport function registerTemporalCommands(program: Command): void {\n const temporal = program\n .command(\"temporal\")\n .description(\"Inspect Temporal workflows for the current app/stack\");\n\n const sharedOpts = (cmd: Command) =>\n cmd\n .option(\"--stack <stack>\", \"Pulumi stack name (auto-detected from current directory)\")\n .option(\"--address <addr>\", \"Temporal server address (default: from stack output)\")\n .option(\"--namespace <ns>\", \"Temporal namespace (default: from stack output)\");\n\n sharedOpts(\n temporal\n .command(\"describe <workflowId> [runId]\")\n .description(\"Show workflow status and metadata\"),\n ).action(async (workflowId: string, runId: string | undefined, opts) => {\n try {\n await describeWorkflow(workflowId, runId, opts);\n } catch (err) {\n logError(err instanceof Error ? err.message : String(err));\n process.exitCode = 1;\n }\n });\n\n sharedOpts(\n temporal\n .command(\"terminate <workflowId> [runId]\")\n .description(\"Terminate a running workflow (e.g. a wedged/looping conversation)\"),\n )\n .option(\"--reason <reason>\", \"Termination reason (recorded in workflow history)\")\n .action(async (workflowId: string, runId: string | undefined, opts) => {\n try {\n await terminateWorkflow(workflowId, runId, opts);\n } catch (err) {\n logError(err instanceof Error ? err.message : String(err));\n process.exitCode = 1;\n }\n });\n\n sharedOpts(\n temporal\n .command(\"history <workflowId> [runId]\")\n .description(\"Show workflow event history\")\n .option(\"-n, --limit <n>\", \"Maximum events to show (default: 200)\")\n .option(\"-f, --follow\", \"Tail the history, waiting for new events until the workflow completes\")\n .option(\"--no-compact\", \"Show all events including WorkflowTask scheduling noise\")\n .option(\"-p, --show-payloads\", \"Decrypt and print activity inputs/outputs (reads TEMPORAL_ENCODING_KEY from the app's K8s secret via kubectl)\"),\n ).action(async (workflowId: string, runId: string | undefined, opts) => {\n try {\n await showHistory(workflowId, runId, opts);\n } catch (err) {\n logError(err instanceof Error ? err.message : String(err));\n process.exitCode = 1;\n }\n });\n\n sharedOpts(\n temporal\n .command(\"recover-conversation <workflowId> [runId]\")\n .description(\n \"Reconstruct an agent conversation transcript from durable Temporal history \" +\n \"(for when the worker can no longer replay it). Requires the encoding key \u2014 \" +\n \"reads TEMPORAL_ENCODING_KEY (or the app's K8s secret).\",\n )\n .option(\"--out <path>\", \"Write the transcript to a file instead of stdout\")\n .option(\"--json\", \"Emit a structured { turns: [...] } JSON array instead of markdown\")\n .option(\n \"--snapshot\",\n \"Emit a store-ready conversation blob { conversationId, messages, artifacts, focus } \" +\n \"with STRUCTURED ModelMessage[] (tool-call parts intact) for a full-fidelity backfill\",\n ),\n ).action(async (workflowId: string, runId: string | undefined, opts) => {\n try {\n await recoverConversation(workflowId, runId, opts);\n } catch (err) {\n logError(err instanceof Error ? err.message : String(err));\n process.exitCode = 1;\n }\n });\n\n sharedOpts(\n temporal\n .command(\"capture-history <workflowId> [runId]\")\n .description(\n \"Capture a workflow's full history to a replay fixture JSON (proto3-JSON, \" +\n \"DECRYPTED payloads) for LOCAL replay debugging. Requires the encoding key. \" +\n \"Defaults to ~/.mesh/replay-histories/<workflowId>.json (outside any repo) \u2014 \" +\n \"do NOT commit the output.\",\n )\n .option(\n \"--out <path>\",\n \"Output path (default: ~/.mesh/replay-histories/<workflowId>.json; a non-gitignored \" +\n \"path inside a git repo triggers a loud warning)\",\n ),\n ).action(async (workflowId: string, runId: string | undefined, opts) => {\n try {\n await captureHistory(workflowId, runId, opts);\n } catch (err) {\n logError(err instanceof Error ? err.message : String(err));\n process.exitCode = 1;\n }\n });\n}\n", "/**\n * Reconstruct an agent conversation's transcript from decoded Temporal history.\n *\n * An agent conversation is a Temporal `agentWorkflow`. Its durable history holds\n * everything needed to replay the human/agent exchange even when the worker can\n * no longer run the workflow (code drift):\n *\n * - `WorkflowExecutionSignaled` with signal name `submit_user_message` \u2192\n * one HUMAN turn. Its input payload is `SubmitUserMessageParams`\n * (agent-sdk `agent-core/workflows/signals.ts`): `{ model, messages,\n * streamId, ... }` where `messages` is a Vercel-AI `ModelMessage[]`; the\n * user text lives in the `user`-role message(s).\n * - `callLLM` `ActivityTaskCompleted` \u2192 one ASSISTANT turn. Its result is\n * `LLMResult` (agent-sdk `agent-core/types.ts`): `{ messages, usage?,\n * telemetry? }` where `messages` is the model's output `ModelMessage[]` \u2014\n * `assistant`-role text parts plus any `tool-call` parts.\n *\n * This module is the PURE reconstruction core: it takes events whose payloads\n * have ALREADY been decrypted into plain objects and returns ordered turns. The\n * Temporal fetch + decrypt wiring lives in `commands/temporal.ts`.\n *\n * Everything here is best-effort and defensive: payload shapes vary across\n * agent-sdk versions, so every field access is guarded and a turn that can't be\n * parsed becomes a placeholder rather than throwing \u2014 the whole point is to\n * recover *something* from an otherwise-unreplayable history.\n */\n\n/** Classification of a single decoded history event. */\nexport interface DecodedEvent {\n /** Temporal event id (stringified). Used in placeholders. */\n eventId: string;\n /** Which kind of conversation event this is. */\n kind: \"userSignal\" | \"callLLMResult\" | \"other\";\n /**\n * The decoded payload object for this event:\n * - `userSignal` \u2192 the `submit_user_message` signal input (SubmitUserMessageParams)\n * - `callLLMResult` \u2192 the `callLLM` activity result (LLMResult)\n * - `other` \u2192 unused\n */\n payload?: unknown;\n}\n\n/**\n * Minimal shape of a raw Temporal history event as consumed by\n * `classifyHistoryEvents`. Only the attribute groups we classify are declared;\n * everything else is ignored. Field access is defensive so partial/variant\n * shapes (across Temporal SDK versions) don't throw.\n */\nexport interface RawHistoryEvent {\n eventId?: unknown;\n activityTaskScheduledEventAttributes?: {\n activityType?: { name?: string | null } | null;\n /**\n * The activity's INPUT payload group. For `callLLM` this is `callLLMArgs`\n * (`{ model, messages }`) whose `messages` is the FULL accumulated\n * conversation \u2014 the highest-fidelity snapshot source.\n */\n input?: { payloads?: unknown[] | null } | null;\n } | null;\n workflowExecutionSignaledEventAttributes?: {\n signalName?: string | null;\n input?: { payloads?: unknown[] | null } | null;\n } | null;\n activityTaskCompletedEventAttributes?: {\n scheduledEventId?: unknown;\n result?: { payloads?: unknown[] | null } | null;\n } | null;\n // Other attribute groups exist on real events but are not classified here.\n [k: string]: unknown;\n}\n\n/**\n * Decrypt+parse a payload group (the `payloads` array from a signal input or\n * activity result) into a plain object. Returns `undefined` when nothing could\n * be decoded, so the pure reconstructor emits a placeholder. Injected so the\n * classification stays pure and unit-testable without a live Temporal codec.\n */\nexport type DecodePayload = (\n payloads: unknown[] | undefined | null,\n) => Promise<unknown>;\n\n/**\n * Classify a raw Temporal history into the ordered `DecodedEvent[]` that\n * `reconstructTranscript` consumes.\n *\n * `ActivityTaskCompleted` events carry only a `scheduledEventId`, not the\n * activity's type name \u2014 so to tell which completions came from `callLLM` we\n * build a map of `ActivityTaskScheduled.eventId \u2192 activityType.name` as we walk\n * the history in event order, then look each completion's `scheduledEventId` up\n * in it. Only `callLLM` completions become `callLLMResult` (assistant) events;\n * completions of other activities (e.g. `routeTools`) are dropped.\n * `submit_user_message` signals become `userSignal` (human) events.\n *\n * Pure aside from the injected `decodePayload` (which performs the decrypt):\n * never throws, and preserves history order.\n */\nexport async function classifyHistoryEvents(\n rawEvents: Iterable<RawHistoryEvent>,\n decodePayload: DecodePayload,\n): Promise<DecodedEvent[]> {\n // scheduledEventId \u2192 activity type name, so we can tell which\n // ActivityTaskCompleted events came from `callLLM`.\n const activityTypeByScheduledId = new Map<string, string>();\n const events: DecodedEvent[] = [];\n\n for (const event of rawEvents) {\n const eventId = String(event.eventId);\n\n const scheduled = event.activityTaskScheduledEventAttributes;\n if (scheduled) {\n const name = scheduled.activityType?.name;\n if (name) activityTypeByScheduledId.set(eventId, name);\n continue;\n }\n\n const signal = event.workflowExecutionSignaledEventAttributes;\n if (signal?.signalName === \"submit_user_message\") {\n events.push({\n eventId,\n kind: \"userSignal\",\n payload: await decodePayload(signal.input?.payloads),\n });\n continue;\n }\n\n const completed = event.activityTaskCompletedEventAttributes;\n if (completed) {\n const scheduledId = String(completed.scheduledEventId);\n if (activityTypeByScheduledId.get(scheduledId) === \"callLLM\") {\n events.push({\n eventId,\n kind: \"callLLMResult\",\n payload: await decodePayload(completed.result?.payloads),\n });\n }\n }\n }\n\n return events;\n}\n\n/**\n * A Vercel-AI `ModelMessage` as it appears in decoded history \u2014 kept structural\n * (not flattened to text) so tool-call / tool-result content parts survive into\n * the snapshot. Typed loosely on purpose: shapes vary across agent-sdk versions\n * and the snapshot is a best-effort passthrough of whatever was persisted.\n */\nexport type ModelMessage = { role?: string; content?: unknown } & Record<\n string,\n unknown\n>;\n\n/**\n * Extract the highest-fidelity STRUCTURED transcript for a store-ready snapshot\n * backfill \u2014 the complete `ModelMessage[]` with tool-call/tool-result parts\n * intact (as opposed to `reconstructTranscript`'s flattened text turns).\n *\n * The source is the `callLLM` activity's SCHEDULED input, `callLLMArgs`\n * (`{ model, messages }`), whose `messages` is the FULL accumulated conversation\n * (user + assistant-with-tool-calls + tool-result messages) up to \u2014 but not\n * including \u2014 that turn's assistant output. We take the LAST `callLLM`\n * scheduled input (the most-accumulated one) and append the LAST `callLLM`\n * completion's output messages (`LLMResult.messages`, the final assistant turn):\n *\n * [...lastScheduledInput.messages, ...lastCompletion.messages]\n *\n * Completions are matched to `callLLM` via `scheduledEventId` (same mapping as\n * `classifyHistoryEvents`).\n *\n * Best-effort and never throws:\n * - no `callLLM` scheduled input (0 assistant turns) \u2192 `[]`;\n * - a scheduled input but no completion (last turn interrupted) \u2192 just the\n * input messages.\n *\n * Pure aside from the injected `decodePayload` (which performs the decrypt).\n */\nexport async function extractSnapshotMessages(\n rawEvents: Iterable<RawHistoryEvent>,\n decodePayload: DecodePayload,\n): Promise<ModelMessage[]> {\n // Track the last `callLLM` scheduled input's payload group and its eventId, so\n // we can (a) decode the most-accumulated messages and (b) match its\n // completion by scheduledEventId.\n let lastCallLLMScheduledId: string | undefined;\n let lastCallLLMInputPayloads: unknown[] | null | undefined;\n // Map every scheduled activity's eventId \u2192 type name so completions resolve.\n const activityTypeByScheduledId = new Map<string, string>();\n // Track the last `callLLM` completion result payloads, keyed by matching the\n // scheduledEventId back to a `callLLM`.\n let lastCallLLMResultPayloads: unknown[] | null | undefined;\n\n for (const event of rawEvents) {\n const eventId = String(event.eventId);\n\n const scheduled = event.activityTaskScheduledEventAttributes;\n if (scheduled) {\n const name = scheduled.activityType?.name;\n if (name) activityTypeByScheduledId.set(eventId, name);\n if (name === \"callLLM\") {\n lastCallLLMScheduledId = eventId;\n lastCallLLMInputPayloads = scheduled.input?.payloads;\n // Reset the result: a completion only \"belongs\" to this newly-scheduled\n // callLLM if it arrives AFTER this point. Without the reset, a mid-turn\n // interrupted last turn (scheduled, never completed) would keep a STALE\n // earlier completion and duplicate that assistant message (it's already\n // inside this turn's accumulated input). Matches the documented contract:\n // scheduled-input-but-no-completion \u2192 just the input messages.\n lastCallLLMResultPayloads = undefined;\n }\n continue;\n }\n\n const completed = event.activityTaskCompletedEventAttributes;\n if (completed) {\n const scheduledId = String(completed.scheduledEventId);\n if (activityTypeByScheduledId.get(scheduledId) === \"callLLM\") {\n lastCallLLMResultPayloads = completed.result?.payloads;\n }\n }\n }\n\n if (lastCallLLMScheduledId === undefined) return [];\n\n const inputArgs = await decodePayload(lastCallLLMInputPayloads);\n const inputMessages = messagesFrom(inputArgs);\n\n const result = await decodePayload(lastCallLLMResultPayloads);\n const resultMessages = messagesFrom(result);\n\n return [...inputMessages, ...resultMessages];\n}\n\n/** Pull a `.messages` array off a decoded `{ messages }` payload, else `[]`. */\nfunction messagesFrom(payload: unknown): ModelMessage[] {\n if (isRecord(payload) && Array.isArray(payload.messages)) {\n return payload.messages as ModelMessage[];\n }\n return [];\n}\n\n/** One reconstructed conversation turn. */\nexport interface Turn {\n role: \"user\" | \"assistant\";\n /** Human-readable text. Empty string is valid (e.g. a tool-only assistant turn). */\n text: string;\n /** Formatted tool-call summaries (`name(compactArgs)`), assistant turns only. */\n toolCalls?: string[];\n /** Originating Temporal event id. */\n eventId: string;\n}\n\n// \u2500\u2500 Extraction helpers (all defensive) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Pull display text out of a ModelMessage `content` (string, or parts array). */\nfunction extractText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .filter(\n (p): p is { type: string; text?: unknown } =>\n !!p && typeof p === \"object\" && (p as { type?: unknown }).type === \"text\",\n )\n .map((p) => (typeof p.text === \"string\" ? p.text : \"\"))\n .join(\"\");\n }\n return \"\";\n}\n\n/** Compact a tool-call's args to a single truncated JSON line. */\nfunction summarizeArgs(args: unknown): string {\n try {\n const str = JSON.stringify(args ?? {});\n return str.length > 100 ? str.substring(0, 97) + \"...\" : str;\n } catch {\n return \"...\";\n }\n}\n\n/** Format a `tool-call` content part as `name(compactArgs)`. */\nfunction formatToolCall(part: Record<string, unknown>): string {\n const name = typeof part.toolName === \"string\" ? part.toolName : \"?\";\n // AI SDK v5 uses `input`; older payloads use `args`. Accept either.\n const args = \"input\" in part ? part.input : part.args;\n return `${name}(${summarizeArgs(args)})`;\n}\n\nfunction isRecord(v: unknown): v is Record<string, unknown> {\n return !!v && typeof v === \"object\";\n}\n\n/**\n * Extract the human text from a `submit_user_message` signal payload. Returns\n * `null` when nothing recognizable is found (caller emits a placeholder).\n */\nfunction extractUserText(payload: unknown): string | null {\n if (!isRecord(payload)) return null;\n\n const messages = payload.messages;\n if (Array.isArray(messages)) {\n const parts: string[] = [];\n for (const m of messages) {\n if (isRecord(m) && m.role === \"user\") {\n const t = extractText(m.content);\n if (t) parts.push(t);\n }\n }\n if (parts.length > 0) return parts.join(\"\\n\");\n }\n\n // Defensive fallbacks for shape drift \u2014 try common single-field carriers.\n for (const field of [\"content\", \"text\", \"message\"] as const) {\n const v = payload[field];\n if (typeof v === \"string\" && v) return v;\n const t = extractText(v);\n if (t) return t;\n }\n return null;\n}\n\n/**\n * Extract the assistant text + tool calls from a `callLLM` result payload.\n * Returns `null` only when nothing at all could be parsed (no text AND no tool\n * calls) \u2014 a tool-only turn (empty text, non-empty toolCalls) is valid.\n */\nfunction extractAssistantTurn(\n payload: unknown,\n): { text: string; toolCalls: string[] } | null {\n if (!isRecord(payload)) return null;\n\n const messages = payload.messages;\n if (!Array.isArray(messages)) {\n // Defensive fallback: some shapes may carry text directly.\n const t = extractText(payload.content) || extractText(payload.text);\n return t ? { text: t, toolCalls: [] } : null;\n }\n\n let text = \"\";\n const toolCalls: string[] = [];\n for (const m of messages) {\n if (!isRecord(m) || m.role !== \"assistant\") continue;\n text += extractText(m.content);\n if (Array.isArray(m.content)) {\n for (const p of m.content) {\n if (isRecord(p) && p.type === \"tool-call\") {\n toolCalls.push(formatToolCall(p));\n }\n }\n }\n }\n\n if (!text && toolCalls.length === 0) return null;\n return { text, toolCalls };\n}\n\n// \u2500\u2500 Reconstruction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Reconstruct ordered conversation turns from decoded history events.\n *\n * Pure: no I/O, never throws. `other` events are skipped; unparseable\n * user/assistant events become placeholder turns so the recovery is complete\n * end-to-end even when individual payloads are corrupt.\n */\nexport function reconstructTranscript(events: DecodedEvent[]): Turn[] {\n const turns: Turn[] = [];\n for (const event of events) {\n if (event.kind === \"userSignal\") {\n const text = extractUserText(event.payload);\n turns.push(\n text !== null\n ? { role: \"user\", text, eventId: event.eventId }\n : { role: \"user\", text: placeholder(event.eventId), eventId: event.eventId },\n );\n } else if (event.kind === \"callLLMResult\") {\n const turn = extractAssistantTurn(event.payload);\n if (turn) {\n turns.push({\n role: \"assistant\",\n text: turn.text,\n eventId: event.eventId,\n ...(turn.toolCalls.length > 0 ? { toolCalls: turn.toolCalls } : {}),\n });\n } else {\n turns.push({\n role: \"assistant\",\n text: placeholder(event.eventId),\n eventId: event.eventId,\n });\n }\n }\n // `other` \u2192 skipped\n }\n return turns;\n}\n\nfunction placeholder(eventId: string): string {\n return `_[unparseable turn: ${eventId}]_`;\n}\n\n// \u2500\u2500 Rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Render turns as a readable markdown transcript. */\nexport function renderTranscriptMarkdown(turns: Turn[]): string {\n const blocks: string[] = [];\n for (const turn of turns) {\n const heading = turn.role === \"user\" ? \"## User\" : \"## Assistant\";\n const lines: string[] = [heading, \"\"];\n if (turn.text) lines.push(turn.text);\n if (turn.toolCalls && turn.toolCalls.length > 0) {\n if (turn.text) lines.push(\"\");\n for (const tc of turn.toolCalls) lines.push(`\u2192 tool: ${tc}`);\n }\n blocks.push(lines.join(\"\\n\"));\n }\n return blocks.join(\"\\n\\n\") + \"\\n\";\n}\n", "/**\n * Temporal payload decryption helpers for mesh CLI.\n *\n * Mirrors the AES-256-GCM codec in `@mesh-tech/app-kit/temporal-codec`. The CLI\n * can't depend on app-kit directly because app-kit depends on mesh-cli \u2014 so\n * the codec logic is intentionally duplicated here, kept small, and bounded\n * to read-only decryption (the CLI never encrypts).\n *\n * Wire format (matches app-kit EncryptionCodec):\n * metadata[\"encoding\"] = \"binary/encrypted\"\n * metadata[\"encryption-key-id\"] = first 16 hex chars of SHA-256(encodingKey)\n * data = IV (12 bytes) || ciphertext || GCM auth tag (16 bytes)\n *\n * Decryption of `data` yields a serialized Temporal Payload protobuf, whose\n * inner `encoding` metadata tells you how to interpret the inner bytes\n * (`json/plain`, `binary/plain`, etc.). We hand-decode the Payload message\n * (a trivial 2-field protobuf) instead of depending on `@temporalio/proto`,\n * so the CLI stays dependency-clean and doesn't need a Temporal SDK peer.\n */\n\nimport { execFileSync } from \"child_process\";\nimport { webcrypto as crypto } from \"node:crypto\";\nimport { logSuccess, logWarn } from \"./log.js\";\n\n// \u2500\u2500 Encoding key resolution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Read the Temporal encoding key from a Kubernetes secret in the given\n * namespace. Returns `undefined` when kubectl is unavailable, the secret\n * doesn't exist, or the caller isn't authenticated to the cluster.\n *\n * The K8s secret is preferred over Pulumi stack output because personal dev\n * stacks ship a placeholder encoding key that cannot decrypt deployed\n * payloads; the K8s secret is always authoritative for a given namespace.\n *\n * @param namespace - Kubernetes namespace matching the Temporal namespace\n * (mesh apps use the same name for both).\n */\nexport function resolveTemporalEncodingKeyFromK8s(\n namespace: string,\n): string | undefined {\n const secretName = `${namespace}-temporal-encoding-key`;\n try {\n const b64 = execFileSync(\n \"kubectl\",\n [\n \"get\",\n \"secret\",\n secretName,\n \"-n\",\n namespace,\n \"-o\",\n \"jsonpath={.data.TEMPORAL_ENCODING_KEY}\",\n ],\n { encoding: \"utf-8\", timeout: 10_000, stdio: [\"pipe\", \"pipe\", \"pipe\"] },\n ).trim();\n\n if (!b64) return undefined;\n return Buffer.from(b64, \"base64\").toString(\"utf-8\");\n } catch {\n // kubectl missing, secret absent, or no cluster access \u2014 caller decides.\n return undefined;\n }\n}\n\n// \u2500\u2500 AES-256-GCM decryption \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst ENCRYPTED_ENCODING = \"binary/encrypted\";\nconst IV_LENGTH = 12;\n\nasync function deriveKey(encodingKey: string): Promise<crypto.CryptoKey> {\n const keyData = await crypto.subtle.digest(\n \"SHA-256\",\n new TextEncoder().encode(encodingKey),\n );\n return crypto.subtle.importKey(\n \"raw\",\n keyData,\n { name: \"AES-GCM\" },\n false,\n [\"decrypt\"],\n );\n}\n\nasync function aesGcmDecrypt(\n data: Uint8Array,\n key: crypto.CryptoKey,\n): Promise<Uint8Array> {\n const iv = data.slice(0, IV_LENGTH);\n const ciphertextWithTag = data.slice(IV_LENGTH);\n const decrypted = await crypto.subtle.decrypt(\n { name: \"AES-GCM\", iv },\n key,\n ciphertextWithTag,\n );\n return new Uint8Array(decrypted);\n}\n\n// \u2500\u2500 Payload decoder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Minimal shape of a Temporal Payload we care about.\n * Matches `temporal.api.common.v1.Payload` from @temporalio/proto but avoids\n * importing the type at module load time (so the CLI still loads without the\n * Temporal SDK installed).\n */\nexport interface RawPayload {\n metadata?: Record<string, Uint8Array | null | undefined> | null;\n data?: Uint8Array | null;\n}\n\n/**\n * A decrypter closure bound to a specific encoding key. Returns a decoded\n * human-readable string for a single encrypted Payload, or `null` when the\n * payload wasn't encrypted and the caller should fall back to raw decoding.\n *\n * Error paths (`[undecryptable]`, `[decode error: ...]`) are returned as\n * strings rather than thrown \u2014 debugging a history is a best-effort read-only\n * operation and one bad payload shouldn't abort the whole dump.\n */\nexport type PayloadDecrypter = (\n payload: RawPayload | null | undefined,\n) => Promise<string | null>;\n\n/**\n * Build a decrypter closure for the given encoding key.\n */\nexport async function createPayloadDecrypter(\n encodingKey: string,\n): Promise<PayloadDecrypter> {\n const key = await deriveKey(encodingKey);\n\n return async (payload) => {\n if (!payload?.metadata) return null;\n\n const encodingBytes = payload.metadata[\"encoding\"];\n if (!encodingBytes) return null;\n const encoding = new TextDecoder().decode(encodingBytes);\n if (encoding !== ENCRYPTED_ENCODING) return null;\n\n if (!payload.data) return \"[encrypted payload missing data]\";\n\n let inner: RawPayload;\n try {\n const decrypted = await aesGcmDecrypt(payload.data, key);\n inner = decodePayloadProtobuf(decrypted);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n return `[undecryptable: ${msg}]`;\n }\n\n return decodeInnerPayload(inner);\n };\n}\n\n/**\n * A closure that decrypts a single encrypted Temporal Payload to its INNER\n * (plaintext) Payload \u2014 the `{ metadata, data }` the worker's codec wrapped \u2014\n * rather than the human-readable string {@link PayloadDecrypter} produces.\n *\n * Returns `null` when the payload isn't encrypted (the caller leaves it as-is).\n * Unlike the display decrypter, decryption errors THROW here: a fixture with a\n * silently-mangled payload would replay wrong, so the caller must surface it.\n */\nexport type RawPayloadDecrypter = (\n payload: RawPayload | null | undefined,\n) => Promise<RawPayload | null>;\n\n/**\n * Build a raw (Payload-in, Payload-out) decrypter for the given encoding key.\n *\n * This is the fixture-capture counterpart of {@link createPayloadDecrypter}:\n * where that one renders a payload for human display (lossy \u2014 binary payloads\n * become placeholders), this one returns the exact decrypted inner Payload so\n * the bytes can be re-serialized into a replayable proto3-JSON history.\n */\nexport async function createRawPayloadDecrypter(\n encodingKey: string,\n): Promise<RawPayloadDecrypter> {\n const key = await deriveKey(encodingKey);\n\n return async (payload) => {\n if (!payload?.metadata) return null;\n\n const encodingBytes = payload.metadata[\"encoding\"];\n if (!encodingBytes) return null;\n const encoding = new TextDecoder().decode(encodingBytes);\n if (encoding !== ENCRYPTED_ENCODING) return null;\n\n if (!payload.data) {\n throw new Error(\"encrypted payload has no data to decrypt\");\n }\n\n const decrypted = await aesGcmDecrypt(payload.data, key);\n return decodePayloadProtobuf(decrypted);\n };\n}\n\n// \u2500\u2500 Hand-rolled Payload protobuf decoder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// temporal.api.common.v1.Payload has exactly two fields:\n// message Payload {\n// map<string, bytes> metadata = 1; // field 1, length-delimited\n// bytes data = 2; // field 2, length-delimited\n// }\n// A protobuf `map<K, V>` is syntactic sugar for:\n// repeated MapEntry { K key = 1; V value = 2; }\n// so each metadata entry is itself a length-delimited submessage with two\n// inner length-delimited fields.\n//\n// Decoding this by hand avoids pulling in `@temporalio/proto` as a peer dep.\n\nfunction readVarint(buf: Uint8Array, pos: number): { value: number; next: number } {\n let value = 0;\n let shift = 0;\n let next = pos;\n while (next < buf.length) {\n const byte = buf[next]!;\n next += 1;\n value |= (byte & 0x7f) << shift;\n if ((byte & 0x80) === 0) return { value, next };\n shift += 7;\n if (shift >= 32) throw new Error(\"varint too large\");\n }\n throw new Error(\"truncated varint\");\n}\n\nfunction readLengthDelimited(\n buf: Uint8Array,\n pos: number,\n): { bytes: Uint8Array; next: number } {\n const { value: length, next: afterLen } = readVarint(buf, pos);\n const end = afterLen + length;\n if (end > buf.length) throw new Error(\"truncated length-delimited field\");\n return { bytes: buf.slice(afterLen, end), next: end };\n}\n\n/**\n * Decode a serialized `temporal.api.common.v1.Payload`.\n *\n * Unknown fields and wire types are skipped rather than throwing; we only\n * need the two documented fields and Temporal's wire format is stable.\n */\nfunction decodePayloadProtobuf(buf: Uint8Array): RawPayload {\n const metadata: Record<string, Uint8Array> = {};\n let data: Uint8Array | undefined;\n let pos = 0;\n\n while (pos < buf.length) {\n const { value: tag, next: afterTag } = readVarint(buf, pos);\n const fieldNumber = tag >>> 3;\n const wireType = tag & 0x7;\n pos = afterTag;\n\n if (wireType === 2) {\n const { bytes, next } = readLengthDelimited(buf, pos);\n pos = next;\n if (fieldNumber === 1) {\n const entry = decodeMetadataEntry(bytes);\n if (entry) metadata[entry.key] = entry.value;\n } else if (fieldNumber === 2) {\n data = bytes;\n }\n // Other fields: ignore.\n } else if (wireType === 0) {\n // varint \u2014 skip\n pos = readVarint(buf, pos).next;\n } else if (wireType === 1) {\n pos += 8; // fixed64\n } else if (wireType === 5) {\n pos += 4; // fixed32\n } else {\n throw new Error(`unsupported wire type ${wireType} at pos ${pos}`);\n }\n }\n\n return { metadata, data: data ?? new Uint8Array(0) };\n}\n\nfunction decodeMetadataEntry(\n buf: Uint8Array,\n): { key: string; value: Uint8Array } | null {\n let key: string | undefined;\n let value: Uint8Array | undefined;\n let pos = 0;\n\n while (pos < buf.length) {\n const { value: tag, next: afterTag } = readVarint(buf, pos);\n const fieldNumber = tag >>> 3;\n const wireType = tag & 0x7;\n pos = afterTag;\n if (wireType !== 2) {\n // Skip unsupported fields defensively.\n if (wireType === 0) pos = readVarint(buf, pos).next;\n else if (wireType === 1) pos += 8;\n else if (wireType === 5) pos += 4;\n else throw new Error(`unsupported metadata wire type ${wireType}`);\n continue;\n }\n const { bytes, next } = readLengthDelimited(buf, pos);\n pos = next;\n if (fieldNumber === 1) key = new TextDecoder().decode(bytes);\n else if (fieldNumber === 2) value = bytes;\n }\n\n if (key === undefined || value === undefined) return null;\n return { key, value };\n}\n\n/**\n * Render a decrypted inner Payload as a human-readable string.\n *\n * Handles the common encodings Mesh workers emit:\n * - `json/plain` \u2192 pretty-printed JSON\n * - `binary/null` \u2192 literal `null`\n * - `binary/plain` \u2192 `[binary N bytes]`\n * - anything else \u2192 `[encoding=X, N bytes]`\n */\nfunction decodeInnerPayload(payload: RawPayload): string {\n const meta = payload.metadata ?? {};\n const encodingBytes = meta[\"encoding\"];\n const encoding = encodingBytes\n ? new TextDecoder().decode(encodingBytes)\n : \"unknown\";\n const data = payload.data ?? new Uint8Array(0);\n\n if (encoding === \"binary/null\") return \"null\";\n if (encoding === \"json/plain\") {\n const text = new TextDecoder().decode(data);\n try {\n return JSON.stringify(JSON.parse(text), null, 2);\n } catch {\n // Not valid JSON despite the encoding claim \u2014 surface the raw text so\n // the debugger sees whatever garbage the worker actually wrote.\n return text;\n }\n }\n if (encoding === \"binary/plain\") return `[binary, ${data.length} bytes]`;\n return `[encoding=${encoding}, ${data.length} bytes]`;\n}\n\n// \u2500\u2500 High-level helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Resolve the Temporal encoding key for a namespace, logging where it came\n * from (or why it couldn't be found). Prefer the K8s secret (authoritative for\n * the namespace); fall back to an explicit TEMPORAL_ENCODING_KEY env var. The\n * env fallback lets you decrypt a workflow's history OFFLINE \u2014 e.g. to\n * reconstruct a conversation, or capture a replay fixture, whose worker can no\n * longer replay it \u2014 using the key from a running `mesh dev` worker env when\n * kubectl/cluster access isn't set up.\n *\n * Returns `undefined` when neither source yields a key.\n */\nexport function resolveEncodingKey(namespace: string): string | undefined {\n const fromK8s = resolveTemporalEncodingKeyFromK8s(namespace);\n const key = fromK8s ?? process.env.TEMPORAL_ENCODING_KEY;\n if (!key) {\n logWarn(\n `Could not read TEMPORAL_ENCODING_KEY from secret ` +\n `${namespace}/${namespace}-temporal-encoding-key, and TEMPORAL_ENCODING_KEY ` +\n \"is not set. Configure kubectl (namespace read \" +\n \"access), or export TEMPORAL_ENCODING_KEY (e.g. from a `mesh dev` worker env).\",\n );\n return undefined;\n }\n logSuccess(\n fromK8s\n ? `Resolved encoding key from ${namespace}-temporal-encoding-key`\n : \"Resolved encoding key from TEMPORAL_ENCODING_KEY env\",\n );\n return key;\n}\n\n/**\n * Resolve the encoding key and build a decrypter for the given namespace.\n * Logs a warning and returns `undefined` when the key can't be read; callers\n * should fall back to printing events without payloads.\n */\nexport async function buildDecrypterForNamespace(\n namespace: string,\n): Promise<PayloadDecrypter | undefined> {\n const key = resolveEncodingKey(namespace);\n if (!key) return undefined;\n return createPayloadDecrypter(key);\n}\n\n/**\n * Resolve the encoding key and build a RAW (Payload-in/Payload-out) decrypter\n * for the given namespace \u2014 used by `capture-history` to decrypt payloads back\n * into replayable proto bytes. Returns `undefined` when the key can't be read;\n * callers decide whether that's fatal.\n */\nexport async function buildRawDecrypterForNamespace(\n namespace: string,\n): Promise<RawPayloadDecrypter | undefined> {\n const key = resolveEncodingKey(namespace);\n if (!key) return undefined;\n return createRawPayloadDecrypter(key);\n}\n"],
5
- "mappings": "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",
3
+ "sources": ["../../../src/utils/log.ts", "../../../src/utils/pulumi.ts", "../../../src/utils/auth-preflight.ts", "../../../src/utils/errors.ts", "../../../src/utils/first-party-contexts.ts", "../../../src/utils/context.ts", "../../../src/utils/bastion.ts", "../../../src/utils/credentials.ts", "../../../src/utils/vpn.ts", "../../../src/utils/index.ts", "../../../src/utils/pid.ts", "../../../src/utils/vpn-join.ts", "../../../src/utils/pulumi-run.ts", "../../../src/utils/kubeconfig.ts", "../../../src/utils/temporal-auth.ts", "../../../src/utils/reachability.ts", "../../../src/utils/workflow-fingerprint.ts", "../../../src/commands/dev-launch.ts", "../../../src/commands/local/seed.ts", "../../../src/commands/local/helpers.ts", "../../../src/utils/cache-home.ts", "../../../src/commands/local/stack.ts", "../../../src/commands/local/mocks.ts", "../../../../api-registry/src/hub-roles.ts", "../../../../api-registry/src/index.ts", "../../../src/commands/local/seed-zitadel.ts", "../../../src/commands/local/auth-provision.ts", "../../../src/utils/mesh-json.ts", "../../../src/commands/local/dev-local.ts", "../../../src/commands/local/docker-runner.ts", "../../../src/utils/stack-flag.ts", "../../../src/commands/peer-addressing.ts", "../../../src/commands/dev-doctor.ts", "../../../src/utils/worktree-identity.ts", "../../../src/commands/dev.ts", "../../../src/utils/tailscale-targets.ts", "../../../src/utils/socks-forward.ts", "../../../src/utils/tunnel-ownership.ts", "../../../src/utils/tailscale.ts", "../../../src/commands/vpn/tunnel.ts", "../../../src/commands/vpn/index.ts", "../../../src/commands/login.ts", "../../../src/utils/aws-auth.ts", "../../../src/utils/capture-history.ts", "../../../src/commands/temporal.ts", "../../../src/utils/recover-conversation.ts", "../../../src/utils/temporal-codec.ts"],
4
+ "sourcesContent": ["/**\n * Logging utilities with colored output.\n *\n * Non-TTY streams (CI, agent runs, piped output) get an [HH:MM:SS] prefix on\n * every line so long quiet stretches are attributable after the fact\n * (MESH-2479); interactive terminals stay clean. Override with\n * MESH_LOG_TIMESTAMPS=1 (force on) / =0 (force off).\n */\n\nimport chalk from \"chalk\";\n\n/**\n * Pure prefix decision, exported for tests: timestamps when the env flag says\n * so, else whenever stderr is not a TTY.\n */\nexport function logPrefix(opts: {\n isTTY: boolean;\n envFlag: string | undefined;\n now: Date;\n}): string {\n const enabled = opts.envFlag === \"1\" || (opts.envFlag !== \"0\" && !opts.isTTY);\n return enabled ? `[${opts.now.toISOString().slice(11, 19)}] ` : \"\";\n}\n\nfunction prefix(): string {\n return chalk.dim(\n logPrefix({\n isTTY: !!process.stderr.isTTY,\n envFlag: process.env.MESH_LOG_TIMESTAMPS,\n now: new Date(),\n }),\n );\n}\n\nexport function logInfo(message: string): void {\n console.error(prefix() + chalk.blue(\"\u2139\"), message);\n}\n\nexport function logSuccess(message: string): void {\n console.error(prefix() + chalk.green(\"\u2713\"), message);\n}\n\nexport function logWarn(message: string): void {\n console.error(prefix() + chalk.yellow(\"\u26A0\"), message);\n}\n\nexport function logError(message: string): void {\n console.error(prefix() + chalk.red(\"\u2717\"), message);\n}\n\n/** Render elapsed milliseconds compactly: \"45s\", \"2m 30s\", \"1h 5m\". */\nexport function formatElapsed(ms: number): string {\n const totalSec = Math.max(0, Math.round(ms / 1000));\n const h = Math.floor(totalSec / 3600);\n const m = Math.floor((totalSec % 3600) / 60);\n const s = totalSec % 60;\n if (h > 0) return `${h}h ${m}m`;\n if (m > 0) return `${m}m ${s}s`;\n return `${s}s`;\n}\n\n/**\n * Periodic \"still working\" line during a long silent operation (registry\n * fetches, image builds) so a non-TTY first run never sits at 0 bytes of\n * output for minutes (MESH-2479). `touch()` marks recent real output, which\n * suppresses the next beat \u2014 the heartbeat only fills silence.\n */\nexport function startHeartbeat(\n label: string,\n intervalMs = 15_000,\n): { stop: () => void; touch: () => void } {\n const startedAt = Date.now();\n let lastOutputAt = startedAt;\n const timer = setInterval(() => {\n if (Date.now() - lastOutputAt < intervalMs) return;\n logInfo(`\u2026 still working: ${label} (${formatElapsed(Date.now() - startedAt)} elapsed)`);\n }, intervalMs);\n timer.unref?.();\n return {\n stop: () => clearInterval(timer),\n touch: () => {\n lastOutputAt = Date.now();\n },\n };\n}\n", "/**\n * Pulumi-related utilities shared across CLI commands.\n */\n\nimport { execFileSync } from \"child_process\";\nimport * as path from \"path\";\nimport * as fs from \"fs\";\n\n/**\n * Find the Pulumi app root by walking up from a starting directory.\n * Returns the first directory containing `Pulumi.yaml`, or null.\n */\nexport function findAppRoot(startDir: string): string | null {\n let dir = startDir;\n while (true) {\n if (fs.existsSync(path.join(dir, \"Pulumi.yaml\"))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Find Pulumi.<stack>.yaml files in a directory.\n * Returns an array of stack names (without the Pulumi. prefix and .yaml suffix).\n */\nexport function findStackConfigs(appRoot: string): string[] {\n return fs.readdirSync(appRoot)\n .filter((f) => /^Pulumi\\..+\\.yaml$/.test(f) && f !== \"Pulumi.yaml\")\n .map((f) => f.replace(/^Pulumi\\./, \"\").replace(/\\.yaml$/, \"\"));\n}\n\n/**\n * Get the currently selected Pulumi stack name, or null.\n */\nexport function getCurrentStack(appRoot: string): string | null {\n try {\n const result = execFileSync(\"pulumi\", [\"stack\", \"--show-name\"], {\n encoding: \"utf-8\",\n cwd: appRoot,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n return result.trim() || null;\n } catch {\n return null;\n }\n}\n\n/**\n * Read a config value from Pulumi.<stack>.yaml.\n * Simple regex-based parser \u2014 handles single-line string values.\n * For complex values, use `pulumi config` CLI instead.\n */\nexport function readStackConfig(appRoot: string, stack: string, key: string): string | null {\n const configFile = path.join(appRoot, `Pulumi.${stack}.yaml`);\n if (!fs.existsSync(configFile)) return null;\n\n const content = fs.readFileSync(configFile, \"utf-8\");\n const pattern = new RegExp(`^\\\\s+${key.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}:\\\\s*(.+)$`, \"m\");\n const match = content.match(pattern);\n if (!match) return null;\n\n return match[1]!.trim().replace(/^[\"']|[\"']$/g, \"\");\n}\n\n/**\n * Run `pulumi stack output <key> --json` with the given extra args.\n *\n * Pulumi redacts secret-tainted outputs as the literal string `\"[secret]\"`.\n * Outputs like `env.register()`'s `dev` blob are tainted because they transitively\n * include secret values (e.g. `TEMPORAL_ENCODING_KEY`, DB passwords), even though\n * much of the blob (tunnels, namespace, service scaffolding) is non-secret.\n *\n * This helper tries without `--show-secrets` first \u2014 which avoids requiring\n * the local machine to have the stack's secrets provider configured (KMS access\n * etc.) \u2014 and retries with `--show-secrets` only if the first call returns a\n * `\"[secret]\"` placeholder. KMS/secrets-provider errors are not retried because\n * they will fail the same way with the flag set.\n */\nexport function pulumiStackOutput(\n appRoot: string,\n key: string,\n extraArgs: string[],\n env?: Record<string, string>,\n): string {\n const execEnv = env ? { ...process.env, ...env } : undefined;\n // First attempt: no --show-secrets (works without PULUMI_CONFIG_PASSPHRASE / KMS)\n try {\n const result = execFileSync(\n \"pulumi\",\n [\"stack\", \"output\", key, \"--json\", ...extraArgs],\n { cwd: appRoot, encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"pipe\"], env: execEnv }\n );\n // If the output contains \"[secret]\" placeholders, retry with --show-secrets\n if (result.includes('\"[secret]\"')) {\n return execFileSync(\n \"pulumi\",\n [\"stack\", \"output\", key, \"--json\", \"--show-secrets\", ...extraArgs],\n { cwd: appRoot, encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"pipe\"], env: execEnv }\n );\n }\n return result;\n } catch (err: unknown) {\n // If the error is a secrets/KMS issue, don't retry with --show-secrets\n // (it will fail the same way). Only retry for other Pulumi errors.\n const errMsg = (err as { stderr?: string })?.stderr ?? \"\";\n if (errMsg.includes(\"kms:\") || errMsg.includes(\"KMS\") || errMsg.includes(\"secrets manager\")) {\n throw err;\n }\n return execFileSync(\n \"pulumi\",\n [\"stack\", \"output\", key, \"--json\", \"--show-secrets\", ...extraArgs],\n { cwd: appRoot, encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"pipe\"], env: execEnv }\n );\n }\n}\n", "/**\n * First-run auth preflight helpers (MESH-2479).\n *\n * `mesh start`/`mesh dev` do long work (registry tarball fetches, docker\n * builds, pnpm installs) that fails minutes in \u2014 with npm's generic advice \u2014\n * when the CodeArtifact token or AWS SSO session is stale. These helpers\n * verify auth BEFORE the long work and name the exact fixing command.\n *\n * Pure parsers/classifiers are exported separately from the impure probes so\n * the branch logic is unit-testable without network or a home directory.\n */\n\nimport * as fs from \"fs\";\nimport * as os from \"os\";\nimport * as path from \"path\";\n\n/**\n * The command that fixes a stale/missing CodeArtifact token \u2014 **broker-first**,\n * so it works for someone with no AWS account at all.\n *\n * Bare on purpose. The `@mesh-tech` registry is one global identity\n * (`utils/registry-identity.ts`): `mesh registry login` resolves it, signs in\n * if it must, and writes `~/.npmrc` in a single run, so no remediation ever\n * needs to name a platform context.\n */\nexport const REGISTRY_LOGIN_FIX = \"mesh registry login\";\n\n/**\n * The AWS-profile form. Only correct for people who **deploy**, or when the\n * registry broker is unavailable.\n *\n * Kept as a separate named constant rather than the default: `--profile`\n * deliberately skips the broker, so printing it as the generic fix sends a\n * developer with no AWS account to a command that cannot work for them.\n */\nexport const REGISTRY_LOGIN_FIX_AWS = \"mesh registry login --profile mesh-dev\";\n\n/**\n * Render the registry fix. Always the bare command \u2014 the registry is global,\n * so a remediation that has to name a context is a remediation the reader has\n * to edit first, and one that names the wrong context does not work at all.\n *\n * @example\n * registryLoginFix(); // \u2192 \"mesh registry login\"\n */\nexport function registryLoginFix(): string {\n return REGISTRY_LOGIN_FIX;\n}\n\n/** The documented AWS SSO login form (root package.json `sso` script). */\nexport const SSO_LOGIN_FIX = \"aws sso login --sso-session=mesh # or: pnpm sso\";\n\n/**\n * The `aws configure sso-session` snippet `pnpm sso` depends on. Printed\n * verbatim when ~/.aws/config has no [sso-session mesh] section \u2014 kept here\n * so the CLI and scripts/sso-login.sh describe the same setup.\n */\nexport const SSO_SESSION_SNIPPET = `[sso-session mesh]\nsso_start_url = https://trabian.awsapps.com/start\nsso_region = us-east-1\nsso_registration_scopes = sso:account:access`;\n\n// ============================================================================\n// ~/.npmrc parsing (pure)\n// ============================================================================\n\n/**\n * The user-level npmrc `aws codeartifact login` writes into \u2014 the token line\n * and, when the namespace flag is omitted, the unscoped `registry=` line.\n *\n * One definition for the whole CLI: the doctor, the probe, and the repair have\n * to be looking at the same file or they disagree about the same machine.\n * `os.homedir()` reads `$HOME` on POSIX, so a scratch-`HOME` test still works.\n */\nexport function homeNpmrcPath(): string {\n return path.join(os.homedir(), \".npmrc\");\n}\n\nexport interface NpmrcRegistryAuth {\n /** Registry base URL, e.g. https://mesh-platform-\u2026codeartifact\u2026/npm/mesh-packages/ */\n endpoint: string;\n token: string;\n}\n\n/**\n * Extract the CodeArtifact registry endpoint + auth token from npmrc content.\n * `aws codeartifact login` writes a line shaped like:\n * //mesh-platform-\u2026d.codeartifact.<region>.amazonaws.com/npm/<repo>/:_authToken=<tok>\n * Returns null when no CodeArtifact token line is present.\n */\nexport function parseNpmrcRegistryAuth(content: string): NpmrcRegistryAuth | null {\n for (const line of content.split(\"\\n\")) {\n const match = line.trim().match(/^\\/\\/([^:]*\\.codeartifact\\.[^:]*):_authToken=(.+)$/);\n if (match && match[1] && match[2]) {\n const endpoint = `https://${match[1]}${match[1].endsWith(\"/\") ? \"\" : \"/\"}`;\n return { endpoint, token: match[2].trim() };\n }\n }\n return null;\n}\n\n/**\n * Find an **unscoped** `registry=` line in npmrc content that points at\n * CodeArtifact \u2014 the line `aws codeartifact login` writes when it is run\n * WITHOUT `--namespace @mesh-tech`.\n *\n * That line makes CodeArtifact the *default* registry, so every public package\n * (lodash, react, \u2026) is fetched through it with the CodeArtifact token. When\n * the token expires 12 hours later, `npm install` of anything at all fails\n * with E401 \u2014 the private-scope breakage everyone expects, plus a total public\n * npm outage nobody does.\n *\n * Deliberately narrow \u2014 it must only match a line that is BOTH unscoped AND a\n * CodeArtifact host, so it never touches:\n * - `@mesh-tech:registry=\u2026` (scoped: the line we WANT)\n * - `//\u2026:_authToken=\u2026` (auth, unaffected by the default registry)\n * - `registry=https://npm.acme.internal/` (a team's legitimate private mirror)\n *\n * Returns the offending line verbatim (as it appears in the file), or null.\n */\nexport function findUnscopedCodeArtifactRegistry(content: string): string | null {\n for (const line of content.split(\"\\n\")) {\n const trimmed = line.trim();\n // npmrc comments start with ; or #\n if (!trimmed || trimmed.startsWith(\";\") || trimmed.startsWith(\"#\")) continue;\n // Anchored at `registry` \u2014 a scoped line starts with `@scope:`, so it can't match.\n const match = trimmed.match(/^registry\\s*=\\s*(\\S+)$/);\n if (!match || !match[1]) continue;\n if (isCodeArtifactRegistryUrl(match[1])) return line;\n }\n return null;\n}\n\n/** True when a registry URL's *host* is a CodeArtifact endpoint. */\nfunction isCodeArtifactRegistryUrl(value: string): boolean {\n try {\n return new URL(value).hostname.includes(\".codeartifact.\");\n } catch {\n // Not a parseable URL (env-var interpolation, protocol-relative, \u2026) \u2014\n // fall back to a substring test so we don't miss an obvious hijack.\n return value.includes(\".codeartifact.\");\n }\n}\n\n/**\n * Remove every unscoped CodeArtifact `registry=` line from npmrc content.\n * Pure: returns the rewritten content plus the first line removed (null when\n * the content was already clean, in which case `content` is returned\n * unchanged and callers should skip the write entirely).\n */\nexport function stripUnscopedCodeArtifactRegistry(content: string): {\n content: string;\n removed: string | null;\n} {\n const offender = findUnscopedCodeArtifactRegistry(content);\n if (offender === null) return { content, removed: null };\n\n const kept = content\n .split(\"\\n\")\n .filter((line) => findUnscopedCodeArtifactRegistry(line) === null);\n // split/join round-trips the file's trailing-newline state exactly.\n return { content: kept.join(\"\\n\"), removed: offender };\n}\n\n/**\n * The `//host/path/:_authToken=` key `aws codeartifact login` writes for a\n * registry endpoint \u2014 the endpoint with its scheme stripped and a leading `//`.\n *\n * Pure and shared so the broker path writes byte-identical lines to the AWS CLI\n * path. Two spellings of the same key would leave a stale token line behind\n * that npm might prefer, and the symptom (E401 hours later) points nowhere\n * near the cause.\n */\nexport function npmrcAuthKeyForEndpoint(endpoint: string): string {\n const withoutScheme = endpoint.replace(/^https?:\\/\\//, \"\");\n return `//${withoutScheme.endsWith(\"/\") ? withoutScheme : `${withoutScheme}/`}`;\n}\n\n/**\n * Upsert `key=value` lines into npmrc content: replace the existing line for a\n * key when present, append it otherwise, and leave every other line untouched.\n *\n * Pure. Always emits a trailing newline, and never reorders lines a\n * user put there themselves \u2014 an npmrc is a config file people hand-edit, and\n * a token refresh that rewrites the whole thing is how unrelated settings\n * silently disappear.\n */\nexport function upsertNpmrcLines(\n content: string,\n entries: ReadonlyArray<{ key: string; value: string }>,\n): string {\n let lines = content.length ? content.split(\"\\n\") : [];\n const trailingNewline = content.endsWith(\"\\n\");\n if (trailingNewline) lines = lines.slice(0, -1);\n\n for (const { key, value } of entries) {\n const line = `${key}=${value}`;\n const idx = lines.findIndex((l) => {\n const trimmed = l.trim();\n if (!trimmed || trimmed.startsWith(\";\") || trimmed.startsWith(\"#\")) return false;\n const eq = trimmed.indexOf(\"=\");\n return eq > 0 && trimmed.slice(0, eq).trim() === key;\n });\n if (idx >= 0) lines[idx] = line;\n else lines.push(line);\n }\n\n return lines.join(\"\\n\") + \"\\n\";\n}\n\n// ============================================================================\n// Registry token probe\n// ============================================================================\n\nexport type RegistryTokenState = \"fresh\" | \"expired\" | \"missing\" | \"unreachable\";\n\n/** Map an HTTP status from an authenticated registry request to a token state. */\nexport function classifyRegistryStatus(status: number): \"fresh\" | \"expired\" {\n // 401/403 mean the token itself was rejected. Anything else (200, 404, \u2026)\n // means auth was accepted \u2014 the token is usable.\n return status === 401 || status === 403 ? \"expired\" : \"fresh\";\n}\n\nexport interface RegistryProbe {\n state: RegistryTokenState;\n detail?: string;\n}\n\n/**\n * Verify the CodeArtifact token in ~/.npmrc actually works, with one small\n * authenticated metadata request (bounded by `timeoutMs`). Never throws.\n */\nexport async function probeRegistryToken(opts?: {\n npmrcPath?: string;\n timeoutMs?: number;\n fetchFn?: typeof fetch;\n}): Promise<RegistryProbe> {\n const npmrcPath = opts?.npmrcPath ?? homeNpmrcPath();\n const timeoutMs = opts?.timeoutMs ?? 8_000;\n const fetchFn = opts?.fetchFn ?? fetch;\n\n let auth: NpmrcRegistryAuth | null = null;\n try {\n auth = parseNpmrcRegistryAuth(fs.readFileSync(npmrcPath, \"utf-8\"));\n } catch {\n // unreadable/missing ~/.npmrc \u2014 same as no auth configured\n }\n if (!auth) return { state: \"missing\" };\n\n try {\n const res = await fetchFn(`${auth.endpoint}${encodeURIComponent(\"@mesh-tech/hub\")}`, {\n method: \"GET\",\n headers: { authorization: `Bearer ${auth.token}` },\n signal: AbortSignal.timeout(timeoutMs),\n });\n const state = classifyRegistryStatus(res.status);\n return state === \"expired\"\n ? { state, detail: `registry answered HTTP ${res.status} for an authenticated request` }\n : { state };\n } catch (err) {\n return {\n state: \"unreachable\",\n detail: err instanceof Error ? err.message : String(err),\n };\n }\n}\n\n/**\n * The one-glance answer to \"can this machine install `@mesh-tech` packages?\"\n * \u2014 the `~/.npmrc` token probe plus the registry session, classified for a\n * caller that must decide before doing anything else.\n */\nexport interface RegistryPreflight {\n /** `valid` = the token works; the rest mirror `RegistryTokenState`. */\n state: \"valid\" | \"missing\" | \"expired\" | \"unreachable\";\n /** Who holds the registry session, when one is cached. */\n email?: string;\n /** The command that sets this machine up. */\n fix: string;\n /** The probe's detail, for the warn/fail line. */\n detail?: string;\n}\n\n/**\n * Classify a probe + session into the preflight verdict. Pure; the network\n * part lives in `registryPreflight`.\n *\n * @example\n * classifyRegistryPreflight({ state: \"fresh\" }, { email: \"dev@trabian.com\" }).state; // \u2192 \"valid\"\n */\nexport function classifyRegistryPreflight(\n probe: RegistryProbe,\n session: { email: string } | null,\n): RegistryPreflight {\n const email = session?.email;\n const fix = \"mesh init\";\n if (probe.state === \"fresh\") return { state: \"valid\", email, fix };\n return { state: probe.state, email, fix, detail: probe.detail };\n}\n\n/**\n * Registry preflight for commands that cannot proceed without package access\n * (`mesh create-app` outside the platform monorepo). Never throws.\n *\n * @example\n * const pf = await registryPreflight();\n * if (pf.state !== \"valid\" && pf.state !== \"unreachable\") process.exit(1);\n */\nexport async function registryPreflight(opts?: {\n npmrcPath?: string;\n timeoutMs?: number;\n fetchFn?: typeof fetch;\n}): Promise<RegistryPreflight> {\n const probe = await probeRegistryToken(opts);\n let session: { email: string } | null = null;\n try {\n const { readRegistrySession } = await import(\"./registry-identity.js\");\n session = readRegistrySession();\n } catch {\n // no session store readable \u2014 the probe alone decides\n }\n return classifyRegistryPreflight(probe, session);\n}\n\n// ============================================================================\n// npm / AWS error classification (pure)\n// ============================================================================\n\n/** True when npm output/an error message is an auth failure (E401/E403). */\nexport function isNpmAuthErrorText(text: string): boolean {\n return /\\bE?40[13]\\b|unauthenticated|unable to authenticate|authentication (required|failed)/i.test(\n text,\n );\n}\n\n/** True when an npm exec error (with stderr/message) is an auth failure. */\nexport function isNpmAuthError(err: unknown): boolean {\n const e = err as { stderr?: unknown; message?: unknown };\n const text = [e?.stderr, e?.message]\n .map((v) => (v === null || v === undefined ? \"\" : String(v)))\n .join(\"\\n\");\n return isNpmAuthErrorText(text);\n}\n\n/** True when an AWS SDK/CLI error message means the ambient session expired. */\nexport function isExpiredAwsTokenMessage(message: string): boolean {\n return /expired|invalid.*(security )?token|token.*(is )?invalid|no credential|could not load credentials|could not be found|sso session/i.test(\n message,\n );\n}\n\n// ============================================================================\n// ~/.aws/config parsing (pure) \u2014 `pnpm sso` / --profile prerequisites\n// ============================================================================\n\n/** Names of `[sso-session <name>]` sections in an AWS config file. */\nexport function parseSsoSessionNames(content: string): string[] {\n const names: string[] = [];\n for (const line of content.split(\"\\n\")) {\n const match = line.trim().match(/^\\[sso-session\\s+([^\\]]+)\\]$/);\n if (match && match[1]) names.push(match[1].trim());\n }\n return names;\n}\n\n/** True when the AWS config file declares the named profile. */\nexport function awsConfigHasProfile(content: string, profile: string): boolean {\n const pattern = new RegExp(\n `^\\\\[(?:profile\\\\s+)?${profile.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}\\\\]$`,\n );\n return content.split(\"\\n\").some((line) => pattern.test(line.trim()));\n}\n\n/** Read ~/.aws/config (honoring AWS_CONFIG_FILE); \"\" when unreadable. */\nexport function readAwsConfig(): string {\n const file = process.env.AWS_CONFIG_FILE ?? path.join(os.homedir(), \".aws\", \"config\");\n try {\n return fs.readFileSync(file, \"utf-8\");\n } catch {\n return \"\";\n }\n}\n\n// ============================================================================\n// App dependency scan (for the dev doctor's registry check)\n// ============================================================================\n\n/** True when the app's package.json depends on any @mesh-tech/* package. */\nexport function appUsesMeshPackages(appRoot: string): boolean {\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(appRoot, \"package.json\"), \"utf-8\")) as {\n dependencies?: Record<string, string>;\n devDependencies?: Record<string, string>;\n optionalDependencies?: Record<string, string>;\n };\n return [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some(\n (deps) => deps && Object.keys(deps).some((name) => name.startsWith(\"@mesh-tech/\")),\n );\n } catch {\n return false;\n }\n}\n", "/**\n * Shared CLI error type with structured remediation.\n *\n * Every failure along the golden path should tell the user the exact `mesh`\n * command (or doc) that fixes it. Remediation strings must be commands or\n * docs \u2014 never \"ask <person>\".\n */\n\nimport chalk from \"chalk\";\n\nexport interface Remediation {\n /** Exact command that fixes the failure, e.g. \"mesh login trabian-dev\" */\n command?: string;\n /** Documentation URL or repo-relative doc path with more context */\n docs?: string;\n /** Whether the owning command can apply this fix itself via --fix */\n autoFixable?: boolean;\n}\n\nexport interface MeshCliErrorOptions {\n remediation?: Remediation;\n /** Process exit code (default 1) */\n exitCode?: number;\n cause?: unknown;\n}\n\nexport class MeshCliError extends Error {\n readonly remediation?: Remediation;\n readonly exitCode: number;\n\n constructor(message: string, options: MeshCliErrorOptions = {}) {\n super(message, options.cause !== undefined ? { cause: options.cause } : undefined);\n this.name = \"MeshCliError\";\n this.remediation = options.remediation;\n this.exitCode = options.exitCode ?? 1;\n }\n}\n\n/**\n * Render an error's BODY \u2014 the message plus its \"\u2192 run: <command>\" /\n * \"\u2192 see: <docs>\" remediation lines, with no leading severity glyph.\n *\n * Use this when the caller supplies its own glyph: a recovery path that logs a\n * refusal through `logWarn` (which prefixes \u26A0) and carries on would otherwise\n * print \"\u26A0 \u2717 \u2026\". The remediation lines are the part that must survive \u2014 they\n * name the corrupt file to delete.\n */\nexport function renderErrorBody(err: unknown): string {\n const message = err instanceof Error ? err.message : String(err);\n if (!(err instanceof MeshCliError)) return message;\n const lines = [message];\n if (err.remediation?.command) {\n lines.push(chalk.cyan(\" \u2192 run: \") + err.remediation.command);\n }\n if (err.remediation?.docs) {\n lines.push(chalk.cyan(\" \u2192 see: \") + err.remediation.docs);\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * Render an error for humans. MeshCliError gets its remediation appended as\n * \"\u2192 run: <command>\" / \"\u2192 see: <docs>\" lines; anything else renders its message.\n */\nexport function renderError(err: unknown): string {\n return chalk.red(\"\u2717\") + \" \" + renderErrorBody(err);\n}\n\n/**\n * Render an error as JSON for agents. Same structure as the human output:\n * { error, remediation?: { command?, docs?, autoFixable? } }.\n */\nexport function renderErrorJson(err: unknown): string {\n if (err instanceof MeshCliError) {\n return JSON.stringify({\n error: err.message,\n ...(err.remediation ? { remediation: err.remediation } : {}),\n });\n }\n return JSON.stringify({ error: err instanceof Error ? err.message : String(err) });\n}\n\nlet jsonPayloadEmitted = false;\n\n/**\n * Print a command's `--json` payload \u2014 the single machine-readable document on\n * stdout \u2014 and record that it happened.\n *\n * A command that emits a payload and *then* throws (the `mesh init` doctor\n * throws by design whenever a check fails) used to produce TWO concatenated\n * JSON documents on stdout, because `handleCliError` appended `{\"error\":\u2026}`.\n * Two documents is not JSON: `mesh init app-tenant --json | jq` failed to parse\n * on exactly the failure path a script cares about. Routing every payload\n * through here lets the handler know stdout is already spoken for.\n */\nexport function emitJsonPayload(payload: unknown): void {\n console.log(JSON.stringify(payload, null, 2));\n jsonPayloadEmitted = true;\n}\n\n/** True once a command has written its `--json` payload to stdout. */\nexport function hasEmittedJsonPayload(): boolean {\n return jsonPayloadEmitted;\n}\n\n/** Test seam \u2014 the flag is module state, so specs have to be able to clear it. */\nexport function resetJsonPayloadEmitted(): void {\n jsonPayloadEmitted = false;\n}\n\n/**\n * Top-level handler for bin/mesh.ts. Prints the error (JSON when the\n * invocation asked for --json anywhere, since per-command flags aren't\n * parsed once dispatch has failed) and exits with the error's code.\n *\n * When the command already emitted a `--json` payload, that payload IS the\n * machine surface \u2014 and it says strictly more than this error would (the\n * doctor's payload carries `ok:false` plus a per-check `fix` command). So the\n * error goes to stderr as human diagnostics and stdout keeps exactly one\n * parseable document.\n */\nexport function handleCliError(err: unknown): never {\n const wantsJson = process.argv.includes(\"--json\");\n if (wantsJson && !hasEmittedJsonPayload()) {\n console.log(renderErrorJson(err));\n } else {\n console.error(renderError(err));\n }\n process.exit(err instanceof MeshCliError ? err.exitCode : 1);\n}\n", "/**\n * Pinned short-name \u2192 FQDN map for FIRST-PARTY Mesh platforms.\n *\n * ## Why this file exists\n *\n * `mesh login mesh.dev` on a clean laptop used to be unresolvable. Discovery\n * has two paths: SSM (needs AWS credentials \u2014 the thing we are removing) and\n * the anonymous `https://cli.<domain>/.well-known/mesh.json` endpoint. The\n * HTTPS path is deliberately gated to contexts with \u22652 dots, because hitting\n * `https://cli.<short-name>/\u2026` for an arbitrary short context would let whoever\n * registers that domain seed our cached issuer/clientId \u2014 login phishing.\n *\n * So the short context every internal doc tells a new developer to use was\n * exactly the one that could not resolve without AWS.\n *\n * ## Why a compiled-in map is the safe fix\n *\n * The alias is resolved from THIS table, which ships inside the CLI and is\n * changed only by a reviewed commit. It is not read from the network, from\n * `~/.config/mesh`, or from an environment variable, so it cannot be seeded by\n * an attacker \u2014 which is the property the \u22652-dot guard protects and the reason\n * that guard must NOT be loosened instead.\n *\n * ## Adding an entry\n *\n * Only add a domain Trabian controls and has verified serves\n * `/.well-known/mesh.json`. The map is the trust anchor; a typo here is a\n * phishing vector, so treat additions as a security review, not a config edit.\n */\nexport const FIRST_PARTY_CONTEXTS: Readonly<Record<string, string>> = Object.freeze({\n // Verified 2026-08-14: https://cli.dev.platform.meshtech.io/.well-known/mesh.json\n // answers with the platform's issuer + CLI client id + registryBroker,\n // anonymously. (Repointed from dev.trabian.meshtech.io \u2014 the trabian tenant\n // platform lives in a different AWS account with no registry; the\n // platform-dev stack in the registry-owning account is the developer front\n // door. mesh-sandbox#32 / #3587.)\n \"mesh.dev\": \"dev.platform.meshtech.io\",\n});\n\n/**\n * The FQDN to run anonymous HTTPS discovery against for a short first-party\n * context, or null when the context is not a pinned alias.\n *\n * `Object.hasOwn` rather than a bare lookup: a plain-object index answers\n * `constructor` / `toString` from the prototype chain, and this function's\n * answer decides which host we trust with our issuer + client id.\n */\nexport function firstPartyDomainFor(context: string): string | null {\n if (!Object.hasOwn(FIRST_PARTY_CONTEXTS, context)) return null;\n const domain = FIRST_PARTY_CONTEXTS[context];\n return typeof domain === \"string\" && domain ? domain : null;\n}\n", "/**\n * Context detection - determines tenant, stage, and platform environment\n * from the current directory and environment variables.\n */\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport { logInfo } from \"./log.js\";\n\nexport interface MeshContext {\n tenant: string;\n stage: string;\n platformEnv: string;\n}\n\n/**\n * Find a file by walking up directories from the starting directory.\n * Returns the full path to the file if found, undefined otherwise.\n */\nfunction findFileUpward(filename: string, startDir: string = process.cwd()): string | undefined {\n let currentDir = startDir;\n const root = path.parse(currentDir).root;\n\n while (currentDir !== root) {\n const filePath = path.join(currentDir, filename);\n if (fs.existsSync(filePath)) {\n return filePath;\n }\n currentDir = path.dirname(currentDir);\n }\n return undefined;\n}\n\n/**\n * Detect tenant and stage from current directory and environment.\n *\n * Priority for stage:\n * 1. --stage flag (passed as stageArg)\n * 2. MESH_STAGE env var\n * 3. SST_STAGE env var\n * 4. .sst/stage file\n * 5. Pulumi config file name\n * 6. Default: \"dev\"\n *\n * Priority for tenant:\n * 1. MESH_TENANT env var\n * 2. config.ts tenant field\n * 3. Pulumi config mesh:tenant\n * 4. Directory path (tenants/{tenant}/...)\n * 5. Default: \"mesh\"\n */\nexport function detectContext(stageArg?: string): MeshContext {\n let tenant: string | undefined;\n let stage: string | undefined;\n\n const cwd = process.cwd();\n\n // Try to detect from Pulumi.yaml\n if (fs.existsSync(\"Pulumi.yaml\")) {\n // Look for Pulumi.*.yaml config files\n const files = fs.readdirSync(\".\").filter(\n (f) => f.startsWith(\"Pulumi.\") && f.endsWith(\".yaml\") && f !== \"Pulumi.yaml\"\n );\n\n const configFile = files[0];\n if (configFile) {\n const content = fs.readFileSync(configFile, \"utf-8\");\n\n // Extract tenant from mesh:tenant config\n const tenantMatch = content.match(/^\\s*mesh:tenant:\\s*[\"']?([^\"'\\n]+)[\"']?/m);\n if (tenantMatch?.[1]) {\n tenant = tenantMatch[1].trim();\n }\n\n // Extract stage from filename: Pulumi.<stage>.yaml\n const stageMatch = configFile.match(/Pulumi\\.(.+)\\.yaml/);\n if (stageMatch?.[1]) {\n stage = stageMatch[1];\n }\n }\n }\n\n // Try to detect from SST config (walk up directories to find it)\n const sstConfigPath = findFileUpward(\"sst.config.ts\");\n let sstDir: string | undefined;\n\n if (sstConfigPath) {\n sstDir = path.dirname(sstConfigPath);\n\n // Check parent config.ts for tenant (relative to sst.config.ts location)\n const parentConfigPath = path.join(sstDir, \"..\", \"config.ts\");\n if (fs.existsSync(parentConfigPath)) {\n const configContent = fs.readFileSync(parentConfigPath, \"utf-8\");\n const tenantMatch = configContent.match(/tenant:\\s*[\"']([^\"']+)[\"']/);\n if (tenantMatch) {\n tenant = tenantMatch[1];\n }\n }\n\n // Check .sst/stage for current stage (relative to sst.config.ts location)\n const sstStagePath = path.join(sstDir, \".sst\", \"stage\");\n if (fs.existsSync(sstStagePath)) {\n stage = fs.readFileSync(sstStagePath, \"utf-8\").trim();\n }\n }\n\n // Fallback: infer tenant from directory path\n if (!tenant) {\n const pathMatch = cwd.match(/tenants\\/([^/]+)/);\n if (pathMatch) {\n tenant = pathMatch[1];\n }\n }\n\n // Apply priority: stageArg > MESH_STAGE > SST_STAGE > detected > default\n stage = stageArg || process.env.MESH_STAGE || process.env.SST_STAGE || stage || \"dev\";\n tenant = process.env.MESH_TENANT || tenant || \"mesh\";\n\n // Resolve platform environment from config.ts if available\n let platformEnv = stage;\n let platformEnvMap: Record<string, string> = {};\n let defaultPlatformEnv: string | undefined;\n\n // Try to read platformEnvMap and defaultPlatformEnv from config.ts (relative to sst.config.ts location)\n const configPath = sstDir ? path.join(sstDir, \"..\", \"config.ts\") : \"../config.ts\";\n if (fs.existsSync(configPath)) {\n const configContent = fs.readFileSync(configPath, \"utf-8\");\n\n // Parse platformEnvMap object\n const mapMatch = configContent.match(/platformEnvMap:\\s*\\{([^}]+)\\}/);\n if (mapMatch?.[1]) {\n const entries = mapMatch[1].matchAll(/(\\w+):\\s*[\"']([^\"']+)[\"']/g);\n for (const entry of entries) {\n const key = entry[1];\n const value = entry[2];\n if (key && value) {\n platformEnvMap[key] = value;\n }\n }\n }\n\n // Parse defaultPlatformEnv\n const defaultMatch = configContent.match(/defaultPlatformEnv:\\s*[\"']([^\"']+)[\"']/);\n if (defaultMatch?.[1]) {\n defaultPlatformEnv = defaultMatch[1];\n }\n }\n\n // Parse env from stage by stripping tenant prefix (matches Pulumi pattern)\n // e.g., \"mesh-dev-temporal\" \u2192 env \"dev-temporal\"\n const tenantPrefix = `${tenant}-`;\n const parsedEnv = stage.startsWith(tenantPrefix)\n ? stage.slice(tenantPrefix.length)\n : stage;\n\n // Use config mapping if available, then defaultPlatformEnv, otherwise use parsed env directly\n platformEnv = platformEnvMap[parsedEnv] ?? platformEnvMap[stage] ?? defaultPlatformEnv ?? parsedEnv;\n\n logInfo(`Stage: ${stage}, Platform: ${platformEnv}, Tenant: ${tenant}`);\n\n return { tenant, stage, platformEnv };\n}\n", "/**\n * Bastion utilities for SSM tunneling to platform services\n */\n\nimport { SSMClient, GetParameterCommand } from \"@aws-sdk/client-ssm\";\nimport { logInfo, logSuccess, logError } from \"./log.js\";\n\n/**\n * Service endpoint info\n */\nexport interface ServiceEndpoint {\n host: string;\n port: number;\n}\n\n/**\n * Platform bastion info from SSM\n */\nexport interface PlatformBastionInfo {\n instanceId: string;\n securityGroupId: string;\n services: Record<string, ServiceEndpoint>;\n}\n\n/**\n * Legacy bastion info (for backward compatibility with db commands)\n */\nexport interface BastionInfo {\n instanceId: string;\n rdsEndpoint: string;\n rdsPort: number;\n}\n\n/**\n * Get platform bastion info from SSM parameter store.\n *\n * Reads the platformBastion key from the platform export at\n * /mesh-platform/{tenant}/{platformEnv}/platform. Falls back to the legacy\n * separate parameter at /mesh-platform/{tenant}/{platformEnv}/platform-bastion\n * for backward compatibility.\n */\nexport async function getPlatformBastionInfo(\n tenant: string,\n platformEnv: string,\n region?: string\n): Promise<PlatformBastionInfo> {\n const ssm = new SSMClient(region ? { region } : {});\n\n // Captured so the final error can distinguish a CREDS failure (the common\n // case \u2014 stale/expired ambient AWS creds, or creds lacking cross-tenant read\n // on the hub param) from a genuinely missing bastion. Without this the read\n // error is swallowed and every failure looks like \"bastion not deployed\".\n let primaryError: unknown;\n\n // Primary: read from the platform export (platformBastion is a nested key)\n const platformPath = `/mesh-platform/${tenant}/${platformEnv}/platform`;\n logInfo(`Looking up platform bastion from ${platformPath}...`);\n\n try {\n const response = await ssm.send(\n new GetParameterCommand({ Name: platformPath })\n );\n\n if (response.Parameter?.Value) {\n const platform = JSON.parse(response.Parameter.Value);\n if (platform.platformBastion) {\n const info = platform.platformBastion as PlatformBastionInfo;\n logSuccess(`Found bastion: ${info.instanceId}`);\n\n const serviceNames = Object.keys(info.services);\n if (serviceNames.length > 0) {\n logInfo(`Available services: ${serviceNames.join(\", \")}`);\n }\n\n return info;\n }\n }\n } catch (err) {\n // Don't swallow: keep the real reason (AccessDenied / ExpiredToken / \u2026) so\n // the final error can be actionable. Then try the legacy path.\n primaryError = err;\n }\n\n // Fallback: legacy separate parameter\n const legacyPath = `/mesh-platform/${tenant}/${platformEnv}/platform-bastion`;\n logInfo(`Trying legacy path ${legacyPath}...`);\n\n try {\n const response = await ssm.send(\n new GetParameterCommand({ Name: legacyPath })\n );\n\n if (!response.Parameter?.Value) {\n throw new Error(`Platform bastion not found`);\n }\n\n const info = JSON.parse(response.Parameter.Value) as PlatformBastionInfo;\n logSuccess(`Found bastion: ${info.instanceId}`);\n\n const serviceNames = Object.keys(info.services);\n if (serviceNames.length > 0) {\n logInfo(`Available services: ${serviceNames.join(\", \")}`);\n }\n\n return info;\n } catch (legacyError) {\n // Classify on the PRIMARY read's error (the meaningful one) \u2014 the legacy\n // path is usually just ParameterNotFound and hides the real cause.\n const cause = primaryError ?? legacyError;\n const name = (cause as { name?: string })?.name ?? \"\";\n const credsProblem = /Expired|UnrecognizedClient|InvalidClientTokenId|InvalidSignature|CredentialsProviderError|AccessDenied/i.test(\n name\n );\n\n if (credsProblem) {\n logError(\n `Could not read the platform bastion from ${platformPath} \u2014 AWS error: ${name}.`\n );\n logInfo(\n \"This is almost always a CREDENTIALS problem, not a missing bastion.\"\n );\n logInfo(\n \" \u2022 The read uses the ambient AWS creds of this process; they must be valid AND able to read the HUB param above.\"\n );\n logInfo(\n \" \u2022 Check: `aws sts get-caller-identity` (ExpiredToken \u2192 refresh; AccessDenied \u2192 those creds lack hub read \u2014 use InfraAdmin-grade creds).\"\n );\n logInfo(\n \" \u2022 Stale creds often hide in the tmux GLOBAL env (`tmux show-environment -g | grep AWS_`); a per-shell `unset` won't clear them.\"\n );\n } else {\n logError(`Platform bastion not found in ${platformPath} or ${legacyPath}`);\n logInfo(\"Make sure platformBastion is enabled in your platform config and deployed.\");\n }\n throw cause;\n }\n}\n\n/**\n * Get bastion info from SSM parameter store (legacy format).\n * Reads from /mesh/{tenant}/{platformEnv}/platform-bastion and extracts RDS info.\n *\n * @deprecated Use getPlatformBastionInfo instead\n */\nexport async function getBastionInfo(\n tenant: string,\n platformEnv: string\n): Promise<BastionInfo> {\n const info = await getPlatformBastionInfo(tenant, platformEnv);\n\n const rdsService = info.services[\"rds\"];\n if (!rdsService) {\n throw new Error(\"RDS service not available in platform bastion. Is RDS enabled?\");\n }\n\n return {\n instanceId: info.instanceId,\n rdsEndpoint: rdsService.host,\n rdsPort: rdsService.port,\n };\n}\n", "/**\n * Database credential utilities\n */\n\nimport {\n SecretsManagerClient,\n GetSecretValueCommand,\n} from \"@aws-sdk/client-secrets-manager\";\nimport { logInfo, logSuccess } from \"./log.js\";\n\nexport interface DatabaseCredentials {\n username: string;\n password: string;\n host: string;\n port: number;\n database: string;\n}\n\n/**\n * Options for credential lookup\n */\nexport interface CredentialLookupOptions {\n /** App name (e.g., \"rdc\") - if provided, uses path mesh/{tenant}/{stage}/{app}/db-credentials */\n app?: string;\n}\n\n/**\n * Get DATABASE_URL from Secrets Manager.\n *\n * Credential path patterns:\n * - With app: mesh/{tenant}/{stage}/{app}/db-credentials\n * - Without app: mesh/{tenant}/{stage}/db-credentials\n *\n * Returns the DATABASE_URL string directly. Use rewriteDatabaseUrl() to\n * modify host/port for tunnel usage.\n */\nexport async function getDatabaseUrl(\n tenant: string,\n stage: string,\n options?: CredentialLookupOptions\n): Promise<string> {\n const secretsManager = new SecretsManagerClient({});\n\n // Build secret path - with or without app name\n const secretName = options?.app\n ? `mesh/${tenant}/${stage}/${options.app}/db-credentials`\n : `mesh/${tenant}/${stage}/db-credentials`;\n logInfo(`Looking up credentials at ${secretName}...`);\n\n try {\n const response = await secretsManager.send(\n new GetSecretValueCommand({ SecretId: secretName })\n );\n\n if (response.SecretString) {\n const secret = JSON.parse(response.SecretString);\n\n // Handle DATABASE_URL format (preferred)\n if (secret.DATABASE_URL) {\n const parsed = new URL(secret.DATABASE_URL);\n logSuccess(`Got credentials for user: ${parsed.username}`);\n return secret.DATABASE_URL;\n }\n\n // Handle individual fields format (legacy)\n if (secret.username && secret.password) {\n logSuccess(`Got credentials for user: ${secret.username}`);\n const database = secret.dbname ?? secret.database ?? \"postgres\";\n return `postgresql://${secret.username}:${encodeURIComponent(secret.password)}@${secret.host ?? \"localhost\"}:${secret.port ?? 5432}/${database}`;\n }\n\n throw new Error(`Secret ${secretName} has unexpected format (needs DATABASE_URL or username/password)`);\n }\n } catch (error) {\n if ((error as Error).message?.includes(\"unexpected format\")) {\n throw error;\n }\n // Secret not found - will be caught by caller\n }\n\n throw new Error(`Could not find credentials at ${secretName}`);\n}\n\n/**\n * @deprecated Use getDatabaseUrl instead - returns DATABASE_URL directly\n */\nexport async function getDbCredentials(\n tenant: string,\n stage: string,\n _platformEnv: string,\n rdsHost: string,\n rdsPort: number,\n options?: CredentialLookupOptions\n): Promise<DatabaseCredentials> {\n const databaseUrl = await getDatabaseUrl(tenant, stage, options);\n const parsed = new URL(databaseUrl);\n\n return {\n username: parsed.username,\n password: decodeURIComponent(parsed.password),\n host: rdsHost,\n port: rdsPort,\n database: parsed.pathname.slice(1),\n };\n}\n\n/**\n * Options for rewriting DATABASE_URL\n */\nexport interface DatabaseUrlRewriteOptions {\n /** New host:port endpoint (e.g., \"localhost:5432\") */\n endpoint?: string;\n /** SSL mode (require, no-verify, disable) */\n sslMode?: string;\n}\n\n/**\n * Rewrite DATABASE_URL with new endpoint and/or SSL mode.\n * Preserves username, password, database name, and other query params.\n *\n * Used by both mesh-env-exec (for sst dev) and mesh db exec (for ad-hoc commands).\n */\nexport function rewriteDatabaseUrl(\n url: string,\n options: DatabaseUrlRewriteOptions\n): string {\n try {\n const parsed = new URL(url);\n\n // Rewrite endpoint if provided\n if (options.endpoint) {\n if (options.endpoint.includes(\":\")) {\n const colonIndex = options.endpoint.lastIndexOf(\":\");\n parsed.hostname = options.endpoint.slice(0, colonIndex);\n parsed.port = options.endpoint.slice(colonIndex + 1);\n } else {\n parsed.hostname = options.endpoint;\n parsed.port = parsed.port || \"5432\";\n }\n }\n\n // Set SSL mode if provided\n if (options.sslMode) {\n parsed.searchParams.set(\"sslmode\", options.sslMode);\n // For Prisma: accept invalid/self-signed certificates through tunnels\n if (options.sslMode === \"require\" || options.sslMode === \"no-verify\") {\n parsed.searchParams.set(\"sslaccept\", \"accept_invalid_certs\");\n }\n }\n\n return parsed.toString();\n } catch {\n // If URL parsing fails, return original\n return url;\n }\n}\n\n/**\n * Build a DATABASE_URL from credentials.\n */\nexport function buildDatabaseUrl(\n creds: DatabaseCredentials,\n options?: DatabaseUrlRewriteOptions\n): string {\n const host = options?.endpoint?.split(\":\")[0] ?? creds.host;\n const port = options?.endpoint?.split(\":\")[1] ?? String(creds.port);\n\n let url = `postgresql://${creds.username}:${encodeURIComponent(creds.password)}@${host}:${port}/${creds.database}`;\n\n if (options?.sslMode) {\n url += `?sslmode=${options.sslMode}`;\n if (options.sslMode === \"require\" || options.sslMode === \"no-verify\") {\n url += \"&sslaccept=accept_invalid_certs\";\n }\n }\n\n return url;\n}\n\n/**\n * Try to read database info from SST outputs (when sst dev has run).\n */\nexport async function readSstOutputs(): Promise<{\n databaseUrl?: string;\n databaseName?: string;\n secretArn?: string;\n} | null> {\n const fs = await import(\"fs\");\n\n if (!fs.existsSync(\".sst/outputs.json\")) {\n return null;\n }\n\n try {\n const content = fs.readFileSync(\".sst/outputs.json\", \"utf-8\");\n const outputs = JSON.parse(content);\n\n if (outputs.databaseUrl) {\n logInfo(\"Found SST outputs with database config\");\n\n // Extract secret ARN from URL if present\n const secretArnMatch = outputs.databaseUrl.match(/secretArn=([^&]+)/);\n const secretArn = secretArnMatch\n ? decodeURIComponent(secretArnMatch[1])\n : undefined;\n\n return {\n databaseUrl: outputs.databaseUrl,\n databaseName: outputs.databaseName,\n secretArn,\n };\n }\n } catch {\n // Ignore parse errors\n }\n\n return null;\n}\n", "/**\n * VPN detection utilities\n */\n\nimport { execFile, execFileSync } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nconst TAILSCALE_PATHS = [\n \"tailscale\",\n \"/Applications/Tailscale.app/Contents/MacOS/Tailscale\",\n];\n\n/**\n * Detect whether a Tailscale VPN connection is active.\n *\n * Uses socket-aware `execTailscaleCmd` so it works in both kernel tun mode\n * (standard socket) and legacy userspace networking (/tmp/tailscale.sock).\n * Returns `true` only when BackendState is \"Running\" and Self.Online is true.\n * Returns `false` silently on any failure (binary missing, daemon stopped,\n * parse error, missing fields).\n */\nexport async function isVpnConnected(): Promise<boolean> {\n try {\n const stdout = await execTailscaleCmd([\"status\", \"--json\"]);\n const status: TailscaleDetailedStatus = JSON.parse(stdout);\n return status.BackendState === \"Running\" && status.Self?.Online === true;\n } catch {\n return false;\n }\n}\n\n// ---------------------------------------------------------------------------\n// DNS record management & Tailscale info\n// ---------------------------------------------------------------------------\n\n/** Standard socket (kernel tun mode), then legacy (userspace mode). */\nconst TAILSCALE_SOCKET_PATHS = [\"/var/run/tailscale/tailscaled.sock\", \"/tmp/tailscale.sock\"];\n\n/** Connection details for the Headscale DNS writer sidecar. */\nexport interface HeadscaleDnsConfig {\n namespace: string;\n pod: string;\n container: string;\n filePath: string;\n}\n\n/**\n * Build a HeadscaleDnsConfig from tenant/env, following the same naming\n * convention as `mesh vpn` (\u2060{tenant}-{env}-headscale).\n *\n * Pod name, container, and file path are infrastructure conventions that\n * rarely change \u2014 override via the optional parameter if they do.\n */\nexport function headscaleDnsConfig(\n tenant = \"mesh\",\n env = \"dev\",\n overrides?: Partial<HeadscaleDnsConfig>,\n): HeadscaleDnsConfig {\n return {\n namespace: `${tenant}-${env}-headscale`,\n pod: \"headscale-0\",\n container: \"dns-writer\",\n filePath: \"/var/lib/headscale/dns/extra-records.json\",\n ...overrides,\n };\n}\n\nexport interface DnsRecord {\n name: string;\n type: \"A\";\n value: string;\n}\n\ninterface TailscaleDetailedStatus {\n BackendState?: string;\n Self?: {\n Online?: boolean;\n HostName?: string;\n TailscaleIPs?: string[];\n };\n}\n\n/**\n * Execute a tailscale CLI command, trying each known binary path.\n * Automatically appends `--socket <path>` when a tailscale socket is found.\n */\nfunction execTailscaleCmd(args: string[]): Promise<string> {\n const socketPath = TAILSCALE_SOCKET_PATHS.find(existsSync);\n\n interface Attempt {\n binary: string;\n args: string[];\n }\n\n const attempts: Attempt[] = [];\n for (const binary of TAILSCALE_PATHS) {\n if (socketPath) {\n attempts.push({ binary, args: [\"--socket\", socketPath, ...args] });\n }\n attempts.push({ binary, args });\n }\n\n return new Promise((resolve, reject) => {\n let index = 0;\n\n function tryNext(): void {\n if (index >= attempts.length) {\n reject(new Error(\"All tailscale binary attempts failed\"));\n return;\n }\n\n const attempt = attempts[index++]!;\n execFile(attempt.binary, attempt.args, (error, stdout) => {\n if (error) {\n tryNext();\n } else {\n resolve(stdout);\n }\n });\n }\n\n tryNext();\n });\n}\n\n/**\n * Retrieve the current Tailscale hostname and IP address.\n *\n * Returns `null` when Tailscale is not installed, not running, or not online.\n */\nexport async function getTailscaleInfo(): Promise<{ hostname: string; ip: string } | null> {\n try {\n const stdout = await execTailscaleCmd([\"status\", \"--json\"]);\n const status: TailscaleDetailedStatus = JSON.parse(stdout);\n\n if (\n status.BackendState === \"Running\" &&\n status.Self?.Online === true &&\n status.Self.HostName &&\n status.Self.TailscaleIPs?.length\n ) {\n return {\n hostname: status.Self.HostName,\n ip: status.Self.TailscaleIPs[0]!,\n };\n }\n\n return null;\n } catch {\n return null;\n }\n}\n\n/** Helper: build kubectl exec args for the dns-writer sidecar. */\nfunction dnsWriterExec(cfg: HeadscaleDnsConfig, cmd: string[], interactive = false): string[] {\n return [\n \"exec\", ...(interactive ? [\"-i\"] : []),\n \"-n\", cfg.namespace, cfg.pod, \"-c\", cfg.container, \"--\", ...cmd,\n ];\n}\n\n/**\n * Read DNS records from the dns-writer sidecar in the Headscale pod.\n *\n * Throws with an actionable message if kubectl can't reach the pod.\n * Returns an empty array when the file is missing or contains invalid JSON.\n */\nexport function readDnsRecords(cfg: HeadscaleDnsConfig): DnsRecord[] {\n try {\n const raw = execFileSync(\"kubectl\", dnsWriterExec(cfg, [\"cat\", cfg.filePath]), {\n encoding: \"utf-8\",\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n const trimmed = raw.trim();\n if (!trimmed || trimmed === \"[]\") return [];\n try {\n return JSON.parse(trimmed);\n } catch {\n console.warn(`[vpn] DNS records file contains invalid JSON, treating as empty`);\n return [];\n }\n } catch (err) {\n const msg = (err as Error).message ?? String(err);\n if (msg.includes(\"not found\") || msg.includes(\"Unable to connect\")) {\n throw new Error(\n `Cannot reach Headscale DNS writer (namespace=${cfg.namespace}, pod=${cfg.pod}).\\n` +\n `Verify kubectl context and that the Headscale pod is running:\\n` +\n ` kubectl get pods -n ${cfg.namespace}`,\n );\n }\n if (msg.includes(\"No such file\")) {\n return [];\n }\n // Unexpected error \u2014 rethrow\n throw err;\n }\n}\n\n/**\n * Write DNS A records to the dns-writer sidecar in the Headscale pod.\n *\n * Pipes JSON via stdin to avoid shell-escaping issues.\n * Throws with an actionable message if kubectl can't reach the pod.\n */\nexport function registerDnsRecords(cfg: HeadscaleDnsConfig, records: DnsRecord[]): void {\n const json = JSON.stringify(records);\n try {\n execFileSync(\n \"kubectl\",\n dnsWriterExec(cfg, [\"sh\", \"-c\", `cat > '${cfg.filePath}'`], true),\n { input: json, stdio: [\"pipe\", \"pipe\", \"pipe\"] },\n );\n } catch (err) {\n const msg = (err as Error).message ?? String(err);\n throw new Error(\n `Failed to write DNS records to Headscale (namespace=${cfg.namespace}, pod=${cfg.pod}).\\n` +\n `${msg.includes(\"not found\") ? \"Pod not found. \" : \"\"}` +\n `Verify kubectl context: kubectl get pods -n ${cfg.namespace}`,\n );\n }\n}\n\n/**\n * Remove DNS records whose `name` ends with `suffix` from the Headscale pod.\n * Best-effort: logs a warning on failure instead of throwing.\n */\nexport function unregisterDnsRecords(cfg: HeadscaleDnsConfig, suffix: string): void {\n try {\n const records = readDnsRecords(cfg);\n const filtered = records.filter((r) => !r.name.endsWith(suffix));\n registerDnsRecords(cfg, filtered);\n } catch {\n // Best-effort cleanup \u2014 silent on failure\n }\n}\n", "/**\n * Utility exports\n */\n\nexport * from \"./errors.js\";\nexport * from \"./log.js\";\nexport * from \"./context.js\";\nexport * from \"./bastion.js\";\nexport * from \"./credentials.js\";\nexport * from \"./pulumi.js\";\nexport * from \"./vpn.js\";\n", "/**\n * Guard for pids the CLI reads back rather than constructs.\n *\n * Every teardown path in the CLI signals a pid it recovered from somewhere:\n * `tunnel-state.json`, `runner.json`, `daemon.json`, or a `ps` argv scan. None\n * of those are trustworthy \u2014 a truncated write, a stale file from an older\n * build, or a hand-edited state dir can put any integer there. POSIX `kill(2)`\n * overloads the pid argument, so the wrong integer is not a no-op:\n *\n * - `kill(0, sig)` \u2192 every process in the CALLER'S OWN process group.\n * - `kill(-1, sig)` \u2192 every process the caller is permitted to signal.\n * - `kill(1, sig)` \u2192 init. And teardown negates before signalling, so a `1`\n * on disk becomes `kill(-1)` \u2014 the broadcast.\n *\n * That is not hypothetical: a `pid: -1` written by a unit test SIGTERM'd the\n * author's entire login session (tmux server, every pane, an in-flight\n * `mesh login`) on 2026-08-01. Refuse the input instead of signalling it.\n *\n * The guard is intentionally strict about the LOW end (`pid >= 2`) so the\n * NEGATED form used for process-group teardown is safe by construction: if\n * `pid >= 2` then `-pid <= -2`, which is always a real process group and never\n * `0` (own group) or `-1` (broadcast). Callers signalling `-pid` need no\n * second check \u2014 see `killSupervisor`.\n */\n\nimport { MeshCliError } from \"./errors.js\";\n\n/** Where a recovered pid came from, for the refusal message + remediation. */\nexport interface PidSource {\n /** What the pid claims to be, e.g. \"tunnel supervisor pid\". */\n what: string;\n /**\n * Absolute path of the state file it was read from, when there is one.\n * Present \u21D2 the refusal tells the user the exact file to delete, so a corrupt\n * state dir does not brick `mesh tunnel down` (which is the command that\n * would otherwise clear it).\n */\n file?: string;\n}\n\n/**\n * Lowest pid the CLI will ever signal. 0 and 1 are the two overloaded values;\n * anything below 0 is already a process-group id, not a pid.\n */\nconst MIN_SIGNALABLE_PID = 2;\n\n/**\n * Why `pid` must not be handed to `process.kill`, or null if it is signalable.\n * Pure \u2014 every branch is unit-testable without spawning anything.\n */\nfunction unsignalableReason(pid: unknown): string | null {\n if (typeof pid !== \"number\") {\n return `expected an integer, got ${typeof pid} (${String(pid)})`;\n }\n if (!Number.isInteger(pid)) {\n // NaN, \u00B1Infinity, and fractions all land here. Number.isInteger is the one\n // predicate that rejects every non-pid a JSON.parse can produce.\n return `expected an integer, got ${String(pid)}`;\n }\n if (pid === 0) {\n return \"0 is not a pid \u2014 kill(0) signals every process in this shell's own process group\";\n }\n if (pid === 1) {\n return \"1 is init \u2014 and teardown negates first, so kill(-1) would signal every process you can signal\";\n }\n if (pid < MIN_SIGNALABLE_PID) {\n return `${pid} is negative \u2014 that is already a process-group id, not a pid`;\n }\n if (pid === process.pid) {\n return `${pid} is this CLI process itself \u2014 signalling it (or its group) would kill the command doing the teardown`;\n }\n if (pid === process.ppid) {\n return `${pid} is this CLI's parent (your shell) \u2014 signalling its group would kill your terminal session`;\n }\n return null;\n}\n\n/**\n * Throw unless `pid` is safe to pass to `process.kill` (directly or negated).\n *\n * Call this at the top of any function that signals a pid it did not construct\n * itself. Existence checks (`process.kill(pid, 0)`) do not need it \u2014 signal 0\n * delivers nothing \u2014 but they gain nothing from skipping it either.\n *\n * @throws {MeshCliError} with `rm <file>` remediation when `source.file` is set.\n */\nexport function assertSignalablePid(pid: unknown, source: PidSource): void {\n const reason = unsignalableReason(pid);\n if (reason === null) return;\n // \"Corrupt or stale\" is only true when the pid was read back from a state\n // file. Call sites without one (e.g. reclaimPort's lsof-derived holder) get a\n // pid that is CORRECT \u2014 that is exactly why it is refused \u2014 so the causal\n // clause is gated on the same condition as the `rm` remediation.\n throw new MeshCliError(\n `Refusing to signal ${source.what} \u2014 ${reason}. ` +\n (source.file\n ? `The recorded value is corrupt or stale; signalling it could terminate unrelated processes.`\n : `Signalling it could terminate unrelated processes.`),\n source.file ? { remediation: { command: `rm ${source.file}` } } : {},\n );\n}\n", "import { getContextConfig, getValidToken } from \"../commands/login.js\";\n\ntype CtxConfig = { issuer?: string; vpnJoinBroker?: string } | null;\n\n/**\n * Resolve the broker URL, preferring the platform-published value from\n * discovery (ContextConfig.vpnJoinBroker) and falling back to DNS-convention\n * derivation for platforms not yet redeployed with the payload field.\n */\nexport function resolveVpnJoinBroker(\n context: string,\n getCfg: (c: string) => CtxConfig = getContextConfig,\n): string | null {\n const cfg = getCfg(context);\n if (cfg?.vpnJoinBroker) return cfg.vpnJoinBroker;\n return deriveVpnJoinBroker(context, getCfg);\n}\n\n/**\n * FALLBACK ONLY. Derive by convention \u2014 issuer host `identity.<env>.<domain>`\n * \u2192 `vpn-join.<env>.<domain>` \u2014 mirroring deriveLoginServer (dev.ts:1479).\n * Prefer resolveVpnJoinBroker, which reads the platform-published URL first.\n */\nexport function deriveVpnJoinBroker(\n context: string,\n getCfg: (c: string) => CtxConfig = getContextConfig,\n): string | null {\n const cfg = getCfg(context);\n if (!cfg?.issuer) return null;\n try {\n const u = new URL(cfg.issuer);\n const parts = u.hostname.split(\".\");\n parts[0] = \"vpn-join\";\n return `https://${parts.join(\".\")}`;\n } catch {\n return null;\n }\n}\n\nexport interface MintDeps {\n getValidToken: (context: string) => Promise<string | null>;\n fetchFn?: typeof fetch;\n}\n\n/**\n * Fetch a single-use pre-auth key from the broker. Returns null on ANY failure\n * (no token, non-2xx, network error) so the caller falls back to the browser\n * relay join \u2014 the broker path is strictly additive.\n */\nexport async function mintPreAuthKey(\n context: string,\n brokerUrl: string,\n deps: MintDeps,\n): Promise<{ authKey: string; loginServer: string } | null> {\n const token = await deps.getValidToken(context);\n if (!token) return null;\n const doFetch = deps.fetchFn ?? fetch;\n try {\n const res = await doFetch(`${brokerUrl}/preauth`, {\n method: \"POST\",\n headers: { authorization: `Bearer ${token}` },\n });\n if (!res.ok) return null;\n const body = (await res.json()) as { authKey?: string; loginServer?: string };\n if (!body.authKey) return null;\n return { authKey: body.authKey, loginServer: body.loginServer ?? \"\" };\n } catch {\n return null;\n }\n}\n\n// Convenience for production callers that use the real login.ts token source.\nexport function realMintDeps(): MintDeps {\n return { getValidToken: (c) => getValidToken(c) };\n}\n", "/**\n * The single credentialed-Pulumi chokepoint (MESH-2085).\n *\n * Every mesh command that shells `pulumi` against the S3 state backend needs\n * AWS credentials from the stack's `mesh:deployerRole`. Historically each\n * command re-implemented that glue and `mesh stack init` skipped it entirely\n * (\u2192 `NoCredentialProviders`). This module centralizes it:\n *\n * 1. read `mesh:deployerRole` from the stack config (clear error if absent),\n * 2. run a LOGIN PREFLIGHT (`probeCredentials`) that names the real problem\n * (\"run `mesh login --device`\") BEFORE pulumi is invoked \u2014 and before\n * `resolveAwsCredentials`' interactive path can hang a browser login over\n * SSH,\n * 3. resolve the deployer creds and return them as env vars to merge into the\n * `pulumi` child process.\n *\n * Callers thread the returned env into their existing `execFileSync(\"pulumi\", \u2026)`\n * calls (deploy passes it once; stack init reuses it across init/select/config).\n */\nimport { logError, logWarn } from \"./log.js\";\nimport { readStackConfig } from \"./pulumi.js\";\nimport { derivePlatformContext, resolveAwsCredentials } from \"./aws-auth.js\";\nimport { probeCredentials, type CredProbe } from \"../commands/login.js\";\n\n/**\n * Map a credential probe to a preflight message. Pure \u2014 no I/O \u2014 so the\n * remediation wording is unit-testable. `null` means \"proceed\" (`ok`, or a\n * non-fatal warning already surfaced). `fatal` callers should stop before\n * touching pulumi; non-fatal is a warning that lets the run continue.\n */\nexport function credProbeToPreflightError(\n probe: CredProbe,\n context: string,\n): { message: string; fatal: boolean } | null {\n switch (probe.state) {\n case \"ok\":\n return null;\n case \"no-session\":\n return {\n fatal: true,\n message:\n `Not logged in for ${context}. Run:\\n` +\n ` mesh login ${context} --device`,\n };\n case \"expired-session\":\n return {\n fatal: true,\n message:\n `Your mesh login session for ${context} expired. Run:\\n` +\n ` mesh login ${context} --device`,\n };\n case \"assume-denied\":\n return {\n fatal: true,\n message:\n `Logged in, but couldn't assume the deployer role \u2014 ${probe.detail}\\n` +\n ` Check mesh:deployerRole in the stack config and your IAM access.`,\n };\n case \"stale-env-override\":\n return {\n fatal: false,\n message:\n `Stale AWS_* env vars are set and will override your mesh login.\\n` +\n ` If pulumi fails with an auth error, clear them first:\\n` +\n ` unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN`,\n };\n }\n}\n\n/**\n * Resolve deployer AWS credentials for a `pulumi` invocation against `stack`,\n * with an up-front login preflight. Returns env vars to merge into the pulumi\n * child (`AWS_ACCESS_KEY_ID`/`_SECRET_ACCESS_KEY`/`_SESSION_TOKEN`). Exits the\n * process with a clear, actionable message on any hard failure \u2014 callers can\n * treat a return as \"creds are ready\".\n */\nexport async function resolvePulumiEnv(opts: {\n appRoot: string;\n stack: string;\n}): Promise<Record<string, string>> {\n const { appRoot, stack } = opts;\n\n const roleArn = readStackConfig(appRoot, stack, \"mesh:deployerRole\");\n if (!roleArn) {\n // Platform stacks are deployed by operators with ambient credentials\n // (SSO/exported env) and typically carry no mesh:deployerRole \u2014 only app\n // stacks do. With ambient creds present, run Pulumi against them directly.\n if (process.env.AWS_ACCESS_KEY_ID) {\n return {};\n }\n logError(\n `No mesh:deployerRole in Pulumi.${stack}.yaml \u2014 mesh needs it to assume a ` +\n `role for the Pulumi backend.\\n` +\n ` Set it (copy the value from a base stack), e.g.:\\n` +\n ` mesh deploy config set mesh:deployerRole arn:aws:iam::<account>:role/<tenant>-<stage>-apps-deployer --stack ${stack}\\n` +\n ` (or provide operator AWS credentials in the environment for platform stacks).`,\n );\n process.exit(1);\n }\n\n // Skip the login preflight whenever explicit ambient AWS credentials are\n // present \u2014 both the CI/OIDC case (key + session token) AND long-lived static\n // keys (key, no session token). Requiring a session token here regressed\n // static-key users: with no cached mesh session the probe returns `no-session`\n // \u2192 fatal, even though `resolveAwsCredentials`' ambient-SDK AssumeRole path\n // (which accepts a bare static key) would have worked. (MESH-2085 review.)\n const hasAmbientCreds = !!process.env.AWS_ACCESS_KEY_ID;\n const context = derivePlatformContext(appRoot, stack);\n if (!hasAmbientCreds && context) {\n const probe = await probeCredentials(context, roleArn);\n const preflight = credProbeToPreflightError(probe, context);\n if (preflight?.fatal) {\n logError(preflight.message);\n process.exit(1);\n }\n if (preflight) logWarn(preflight.message);\n }\n\n const resolved = await resolveAwsCredentials(roleArn, appRoot, stack);\n if (!resolved) {\n logError(\n `Failed to obtain AWS credentials for ${roleArn}.\\n` +\n (context ? ` Try: mesh login ${context} --device\\n` : \"\") +\n ` (or provide AWS credentials in the environment).`,\n );\n process.exit(1);\n }\n return { ...resolved.env };\n}\n", "/**\n * Turnkey kubeconfig for `mesh dev`.\n *\n * `mesh dev` shells out to `kubectl` (Temporal encoding-key read, temporal-pod\n * env fallback). Rather than depend on the operator's ambient ~/.kube/config\n * (which may be unset/stale, and which the apps-deployer role can't populate via\n * `aws eks update-kubeconfig` \u2014 it lacks `eks:DescribeCluster`), we build a\n * kubeconfig from the platform EKS cluster data in SSM and authenticate via\n * `aws eks get-token` (STS-only \u2014 needs just the cluster name).\n *\n * Mirrors the kubeconfig the Pulumi k8s provider builds in\n * `@mesh-tech/infra-components` AppEnvironment.buildExecKubeconfig.\n */\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\n\ninterface EksClusterData {\n clusterEndpoint: string;\n clusterCaData: string;\n clusterName: string;\n}\n\n/**\n * Build a kubeconfig YAML that authenticates to `cluster` via `aws eks get-token`.\n * Pure \u2014 no I/O. The `region` is for the get-token exec args (the active AWS\n * identity is whatever the process runs as: deployer / SSO / CI).\n */\nexport function buildKubeconfig(cluster: EksClusterData, region: string): string {\n return `apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n server: ${cluster.clusterEndpoint}\n certificate-authority-data: ${cluster.clusterCaData}\n name: eks-cluster\ncontexts:\n- context:\n cluster: eks-cluster\n user: eks-user\n name: eks-context\ncurrent-context: eks-context\nusers:\n- name: eks-user\n user:\n exec:\n apiVersion: client.authentication.k8s.io/v1beta1\n command: aws\n args:\n - eks\n - get-token\n - --cluster-name\n - ${cluster.clusterName}\n - --region\n - ${region}\n`;\n}\n\n/**\n * Deterministic path of the session-scoped kubeconfig written by\n * {@link ensureKubeconfig}. Exposed so callers can point `KUBECONFIG` at the\n * last-good file when a fresh SSM re-resolve isn't available (e.g. on\n * `mesh dev restart --refresh-env`), rather than letting kubectl fall back to\n * `~/.kube/config`.\n */\nexport function sessionKubeconfigPath(sessionName: string): string {\n return path.join(os.tmpdir(), 'mesh-dev-sessions', `${sessionName}.kubeconfig`);\n}\n\n/** Why {@link ensureKubeconfig} couldn't resolve the cluster. */\nexport interface EnsureKubeconfigFailure {\n /** The SSM parameter name that was read (or attempted). */\n parameter: string;\n /** The exception thrown, or a synthetic Error for empty/malformed data. */\n error: unknown;\n}\n\nexport interface EnsureKubeconfigDeps {\n /** SSM GetParameter (decrypted value or undefined) \u2014 injectable for tests. */\n getParameter?: (name: string) => Promise<string | undefined>;\n /**\n * Invoked when cluster resolution fails, with the parameter tried and the\n * underlying error \u2014 so callers can say WHAT failed instead of guessing\n * \"check your SSM read access\" at the user (MESH-2515).\n */\n onError?: (failure: EnsureKubeconfigFailure) => void;\n}\n\n/**\n * The HUB platform name for platform-level resource lookups (EKS export,\n * bastion). `platform.name` in the dev output is the hub (e.g. \"trabian\");\n * when absent \u2014 outputs predating the field \u2014 fall back to the default hub\n * \"mesh\", NEVER to the app tenant. This one definition pins the axis that\n * regressed in the original MESH-2515 bug.\n */\nexport function resolveHubPlatformName(\n platform: { name?: string; tenant?: string; env?: string } | undefined,\n): string {\n return platform?.name ?? 'mesh';\n}\n\nasync function ssmGetParameter(name: string): Promise<string | undefined> {\n const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');\n const ssm = new SSMClient({ region: process.env.AWS_REGION || 'us-east-2' });\n const resp = await ssm.send(new GetParameterCommand({ Name: name, WithDecryption: true }));\n return resp.Parameter?.Value;\n}\n\n/**\n * Resolve the hub EKS cluster from SSM and write a session-scoped kubeconfig.\n *\n * Reads `/mesh-platform/{platformName}/{env}/core/eks` \u2014 platform-level cluster\n * data lives under the app's HUB platform (`platform.name` in the dev output,\n * e.g. \"trabian\"; \"mesh\" is only the default hub, MESH-2515). Writes to\n * `${tmpdir}/mesh-dev-sessions/{sessionName}.kubeconfig` (never touches\n * ~/.kube/config). Returns the path, or `null` if the cluster data can't be\n * resolved (best-effort \u2014 the caller continues without it; the failure detail\n * goes to `deps.onError`).\n */\nexport async function ensureKubeconfig(\n platformName: string,\n env: string,\n sessionName: string,\n deps: EnsureKubeconfigDeps = {},\n): Promise<string | null> {\n const parameter = `/mesh-platform/${platformName}/${env}/core/eks`;\n const getParameter = deps.getParameter ?? ssmGetParameter;\n\n let cluster: EksClusterData;\n try {\n const value = await getParameter(parameter);\n if (!value) throw new Error('parameter is empty or missing');\n const parsed = JSON.parse(value) as Partial<EksClusterData>;\n if (!parsed.clusterEndpoint || !parsed.clusterCaData || !parsed.clusterName) {\n throw new Error('cluster data is missing clusterEndpoint/clusterCaData/clusterName');\n }\n cluster = {\n clusterEndpoint: parsed.clusterEndpoint,\n clusterCaData: parsed.clusterCaData,\n clusterName: parsed.clusterName,\n };\n } catch (error) {\n deps.onError?.({ parameter, error });\n return null;\n }\n\n const region = process.env.AWS_REGION || 'us-east-2';\n const kubeconfigPath = sessionKubeconfigPath(sessionName);\n if (!fs.existsSync(path.dirname(kubeconfigPath))) {\n fs.mkdirSync(path.dirname(kubeconfigPath), { recursive: true });\n }\n fs.writeFileSync(kubeconfigPath, buildKubeconfig(cluster, region), { mode: 0o600 });\n return kubeconfigPath;\n}\n", "/**\n * Temporal auth resolution for the mesh CLI.\n *\n * Two responsibilities, kept together because they're always used as a pair:\n * 1. `resolveTemporalAuth` \u2014 discover the M2M client_credentials + Zitadel\n * issuer/project for a tenant/env from SSM (with a kubectl pod-env fallback).\n * This is what `mesh dev` injects into worker/service env.\n * 2. `acquireTemporalBearer` \u2014 exchange those credentials for a Zitadel Bearer\n * JWT via the OAuth2 client_credentials grant, so CLI commands that talk to\n * an auth-enforcing Temporal frontend (describe/history/terminate) can attach\n * `authorization: Bearer <token>` gRPC metadata. Without it the server's\n * MeshAuthorizer denies calls with \"Request unauthorized\".\n *\n * The token fetch is intentionally reimplemented here (not imported from\n * `@mesh-tech/app-kit`, which mirrors it) because app-kit *depends on* mesh-cli \u2014\n * importing it back would create a dependency cycle.\n */\n\nimport { execFileSync } from \"node:child_process\";\nimport { logSuccess, logWarn } from \"./log.js\";\n\n/**\n * Discover Temporal M2M credentials + Zitadel issuer/project for a tenant/env.\n *\n * Returns an env-var-shaped record (TEMPORAL_AUTH_CLIENT_ID/SECRET,\n * ZITADEL_ISSUER, ZITADEL_PROJECT_ID, \u2026). Missing pieces are simply absent \u2014\n * callers decide whether what resolved is enough to mint a token.\n *\n * `platformName` keys the Zitadel issuer lookup and is the platform NAME\n * (e.g. \"trabian\"/\"mesh\"), NOT the app tenant \u2014 getting it wrong means the\n * issuer never resolves, so no Bearer, so Temporal denies with \"Request\n * unauthorized\".\n */\nexport async function resolveTemporalAuth(\n tenant: string,\n env: string,\n platformName: string = tenant,\n): Promise<Record<string, string>> {\n const { SSMClient, GetParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient({ region: process.env.AWS_REGION || \"us-east-2\" });\n\n const results: Record<string, string> = {};\n\n // Each SSM lookup is independent so a missing optional parameter\n // (e.g. token-exchange-app) doesn't prevent other credentials from resolving.\n async function trySSM(name: string): Promise<string | undefined> {\n try {\n const resp = await ssm.send(new GetParameterCommand({ Name: name, WithDecryption: true }));\n return resp.Parameter?.Value ?? undefined;\n } catch {\n return undefined;\n }\n }\n\n try {\n // \u2500\u2500 Scheme 1: Zitadel-managed per-tenant credentials \u2500\u2500\n const workerPath = `/mesh-platform/${tenant}/${env}/temporal/worker-service-account-key`;\n const exchangePath = `/mesh-platform/${tenant}/${env}/temporal/token-exchange-app`;\n\n const workerJson = await trySSM(workerPath);\n if (workerJson) {\n const worker = JSON.parse(workerJson) as { clientId: string; clientSecret: string };\n results.TEMPORAL_AUTH_CLIENT_ID = worker.clientId;\n results.TEMPORAL_AUTH_CLIENT_SECRET = worker.clientSecret;\n }\n\n // Token exchange app credentials (optional \u2014 not all tenants have this)\n const exchangeJson = await trySSM(exchangePath);\n if (exchangeJson) {\n const exchange = JSON.parse(exchangeJson) as { clientId: string; clientSecret: string };\n results.ZITADEL_TOKEN_EXCHANGE_CLIENT_ID = exchange.clientId;\n results.ZITADEL_TOKEN_EXCHANGE_CLIENT_SECRET = exchange.clientSecret;\n }\n\n // Zitadel issuer URL and project ID from the PLATFORM's config, keyed by the\n // platform NAME (not the app tenant, not a hardcoded \"mesh\").\n const platformJson = await trySSM(`/mesh-platform/${platformName}/${env}/platform/zitadel`);\n if (platformJson) {\n const platform = JSON.parse(platformJson) as { endpoint?: string; projectId?: string };\n if (platform.endpoint) results.ZITADEL_ISSUER = platform.endpoint;\n if (platform.projectId) results.ZITADEL_PROJECT_ID = platform.projectId;\n }\n\n // \u2500\u2500 Scheme 2: Legacy shared credentials (fallback) \u2500\u2500\n if (!results.TEMPORAL_AUTH_CLIENT_ID) {\n const legacyClientId = await trySSM(\"/platform/temporal/auth/CLIENT_ID\");\n const legacyClientSecret = await trySSM(\"/platform/temporal/auth/CLIENT_SECRET\");\n if (legacyClientId && legacyClientSecret) {\n results.TEMPORAL_AUTH_CLIENT_ID = legacyClientId;\n results.TEMPORAL_AUTH_CLIENT_SECRET = legacyClientSecret;\n }\n }\n\n // \u2500\u2500 Discover Zitadel config from Temporal server pods (final fallback) \u2500\u2500\n if (!results.ZITADEL_ISSUER) {\n try {\n const podEnv = execFileSync(\n \"kubectl\",\n [\n \"get\",\n \"pods\",\n \"-n\",\n `temporal-${env}`,\n \"-l\",\n \"app.kubernetes.io/component=frontend\",\n \"-o\",\n \"jsonpath={.items[0].spec.containers[0].env}\",\n ],\n { encoding: \"utf-8\", timeout: 10_000, stdio: [\"pipe\", \"pipe\", \"pipe\"] },\n ).trim();\n\n if (podEnv) {\n const envVars = JSON.parse(podEnv) as Array<{ name: string; value?: string }>;\n for (const v of envVars) {\n if (v.name === \"ZITADEL_ISSUER_URL\" && v.value && !results.ZITADEL_ISSUER) {\n results.ZITADEL_ISSUER = v.value;\n }\n if (v.name === \"ZITADEL_PROJECT_ID\" && v.value && !results.ZITADEL_PROJECT_ID) {\n results.ZITADEL_PROJECT_ID = v.value;\n }\n }\n }\n } catch (err) {\n const detail = err instanceof Error ? err.message : String(err);\n const stderr = String((err as { stderr?: unknown })?.stderr ?? \"\");\n if (/\\.kube[/\\\\]config|error loading config file/i.test(`${detail} ${stderr}`)) {\n logWarn(\n \"kubectl could not read a kubeconfig and fell back to ~/.kube/config \" +\n \"(macOS blocks it for shells without Full Disk Access). Run inside the \" +\n \"mesh dev tmux session, or export KUBECONFIG=$TMPDIR/mesh-dev-sessions/<session>.kubeconfig.\",\n );\n }\n }\n }\n\n if (results.TEMPORAL_AUTH_CLIENT_ID && results.ZITADEL_ISSUER) {\n logSuccess(`Temporal auth resolved (client: ${results.TEMPORAL_AUTH_CLIENT_ID})`);\n } else if (Object.keys(results).length > 0) {\n logWarn(\"Partial Temporal auth resolved (some credentials missing)\");\n }\n } catch (err) {\n logWarn(\n `Could not resolve Temporal auth from SSM: ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n\n return results;\n}\n\n/**\n * Exchange resolved M2M credentials for a Zitadel Bearer access token via the\n * OAuth2 client_credentials grant. Reads from the passed record first, then the\n * ambient env (which `mesh dev` may already have populated).\n *\n * Returns `undefined` when credentials are incomplete \u2014 the caller then connects\n * unauthenticated (correct for local/non-enforcing Temporal servers).\n *\n * Mirrors `@mesh-tech/app-kit`'s `acquireToken` (kept in sync deliberately; see\n * the module doc for why it isn't imported).\n */\nexport async function acquireTemporalBearer(\n vars: Record<string, string | undefined> = {},\n): Promise<string | undefined> {\n const issuerUrl = vars.ZITADEL_ISSUER ?? process.env.ZITADEL_ISSUER;\n const projectId = vars.ZITADEL_PROJECT_ID ?? process.env.ZITADEL_PROJECT_ID;\n const clientId = vars.TEMPORAL_AUTH_CLIENT_ID ?? process.env.TEMPORAL_AUTH_CLIENT_ID;\n const clientSecret = vars.TEMPORAL_AUTH_CLIENT_SECRET ?? process.env.TEMPORAL_AUTH_CLIENT_SECRET;\n\n if (!issuerUrl || !clientId || !clientSecret) {\n return undefined;\n }\n\n const scopes = [\"openid\"];\n if (projectId) {\n scopes.push(`urn:zitadel:iam:org:project:id:${projectId}:aud`);\n // Request project roles in the token (MeshAuthorizer Path A). Same env gate\n // as app-kit: skipping the roles scope falls back to client_id parsing\n // (Path B), which currently also yields the reader bit.\n if (process.env.TEMPORAL_M2M_SKIP_ROLES_SCOPE !== \"true\") {\n scopes.push(\"urn:zitadel:iam:org:projects:roles\");\n }\n }\n\n const response = await fetch(`${issuerUrl}/oauth/v2/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"client_credentials\",\n client_id: clientId,\n client_secret: clientSecret,\n scope: scopes.join(\" \"),\n }),\n });\n\n if (!response.ok) {\n const text = await response.text();\n throw new Error(\n `Temporal token acquisition failed: ${response.status} ${response.statusText} \u2014 ${text}`,\n );\n }\n\n const data = (await response.json()) as { access_token: string };\n return data.access_token;\n}\n", "/**\n * TCP reachability probe for transport selection.\n *\n * `mesh dev`'s VPN detection is otherwise a *presence* check \u2014 it asks whether a\n * Tailscale/VPN connection exists, not whether the mesh VPC actually routes. On a\n * machine using Tailscale for SSH (but not the mesh Headscale VPN) that presence\n * check returns true, `mesh dev` picks \"VPN-direct\", and the worker then dies with\n * `DnsResolutionError` / `ECONNREFUSED` on the Temporal frontend. Dialing the\n * endpoint before committing to VPN-direct turns that tribal-knowledge failure\n * into an automatic SSM fallback (MESH-2016).\n */\nimport { createConnection } from \"node:net\";\n\n/**\n * Resolve `true` if a TCP connection to `host:port` completes within\n * `timeoutMs`, else `false`. Never rejects \u2014 any failure (timeout, refused, DNS)\n * is a `false`, since for transport selection \"can't reach it\" is the only signal\n * that matters. The socket is always destroyed.\n */\nexport function probeTcpReachable(host: string, port: number, timeoutMs = 1500): Promise<boolean> {\n return new Promise((resolve) => {\n let settled = false;\n const socket = createConnection({ host, port });\n const finish = (ok: boolean) => {\n if (settled) return;\n settled = true;\n socket.destroy();\n resolve(ok);\n };\n socket.setTimeout(timeoutMs);\n socket.once(\"connect\", () => finish(true));\n socket.once(\"timeout\", () => finish(false));\n socket.once(\"error\", () => finish(false));\n });\n}\n\n/**\n * Resolve `true` if a connection to `host:port` completes AND survives a brief\n * grace window (`holdMs`). Stronger than {@link probeTcpReachable} for probing a\n * LOCAL FORWARDER port, where \"the port accepts\" is not proof the tunnel carries\n * traffic: a forwarder (SSM port-forward, or the userspace-Tailscale SOCKS\n * bridge) binds its local port even when its upstream leg is dead \u2014 so a plain\n * connect succeeds while nothing flows. A dead-upstream forwarder accepts the\n * TCP connection and then tears it down SYNCHRONOUSLY (e.g. the Tailscale bridge\n * dials a SOCKS port the daemon isn't on and destroys the client \u2014 MESH-2169);\n * measured, that teardown lands in ~1ms.\n *\n * The window is deliberately SMALL \u2014 just enough to clear that ~1ms fail-fast\n * teardown. It is NOT a latency budget for the upstream: a real upstream need\n * send no bytes and may itself idle-close a bare connection (Temporal's UI drops\n * an idle HTTP connection at ~50ms; Temporal gRPC and Postgres hold indefinitely).\n * So \"closed within ~1ms of connecting\" means the forwarder never reached a real\n * upstream; surviving `holdMs` means it did. A larger window would mis-report a\n * healthy-but-idle-closing service (like the Temporal UI) as DOWN. Never rejects;\n * the socket is always destroyed.\n */\nexport function probeConnectionHolds(\n host: string,\n port: number,\n timeoutMs = 800,\n holdMs = 25,\n): Promise<boolean> {\n return new Promise((resolve) => {\n let settled = false;\n let holdTimer: ReturnType<typeof setTimeout> | undefined;\n const socket = createConnection({ host, port });\n const finish = (ok: boolean) => {\n if (settled) return;\n settled = true;\n if (holdTimer) clearTimeout(holdTimer);\n socket.destroy();\n resolve(ok);\n };\n socket.setTimeout(timeoutMs);\n // Healthy iff the connection survives the grace window after connecting.\n socket.once(\"connect\", () => {\n holdTimer = setTimeout(() => finish(true), holdMs);\n });\n // Closed before the grace window elapsed \u2192 the forwarder's upstream is dead.\n socket.once(\"close\", () => finish(false));\n socket.once(\"timeout\", () => finish(false));\n socket.once(\"error\", () => finish(false));\n });\n}\n", "/**\n * Workflow-source fingerprint for relaunch safety (MESH-2017).\n *\n * A `mesh dev` relaunch (or `restart <worker>`) restarts the Temporal worker onto\n * whatever code is current. If the WORKFLOW code changed since the session\n * started, in-flight conversations replay against the new code and \u2014 if the change\n * is replay-incompatible \u2014 get stranded (observed: conversation 523a4103). The\n * replay-safety gate (MESH-2091) catches this at CI; this catches it at the *local*\n * dev loop, where uncommitted edits haven't been through CI.\n *\n * We fingerprint the source the agent/interpreter worker bundles into Temporal\n * workflows: the workflow packages agent-sdk, workflow-interpreter, agent-contracts\n * (the same set the CI replay-gate path filter covers). A content hash (not mtime)\n * so a `git checkout`/rebase that rewrites mtimes without changing content doesn't\n * produce a false \"code changed\" warning; paths are hashed repo-root-relative so\n * moving/renaming the checkout doesn't either.\n *\n * NOTE (MESH-2017 follow-up): this covers the shared workflow packages, not a\n * tenant repo's own worker source \u2014 `workflowSourceDirs` accepts extra dirs for\n * that, but no caller wires them yet, so in a repo without those packages the\n * fingerprint is empty and the guard is inert. Tracked separately.\n */\nimport { createHash } from \"node:crypto\";\nimport { existsSync, readFileSync, readdirSync } from \"node:fs\";\nimport { dirname, isAbsolute, join, relative } from \"node:path\";\n\n/**\n * Workspace packages whose src the agent/interpreter worker bundles as workflows.\n * MUST stay a subset of the CI replay-gate path filter (`checks.yaml` \u2192\n * `replay-changes`); a drift-guard test asserts it (MESH-2093).\n */\nexport const WORKFLOW_PACKAGE_DIRS = [\n \"packages/agent-sdk/src\",\n \"packages/workflow-interpreter/src\",\n \"packages/agent-contracts/src\",\n];\n\nconst EXCLUDE = /(^|\\/)(node_modules|dist|__tests__)(\\/|$)|\\.(test|spec)\\.[cm]?tsx?$/;\n\n/**\n * Walk up from `startDir` to the monorepo root (the dir with pnpm-workspace.yaml).\n * Falls back to `startDir` if none is found.\n */\nexport function findRepoRoot(startDir: string): string {\n let dir = startDir;\n for (;;) {\n if (existsSync(join(dir, \"pnpm-workspace.yaml\"))) return dir;\n const parent = dirname(dir);\n if (parent === dir) return startDir;\n dir = parent;\n }\n}\n\n/**\n * The set of directories whose `.ts` the worker turns into Temporal workflows.\n *\n * Prefers the shared workflow **packages** (agent-sdk / interpreter / contracts)\n * when they exist \u2014 the precise, low-noise signal for the monorepo agent/interpreter\n * dev loop. When they're absent (a **tenant repo**), falls back to the app's own\n * worker source (`extraDirs`) so the guard isn't silently inert there (MESH-2093).\n * Only existing dirs are returned.\n */\nexport function workflowSourceDirs(repoRoot: string, extraDirs: string[] = []): string[] {\n const pkgDirs = WORKFLOW_PACKAGE_DIRS.map((d) => join(repoRoot, d)).filter((d) => existsSync(d));\n if (pkgDirs.length > 0) return pkgDirs;\n return extraDirs.filter((d) => existsSync(d));\n}\n\n/**\n * Resolve the worker service(s)' source dirs from a dev-output `services` map, so a\n * tenant repo (no shared workflow packages) still fingerprints the code its worker\n * actually runs. A service is a worker when its name matches `/worker/i` (best\n * effort until dev-output carries a `type: \"temporal-worker\"` marker \u2014 MESH-2093).\n */\nexport function resolveWorkerSourceDirs(\n repoRoot: string,\n services: Record<string, { src?: string }> = {},\n): string[] {\n const dirs: string[] = [];\n for (const [name, svc] of Object.entries(services)) {\n if (/worker/i.test(name) && svc?.src) {\n dirs.push(isAbsolute(svc.src) ? svc.src : join(repoRoot, svc.src));\n }\n }\n return dirs;\n}\n\n/**\n * Content fingerprint of every non-test `.ts`/`.tsx` under `dirs`. Stable across\n * runs for identical content; changes iff a workflow source file's content\n * changes. Returns a short hex digest ('' if no files).\n *\n * Each file is labelled by its path relative to `relativeTo` (when given) so the\n * hash is stable across checkout location/renames; falls back to the absolute path\n * otherwise.\n */\nexport function computeWorkflowFingerprint(dirs: string[], relativeTo?: string): string {\n const files: string[] = [];\n for (const dir of dirs) {\n let rels: string[];\n try {\n rels = readdirSync(dir, { recursive: true }) as string[];\n } catch {\n continue; // dir removed mid-scan\n }\n for (const rel of rels) {\n const p = String(rel);\n if (/\\.(ts|tsx)$/.test(p) && !EXCLUDE.test(p)) files.push(join(dir, p));\n }\n }\n files.sort();\n if (files.length === 0) return \"\";\n const h = createHash(\"sha256\");\n for (const f of files) {\n try {\n const content = readFileSync(f);\n h.update(relativeTo ? relative(relativeTo, f) : f);\n h.update(\"\\0\");\n h.update(content);\n h.update(\"\\0\");\n } catch {\n /* unreadable/removed mid-scan \u2014 skip */\n }\n }\n return h.digest(\"hex\").slice(0, 16);\n}\n\n/**\n * Convenience: fingerprint an app's workflow source. Pass the dev-output `services`\n * map so a tenant repo falls back to its worker source when the shared workflow\n * packages aren't present.\n */\nexport function fingerprintWorkflowSource(\n appRoot: string,\n services: Record<string, { src?: string }> = {},\n): string {\n const repoRoot = findRepoRoot(appRoot);\n const workerDirs = resolveWorkerSourceDirs(repoRoot, services);\n return computeWorkflowFingerprint(workflowSourceDirs(repoRoot, workerDirs), repoRoot);\n}\n", "/**\n * Helpers for launching `mesh dev` services via per-session env files.\n *\n * Background: `mesh dev` used to inline every env var into a single giant\n * `env K1=V1 K2=V2 \u2026 <command>` string passed to `tmux send-keys`. With large\n * values (system prompts, encoding keys, OAuth creds) tmux intermittently\n * truncated the string \u2014 the service never started \u2014 and `mesh dev restart`\n * re-derived the env from scratch, drifting from what the service was\n * originally launched with (e.g. a different TEMPORAL_ENCODING_KEY).\n *\n * Instead, the full env is written once at launch to a 0600 file under the\n * session state dir, and the send-keys command is just:\n *\n * cd '<dir>' && source '<envfile>' && <command>\n *\n * Restart sources the SAME file, guaranteeing a byte-identical environment.\n */\n\nimport * as fs from 'fs';\nimport * as net from 'net';\nimport * as path from 'path';\n\n/** Env files contain secrets \u2014 owner read/write only. */\nexport const ENV_FILE_MODE = 0o600;\n\n/** Valid POSIX env var name. Anything else is rejected to avoid shell injection. */\nconst ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\n/**\n * Single-quote a value for POSIX shells (bash/zsh), escaping embedded\n * single quotes via the standard `'\\''` dance. Safe for newlines, spaces,\n * `$`, backticks, etc.\n */\nexport function quoteShellValue(value: string): string {\n return `'${value.replace(/'/g, \"'\\\\''\")}'`;\n}\n\n/**\n * Render a sourceable env file: one `export K='V'` per line.\n * Throws on env var names that aren't valid shell identifiers.\n */\nexport function renderEnvFile(env: Record<string, string>): string {\n const lines: string[] = [\n '# Auto-generated by mesh dev \u2014 do not edit.',\n '# Sourced by service launch/restart commands; this file IS the',\n \"# session's env contract for the service (restart fidelity).\",\n ];\n for (const [key, value] of Object.entries(env)) {\n if (!ENV_KEY_RE.test(key)) {\n throw new Error(`Invalid env var name for env file: ${JSON.stringify(key)}`);\n }\n lines.push(`export ${key}=${quoteShellValue(value)}`);\n }\n return lines.join('\\n') + '\\n';\n}\n\n/** Filesystem-safe env file name for a service. */\nexport function envFileName(serviceName: string): string {\n return `${serviceName.replace(/[^A-Za-z0-9._-]/g, '-')}.env.sh`;\n}\n\n/**\n * Write the env file (creating parent dirs) with mode 0600.\n * chmod is applied explicitly so a pre-existing file is tightened too.\n */\nexport function writeEnvFile(filePath: string, env: Record<string, string>): void {\n fs.mkdirSync(path.dirname(filePath), { recursive: true });\n fs.writeFileSync(filePath, renderEnvFile(env), { mode: ENV_FILE_MODE });\n fs.chmodSync(filePath, ENV_FILE_MODE);\n}\n\n/**\n * Build the short, untruncatable command sent to tmux:\n * cd '<dir>' && source '<envfile>' && <command>\n *\n * `command` is the service's dev command (already shell syntax) and is\n * passed through verbatim.\n *\n * With `logShipper` (mesh dev --local: the path of assets/log-shipper.mjs),\n * the service's combined stdout/stderr is piped through the shipper: raw\n * bytes go to the pane (its stderr), ANSI-stripped lines ship to the local\n * platform's OTel collector via OTLP \u2014 the same ingestion path instrumented\n * apps use, carrying the OTEL_* resource attributes from the env file. Logs\n * never cross the macOS Docker file mount (whose stale attribute caching\n * hides appends from in-container tailers).\n */\nexport function buildLaunchCommand(\n envFilePath: string,\n dir: string,\n command: string,\n logShipper?: string,\n): string {\n const base = `cd ${quoteShellValue(dir)} && source ${quoteShellValue(envFilePath)} && `;\n if (!logShipper) return `${base}${command}`;\n // NODE_OPTIONS= : the shipper must not inherit the service's OTel\n // auto-instrumentation \u2014 its own OTLP POSTs would show up as traces.\n return `${base}{ ${command}; } 2>&1 | NODE_OPTIONS= node ${quoteShellValue(logShipper)}`;\n}\n\n/**\n * Poll until a TCP port accepts connections, or the timeout elapses.\n * Returns true when the port accepted, false on timeout.\n */\nexport async function waitForPort(\n host: string,\n port: number,\n timeoutMs: number,\n intervalMs = 500,\n): Promise<boolean> {\n const deadline = Date.now() + timeoutMs;\n\n // Always attempt at least once, even with a zero/negative timeout.\n for (;;) {\n const remaining = deadline - Date.now();\n const attemptTimeout = Math.max(250, Math.min(1000, remaining));\n if (await tryConnect(host, port, attemptTimeout)) return true;\n if (Date.now() + intervalMs >= deadline) return false;\n await new Promise((resolve) => setTimeout(resolve, intervalMs));\n }\n}\n\nfunction tryConnect(host: string, port: number, timeoutMs: number): Promise<boolean> {\n return new Promise((resolve) => {\n const socket = net.connect({ host, port });\n const done = (ok: boolean) => {\n socket.removeAllListeners();\n socket.destroy();\n resolve(ok);\n };\n socket.setTimeout(timeoutMs);\n socket.once('connect', () => done(true));\n socket.once('timeout', () => done(false));\n socket.once('error', () => done(false));\n });\n}\n", "/**\n * First-boot seeding for the `mesh start` local platform.\n *\n * Registers tenant `local` / env `dev` in the local AWS fabric's (ministack)\n * SSM registry with the same shape TenantEnvironments writes in the cloud\n * (/mesh-platform/{tenant}/{env}/app-tenants \u2192 TenantEnvironmentsExports), and\n * creates the tenant artifacts bucket. Idempotent \u2014 parameters are overwritten,\n * the bucket create tolerates \"already owned by you\".\n *\n * Zitadel org/project/CLI-app + test-user seeding (the `mesh login local`\n * path) is tracked as a follow-up on the mesh start epic.\n */\n\nimport { logInfo, logSuccess } from \"../../utils/log.js\";\nimport { MeshCliError } from \"../../utils/errors.js\";\n\nexport const LOCAL_TENANT = \"local\";\nexport const LOCAL_ENV = \"dev\";\nexport const LOCAL_AWS_ENDPOINT = \"http://localhost:4566\";\nexport const LOCAL_AWS_REGION = \"us-east-2\";\nexport const ARTIFACTS_BUCKET = \"mesh-local-artifacts\";\nexport const APP_TENANTS_PARAM = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/app-tenants`;\n\nexport const LOCAL_AWS_CONFIG = {\n endpoint: LOCAL_AWS_ENDPOINT,\n region: LOCAL_AWS_REGION,\n credentials: { accessKeyId: \"test\", secretAccessKey: \"test\" },\n};\nconst AWS_CONFIG = LOCAL_AWS_CONFIG;\n\n/**\n * Env vars that point any AWS SDK process on the host at the local fabric \u2014\n * the SDK-native endpoint override (AWS_ENDPOINT_URL, supported since SDK JS\n * v3 ~3.428), so no client-code changes are needed. `mesh dev` local mode\n * injects these into app processes; humans can `eval` them from the\n * `mesh start` output. In-network services get the same wiring with\n * http://ministack:4566 (see the compose file's x-local-aws-env anchor).\n */\nexport function localAwsEnv(): Record<string, string> {\n return {\n AWS_ENDPOINT_URL: LOCAL_AWS_ENDPOINT,\n AWS_REGION: LOCAL_AWS_REGION,\n AWS_ACCESS_KEY_ID: \"test\",\n AWS_SECRET_ACCESS_KEY: \"test\",\n };\n}\n\n/**\n * The local tenant registration, shaped like TenantEnvironmentsExports\n * (libs/infra-components \u2026/platform/TenantEnvironments.ts) so consumers of the\n * registry (Hub API, init checks) read local and cloud identically. AWS-only\n * fields carry inert local placeholders.\n */\nexport function buildAppTenantsSeed() {\n const tenantExport = {\n albGroupName: LOCAL_TENANT,\n certificateArn: \"\",\n baseDomain: \"localhost\",\n deployerRoleArn: \"arn:aws:iam::000000000000:role/local-dev-deployer\",\n artifactsBucket: ARTIFACTS_BUCKET,\n spicedb: {\n endpoint: \"localhost:50051\",\n presharedKeySecret: \"local-dev-key\",\n },\n zitadel: {\n orgId: \"local\",\n domain: \"localhost:8080\",\n issuer: \"http://localhost:8080\",\n },\n };\n return {\n hubTenant: LOCAL_TENANT,\n primaryTenant: tenantExport,\n sharedTenants: {},\n };\n}\n\nexport interface SeedResult {\n parameter: string;\n bucket: string;\n}\n\nexport const FABRIC_CHECK_PATH = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/.fabric-check`;\n\n/** The tenant's Temporal namespace, matching the cloud convention\n * TemporalNamespaceProvisioner uses ({tenant}-{env}, e.g. \"acme-dev\"). */\nexport const TEMPORAL_NAMESPACE = `${LOCAL_TENANT}-${LOCAL_ENV}`;\nexport const TEMPORAL_ADDRESS = \"localhost:7233\";\n\n/**\n * Register the local tenant's Temporal namespace ({tenant}-{env} \u2014 the same\n * name TemporalNamespaceProvisioner would create in the cloud). Idempotent:\n * \"already exists\" is success. Dynamic import \u2014 @temporalio/client is an\n * optionalDependency (same pattern as commands/temporal.ts).\n */\nexport async function seedTemporalNamespace(): Promise<void> {\n await ensureTemporalNamespace(\n TEMPORAL_NAMESPACE,\n `Mesh local tenant '${LOCAL_TENANT}' env '${LOCAL_ENV}' (seeded by mesh start)`,\n );\n}\n\n/**\n * Idempotently register a Temporal namespace. `mesh dev --local` uses this\n * for the per-app namespaces ({tenant}-{env}-{app}) the cloud provisioner\n * creates \u2014 the Hub's per-app workflow views resolve exactly that name.\n */\nexport async function ensureTemporalNamespace(namespace: string, description?: string): Promise<void> {\n const { Connection } = await import(\"@temporalio/client\");\n const connection = await Connection.connect({ address: TEMPORAL_ADDRESS });\n try {\n await connection.workflowService.registerNamespace({\n namespace,\n description: description ?? `Mesh local namespace '${namespace}'`,\n // protobufjs accepts plain numbers for int64 at runtime; the generated\n // typing insists on Long (whose package isn't a direct dep), hence any.\n workflowExecutionRetentionPeriod: { seconds: (3 * 24 * 60 * 60) as any },\n });\n // A freshly registered namespace isn't usable until the frontend's\n // namespace cache refreshes (~10s) \u2014 a worker connecting immediately\n // would die on \"Namespace not found\". Wait it out once, here.\n logInfo(`Registered Temporal namespace '${namespace}' \u2014 waiting for it to become active\u2026`);\n const deadline = Date.now() + 20_000;\n while (Date.now() < deadline) {\n try {\n await connection.workflowService.describeTaskQueue({\n namespace,\n taskQueue: { name: \"namespace-propagation-probe\" },\n taskQueueType: 1,\n } as any);\n break;\n } catch {\n await new Promise((resolve) => setTimeout(resolve, 1_000));\n }\n }\n logSuccess(`Temporal namespace '${namespace}' is active`);\n } catch (err: any) {\n if (err?.name === \"NamespaceAlreadyExistsError\" || /already exists/i.test(err?.message ?? \"\")) {\n logInfo(`Temporal namespace '${namespace}' already exists`);\n } else {\n throw err;\n }\n } finally {\n await connection.close();\n }\n}\n\n/**\n * A deterministic >4 KB payload for the Advanced-tier round-trip. Standard\n * SSM params cap at 4 KB; mesh.exports.Export writes tier \"Advanced\" values\n * up to 8 KB (Export.ts), and the emulator's fidelity there is the one open\n * question the design (\u00A72.5) requires this check to answer on every boot.\n */\nexport function buildFabricCheckPayload(targetBytes = 8_000): string {\n const base = { probe: \"advanced-tier-round-trip\", tenant: LOCAL_TENANT, env: LOCAL_ENV, pad: \"\" };\n const overhead = JSON.stringify(base).length;\n return JSON.stringify({ ...base, pad: \"x\".repeat(Math.max(0, targetBytes - overhead)) });\n}\n\n/**\n * Verify the local AWS fabric behaves like real SSM where the platform\n * depends on it: an 8 KB Advanced-tier parameter round-trips intact, and\n * child parameters under a path come back via GetParametersByPath (the\n * shape mesh.exports.Export's splitKeys mode reads). Check params are\n * removed afterwards.\n */\nexport async function verifyFabric(): Promise<void> {\n const { SSMClient, PutParameterCommand, GetParameterCommand, GetParametersByPathCommand, DeleteParametersCommand } =\n await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(AWS_CONFIG);\n const payload = buildFabricCheckPayload();\n const mainParam = FABRIC_CHECK_PATH;\n const childParams = [`${FABRIC_CHECK_PATH}/vpc`, `${FABRIC_CHECK_PATH}/eks`];\n\n try {\n await ssm.send(\n new PutParameterCommand({\n Name: mainParam,\n Type: \"String\",\n Tier: \"Advanced\",\n Overwrite: true,\n Value: payload,\n }),\n );\n for (const name of childParams) {\n await ssm.send(\n new PutParameterCommand({ Name: name, Type: \"String\", Overwrite: true, Value: `{\"probe\":\"${name}\"}` }),\n );\n }\n\n const roundTrip = await ssm.send(new GetParameterCommand({ Name: mainParam }));\n if (roundTrip.Parameter?.Value !== payload) {\n throw new MeshCliError(\n `Local AWS fabric failed the ${payload.length}-byte Advanced-tier SSM round-trip (got ${roundTrip.Parameter?.Value?.length ?? 0} bytes back).`,\n {\n remediation: {\n docs: \"libs/mesh-cli/stack/docker-compose.yml \u2014 swap the ministack image for motoserver/moto (design \u00A72.5 fallback)\",\n },\n },\n );\n }\n\n const byPath = await ssm.send(\n new GetParametersByPathCommand({ Path: FABRIC_CHECK_PATH, Recursive: true }),\n );\n const returned = new Set((byPath.Parameters ?? []).map((p) => p.Name));\n const missing = childParams.filter((name) => !returned.has(name));\n if (missing.length > 0) {\n throw new MeshCliError(\n `Local AWS fabric's GetParametersByPath missed child parameter(s): ${missing.join(\", \")} (splitKeys exports would not resolve).`,\n {\n remediation: {\n docs: \"libs/mesh-cli/stack/docker-compose.yml \u2014 swap the ministack image for motoserver/moto (design \u00A72.5 fallback)\",\n },\n },\n );\n }\n\n logSuccess(`Fabric check passed: ${payload.length}-byte Advanced-tier round-trip + GetParametersByPath`);\n } finally {\n await ssm\n .send(new DeleteParametersCommand({ Names: [mainParam, ...childParams] }))\n .catch(() => {});\n }\n}\n\n/** Write the Hub-readable tenant + environment registrations (the shapes\n * hub-api's SSM assembler requires: RawTenant at /{tenant}, RawEnvironment\n * at /{tenant}/{env}). Exported for reuse by app-tenant provisioning. */\nexport async function registerTenantEnv(\n tenant: string,\n opts: { tier?: \"hub\" | \"dedicated\" | \"shared\" } = {},\n): Promise<void> {\n const { SSMClient, PutParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(AWS_CONFIG);\n await ssm.send(\n new PutParameterCommand({\n Name: `/mesh-platform/${tenant}`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({ name: tenant, tier: opts.tier ?? \"shared\", subdomain: tenant }),\n Description: `Tenant registration (seeded by mesh CLI, local platform)`,\n }),\n );\n await ssm.send(\n new PutParameterCommand({\n Name: `/mesh-platform/${tenant}/${LOCAL_ENV}`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({\n env: LOCAL_ENV,\n region: LOCAL_AWS_REGION,\n baseDomain: \"localhost\",\n // monitoring is true: the local stack runs the same Loki/Tempo/\n // Prometheus backends the hosted platform provides.\n services: { temporal: true, zitadel: true, monitoring: true },\n }),\n Description: `Environment registration (seeded by mesh CLI, local platform)`,\n }),\n );\n}\n\nexport async function seedLocalPlatform(): Promise<SeedResult> {\n const { SSMClient, PutParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(AWS_CONFIG);\n await ssm.send(\n new PutParameterCommand({\n Name: APP_TENANTS_PARAM,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify(buildAppTenantsSeed()),\n Description: `TenantEnvironments exports for ${LOCAL_TENANT}-${LOCAL_ENV} (seeded by mesh start)`,\n }),\n );\n await registerTenantEnv(LOCAL_TENANT, { tier: \"hub\" });\n logSuccess(`Registered tenant '${LOCAL_TENANT}' env '${LOCAL_ENV}' \u2192 ${APP_TENANTS_PARAM}`);\n\n const { S3Client, CreateBucketCommand } = await import(\"@aws-sdk/client-s3\");\n const s3 = new S3Client({ ...AWS_CONFIG, forcePathStyle: true });\n try {\n await s3.send(new CreateBucketCommand({ Bucket: ARTIFACTS_BUCKET }));\n logSuccess(`Created artifacts bucket s3://${ARTIFACTS_BUCKET}`);\n } catch (err: any) {\n if (err?.name === \"BucketAlreadyOwnedByYou\" || err?.name === \"BucketAlreadyExists\") {\n logInfo(`Artifacts bucket s3://${ARTIFACTS_BUCKET} already exists`);\n } else {\n throw err;\n }\n }\n\n await seedTemporalNamespace();\n\n return { parameter: APP_TENANTS_PARAM, bucket: ARTIFACTS_BUCKET };\n}\n", "/**\n * Small shared helpers for the local-platform commands \u2014 extracted from the\n * copies that had grown in auth-provision/mocks (secret upsert) and\n * dev-local/stack (TCP probe).\n */\n\nimport * as net from \"net\";\nimport { LOCAL_AWS_CONFIG } from \"./seed.js\";\n\n/** The subset of SecretsManagerClient upsertLocalSecret depends on. */\nexport interface SecretsSender {\n send(command: unknown): Promise<unknown>;\n}\n\n/**\n * Create-or-update a Secrets Manager secret in the local AWS fabric\n * (ministack). Create first; on ResourceExistsException, put a new value.\n * `client` is injectable for tests; defaults to a client against the local\n * fabric.\n */\nexport async function upsertLocalSecret(\n secretId: string,\n value: Record<string, string>,\n client?: SecretsSender,\n): Promise<void> {\n const { SecretsManagerClient, CreateSecretCommand, PutSecretValueCommand } = await import(\n \"@aws-sdk/client-secrets-manager\"\n );\n const sm = client ?? new SecretsManagerClient(LOCAL_AWS_CONFIG);\n const secretString = JSON.stringify(value);\n try {\n await sm.send(new CreateSecretCommand({ Name: secretId, SecretString: secretString }));\n } catch (err: any) {\n if (err?.name === \"ResourceExistsException\") {\n await sm.send(new PutSecretValueCommand({ SecretId: secretId, SecretString: secretString }));\n } else {\n throw err;\n }\n }\n}\n\n/** True when a TCP connect to host:port succeeds within timeoutMs. */\nexport function probeTcp(\n port: number,\n opts: { host?: string; timeoutMs?: number } = {},\n): Promise<boolean> {\n const { host = \"127.0.0.1\", timeoutMs = 2000 } = opts;\n return new Promise((resolve) => {\n const socket = net.connect({ host, port, timeout: timeoutMs });\n socket.once(\"connect\", () => {\n socket.destroy();\n resolve(true);\n });\n socket.once(\"error\", () => resolve(false));\n socket.once(\"timeout\", () => {\n socket.destroy();\n resolve(false);\n });\n });\n}\n", "import fs from \"node:fs\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\n/**\n * Root of the CLI's on-disk cache \u2014 logs, probe targets, compose output,\n * seeded credentials, hub build contexts.\n *\n * Defaults to `~/.cache/mesh`. `MESH_CACHE_HOME` overrides it, and the override\n * is read on every call rather than captured at import, so a process that sets\n * it after this module loads still gets it.\n *\n * WHY THE OVERRIDE EXISTS. Several helpers here `mkdirSync` eagerly, so merely\n * *calling* one writes to the user's home \u2014 which meant `pnpm test` wrote into\n * `~/.cache/mesh` on any machine that runs it, and fail outright under a sandbox\n * that denies writes outside the workspace (`EPERM: mkdir\n * '/Users/\u2026/.cache/mesh/mesh-local/probes'`). The suite points this at a\n * gitignored directory inside the package \u2014 see `test/setup.ts` \u2014 so tests never\n * reach outside the repo.\n */\nexport function meshCacheHome(): string {\n // `||` not `??`: an empty MESH_CACHE_HOME must fall back, or path.join()\n // resolves cache paths against the filesystem root.\n return process.env.MESH_CACHE_HOME || path.join(os.homedir(), \".cache\", \"mesh\");\n}\n\n/**\n * A directory under the cache root, created if absent.\n *\n * @example\n * meshCacheDir(\"mesh-local\", \"probes\"); // \u2192 \"/Users/me/.cache/mesh/mesh-local/probes\"\n */\nexport function meshCacheDir(...segments: string[]): string {\n const dir = path.join(meshCacheHome(), ...segments);\n fs.mkdirSync(dir, { recursive: true });\n return dir;\n}\n", "/**\n * Helpers for the `mesh start/stop/status` full-local platform stack.\n *\n * The docker compose project (name `mesh-local`) ships inside the CLI package\n * at `<package root>/stack/`, so the stack works in any repo with only Docker\n * installed and is version-locked to the CLI.\n */\n\nimport { execFileSync, spawn, spawnSync } from \"child_process\";\nimport * as fs from \"fs\";\nimport { probeTcp } from \"./helpers.js\";\nimport * as path from \"path\";\nimport { fileURLToPath } from \"url\";\nimport { parse as parseYaml } from \"yaml\";\nimport { meshCacheDir } from \"../../utils/cache-home.js\";\nimport { MeshCliError } from \"../../utils/errors.js\";\nimport { startHeartbeat } from \"../../utils/log.js\";\n\nexport const COMPOSE_PROJECT = \"mesh-local\";\n\n/**\n * Host directory `mesh dev --local` writes service logs into\n * ({namespace}/{service}.log). The stack's OTel collector tails it (filelog\n * receiver) and ships to Loki with the hosted k8s_* label scheme, so the\n * Hub's logs view works on local runs unchanged.\n */\nexport function localLogsDir(): string {\n return meshCacheDir(\"mesh-local\", \"logs\");\n}\n\n/**\n * Host directory holding blackbox probe targets (Prometheus file_sd JSON) \u2014\n * written by `mesh dev --externals` for external services, mounted into the\n * stack's Prometheus. The local analog of the hosted Probe CRs.\n */\nexport function localProbesDir(): string {\n return meshCacheDir(\"mesh-local\", \"probes\");\n}\n\n/**\n * Register the app's OWN services as blackbox probe targets.\n *\n * Uptime in the Hub has three sources: blackbox probes, Kubernetes pod\n * readiness, and external-service probes. Locally the middle one cannot exist \u2014\n * `mesh dev --local` runs services as host processes, not Deployments, so\n * kube-state-metrics has nothing to report \u2014 and nothing used to register the\n * host processes with blackbox either. The result was an app that is plainly\n * running and shows no uptime at all, which reads as a broken panel rather than\n * an unmonitored one.\n *\n * The labels match what `ExternalService` already emits (`tenant`/`env`/`app`),\n * because that is what the Hub's scoped discovery query selects on. `target` is\n * the service name, which is how the Hub names the row.\n *\n * Targets use `host.docker.internal` \u2014 Prometheus and blackbox run inside the\n * stack's network and reach host processes only through that name.\n *\n * Returns the file written, for the session to remove on `--kill`; a stale probe\n * would otherwise report a service down long after its session ended.\n */\nexport function writeAppServiceProbes(args: {\n tenant: string;\n env: string;\n app: string;\n /** Service name \u2192 host port. Mock stand-ins are the caller's to exclude. */\n services: Record<string, number>;\n /** Health path every service exposes. */\n healthPath?: string;\n}): string | undefined {\n const entries = Object.entries(args.services);\n if (entries.length === 0) return undefined;\n const healthPath = args.healthPath ?? \"/health\";\n const targets = entries.map(([name, port]) => ({\n targets: [`http://host.docker.internal:${port}${healthPath}`],\n labels: {\n type: \"service\",\n tenant: args.tenant,\n env: args.env,\n app: args.app,\n service: name,\n target: name,\n },\n }));\n const file = path.join(localProbesDir(), `${args.tenant}-${args.app}-services.json`);\n fs.writeFileSync(file, `${JSON.stringify(targets, null, 2)}\\n`);\n return file;\n}\n\n/** Endpoints the local stack exposes on the host. */\nexport interface StackEndpoint {\n service: string;\n label: string;\n url: string;\n /** How `mesh status` probes it from the host */\n probe: { kind: \"http\"; url: string } | { kind: \"tcp\"; port: number } | { kind: \"none\" };\n /** Extra hint printed under the endpoint (credentials, tokens, \u2026) */\n hint?: string;\n}\n\nexport const STACK_ENDPOINTS: StackEndpoint[] = [\n {\n service: \"temporal\",\n label: \"Temporal gRPC\",\n url: \"localhost:7233\",\n probe: { kind: \"tcp\", port: 7233 },\n },\n {\n service: \"temporal-ui\",\n label: \"Temporal UI\",\n url: \"http://localhost:8233/namespaces/local-dev/workflows\",\n probe: { kind: \"http\", url: \"http://localhost:8233\" },\n hint: \"seeded namespace: local-dev\",\n },\n {\n service: \"zitadel\",\n label: \"Zitadel console\",\n url: \"http://localhost:8080\",\n probe: { kind: \"http\", url: \"http://localhost:8080/debug/healthz\" },\n hint: \"admin@local.mesh / LocalDev1!\",\n },\n {\n service: \"mailpit\",\n label: \"Mailbox (local mail)\",\n url: \"http://localhost:8025\",\n probe: { kind: \"http\", url: \"http://localhost:8025/readyz\" },\n hint: \"every activation + password-reset mail Zitadel sends locally lands here\",\n },\n {\n service: \"database\",\n label: \"Postgres\",\n url: \"postgres://postgres:postgres@localhost:5433\",\n probe: { kind: \"tcp\", port: 5433 },\n hint: \"databases: temporal, app, spicedb, hub\",\n },\n {\n service: \"spicedb\",\n label: \"SpiceDB gRPC\",\n url: \"localhost:50051\",\n probe: { kind: \"tcp\", port: 50051 },\n hint: \"preshared key: local-dev-key\",\n },\n {\n service: \"ministack\",\n label: \"ministack (local AWS endpoint)\",\n url: \"http://localhost:4566\",\n probe: { kind: \"http\", url: \"http://localhost:4566/_ministack/health\" },\n hint: \"SSM registry under /mesh-platform/local/dev\",\n },\n {\n service: \"stackport\",\n label: \"StackPort (local AWS console)\",\n url: \"http://localhost:4567\",\n probe: { kind: \"http\", url: \"http://localhost:4567\" },\n hint: \"browse the registry (SSM), secrets, S3 artifacts\",\n },\n {\n service: \"memcached\",\n label: \"Memcached\",\n url: \"localhost:11211\",\n probe: { kind: \"tcp\", port: 11211 },\n },\n {\n service: \"loki\",\n label: \"Loki (logs)\",\n url: \"http://localhost:3100\",\n probe: { kind: \"http\", url: \"http://localhost:3100/ready\" },\n hint: \"mesh dev --local service logs, hosted label scheme\",\n },\n {\n service: \"tempo\",\n label: \"Tempo (traces)\",\n url: \"http://localhost:3200\",\n probe: { kind: \"http\", url: \"http://localhost:3200/ready\" },\n },\n {\n service: \"prometheus\",\n label: \"Prometheus (metrics)\",\n url: \"http://localhost:9090\",\n probe: { kind: \"http\", url: \"http://localhost:9090/-/ready\" },\n },\n {\n service: \"otel-collector\",\n label: \"OTel collector (OTLP in)\",\n url: \"http://localhost:4318\",\n probe: { kind: \"http\", url: \"http://localhost:13133\" },\n hint: \"apps: OTEL_EXPORTER_OTLP_ENDPOINT (injected by mesh dev --local)\",\n },\n {\n service: \"elasticsearch\",\n label: \"OpenSearch (Temporal visibility)\",\n url: \"http://localhost:9200\",\n probe: { kind: \"http\", url: \"http://localhost:9200\" },\n },\n];\n\n/** Default host port for the containerized local Hub. `mesh hub dev` derives\n * its dev-server default from this too (hub/index.ts), so the two Hubs share\n * one source for \"9000\" while keeping separate override variables\n * (MESH_HUB_PORT here, MESH_HUB_DEV_PORT there). */\nexport const DEFAULT_HUB_PORT = \"9000\";\n\n/**\n * Host port the Hub is published on (docker-compose.hub.yml binds\n * `${MESH_HUB_PORT:-9000}:4180`). Override with MESH_HUB_PORT to run the\n * local Hub beside another process that owns 9000 \u2014 e.g. a hub-ui dev\n * server running from a different checkout. Export it for BOTH `mesh start`\n * and later `mesh status` calls: the compose file and these probes read the\n * same variable (MESH-2256 tracks deriving it from the running stack so\n * `mesh status` stops depending on shell hygiene). `mesh start` reconciles\n * the seeded Zitadel Hub app's redirect URIs to include the chosen port \u2014\n * Zitadel requires every redirect URI to be registered even in devMode\n * (devMode only allows http).\n *\n * Empty string falls back like compose's `${MESH_HUB_PORT:-9000}` does \u2014\n * `??` alone would diverge (probe `:`, publish 9000).\n */\nexport function hubPort(): string {\n const raw = process.env.MESH_HUB_PORT?.trim();\n if (!raw) return DEFAULT_HUB_PORT;\n if (!/^\\d+$/.test(raw) || Number(raw) < 1 || Number(raw) > 65535) {\n throw new MeshCliError(`MESH_HUB_PORT must be a TCP port number 1\u201365535 (got '${raw}').`, {\n remediation: { command: \"unset MESH_HUB_PORT # or export a valid port, e.g. 9100\" },\n });\n }\n return raw;\n}\n\n/** Hub overlay endpoints \u2014 probed only when the hub containers exist.\n * A function (not a module constant) so hubPort() is read at call time:\n * the CLI's env is fixed before node starts, but tests and programmatic\n * callers set MESH_HUB_PORT after import. */\nexport function hubEndpoints(): StackEndpoint[] {\n return [\n {\n service: \"hub-api\",\n label: \"Hub API\",\n url: \"http://localhost:4568\",\n probe: { kind: \"http\", url: \"http://localhost:4568/health\" },\n },\n {\n service: \"hub-ui\",\n label: \"Hub UI\",\n url: `http://localhost:${hubPort()}`,\n probe: { kind: \"http\", url: `http://localhost:${hubPort()}/ping` },\n hint: \"sign in: admin@local.mesh or dev@local.mesh / LocalDev1! (oauth2-proxy, same as deployed)\",\n },\n ];\n}\n\n/**\n * Locate the CLI package root (the directory whose package.json is\n * @mesh-tech/mesh-cli). Works from both the compiled layout (dist/src/\u2026)\n * and the source layout (src/\u2026 under tsx).\n */\nexport function findPackageRoot(startDir?: string): string {\n let dir = startDir ?? path.dirname(fileURLToPath(import.meta.url));\n for (let i = 0; i < 8; i++) {\n const pkgPath = path.join(dir, \"package.json\");\n if (fs.existsSync(pkgPath)) {\n try {\n const pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf-8\"));\n if (pkg.name === \"@mesh-tech/mesh-cli\") return dir;\n } catch {\n // unreadable package.json \u2014 keep walking\n }\n }\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n throw new MeshCliError(\"Could not locate the mesh-cli package root (stack assets missing).\", {\n remediation: { command: \"npm install -g @mesh-tech/mesh-cli\" },\n });\n}\n\nexport function stackDir(): string {\n const dir = path.join(findPackageRoot(), \"stack\");\n if (!fs.existsSync(path.join(dir, \"docker-compose.yml\"))) {\n throw new MeshCliError(`Local stack assets not found at ${dir}.`, {\n remediation: { command: \"npm install -g @mesh-tech/mesh-cli\" },\n });\n }\n return dir;\n}\n\n/** The compose overlay that declares the Hub services. */\nexport const HUB_OVERLAY_FILE = \"docker-compose.hub.yml\";\n\n/** Top-level service names a compose file declares. Pure \u2014 takes the file text. */\nexport function parseComposeServiceNames(yamlText: string): string[] {\n const doc = parseYaml(yamlText) as { services?: Record<string, unknown> } | null;\n return Object.keys(doc?.services ?? {});\n}\n\nlet hubOverlayServicesCache: ReadonlySet<string> | undefined;\n\n/**\n * The services the Hub overlay \u2014 and only the overlay \u2014 declares.\n *\n * Read from `docker-compose.hub.yml` rather than hard-coded beside\n * ONE_SHOT_SERVICES on purpose: a second name list rots the moment the\n * overlay gains or renames a service. (It already declares three \u2014 hub-api,\n * hub-ui, hub-auth \u2014 not the two the Hub is usually described by.)\n */\nexport function hubOverlayServices(): ReadonlySet<string> {\n if (!hubOverlayServicesCache) {\n const file = path.join(stackDir(), HUB_OVERLAY_FILE);\n if (!fs.existsSync(file)) {\n throw new MeshCliError(`Local stack assets are incomplete: ${file} is missing.`, {\n remediation: { command: \"npm install -g @mesh-tech/mesh-cli\" },\n });\n }\n hubOverlayServicesCache = new Set(parseComposeServiceNames(fs.readFileSync(file, \"utf-8\")));\n }\n return hubOverlayServicesCache;\n}\n\n/**\n * The compose project is a MACHINE-WIDE singleton (`mesh-local`), but any\n * checkout of the CLI can drive it \u2014 and `docker compose up` from a checkout\n * whose stack config differs will recreate shared containers out from under\n * whatever else is running against the stack (a second project's `mesh dev`\n * session, a Hub built from another worktree, \u2026).\n *\n * Returns the working dir of the checkout that last (re)created the running\n * stack when it is NOT this one; undefined when the stack isn't running or\n * is already owned here. Callers gate lifecycle commands on it.\n */\nexport function stackOwnedElsewhere(): string | undefined {\n // Fail-open by design: a stack that exists but is NOT running (`ps -q`\n // without -a) has nothing to disrupt, so `start` silently re-owns it; and\n // any docker/inspect hiccup disables the guard rather than blocking\n // legitimate lifecycle commands.\n try {\n const first = compose([\"ps\", \"-q\"]).trim().split(\"\\n\").filter(Boolean)[0];\n if (!first) return undefined;\n const label = execFileSync(\n \"docker\",\n [\"inspect\", first, \"--format\", '{{ index .Config.Labels \"com.docker.compose.project.working_dir\" }}'],\n { encoding: \"utf-8\" },\n ).trim();\n if (!label) return undefined;\n // realpath both sides: symlinked layouts (macOS /tmp \u2192 /private/tmp,\n // linked worktree parents) must not make the same physical dir compare\n // as foreign. Fall back to the raw path when realpath fails (dir gone).\n const real = (p: string) => {\n try {\n return fs.realpathSync(p);\n } catch {\n return path.resolve(p);\n }\n };\n return real(label) === real(stackDir()) ? undefined : label;\n } catch {\n return undefined;\n }\n}\n\n/** Throw a remediated error if the docker daemon isn't reachable. */\nexport function ensureDockerAvailable(): void {\n const probe = spawnSync(\"docker\", [\"info\", \"--format\", \"{{.ServerVersion}}\"], {\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n if (probe.error || probe.status !== 0) {\n throw new MeshCliError(\"Docker is not available (is Docker Desktop / the docker daemon running?).\", {\n remediation: { docs: \"https://docs.docker.com/get-docker/\" },\n });\n }\n}\n\n/** Run `docker compose` against the packaged stack. `hub` adds the Hub\n * overlay file (safe for ps/logs/down: services simply may not exist);\n * `env` adds process-env overrides (e.g. MESH_HUB_VERSION). */\nexport function compose(\n args: string[],\n opts: { inherit?: boolean; hub?: boolean; env?: Record<string, string> } = {},\n): string {\n const dir = stackDir();\n const files = [\"-f\", path.join(dir, \"docker-compose.yml\")];\n if (opts.hub) files.push(\"-f\", path.join(dir, \"docker-compose.hub.yml\"));\n const fullArgs = [\"compose\", \"-p\", COMPOSE_PROJECT, ...files, ...args];\n return execFileSync(\"docker\", fullArgs, {\n cwd: dir,\n encoding: \"utf-8\",\n // MESH_LOCAL_LOGS on every call: the compose file mounts it into the\n // OTel collector, and an unset variable would both warn and resolve to\n // a different mount (recreate churn between CLI and raw compose runs).\n // IGNORE_ORPHANS: base-only `up` calls would otherwise flag the hub\n // overlay's running containers as orphans.\n env: {\n ...process.env,\n MESH_LOCAL_LOGS: localLogsDir(),\n MESH_LOCAL_PROBES: localProbesDir(),\n COMPOSE_IGNORE_ORPHANS: \"1\",\n ...opts.env,\n },\n stdio: opts.inherit ? [\"ignore\", \"inherit\", \"inherit\"] : [\"ignore\", \"pipe\", \"pipe\"],\n }) as unknown as string;\n}\n\n/**\n * Pick the one line worth reading out of a failed compose run's output:\n * the last error-looking line, else the last non-empty line (MESH-2479).\n * Pure so the selection heuristic is unit-testable.\n */\nexport function summarizeComposeFailure(output: string): string {\n const lines = output\n .split(\"\\n\")\n .map((l) => l.trim())\n .filter(Boolean);\n const errorish = lines.filter((l) =>\n /\\b(error|failed|failure|fatal|denied|unauthorized|cannot|no such)\\b/i.test(l),\n );\n const line = errorish.at(-1) ?? lines.at(-1) ?? \"no output captured\";\n return line.length > 300 ? `${line.slice(0, 297)}\u2026` : line;\n}\n\n/** Where a lifecycle command's full compose output is written (per operation). */\nexport function composeLogPath(op: string): string {\n const dir = meshCacheDir(\"mesh-local\");\n return path.join(dir, `compose-${op.replace(/[^a-z0-9-]/gi, \"_\")}.log`);\n}\n\n/**\n * `docker compose` for lifecycle commands (`up -d`): stream output like\n * `compose({inherit: true})` does, but never bury the failure \u2014 on a non-zero\n * exit throw a one-line summary instead of leaving ~200 lines of pull/create\n * noise as the only signal (MESH-2479). In non-TTY runs the output is also\n * teed to a log file (named in the error) and a heartbeat fills long quiet\n * stretches; interactive terminals keep docker's own progress rendering.\n */\nexport async function composeStreamed(\n args: string[],\n opts: { hub?: boolean; env?: Record<string, string> } = {},\n): Promise<void> {\n const dir = stackDir();\n const files = [\"-f\", path.join(dir, \"docker-compose.yml\")];\n if (opts.hub) files.push(\"-f\", path.join(dir, \"docker-compose.hub.yml\"));\n const fullArgs = [\"compose\", \"-p\", COMPOSE_PROJECT, ...files, ...args];\n const env = {\n ...process.env,\n MESH_LOCAL_LOGS: localLogsDir(),\n MESH_LOCAL_PROBES: localProbesDir(),\n COMPOSE_IGNORE_ORPHANS: \"1\",\n ...opts.env,\n };\n const op = args[0] ?? \"cmd\";\n\n if (process.stderr.isTTY) {\n const res = spawnSync(\"docker\", fullArgs, {\n cwd: dir,\n env,\n stdio: [\"ignore\", \"inherit\", \"inherit\"],\n });\n if (res.status !== 0) {\n throw new MeshCliError(\n `docker compose ${op} failed (exit ${res.status ?? \"?\"}) \u2014 the compose output above has the details.`,\n {\n remediation: {\n command: `docker compose -p ${COMPOSE_PROJECT} ps && docker compose -p ${COMPOSE_PROJECT} logs --tail 50`,\n },\n },\n );\n }\n return;\n }\n\n const logPath = composeLogPath(op);\n const logStream = fs.createWriteStream(logPath);\n const heartbeat = startHeartbeat(`docker compose ${args.join(\" \")}`);\n let captured = \"\";\n let exitCode: number;\n try {\n exitCode = await new Promise<number>((resolve, reject) => {\n const child = spawn(\"docker\", fullArgs, {\n cwd: dir,\n env,\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n });\n const consume = (chunk: Buffer) => {\n heartbeat.touch();\n const text = chunk.toString();\n captured += text;\n logStream.write(text);\n process.stderr.write(text);\n };\n child.stdout.on(\"data\", consume);\n child.stderr.on(\"data\", consume);\n child.on(\"error\", reject);\n child.on(\"close\", (code) => resolve(code ?? 1));\n });\n } finally {\n heartbeat.stop();\n // Flush the log file before any throw can reach handleCliError's\n // process.exit(), which doesn't wait for pending writes.\n await new Promise<void>((resolve) => logStream.end(resolve));\n }\n if (exitCode !== 0) {\n throw new MeshCliError(\n `docker compose ${op} failed (exit ${exitCode}): ${summarizeComposeFailure(captured)}`,\n {\n remediation: {\n command: `docker compose -p ${COMPOSE_PROJECT} logs --tail 50`,\n docs: logPath,\n },\n },\n );\n }\n}\n\nexport interface ServiceStatus {\n name: string;\n state: string;\n health?: string;\n}\n\n/** Parse `docker compose ps --format json` (NDJSON, one object per line). */\nexport function parseComposePs(output: string): ServiceStatus[] {\n const services: ServiceStatus[] = [];\n for (const line of output.split(\"\\n\")) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n try {\n const entry = JSON.parse(trimmed);\n services.push({\n name: entry.Service ?? entry.Name ?? \"unknown\",\n state: entry.State ?? \"unknown\",\n health: entry.Health || undefined,\n });\n } catch {\n // non-JSON noise from docker \u2014 skip\n }\n }\n return services;\n}\n\nexport function stackServices(): ServiceStatus[] {\n // Include the hub overlay so its containers show up when running.\n return parseComposePs(compose([\"ps\", \"-a\", \"--format\", \"json\"], { hub: true }));\n}\n\n/** Services expected to exit (one-shot jobs) \u2014 an `exited` state is fine. */\nexport const ONE_SHOT_SERVICES = new Set([\"spicedb-migrate\", \"zitadel-machinekey-init\"]);\n\n/**\n * The services whose `exited` state is a real failure *right now*.\n *\n * `stackServices()` scans the whole `mesh-local` compose project, overlay\n * included \u2014 but `mesh start` brings the base stack up first and the Hub\n * overlay only afterwards. Before that point an `exited` hub container is a\n * leftover from a previous run (a Docker restart, a laptop sleep), not a\n * crash: the base `up -d` deliberately never started it. Counting it made\n * `mesh start` abort before it ever tried to start the Hub, pointing at the\n * wrong thing.\n *\n * Once the overlay HAS been started, an exited overlay service is a crash\n * like any other \u2014 pass `overlayStarted: true` and the signal is preserved.\n */\nexport function crashedServices(\n services: ServiceStatus[],\n opts: { overlayServices: ReadonlySet<string>; overlayStarted: boolean },\n): ServiceStatus[] {\n return services.filter(\n (s) =>\n s.state === \"exited\" &&\n !ONE_SHOT_SERVICES.has(s.name) &&\n (opts.overlayStarted || !opts.overlayServices.has(s.name)),\n );\n}\n\n/**\n * Whether the Hub overlay is up in this run \u2014 i.e. at least one overlay\n * container is actually `running`. `mesh status` has no other way to know\n * (it never started anything), and \"a container exists\" is not it: a\n * leftover `exited` one exists too.\n */\nexport function hubOverlayRunning(\n services: ServiceStatus[],\n overlayServices: ReadonlySet<string>,\n): boolean {\n return services.some((s) => overlayServices.has(s.name) && s.state === \"running\");\n}\n\n/**\n * Whether the Hub API is serving. `some(s => s.name === \"hub-api\")` is not\n * the same question \u2014 that is true of a leftover exited container, and\n * probing Hub endpoints that were never meant to be up this run reports the\n * stack unhealthy for a Hub nobody started.\n */\nexport function hubApiRunning(services: ServiceStatus[]): boolean {\n return services.some((s) => s.name === \"hub-api\" && s.state === \"running\");\n}\n\n/** Probe a host endpoint (http 2xx-4xx counts as up; tcp connect counts as up). */\nexport async function probeEndpoint(endpoint: StackEndpoint): Promise<boolean> {\n if (endpoint.probe.kind === \"http\") {\n try {\n const res = await fetch(endpoint.probe.url, { signal: AbortSignal.timeout(3000) });\n return res.status < 500;\n } catch {\n return false;\n }\n }\n if (endpoint.probe.kind === \"tcp\") {\n return probeTcp(endpoint.probe.port, { timeoutMs: 3000 });\n }\n return true;\n}\n", "/**\n * `mesh dev --externals` (alias `--mock`) \u2014 external-service realizations\n * for a local dev session (MESH-1885, design C4).\n *\n * Apps DECLARE how each external dependency is realized locally in the root\n * package.json under `mesh.externals` (legacy key `mesh.mocks` still read).\n * Every declaration runs in exactly one of three MODES:\n *\n * mock \u2014 the service is MIMICKED: no real instance exists locally or\n * remotely. Realized from an OpenAPI spec (`openapi` \u2192 Prism\n * mock server) or a hand-written mock process (`src`).\n * local \u2014 a LOCAL VERSION of the real service runs on this machine,\n * e.g. a vendor-shaped database replica. Realized from a docker\n * compose file (`compose` + published `port`).\n * remote \u2014 the session connects to the ACTUAL external service: either\n * the vendor's real endpoint with inline `credentials` (e.g. a\n * sandbox), or the external service as configured in the app's\n * tenant environment (`remote.env`, e.g. dev \u2014 credentials are\n * pulled from that environment's secret).\n *\n * \"mesh\": {\n * \"externals\": {\n * \"plaid-db\": { // \u2500\u2500 local (default) \u2500\u2500\n * \"mode\": \"local\",\n * \"compose\": \"libs/plaid-db/docker/docker-compose.yml\",\n * \"port\": 1433, // REQUIRED: the published port\n * \"probe\": \"tcp\", // non-HTTP uptime probe\n * \"external\": \"plaid-db\",\n * \"credentials\": { // {{host}}/{{port}} \u2192 localhost:1433\n * \"host\": \"{{host}}\", \"port\": \"{{port}}\",\n * \"name\": \"Plaid_Clean\", \"username\": \"sa\",\n * \"password\": \"{{env:PLAID_DB_PASSWORD}}\"\n * },\n * \"remote\": { \"env\": \"dev\" } // `--externals plaid-db=remote`\n * }, // pulls the dev-configured secret\n * \"plaid\": { // \u2500\u2500 remote \u2500\u2500\n * \"mode\": \"remote\",\n * \"external\": \"plaid\", // the vendor's REAL sandbox\n * \"displayName\": \"Plaid\", // the Hub shows the real name\u2026\n * \"type\": \"api\", // \u2026and the integration type\n * \"docs\": \"https://plaid.com/docs/api/\", // vendor reference docs\n * \"credentials\": {\n * \"endpoint\": \"https://sandbox.plaid.com\",\n * \"client_id\": \"{{env:PLAID_CLIENT_ID}}\",\n * \"secret\": \"{{env:PLAID_SECRET}}\"\n * }\n * },\n * \"vendorpay\": { // \u2500\u2500 mock \u2500\u2500\n * \"mode\": \"mock\",\n * \"openapi\": \"api/docs/spec/openapi.json\", // spec \u2192 Prism emulation\n * \"external\": \"vendorpay\",\n * \"credentials\": { \"baseUrl\": \"{{url}}\", \"apiKey\": \"mock\" }\n * }\n * }\n * }\n *\n * `mode` picks the DEFAULT; a declaration may carry more than one\n * realization (e.g. compose + remote.env) and the CLI overrides per\n * external at run time: `mesh dev --externals plaid-db=remote,plaid`.\n * Declarations without `mode` infer it from their fields (compose \u2192 local,\n * openapi/src \u2192 mock, sandbox/credentials-only \u2192 remote), so pre-mode\n * declarations keep working (`sandbox: true` is a deprecated alias for\n * mode remote).\n *\n * All credential values support `{{env:VAR}}` interpolation from the\n * developer's environment, so real secrets (sandbox keys, DB passwords)\n * never land in package.json.\n *\n * Realization: mock externals join the SAME orchestration as services named\n * mock-{name} (ports, env files, tmux windows, Loki log pipeline); local\n * externals come up via `docker compose up -d --wait` before the app's\n * services and are torn down on `mesh dev --kill`; remote externals spawn\n * nothing. In every mode the ExternalService credential secret is seeded at\n * mesh/{tenant}/{env}/external/{name} so the app's resolveCredentials()\n * path runs UNCHANGED, the vendor is registered in the local Hub's\n * external-services view, and a blackbox uptime probe watches the\n * realization \u2014 remote endpoints included.\n */\n\nimport { execFileSync } from \"child_process\";\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport { logInfo, logSuccess, logWarn } from \"../../utils/log.js\";\nimport { MeshCliError } from \"../../utils/errors.js\";\nimport { LOCAL_ENV, LOCAL_AWS_CONFIG } from \"./seed.js\";\nimport { probeTcp, upsertLocalSecret } from \"./helpers.js\";\nimport { localProbesDir } from \"./stack.js\";\n\nexport interface LocalMockDecl {\n /** Default mode for this external: \"mock\" (emulate via openapi/src),\n * \"local\" (docker compose local version), \"remote\" (the actual service).\n * Inferred from the realization fields when omitted; overridable per run\n * with `mesh dev --externals name=mode`. */\n mode?: ExternalMode;\n /** Mock mode: directory (relative to app root) with a package.json `dev`\n * script \u2014 a hand-written mock process. */\n src?: string;\n /** Local mode: docker compose file (relative to app root) running a local\n * version of the external service \u2014 e.g. a vendor-shaped database\n * replica. `mesh dev` runs `docker compose up -d --wait` before the\n * app's services and `docker compose down` on `--kill`. Requires `port`. */\n compose?: string;\n /** Mock mode: OpenAPI spec file (relative to app root); the CLI emulates\n * the service with a Prism mock server (`npx @stoplight/prism-cli mock`)\n * as mock-{name}. */\n openapi?: string;\n /** @deprecated alias for `mode: \"remote\"` (with inline credentials). */\n sandbox?: boolean;\n /** Remote-mode credential source. When the external runs in remote mode,\n * its credentials come from (first match): `remote.credentials` (inline,\n * env-interpolated), the top-level `credentials` if this declaration has\n * no local/mock realization (a pure-remote declaration), or the external\n * service configured in the app's tenant environment `remote.env`\n * (default \"dev\") \u2014 secret mesh/{tenant}/{env}/external/{name}, read\n * with the developer's ambient AWS credentials. `remote.profile` names\n * the AWS profile for that pull when the shell carries no AWS identity\n * (no AWS_ACCESS_KEY_ID / AWS_PROFILE); the MESH_AWS_PROFILE env var\n * overrides it \u2014 the same convention repo pull scripts use. */\n remote?: { env?: string; credentials?: Record<string, string>; profile?: string };\n /** Human-facing integration name shown in the Hub (e.g. \"Plaid\",\n * \"Unit21\"). @default the external name title-cased (\"plaid-db\" \u2192\n * \"Plaid Db\"). */\n displayName?: string;\n /** Vendor reference documentation for the integration, linked from the\n * Hub \u2014 a URL string or `{ url, provider }`.\n * @example \"https://plaid.com/docs/api/\" */\n docs?: string | { url: string; provider?: string };\n /** Fixed port; omitted = auto-allocated after the app's services.\n * REQUIRED in local (compose) mode \u2014 the compose file's published port. */\n port?: number;\n /** Launch command (default: [\"pnpm\", \"dev\"]; openapi mocks synthesize a\n * Prism command) */\n command?: string[];\n /** Extra env for the mock process */\n env?: Record<string, string>;\n /** ExternalService name this declaration realizes */\n external?: string;\n /** Credential JSON seeded into the external secret (the local/mock\n * realization's template; also the remote source for pure-remote\n * declarations). Tokens: \"{{url}}\" \u2192 the stand-in's URL, \"{{host}}\"/\n * \"{{port}}\" \u2192 its host/port, and \"{{env:VAR}}\" \u2192 the developer's\n * environment (all modes). */\n credentials?: Record<string, string>;\n /** Integration type shown in the Hub \u2014 api, database, object_storage,\n * sftp, banking_core, \u2026 (@default \"other\"). Display only; derived\n * reachability probes key off the credential fields themselves (a\n * `bucket` credential probes TCP to the bucket endpoint regardless of\n * the declared type). */\n type?: string;\n /** Health endpoint path probed by the platform's blackbox exporter\n * (uptime in the Hub). @default \"/health\" for local/mock realizations;\n * remote endpoints default to probing the endpoint root (\"\") since\n * vendors rarely expose /health. */\n healthPath?: string;\n /** Blackbox probe module. \"http\" (default) GETs the health URL and\n * expects 2xx; \"tcp\" only checks the port accepts connections \u2014 REQUIRED\n * for non-HTTP externals (databases, SFTP, \u2026) whose uptime would\n * otherwise always read down. */\n probe?: \"http\" | \"tcp\";\n}\n\n/** The three ways an external service exists in a local dev session. */\nexport type ExternalMode = \"mock\" | \"local\" | \"remote\";\n\nconst EXTERNAL_MODES: readonly ExternalMode[] = [\"mock\", \"local\", \"remote\"];\n\nconst DECL_DOCS = { docs: 'package.json \u2192 \"mesh\": { \"externals\": { \u2026 } }' };\n\n/**\n * Resolve + validate an external's mode: explicit `mode` (or the one\n * inferred from the realization fields), checked against the fields that\n * mode needs. Pre-mode declarations infer: compose \u2192 local, openapi/src \u2192\n * mock, sandbox/credentials-only \u2192 remote.\n */\nexport function externalMode(name: string, decl: LocalMockDecl): ExternalMode {\n const mode: ExternalMode | undefined =\n decl.mode ??\n (decl.compose\n ? \"local\"\n : decl.openapi || decl.src\n ? \"mock\"\n : decl.sandbox || decl.credentials || decl.remote\n ? \"remote\"\n : undefined);\n if (!mode || !EXTERNAL_MODES.includes(mode)) {\n throw new MeshCliError(\n `External '${name}': cannot determine mode \u2014 set \"mode\" to mock | local | remote (or declare openapi/src, compose, or credentials).`,\n { remediation: DECL_DOCS },\n );\n }\n if (mode === \"local\") {\n if (!decl.compose) {\n throw new MeshCliError(\n `External '${name}': local mode requires \"compose\" \u2014 the docker compose file running the local version of the service.`,\n { remediation: DECL_DOCS },\n );\n }\n if (typeof decl.port !== \"number\") {\n throw new MeshCliError(\n `External '${name}': local (compose) mode requires \"port\" \u2014 the port the compose file publishes on localhost.`,\n { remediation: DECL_DOCS },\n );\n }\n }\n if (mode === \"mock\" && !decl.openapi && !decl.src) {\n throw new MeshCliError(\n `External '${name}': mock mode emulates the service \u2014 declare \"openapi\" (spec \u2192 Prism mock) or \"src\" (mock process).`,\n { remediation: DECL_DOCS },\n );\n }\n if (mode === \"remote\" && !remoteInlineCredentials(decl) && !decl.external) {\n throw new MeshCliError(\n `External '${name}': remote mode needs a credential source \u2014 inline \"credentials\", \"remote\": { \"env\": \u2026 }, or \"external\" (pulls that environment's configured secret).`,\n { remediation: DECL_DOCS },\n );\n }\n return mode;\n}\n\n/** A parsed `--externals` selection: the externals named, and the per-run\n * mode overrides typed alongside them (`plaid-db=remote`). */\nexport interface ExternalsSelection {\n names: string[];\n overrides: Map<string, ExternalMode>;\n}\n\n/**\n * Parse a `--externals` value \u2014 a comma-separated list of `name` or\n * `name=mode` entries. An explicit mode overrides that declaration's\n * default FOR THIS RUN; a bare name keeps the declared default.\n *\n * Names are returned in the order typed, de-duplicated (the last mode\n * given for a name wins, so `--externals plaid,plaid=remote` is remote).\n */\nexport function parseExternalsSelection(input: string): ExternalsSelection {\n const names: string[] = [];\n const overrides = new Map<string, ExternalMode>();\n for (const entry of input.split(\",\").map((s) => s.trim()).filter(Boolean)) {\n const [rawName, mode, ...rest] = entry.split(\"=\").map((s) => s.trim());\n const name = rawName ?? \"\";\n if (!name) {\n throw new MeshCliError(\n `Bad --externals entry '${entry}' \u2014 expected name or name=mode with mode \u2208 ${EXTERNAL_MODES.join(\" | \")}.`,\n { remediation: { command: \"mesh dev --externals plaid-db=remote,plaid\" } },\n );\n }\n if (mode !== undefined) {\n if (rest.length > 0 || !EXTERNAL_MODES.includes(mode as ExternalMode)) {\n throw new MeshCliError(\n `Bad --externals entry '${entry}' \u2014 expected name or name=mode with mode \u2208 ${EXTERNAL_MODES.join(\" | \")}.`,\n { remediation: { command: \"mesh dev --externals plaid-db=remote,plaid\" } },\n );\n }\n overrides.set(name, mode as ExternalMode);\n }\n if (!names.includes(name)) names.push(name);\n }\n return { names, overrides };\n}\n\n/**\n * The inline credential template a remote-mode run should use, if any:\n * `remote.credentials` always wins; top-level `credentials` count only when\n * the declaration has no local/mock realization (otherwise they are that\n * realization's template, full of {{host}}/{{port}} tokens that only mean\n * something against a local endpoint).\n */\nexport function remoteInlineCredentials(decl: LocalMockDecl): Record<string, string> | undefined {\n if (decl.remote?.credentials) return decl.remote.credentials;\n if (decl.credentials && !decl.compose && !decl.openapi && !decl.src) return decl.credentials;\n return undefined;\n}\n\n/** Whether this declaration runs as a mock-{name} service in the dev\n * orchestration (mock mode) vs outside it (local compose / remote). */\nexport function isServiceMode(name: string, decl: LocalMockDecl): boolean {\n return externalMode(name, decl) === \"mock\";\n}\n\n/** Endpoint tokens available to credential interpolation. */\nexport interface MockEndpoint {\n url: string;\n host: string;\n port: number;\n}\n\n/**\n * Interpolate one credential value: {{url}}/{{host}}/{{port}} from the\n * stand-in's endpoint, {{env:VAR}} from the developer's environment\n * (missing env vars are a hard error \u2014 a half-seeded secret is worse).\n */\nexport function interpolateCredentialValue(\n raw: string,\n endpoint: MockEndpoint | undefined,\n env: NodeJS.ProcessEnv = process.env,\n): string {\n let value = raw.replace(/\\{\\{env:([A-Za-z_][A-Za-z0-9_]*)\\}\\}/g, (_, name: string) => {\n const resolved = env[name];\n if (resolved === undefined) {\n throw new MeshCliError(\n `Credential value references {{env:${name}}} but ${name} is not set in your environment.`,\n { remediation: { command: `export ${name}=\u2026 # then re-run mesh dev` } },\n );\n }\n return resolved;\n });\n if (endpoint) {\n value = value\n .replaceAll(\"{{url}}\", endpoint.url)\n .replaceAll(\"{{host}}\", endpoint.host)\n .replaceAll(\"{{port}}\", String(endpoint.port));\n }\n return value;\n}\n\n/** The Prism mock command for an openapi-mode declaration. `$PORT` expands\n * in the service's launch shell (tmux and docker runners both source the\n * env file), so port reallocation keeps working. */\nexport function openapiMockCommand(decl: LocalMockDecl): string[] {\n return (\n decl.command ?? [\n \"npx\",\n \"-y\",\n \"@stoplight/prism-cli@5\",\n \"mock\",\n \"-p\",\n \"$PORT\",\n \"-h\",\n \"0.0.0.0\",\n decl.openapi!,\n ]\n );\n}\n\n/** Compose-mode project name \u2014 pinned so `--kill` can tear down exactly\n * what `up` created, regardless of the compose file's own `name:`. */\nexport function composeProjectName(sessionName: string, mockName: string): string {\n return `mesh-ext-${sessionName}-${mockName}`.toLowerCase().replace(/[^a-z0-9_-]/g, \"-\");\n}\n\n/** A compose-mode external recorded in session state for teardown. */\nexport interface ComposeExternalRef {\n name: string;\n composeFile: string;\n project: string;\n}\n\n/** Wait until the external's published port accepts TCP (bounded). */\nasync function waitForPort(port: number, timeoutMs: number): Promise<boolean> {\n const deadline = Date.now() + timeoutMs;\n while (Date.now() < deadline) {\n if (await probeTcp(port, { timeoutMs: 2000 })) return true;\n await new Promise((r) => setTimeout(r, 2000));\n }\n return false;\n}\n\n/** What an existing realization of a compose external means for this run.\n * - `up` \u2192 nothing foreign in the way (or the realization is OURS):\n * normal idempotent `up`, ref recorded for `--kill`.\n * - `adopt-served` \u2192 ANOTHER realization of THIS compose file already serves\n * the declared port.\n * - `adopt-stopped` \u2192 a stopped container from another compose project holds\n * a `container_name` this file pins.\n * - `conflict` \u2192 the port is served by something this compose file does\n * not define (a native database, an unrelated container).\n * Never adopted: the app's credentials would be seeded\n * against the wrong service. */\nexport type ComposeExternalPlan =\n | { action: \"up\" }\n | { action: \"adopt-served\"; container: string }\n | { action: \"adopt-stopped\"; container: string }\n | { action: \"conflict\"; container?: string };\n\n/** The docker/network lookups `planComposeExternal` needs, injectable so the\n * branch table is unit-testable without a docker daemon. */\nexport interface ComposeExternalProbes {\n /** Is a container of OUR compose project already running for this file? */\n ownsRunning(composeFile: string, project: string): boolean;\n /** Does anything accept TCP on the declared port? */\n portServed(port: number): Promise<boolean>;\n /** A pinned `container_name` held by a DIFFERENT compose project. */\n foreignPinned(composeFile: string, project: string): string | undefined;\n /** Who publishes `port`, and is it a service THIS compose file defines?\n * Undefined when nothing in docker publishes it (a native process, or no\n * docker at all) \u2014 which is itself disqualifying for adoption. */\n portPublisher(\n composeFile: string,\n port: number,\n ): { container: string; definedByFile: boolean } | undefined;\n}\n\n/**\n * Decide how to realize a compose external, given what already exists.\n *\n * OWNERSHIP FIRST: a port probe can't tell WHO serves the port, and the\n * ordinary flow re-runs `mesh dev` against a session whose compose externals\n * are deliberately left running. Adopting there would drop the ref from\n * session state and permanently orphan the checkout's own container, so our\n * own realization takes the normal `up` path (idempotent, ref re-recorded)\n * and only a foreign one is adopted.\n *\n * IDENTITY SECOND: a foreign publisher is only adopted when it is a service\n * THIS compose file defines. Well-known ports (1433/5432/22) are exactly\n * where a native install or an unrelated container turns up, and adopting one\n * silently seeds the app's credentials against the wrong database.\n */\nexport async function planComposeExternal(\n composeFile: string,\n project: string,\n port: number,\n probes: ComposeExternalProbes,\n): Promise<ComposeExternalPlan> {\n if (probes.ownsRunning(composeFile, project)) return { action: \"up\" };\n if (await probes.portServed(port)) {\n const publisher = probes.portPublisher(composeFile, port);\n if (!publisher || !publisher.definedByFile) {\n return { action: \"conflict\", ...(publisher ? { container: publisher.container } : {}) };\n }\n return { action: \"adopt-served\", container: publisher.container };\n }\n // Pinned container_name held by a STOPPED container from another checkout/\n // project would make `up` fail with a name conflict \u2014 start that container\n // instead. (A RUNNING one is caught by the port probe above.)\n const foreign = probes.foreignPinned(composeFile, project);\n if (foreign) return { action: \"adopt-stopped\", container: foreign };\n return { action: \"up\" };\n}\n\n/** Real docker/network probes \u2014 replaced in tests. */\nconst dockerProbes: ComposeExternalProbes = {\n ownsRunning: ownsRunningRealization,\n portServed: (port) => probeTcp(port, { timeoutMs: 2000 }),\n foreignPinned: foreignPinnedContainer,\n portPublisher: composeFilePortPublisher,\n};\n\n/**\n * Bring a compose-mode external up (idempotent; waits for healthchecks \u2014\n * vendor DBs restoring seed data can take a while, hence the timeout).\n *\n * ADOPTION: the same external is often realized by more than one checkout \u2014\n * e.g. a vendor-DB compose ported from a legacy repo pins `container_name`\n * and a host port, so whichever checkout created it first blocks every other\n * `docker compose up` (name + port conflicts). An external service is by\n * definition outside the app, so if ANOTHER checkout already serves its\n * published port \u2014 or left a stopped container holding the pinned name \u2014\n * this ADOPTS that realization instead of failing: skip/`docker start`, wait\n * for the port, and return undefined so `--kill` never tears down what\n * another checkout owns. Our OWN realization is never adopted (see\n * `planComposeExternal`) \u2014 it stays recorded so `--kill` still cleans it up,\n * and a publisher this compose file doesn't define is never adopted either\n * (it would seed the app's credentials against the wrong service).\n */\nexport async function composeExternalUp(\n appRoot: string,\n sessionName: string,\n name: string,\n decl: LocalMockDecl,\n probes: ComposeExternalProbes = dockerProbes,\n): Promise<ComposeExternalRef | undefined> {\n const composeFile = path.resolve(appRoot, decl.compose!);\n if (!fs.existsSync(composeFile)) {\n throw new MeshCliError(`External '${name}': compose file not found at ${composeFile}.`, {\n remediation: { docs: 'package.json \u2192 \"mesh\": { \"externals\": { \u2026 } }' },\n });\n }\n const project = composeProjectName(sessionName, name);\n const plan = await planComposeExternal(composeFile, project, decl.port!, probes);\n if (plan.action === \"conflict\") {\n throw new MeshCliError(\n `External '${name}': localhost:${decl.port} is already served by ${\n plan.container ? `container '${plan.container}'` : \"a process outside docker\"\n }, which ${decl.compose} does not define \u2014 refusing to seed '${name}' credentials against it.`,\n {\n remediation: {\n command: `docker ps --filter publish=${decl.port} # stop it, or change the declared port`,\n },\n },\n );\n }\n if (plan.action === \"adopt-served\") {\n logInfo(\n `External '${name}' already served on localhost:${decl.port} by container '${plan.container}' \u2014 adopting it (started outside this checkout; \\`mesh dev --kill\\` won't touch it).`,\n );\n return undefined;\n }\n if (plan.action === \"adopt-stopped\") {\n const { container } = plan;\n logInfo(`External '${name}': container '${container}' exists from another checkout \u2014 starting and adopting it.`);\n execFileSync(\"docker\", [\"start\", container], { stdio: [\"ignore\", \"ignore\", \"inherit\"] });\n if (!(await waitForPort(decl.port!, 300_000))) {\n throw new MeshCliError(\n `External '${name}': adopted container '${container}' never served localhost:${decl.port}.`,\n { remediation: { command: `docker logs ${container} # then: docker rm -f ${container} and re-run` } },\n );\n }\n return undefined;\n }\n execFileSync(\n \"docker\",\n [\"compose\", \"-p\", project, \"-f\", composeFile, \"up\", \"-d\", \"--wait\", \"--wait-timeout\", \"300\"],\n { stdio: [\"ignore\", \"inherit\", \"inherit\"] },\n );\n return { name, composeFile, project };\n}\n\n/** Whether OUR compose project already has a container running for this file.\n * `docker compose ps` (running only) covers services WITHOUT a pinned\n * `container_name` too, which the label-inspect path below structurally\n * cannot \u2014 an unpinned service of ours is just `<project>-<svc>-1`. */\nfunction ownsRunningRealization(composeFile: string, project: string): boolean {\n try {\n const out = execFileSync(\n \"docker\",\n [\"compose\", \"-p\", project, \"-f\", composeFile, \"ps\", \"--format\", \"json\"],\n { encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] },\n ).trim();\n // Compose emits either a JSON array or NDJSON depending on version;\n // \"has any row\" is all we need from it.\n return out.length > 0 && out !== \"[]\";\n } catch {\n // ps hiccup \u2014 treat as \"not ours\" and let the normal paths decide.\n return false;\n }\n}\n\n/** The compose file's rendered service map, or undefined when it can't be\n * rendered (docker missing / bad file \u2014 callers decide what that means). */\nfunction composeServices(composeFile: string): Record<string, any> | undefined {\n try {\n const config = JSON.parse(\n execFileSync(\"docker\", [\"compose\", \"-f\", composeFile, \"config\", \"--format\", \"json\"], {\n encoding: \"utf-8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n }),\n );\n const services = config?.services;\n return services && typeof services === \"object\" ? services : undefined;\n } catch {\n return undefined;\n }\n}\n\n/** The container publishing `port` on the host, with the compose identity\n * labels docker stamps on it. Undefined when nothing in docker publishes it\n * (a native process, or no docker at all). */\ninterface PortPublisher {\n container: string;\n image?: string;\n project?: string;\n service?: string;\n}\n\nfunction portPublisher(port: number): PortPublisher | undefined {\n try {\n const out = execFileSync(\n \"docker\",\n [\n \"ps\",\n \"--filter\",\n `publish=${port}`,\n \"--format\",\n '{{.Names}}\\t{{.Image}}\\t{{.Label \"com.docker.compose.project\"}}\\t{{.Label \"com.docker.compose.service\"}}',\n ],\n { encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] },\n );\n const line = out\n .split(\"\\n\")\n .map((l) => l.trim())\n .filter(Boolean)[0];\n if (!line) return undefined;\n const [container, image, project, service] = line.split(\"\\t\");\n if (!container) return undefined;\n return {\n container,\n image: image || undefined,\n project: project || undefined,\n service: service || undefined,\n };\n } catch {\n return undefined;\n }\n}\n\n/** Whether the container publishing the declared port is a service THIS\n * compose file defines \u2014 the evidence adoption requires. Matches on the\n * pinned `container_name`, or on the compose service name (cross-checked\n * against the image when both sides declare one, so an unrelated project's\n * service that happens to be called `db` isn't mistaken for ours). */\nfunction composeFileOwns(composeFile: string, publisher: PortPublisher | undefined): boolean {\n if (!publisher) return false;\n const services = composeServices(composeFile);\n if (!services) return false; // no identity evidence \u2014 don't adopt\n for (const [key, svc] of Object.entries<any>(services)) {\n if (svc?.container_name && svc.container_name === publisher.container) return true;\n if (publisher.service && publisher.service === key) {\n if (!svc?.image || !publisher.image || svc.image === publisher.image) return true;\n }\n }\n return false;\n}\n\n/** `ComposeExternalProbes.portPublisher` \u2014 who serves the declared port, and\n * whether this compose file defines it. Undefined when docker publishes\n * nothing there (a native process on the port, or no docker at all), which\n * `planComposeExternal` treats as a conflict rather than an adoption. */\nfunction composeFilePortPublisher(\n composeFile: string,\n port: number,\n): { container: string; definedByFile: boolean } | undefined {\n const publisher = portPublisher(port);\n if (!publisher) return undefined;\n return { container: publisher.container, definedByFile: composeFileOwns(composeFile, publisher) };\n}\n\n/** Find a container holding a `container_name` this compose file pins, when\n * it belongs to a different compose project (another checkout's realization\n * of the same external). Returns the container name, or undefined. */\nfunction foreignPinnedContainer(composeFile: string, project: string): string | undefined {\n // config render hiccup \u2014 fall through to the normal `up`, which reports\n // real errors itself.\n for (const svc of Object.values<any>(composeServices(composeFile) ?? {})) {\n const pinned = svc?.container_name;\n if (!pinned) continue;\n try {\n const owner = execFileSync(\n \"docker\",\n [\"inspect\", pinned, \"--format\", '{{ index .Config.Labels \"com.docker.compose.project\" }}'],\n { encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] },\n ).trim();\n if (owner !== project) return pinned;\n } catch {\n // no container by that name \u2014 nothing to adopt for this service\n }\n }\n return undefined;\n}\n\n/** Tear down compose-mode externals recorded in session state. Volumes are\n * kept (vendor DB data survives sessions, like the cloud external it\n * stands in for); best-effort per project. */\nexport function composeExternalsDown(refs: ComposeExternalRef[] | undefined): void {\n for (const ref of refs ?? []) {\n try {\n execFileSync(\"docker\", [\"compose\", \"-p\", ref.project, \"-f\", ref.composeFile, \"down\", \"--remove-orphans\"], {\n stdio: [\"ignore\", \"inherit\", \"inherit\"],\n });\n } catch {\n /* best effort \u2014 the file may be gone or docker down */\n }\n }\n}\n\n/** Same heuristic the schema seed uses: which credential keys are secret. */\nconst SECRET_KEY_RE = /key|secret|password|token/i;\n\n/** Read the app's declared externals (root package.json \u2192 mesh.externals;\n * legacy key mesh.mocks still read, externals wins on name collision). */\nexport function readLocalMocks(appRoot: string): Record<string, LocalMockDecl> {\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(appRoot, \"package.json\"), \"utf-8\"));\n const mocks = pkg?.mesh?.mocks;\n const externals = pkg?.mesh?.externals;\n return {\n ...(mocks && typeof mocks === \"object\" ? mocks : {}),\n ...(externals && typeof externals === \"object\" ? externals : {}),\n };\n } catch {\n return {};\n }\n}\n\nexport function externalSecretPath(tenant: string, external: string): string {\n return `mesh/${tenant}/${LOCAL_ENV}/external/${external}`;\n}\n\n/**\n * Pull an external service's credentials as configured in the app's tenant\n * environment (the CLOUD platform, not the local fabric) \u2014 secret\n * mesh/{tenant}/{env}/external/{name}, read with the developer's ambient\n * AWS credentials (AWS_PROFILE / SSO session for that tenant's account).\n * When the shell carries no AWS identity at all, `profile` (from the\n * declaration; MESH_AWS_PROFILE overrides) names the profile to use \u2014 the\n * same convention repo pull scripts follow.\n */\nexport async function fetchRemoteExternalCredentials(\n tenant: string,\n env: string,\n external: string,\n profile?: string,\n): Promise<Record<string, string>> {\n const secretId = `mesh/${tenant}/${env}/external/${external}`;\n const region = process.env.MESH_PLATFORM_REGION ?? process.env.AWS_REGION ?? \"us-east-2\";\n const fallbackProfile =\n !process.env.AWS_ACCESS_KEY_ID && !process.env.AWS_PROFILE\n ? (process.env.MESH_AWS_PROFILE ?? profile)\n : undefined;\n try {\n const { SecretsManagerClient, GetSecretValueCommand } = await import(\n \"@aws-sdk/client-secrets-manager\"\n );\n // The SDK's default chain reads AWS_PROFILE at first resolution \u2014 scope\n // the fallback to this client's call (no ambient identity to clobber).\n if (fallbackProfile) process.env.AWS_PROFILE = fallbackProfile;\n const sm = new SecretsManagerClient({ region });\n let res;\n try {\n res = await sm.send(new GetSecretValueCommand({ SecretId: secretId }));\n } finally {\n if (fallbackProfile) delete process.env.AWS_PROFILE;\n }\n const parsed: unknown = JSON.parse(res.SecretString ?? \"{}\");\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n throw new Error(\"secret value is not a JSON object\");\n }\n return Object.fromEntries(Object.entries(parsed).map(([key, v]) => [key, String(v)]));\n } catch (err) {\n throw new MeshCliError(\n `External '${external}': could not pull remote credentials from the ${env} environment (${secretId}, region ${region}): ${err instanceof Error ? err.message : err}`,\n {\n remediation: {\n command: `AWS_PROFILE=<${tenant}-${env} profile> mesh dev \u2026 # or set \"remote\": { \"profile\": \u2026 } on the declaration / MESH_AWS_PROFILE`,\n },\n },\n );\n }\n}\n\n/** The prober runs inside the stack: anything addressed at the developer\n * host must go through host.docker.internal. */\nfunction hostForProber(target: string): string {\n return target.replace(/\\b(?:localhost|127\\.0\\.0\\.1)\\b/, \"host.docker.internal\");\n}\n\n/**\n * A credential's endpoint value as an HTTP probe target, or undefined when\n * it isn't one. Credentials pulled from a tenant secret carry whatever the\n * vendor configured \u2014 a JDBC/ODBC connection string, an `sftp://` URL, or\n * an http URL with embedded userinfo. Anything non-http(s) falls through to\n * the tcp branch, and userinfo is stripped: the target is written to a\n * plaintext probe file and relabeled into Prometheus' `instance` label, so\n * a password in it would leak into every `probe_*` series.\n */\nfunction httpProbeUrl(raw: string | undefined): string | undefined {\n if (!raw) return undefined;\n let parsed: URL;\n try {\n parsed = new URL(raw);\n } catch {\n return undefined;\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") return undefined;\n parsed.username = \"\";\n parsed.password = \"\";\n return hostForProber(parsed.toString());\n}\n\n/** Reachability target for a non-HTTP integration, derived from its\n * credentials: a host/port pair (databases, SFTP) or an S3 bucket endpoint\n * (probed via TCP \u2014 no SDK or credentials needed, the technique CSP's\n * network lib used). */\nfunction derivedTcpTarget(creds: Record<string, string>): string | undefined {\n if (creds.host && creds.port) return `${hostForProber(creds.host)}:${creds.port}`;\n if (creds.bucket) return `${creds.bucket}.s3.${creds.region || \"us-east-1\"}.amazonaws.com:443`;\n return undefined;\n}\n\n/** The blackbox probe target for a realization, by mode: local/mock\n * stand-ins are probed on the host (the prober runs inside the stack \u2192\n * host.docker.internal); remote externals are probed at their REAL\n * endpoint, so the Hub shows actual vendor reachability. Every integration\n * gets AT LEAST a reachability check when its credentials expose a target:\n * an HTTP endpoint, a host(:port), or an S3 bucket (probed via TCP to the\n * bucket endpoint \u2014 no SDK/credentials needed, the same technique CSP's\n * network lib used). Undefined = nothing probeable in the credentials.\n *\n * Target and module come from ONE decision here so they can't drift: a\n * misrouted probe just reports probe_success 0, indistinguishable from a\n * genuinely down service. `tcp` yields host:port with no scheme/path and\n * routes the scrape to blackbox's tcp_connect (the scrape config's default\n * stays http_2xx); `http` GETs the target and expects 2xx. */\nexport function resolveProbeTarget(\n mode: ExternalMode,\n decl: LocalMockDecl,\n endpoint: MockEndpoint | undefined,\n creds: Record<string, string>,\n): { target: string; module?: \"tcp_connect\" } | undefined {\n if (mode === \"remote\") {\n // The endpoint comes from a TENANT SECRET, so it may be anything a\n // vendor credential carries \u2014 a JDBC/ODBC connection string, or a URL\n // with userinfo. Only a real http(s) URL is probeable, and its\n // credentials must never reach the probe file (world-readable) or the\n // Prometheus `instance` label.\n const url = httpProbeUrl(creds.endpoint ?? creds.url ?? creds.baseUrl);\n // Reachability fallbacks for non-HTTP integrations: host:port\n // (databases, SFTP) or the S3 bucket endpoint (object storage).\n const tcpTarget = derivedTcpTarget(creds);\n if (decl.probe === \"tcp\" || (!url && tcpTarget)) {\n if (!tcpTarget && decl.probe === \"tcp\") {\n logWarn(\n `External '${decl.external ?? \"\"}': probe: \"tcp\" declared but the remote credentials carry no host/port (or bucket) to probe \u2014 no uptime probe registered.`,\n );\n }\n return tcpTarget ? { target: tcpTarget, module: \"tcp_connect\" } : undefined;\n }\n // Remote endpoints rarely expose /health \u2014 default to the endpoint\n // root (Plaid's sandbox, for one, answers 200 there).\n return url ? { target: url.replace(/\\/+$/, \"\") + (decl.healthPath ?? \"\") } : undefined;\n }\n if (!endpoint) return undefined;\n if (decl.probe === \"tcp\") {\n return { target: `host.docker.internal:${endpoint.port}`, module: \"tcp_connect\" };\n }\n return {\n target: hostForProber(endpoint.url).replace(/\\/+$/, \"\") + (decl.healthPath ?? \"/health\"),\n };\n}\n\n/** Hub display name: explicit `displayName`, else the external name\n * title-cased on word boundaries (\"plaid-db\" \u2192 \"Plaid Db\"). */\nexport function externalDisplayName(name: string, decl: LocalMockDecl): string {\n return (\n decl.displayName ??\n name\n .split(/[-_\\s]+/)\n .filter(Boolean)\n .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n .join(\" \")\n );\n}\n\n/** Normalize the decl's `docs` (string URL or {url, provider}) for the meta. */\nexport function externalDocs(\n decl: LocalMockDecl,\n): { url: string; provider?: string } | undefined {\n if (!decl.docs) return undefined;\n return typeof decl.docs === \"string\" ? { url: decl.docs } : decl.docs;\n}\n\n/** Prometheus file_sd target file for one external's uptime probe. One\n * file per (tenant, app, external) so a session's probes can be removed on\n * `--kill` without touching another app's. */\nexport function localProbeFile(tenant: string, app: string, external: string): string {\n return path.join(localProbesDir(), `${tenant}-${app}-${external}.json`);\n}\n\n/** Drop probe target files (best effort). Called on `mesh dev --kill` so\n * Prometheus stops scraping stand-ins that no longer exist \u2014 otherwise the\n * Hub shows an integration down for a session that isn't running. */\nexport function localProbesRemove(files: string[] | undefined): void {\n for (const file of files ?? []) {\n try {\n fs.rmSync(file, { force: true });\n } catch {\n /* best effort \u2014 the cache dir may be gone */\n }\n }\n}\n\n/**\n * Wire a realization into the platform: seed the external-service\n * credential secret and register the vendor in the local Hub's\n * external-services view \u2014 the same shapes ExternalService exports in the\n * cloud. `endpoint` is the local stand-in's address (absent in remote\n * mode \u2014 the credentials carry the real endpoint).\n *\n * Returns the probe target file this realization registered, if any, so the\n * caller can record it in session state and remove it on `--kill`.\n */\nexport async function seedLocalMock(args: {\n tenant: string;\n app: string;\n name: string;\n decl: LocalMockDecl;\n endpoint?: MockEndpoint;\n}): Promise<string | undefined> {\n const { tenant, app, name, decl, endpoint } = args;\n if (!decl.external) return undefined;\n const mode = externalMode(name, decl);\n\n // Credential values by mode: local/mock interpolate the declared template\n // against the stand-in's endpoint; remote uses inline credentials or\n // pulls the tenant environment's configured secret.\n let value: Record<string, string>;\n if (mode === \"remote\") {\n const inline = remoteInlineCredentials(decl);\n value = inline\n ? Object.fromEntries(\n Object.entries(inline).map(([key, raw]) => [\n key,\n interpolateCredentialValue(raw, undefined),\n ]),\n )\n : await fetchRemoteExternalCredentials(\n tenant,\n decl.remote?.env ?? \"dev\",\n decl.external,\n decl.remote?.profile,\n );\n } else {\n if (!decl.credentials) return undefined;\n value = Object.fromEntries(\n Object.entries(decl.credentials).map(([key, raw]) => [\n key,\n interpolateCredentialValue(raw, endpoint),\n ]),\n );\n }\n\n const secretName = externalSecretPath(tenant, decl.external);\n await upsertLocalSecret(secretName, value);\n // The .config mirror \u2014 non-secret fields only. This is what the Hub's\n // credential views READ (the main secret is runtime-only by design), so\n // baseUrl etc. display in the Integrations detail page.\n await upsertLocalSecret(\n `${secretName}/.config`,\n Object.fromEntries(Object.entries(value).filter(([key]) => !SECRET_KEY_RE.test(key))),\n );\n\n // Hub discovery \u2014 the same per-stack shapes ExternalService exports in the\n // cloud (/apps/{app}/stacks/{stack}/external-services/{name}/\u2026).\n const { SSMClient, PutParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(LOCAL_AWS_CONFIG);\n const base = `/mesh-platform/${tenant}/${LOCAL_ENV}/apps/${app}/stacks/local/external-services/${decl.external}`;\n await ssm.send(\n new PutParameterCommand({\n Name: `${base}/meta`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({\n name: decl.external,\n // The integration keeps its REAL name in the Hub (\"Plaid\", not a\n // generated phrase); the description leads with it too, so display\n // heuristics that promote description heads still land on the name.\n displayName: externalDisplayName(name, decl),\n type: decl.type ?? \"other\",\n description:\n mode === \"local\"\n ? `${externalDisplayName(name, decl)} \u2014 local replica via mesh dev (docker compose)`\n : mode === \"remote\"\n ? remoteInlineCredentials(decl)\n ? `${externalDisplayName(name, decl)} \u2014 remote service (vendor credentials)`\n : `${externalDisplayName(name, decl)} \u2014 remote service (${decl.remote?.env ?? \"dev\"} environment credentials)`\n : `${externalDisplayName(name, decl)} \u2014 emulated by mesh dev (mock)`,\n secretPrefix: secretName,\n ...(externalDocs(decl) ? { docs: externalDocs(decl) } : {}),\n }),\n Description: `External service registration (local mock ${name})`,\n }),\n );\n await ssm.send(\n new PutParameterCommand({\n Name: `${base}/credentials`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({\n fields: Object.fromEntries(\n Object.keys(value).map((key) => [\n key,\n { type: \"field\", name: key, description: \"\", secret: SECRET_KEY_RE.test(key), optional: false },\n ]),\n ),\n keyedBy: null,\n }),\n Description: `External service credential schema (local mock ${name})`,\n }),\n );\n\n // Blackbox probe target (Prometheus file_sd) \u2014 the local analog of the\n // Probe CR ExternalService creates in the cloud, with the SAME label set,\n // so probe_success drives the Hub's health/uptime views unchanged.\n // Local/mock stand-ins are probed on the host; remote externals at their\n // real endpoint (host:port via the tcp_connect module for non-HTTP\n // externals \u2014 the __probe_module label routes the module through the\n // scrape config's relabeling, and \"__\"-prefixed labels drop after\n // relabeling so it never pollutes the stored series).\n const probe = resolveProbeTarget(mode, decl, endpoint, value);\n\n await ssm.send(\n new PutParameterCommand({\n Name: `${base}/healthCheck`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({ intervalSeconds: 30, timeoutSeconds: 10, hasCustomCheck: false, hasProbe: !!probe }),\n Description: `External service health check config (local mock ${name})`,\n }),\n );\n\n // The file and `hasProbe` must never disagree: a realization that no\n // longer resolves a probe target drops the file it wrote on a previous\n // run, or Prometheus keeps scraping the old target forever.\n const probeFile = localProbeFile(tenant, app, decl.external);\n if (probe) {\n fs.writeFileSync(\n probeFile,\n JSON.stringify(\n [\n {\n targets: [probe.target],\n labels: {\n type: \"external-service\",\n tenant,\n env: LOCAL_ENV,\n app,\n external_service: decl.external,\n target: decl.external,\n // \"__\"-prefixed labels drop after relabeling, so the module\n // selector never pollutes the stored series.\n ...(probe.module ? { __probe_module: probe.module } : {}),\n },\n },\n ],\n null,\n 2,\n ),\n );\n } else {\n localProbesRemove([probeFile]);\n }\n\n const endpointShown =\n endpoint?.url ??\n value.endpoint ??\n value.url ??\n value.baseUrl ??\n (value.host ? `${value.host}${value.port ? `:${value.port}` : \"\"}` : \"credentials\");\n logSuccess(\n `External '${name}' wired (${mode}): ${secretName} \u2192 ${endpointShown}` +\n ` (Hub registration${probe ? \" + uptime probe\" : \"\"})`,\n );\n return probe ? probeFile : undefined;\n}\n", "/**\n * @mesh-tech/api-registry/hub-roles \u2014 the Hub operator role vocabulary, declared\n * once (MESH-2504).\n *\n * This is a contract between three parties that cannot import each other:\n *\n * - `libs/infra-components` DECLARES the roles, by handing\n * `hubOperatorRoleKeys()` to the Hub's Zitadel project. A role that isn't\n * declared is not grantable.\n * - `apps/hub/api` ENFORCES them, in `middleware/authz.ts` `checkPolicy`.\n * - `apps/hub/ui` PRESENTS them, in `lib/auth.server.ts` (which affordances to\n * render) and `lib/api/types.ts` (`UserRole`).\n *\n * plus `libs/mesh-cli`, which seeds the same roles into the local Zitadel, and\n * `apps/hub/ai-assistant`, whose user-creation tools offer them as choices.\n *\n * Before this module the vocabulary was restated in each of those places and kept\n * in sync by comment. Every drift mode was silent: miss the declaration and the\n * role is never grantable; miss the enforcement and it silently cannot write; miss\n * the presentation and the UI reads it as no-role. Nothing threw and no build\n * failed \u2014 the roles claim just didn't contain what someone expected.\n *\n * This module carries no dependencies on purpose. It is reachable from Pulumi\n * (infra-components), from node (hub-api, mesh-cli) and from the browser (hub-ui)\n * because it is plain data plus two pure parsers, and it is a separate export path\n * from the package root so importing it never pulls zod in.\n *\n * ## The two classes\n *\n * Platform roles are GLOBAL: `ops` and `admin` run every tenant the Hub\n * administers, and are the only roles that may mutate.\n *\n * Tenant-scoped roles \u2014 `developer`, `auditor`, `vendor` \u2014 are read-only and are\n * granted per tenant as tenant-keyed role keys (`developer:<tenant>`,\n * `vendor:<tenant>`, `auditor:<tenant>`). hub-api's `checkPolicy` parses the key\n * and only allows reads on the named tenant. The bare keys stay declared so\n * pre-existing grants keep resolving, but a bare key grants nothing\n * tenant-scoped.\n *\n * `developer` moved from the global class to the tenant-scoped one on the Hub\n * RBAC project's direction that tenant access is a scope on the PERSON, applied\n * to every read-only role they hold \u2014 a developer confined to one tenant must\n * not see another tenant's apps or logs. A pre-existing bare `developer` grant\n * therefore reads nothing tenant-scoped until it is re-keyed to a tenant.\n *\n * The model is the MESH-2496 decision, enforced by MESH-2122.\n */\n\n/** Global roles; the only ones that may mutate. */\nexport const HUB_STAFF_ROLES = ['ops', 'admin'] as const;\n\n/** Granted per tenant as `<role>:<tenant>` keys; read-only, may never write. */\nexport const HUB_RESTRICTED_ROLES = ['developer', 'auditor', 'vendor'] as const;\n\nexport type HubStaffRole = (typeof HUB_STAFF_ROLES)[number];\nexport type HubRestrictedRole = (typeof HUB_RESTRICTED_ROLES)[number];\nexport type HubRole = HubStaffRole | HubRestrictedRole;\n\n/**\n * Every base role, staff first. This is the grantable vocabulary before tenant\n * keying \u2014 what the local Zitadel seed registers and what the UI offers when\n * creating a user.\n */\nexport const HUB_BASE_ROLES = [\n ...HUB_STAFF_ROLES,\n ...HUB_RESTRICTED_ROLES,\n] as const satisfies readonly HubRole[];\n\n/**\n * The roles allowed to mutate \u2014 a subset of the staff roles, since a restricted\n * role may never write. Asserted against {@link HUB_STAFF_ROLES} in the tests.\n */\nexport const HUB_WRITE_ROLES = ['ops', 'admin'] as const satisfies readonly HubStaffRole[];\n\n/**\n * Base roles ordered by privilege, highest first, for highest-privilege-wins\n * presentation in the UI.\n *\n * Precedence lives on the presentation side of the contract on purpose: it decides\n * which affordances to render, and the API deliberately returns the raw verified\n * vocabulary. It is declared here rather than in the UI only so that adding a role\n * without ranking it is a test failure instead of a role the UI silently ignores.\n */\nexport const HUB_ROLE_PRIORITY = [\n 'ops',\n 'admin',\n 'developer',\n 'auditor',\n 'vendor',\n] as const satisfies readonly HubRole[];\n\n/**\n * Separates a restricted role from the tenant it is granted on\n * (`vendor:acme`). Shared because the declaration side builds keys with it and\n * the enforcement side parses them with it \u2014 a divergence here would turn every\n * restricted grant into a no-op that still looks provisioned.\n */\nexport const HUB_ROLE_KEY_SEPARATOR = ':';\n\n/**\n * Separates the tenant from an optional app inside a keyed grant\n * (`vendor:acme/orders`). A tenant-scoped role that names an app reaches ONLY\n * that app's surfaces inside the tenant \u2014 the shape most vendors are handed,\n * since a vendor is engaged for one app, not an estate. A key with no app half\n * reaches the whole tenant, as before.\n */\nexport const HUB_ROLE_APP_SEPARATOR = '/';\n\nconst RESTRICTED = new Set<string>(HUB_RESTRICTED_ROLES);\n\n/**\n * The restricted base of a role key (`vendor:acme` \u2192 `vendor`), or null when the\n * key is not a restricted role at all.\n *\n * A bare `vendor`/`auditor` returns its own base: it IS a restricted role, it just\n * names no tenant. Callers that need the tenant ask {@link hubRoleKeyTenant}.\n */\nexport function restrictedRoleBase(roleKey: string): HubRestrictedRole | null {\n const sep = roleKey.indexOf(HUB_ROLE_KEY_SEPARATOR);\n const base = sep === -1 ? roleKey : roleKey.slice(0, sep);\n return RESTRICTED.has(base) ? (base as HubRestrictedRole) : null;\n}\n\n/**\n * The tenant a role key names, or null when the key carries none.\n *\n * Splits on the FIRST separator, so the tenant half is returned whole. That is\n * deliberate rather than incidental: it means a tenant name containing the\n * separator cannot silently read as a different tenant's grant \u2014 and\n * {@link hubOperatorRoleKeys} refuses to mint such a key in the first place.\n */\nexport function hubRoleKeyTenant(roleKey: string): string | null {\n const sep = roleKey.indexOf(HUB_ROLE_KEY_SEPARATOR);\n if (sep === -1) return null;\n const scope = roleKey.slice(sep + 1);\n const appSep = scope.indexOf(HUB_ROLE_APP_SEPARATOR);\n return appSep === -1 ? scope : scope.slice(0, appSep);\n}\n\n/**\n * The app a role key narrows to (`vendor:acme/orders` \u2192 `orders`), or null when\n * the key reaches its whole tenant. Split on the first app separator after the\n * tenant, so an app name is returned whole; {@link hubOperatorRoleKeys} refuses\n * tenant names that contain it, for the same reason it refuses the key separator.\n */\nexport function hubRoleKeyApp(roleKey: string): string | null {\n const sep = roleKey.indexOf(HUB_ROLE_KEY_SEPARATOR);\n if (sep === -1) return null;\n const scope = roleKey.slice(sep + 1);\n const appSep = scope.indexOf(HUB_ROLE_APP_SEPARATOR);\n return appSep === -1 || appSep === scope.length - 1 ? null : scope.slice(appSep + 1);\n}\n\n/**\n * The full set of role keys the Hub's Zitadel project declares: the base roles\n * plus a tenant-keyed pair per known tenant, deduped and in a stable order so the\n * Pulumi diff doesn't churn when tenant config reorders.\n */\nexport function hubOperatorRoleKeys(tenants: string[]): string[] {\n const cleaned = [...new Set(tenants.filter((t) => t !== ''))].sort();\n for (const tenant of cleaned) {\n for (const sep of [HUB_ROLE_KEY_SEPARATOR, HUB_ROLE_APP_SEPARATOR]) {\n if (tenant.includes(sep)) {\n throw new Error(\n `hubOperatorRoleKeys: tenant name \"${tenant}\" contains the role-key separator ` +\n `\"${sep}\" \u2014 it would parse as a different tenant's or app's grant`,\n );\n }\n }\n }\n return [\n ...HUB_BASE_ROLES,\n ...cleaned.flatMap((tenant) =>\n HUB_RESTRICTED_ROLES.map((role) => `${role}${HUB_ROLE_KEY_SEPARATOR}${tenant}`),\n ),\n ];\n}\n", "/**\n * @mesh-tech/api-registry \u2014 the shared contract between an Integration (producer,\n * infra-components) and a docs/discovery consumer (the hub). Types + path\n * helpers + the entry zod contract; no Pulumi, no Temporal, so either side can\n * import it.\n */\n\nimport { z } from 'zod';\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Rate limiting \u2014 the declared half of the platform rate-limit lifecycle.\n// An integration DECLARES token-bucket defaults per request class here; the\n// deployment exports them to the registry entry (so the hub renders them\n// without a live call), app-kit's limiter enforces them, and per-subject\n// overrides ride the authz metadata payload\n// (`@mesh-tech/authz` `rate_limit_overrides` / `rateLimitOverrideFor`).\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * One token-bucket rate: `rps` sustained refill per second, `burst` bucket\n * capacity (the largest instantaneous spike allowed). Both positive integers.\n *\n * @example\n * const readLimit: RateLimitSpec = { rps: 10, burst: 50 };\n */\nexport interface RateLimitSpec {\n /** Sustained requests per second (bucket refill rate). */\n rps: number;\n /** Bucket capacity \u2014 the largest instantaneous burst allowed. */\n burst: number;\n}\n\n/** Zod mirror of {@link RateLimitSpec}: both values must be positive integers. */\nexport const rateLimitSpecSchema = z.object({\n rps: z.number().int().min(1),\n burst: z.number().int().min(1),\n});\n\n/**\n * Declared rate-limit defaults keyed by request class. The canonical class\n * vocabulary is `reads` / `writes` (what the hub's override console offers);\n * the type is open so an integration can declare narrower classes its own\n * classifier produces. A class with no entry is not limited.\n *\n * @example\n * const rateLimits: RateLimitDefaults = {\n * reads: { rps: 10, burst: 50 },\n * writes: { rps: 2, burst: 5 },\n * };\n */\nexport type RateLimitDefaults = Record<string, RateLimitSpec>;\n\n/** Zod mirror of {@link RateLimitDefaults}. */\nexport const rateLimitDefaultsSchema = z.record(z.string().min(1), rateLimitSpecSchema);\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Integration health declaration \u2014 every integration states, in its\n// definition, whether its liveness can be measured. There is no third\n// answer: an integration that declares neither is a dependency nobody\n// decided not to measure, which is how one ships unwatched.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * How an integration's liveness is (or is not) measured.\n *\n * `{ op }` names a cheap, side-effect-free vendor operation the ops\n * listener's `GET /health` invokes with real credentials \u2014 the round-trip\n * that proves endpoint, auth, and identity together, so a credential\n * expiry reads as down rather than green.\n *\n * `{ unavailable }` states why no such operation exists. The reason is\n * rendered verbatim wherever this integration's health would appear, so\n * write it for the operator reading the tile, not for the compiler.\n *\n * @example\n * const health: IntegrationHealth = { op: \"Inquiry_Ping\" };\n *\n * @example\n * const health: IntegrationHealth = {\n * unavailable: \"The vendor exposes no read that does not bill per call.\",\n * };\n */\nexport type IntegrationHealth = { op: string } | { unavailable: string };\n\n/**\n * Narrow a health declaration to the probed case. Every layer that decides\n * whether to mount, wire, or sweep a probe branches on this one predicate.\n *\n * @example\n * if (isHealthProbed(definition.health)) mountHealthRoute(definition.health.op);\n */\nexport function isHealthProbed(health: IntegrationHealth): health is { op: string } {\n return 'op' in health;\n}\n\n/**\n * Zod mirror of {@link IntegrationHealth}. Both arms trim, then reject an\n * empty result: `min(1)` counts characters, so a whitespace-only reason\n * would otherwise satisfy every ingest point and publish a declaration that\n * renders as the blank this contract exists to replace. Trimming here fixes\n * it once for every layer instead of once per consumer.\n */\nexport const integrationHealthSchema = z.union([\n z.object({ op: z.string().trim().min(1) }),\n z.object({ unavailable: z.string().trim().min(1) }),\n]);\n\n/**\n * Validate a health declaration at ingest, where a definition crosses into\n * the platform (serve boot, registry publish). TypeScript alone cannot\n * refuse `{ unavailable: \"\" }` or a missing declaration from an untyped\n * JavaScript consumer, and an empty reason renders as a blank in the Hub \u2014\n * indistinguishable from the unwatched dependency this contract exists to\n * make impossible. Throws naming `context` so the failure points at the\n * integration, not at this function.\n *\n * @example\n * assertValidHealth(definition.health, `serveIntegration \"${definition.name}\"`);\n */\nexport function assertValidHealth(health: unknown, context: string): asserts health is IntegrationHealth {\n const result = integrationHealthSchema.safeParse(health);\n if (result.success) return;\n throw new Error(\n `${context}: invalid health declaration \u2014 every integration must declare ` +\n `health: { op: \"<cheap side-effect-free vendor op>\" } or ` +\n `health: { unavailable: \"<why this vendor cannot be probed>\" } ` +\n `(got ${JSON.stringify(health)})`,\n );\n}\n\n/** The transports an Integration projects. Only enabled surfaces appear. */\nexport interface ApiSurface {\n http?: { url: string };\n nexus?: { endpoint: string; taskQueue: string };\n}\n\n/** One per-owner registry record at `/mesh-platform/{tenant}/{env}/apis/<name>`. */\nexport interface ApiRegistryEntry {\n /** Contract version for forward-compat; absent = 1 (see `parseApiRegistryEntry`). */\n schemaVersion?: number;\n /** Unique key (the Integration name). */\n name: string;\n /** Family \u2014 groups variants (e.g. multiple SilverLake versions) in the hub. */\n provider?: string;\n /** Human version label + selector. */\n version?: string;\n title?: string;\n description?: string;\n surfaces: ApiSurface;\n /** Multi-instance axis: null/absent = single instance. */\n credentials?: { keyedBy?: string | null };\n /** Pointer to the FULL OpenAPI doc in the tenant artifacts bucket. */\n docs: { url: string; contentHash: string };\n /** The deployment's enabled operation subset (availability overlay source). */\n enabledOps: string[];\n /**\n * The in-cluster URL of the runtime's operator status payload \u2014 app-kit's\n * ops listener (`GET /status`, alongside `/health`). ClusterIP DNS, so it\n * is dialable only from inside the cluster; absent for non-K8s deploys.\n */\n status?: { url: string };\n /**\n * package.json version of the served directory, read at deploy time.\n * Best-effort: for a published-package wrapper deploy this is the wrapper's\n * version; the runtime `/status` payload reports the app's own.\n */\n appVersion?: string;\n /**\n * The deployment's DECLARED per-class rate-limit defaults, when the\n * integration declares any; the hub renders these as the baseline next to\n * per-subject overrides. Declared, not proven: enforcement is wired by the\n * serve entry (automatic on the generic dispatch surface; a custom router\n * mounts the middleware itself, and the nexus surface has no enforcement\n * point). Absent = nothing declared.\n */\n rateLimits?: RateLimitDefaults;\n /**\n * The integration's health declaration, copied from its definition \u2014 the\n * probed op, or the stated reason it cannot be probed. Optional on the\n * ENTRY (not on the definition): entries written before the declaration\n * became required predate it, and a consumer must tell \"wrote no\n * declaration\" from \"declared unavailable\" rather than reading the\n * silence as a reason.\n */\n health?: IntegrationHealth;\n /**\n * Rate classes the app's classifier PRODUCES beyond the built-ins, copied\n * from {@link IntegrationDefinition.producedRateClasses}. Consumers use it\n * to tell a wired custom class (validates clean) from a declared-only one\n * (published but never matched). Absent = the app does not publish the\n * contract; validation then falls back to the hedged declared-class note.\n */\n producedRateClasses?: string[];\n}\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Registry entry zod contract \u2014 consumers parse untrusted SSM JSON through\n// this so producer/consumer drift fails with a clear validation error rather\n// than propagating a malformed entry.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Zod mirror of {@link ApiSurface}. */\nexport const apiSurfaceSchema = z.object({\n http: z.object({ url: z.string().min(1) }).optional(),\n nexus: z.object({ endpoint: z.string().min(1), taskQueue: z.string().min(1) }).optional(),\n});\n\n/**\n * Zod mirror of {@link ApiRegistryEntry}. Unknown keys are stripped (a newer\n * producer may add fields); `schemaVersion` defaults to 1 when absent so\n * consumers can branch on it forward-compatibly.\n */\nexport const apiRegistryEntrySchema = z.object({\n schemaVersion: z.number().int().positive().default(1),\n name: z.string().min(1),\n provider: z.string().optional(),\n version: z.string().optional(),\n title: z.string().optional(),\n description: z.string().optional(),\n surfaces: apiSurfaceSchema,\n credentials: z.object({ keyedBy: z.string().nullish() }).optional(),\n docs: z.object({ url: z.string().min(1), contentHash: z.string().min(1) }),\n enabledOps: z.array(z.string()),\n // The consumer schema strips unknown keys, so a producer-side field that is\n // not mirrored here never reaches a consumer \u2014 mirror every addition.\n status: z.object({ url: z.string().min(1) }).optional(),\n appVersion: z.string().min(1).optional(),\n rateLimits: rateLimitDefaultsSchema.optional(),\n health: integrationHealthSchema.optional(),\n producedRateClasses: z.array(z.string().min(1)).optional(),\n});\n\n/** A parsed entry: {@link ApiRegistryEntry} with `schemaVersion` resolved. */\nexport type ParsedApiRegistryEntry = z.infer<typeof apiRegistryEntrySchema>;\n\n// Compile-time drift guard: the zod contract must stay assignable to the\n// hand-written interface (which keeps the per-field docs).\nconst _entryContractCheck: ApiRegistryEntry = {} as ParsedApiRegistryEntry;\nvoid _entryContractCheck;\n\n/**\n * Parse an untrusted registry entry (e.g. JSON from SSM). Throws a clear\n * validation error naming the offending fields on producer/consumer drift.\n */\nexport function parseApiRegistryEntry(value: unknown): ParsedApiRegistryEntry {\n const result = apiRegistryEntrySchema.safeParse(value);\n if (!result.success) {\n const detail = result.error.issues\n .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)\n .join('; ');\n throw new Error(`api-registry: invalid ApiRegistryEntry \u2014 ${detail}`);\n }\n return result.data;\n}\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Integration status \u2014 the operator payload app-kit's ops listener serves at\n// the registry entry's `status.url`. Owned here (not app-kit) because the\n// consumer side (the hub) depends on api-registry only, and untrusted JSON\n// from another pod needs a schema to parse through.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The versioned operator self-report served at `GET /status` on an\n * integration's ops listener. Names and flags only \u2014 no credential values,\n * no secret paths, nothing request-scoped.\n */\nexport interface IntegrationStatus {\n /** Payload contract version \u2014 bump when the shape changes incompatibly. */\n contract: 'v1';\n /** Integration identity, from the definition. */\n name: string;\n provider?: string;\n type?: string;\n title?: string;\n /** The definition's own version label (e.g. a vendor API generation). */\n definitionVersion?: string;\n /** The served application package's version (app-supplied wins over deploy-injected). */\n packageVersion?: string;\n tenant: string;\n /** Multi-instance selector, when the definition is keyed. */\n instanceKey?: string;\n /** Surfaces this process booted (from INTEGRATION_SURFACES). */\n surfaces: readonly ('http' | 'nexus')[];\n /** Whether the process talks to a real core or serves canned fixtures. */\n coreMode: 'mock' | 'live';\n /** The env variable that decided `coreMode: \"mock\"`, so an operator can see why. */\n coreModeSource?: string;\n /** The curated op set this deployment serves \u2014 same list /health reports. */\n enabledOps: readonly string[];\n /** True when platform-managed credentials are wired (secret prefix set). */\n credentialsWired: boolean;\n /**\n * The DECLARED per-class rate-limit defaults, when the definition declares\n * any and the serve entry has not disabled limiting. Declared, not proven:\n * a custom router that never mounts the middleware, or a nexus-only\n * process, declares without enforcing. Defaults only \u2014 per-subject\n * overrides live in the authz metadata payload and are not echoed here.\n */\n rateLimits?: RateLimitDefaults;\n /**\n * The health declaration this process booted with \u2014 the op it probes, or\n * the stated reason it cannot be probed. Optional for the same reason as\n * on the registry entry: a payload from an older app-kit omits it, and\n * absent must not read as a reason.\n */\n health?: IntegrationHealth;\n /** Process boot time (ISO 8601). */\n startedAt: string;\n}\n\n/** Zod mirror of {@link IntegrationStatus} \u2014 parse untrusted payloads through this. */\nexport const integrationStatusSchema = z.object({\n contract: z.literal('v1'),\n name: z.string().min(1),\n provider: z.string().optional(),\n type: z.string().optional(),\n title: z.string().optional(),\n definitionVersion: z.string().optional(),\n packageVersion: z.string().min(1).optional(),\n tenant: z.string().min(1),\n instanceKey: z.string().optional(),\n surfaces: z.array(z.enum(['http', 'nexus'])),\n coreMode: z.enum(['mock', 'live']),\n coreModeSource: z.string().optional(),\n enabledOps: z.array(z.string()),\n credentialsWired: z.boolean(),\n rateLimits: rateLimitDefaultsSchema.optional(),\n health: integrationHealthSchema.optional(),\n startedAt: z.string().min(1),\n});\n\n// Compile-time drift guard, same pattern as the registry entry above.\nconst _statusContractCheck: IntegrationStatus = {} as z.infer<typeof integrationStatusSchema>;\nvoid _statusContractCheck;\n\n/** Parse an untrusted status payload; throws naming the offending fields. */\nexport function parseIntegrationStatus(value: unknown): IntegrationStatus {\n const result = integrationStatusSchema.safeParse(value);\n if (!result.success) {\n const detail = result.error.issues\n .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)\n .join('; ');\n throw new Error(`api-registry: invalid IntegrationStatus \u2014 ${detail}`);\n }\n return result.data;\n}\n\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Credential schema \u2014 the shared shape for an integration's outbound creds.\n// Owned here so both infra-components (ExternalService secret) and an\n// IntegrationDefinition reference ONE schema type. Mirrors the secret JSON tree.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** A single credential field (a leaf in the schema tree). */\nexport interface CredentialField {\n /** Human-readable field name shown in CLI/UI. */\n name: string;\n description?: string;\n /** Mask in UI + encrypt at rest. */\n secret?: boolean;\n /** May be skipped. */\n optional?: boolean;\n /**\n * Multi-line value (e.g. a PEM block): editors MUST render a paste-faithful\n * multi-line input \u2014 a single-line input collapses the newlines and stores\n * a corrupted value that fails only at connection time.\n */\n multiline?: boolean;\n}\n\n/** A nested group of credential fields. */\nexport interface CredentialGroup {\n name: string;\n description?: string;\n fields: Record<string, CredentialField | CredentialGroup>;\n}\n\nexport type CredentialEntry = CredentialField | CredentialGroup;\n\n/** A credential schema: field definitions keyed by storage key (the secret JSON shape). */\nexport interface CredentialSchema {\n fields: Record<string, CredentialEntry>;\n}\n\n/** Type guard: is this entry a nested group (vs a leaf field)? */\nexport const isCredentialGroup = (e: CredentialEntry): e is CredentialGroup => 'fields' in e;\n\n/**\n * A reusable, package-exported description of how to integrate one third-party\n * service. The data (infra-safe) half \u2014 `mesh.apps.Integration` consumes this.\n * The runtime factories (`buildApi`/`nexusService`) are added by\n * `@mesh-tech/app-kit`'s `IntegrationRuntime`, which extends this.\n */\nexport interface IntegrationDefinition {\n /** Unique key \u2014 the integration's name. */\n name: string;\n /** Family label \u2014 groups variants (e.g. multiple versions) in the hub. */\n provider?: string;\n version?: string;\n title?: string;\n description?: string;\n /** ExternalService category for the Hub UI (e.g. \"banking_core\"). */\n type?: string;\n /** Outbound credential schema (the secret shape). */\n credentialSchema: CredentialSchema;\n /** Package whose built `dist/openapi.json` is the full OpenAPI doc. */\n docPackage?: string;\n /** Multi-instance key (per-FI); absent = single instance. */\n keyedBy?: string;\n /** Default operation subset; overridable per deployment via ENABLED_OPS. */\n defaultEnabledOps: string[];\n /**\n * Surface-mediated health declaration \u2014 required, and deliberately not\n * optional: an integration either names the vendor op that measures it or\n * says why none exists ({@link IntegrationHealth}).\n *\n * `serveIntegration` runs an always-on ops listener serving `GET /status`\n * for every integration; declaring `{ op }` additionally mounts\n * `GET /health` there (invoking the op against the vendor) and signals\n * `mesh.apps.Integration` to wire an in-cluster probe at that route.\n * Declaring `{ unavailable }` mounts nothing and publishes the reason, so\n * the Hub shows a stated \"not health-checked\" rather than a blank.\n */\n health: IntegrationHealth;\n /**\n * Declared per-class rate-limit defaults ({@link RateLimitDefaults}).\n * Declaring this makes the deployment rate-limited: `mesh.apps.Integration`\n * exports the defaults to the registry entry and app-kit's limiter enforces\n * them per subject, with per-subject overrides delivered live through the\n * authz metadata payload. Absent = no platform rate limiting.\n *\n * @example\n * rateLimits: { reads: { rps: 10, burst: 50 }, writes: { rps: 2, burst: 5 } }\n */\n rateLimits?: RateLimitDefaults;\n /**\n * Rate classes the serve entry's classifier actually PRODUCES beyond the\n * built-ins ({@link BUILTIN_RATE_CLASSES}) \u2014 the app's own promise that a\n * custom class in `rateLimits` is wired, not just declared. The classifier\n * (`classifyOp` / a custom router's `classify`) is a runtime function the\n * deploy cannot inspect, so this list is how deploy-time and console-time\n * validation learn what enforcement can really match: a class listed here\n * validates clean, a `rateLimits` class in neither set is one nothing ever\n * produces. Omit when the app uses only the built-ins.\n *\n * @example\n * producedRateClasses: ['exports']\n */\n producedRateClasses?: string[];\n}\n\n/**\n * The request classes app-kit's DEFAULT enforcement produces with no custom\n * classifier: `classifyByMethod` on HTTP routes and the dispatch surface's\n * built-in op classing. Every deployment can match these; anything beyond\n * them exists only when the app's classifier produces it (declared via\n * {@link IntegrationDefinition.producedRateClasses}).\n *\n * Mirrored (not imported) by `KNOWN_RATE_CLASSES` in `@mesh-tech/authz`,\n * which cannot depend on this package; `apps/hub/api` depends on both and\n * pins their equality, so drift fails a test rather than passing silently.\n *\n * @example\n * BUILTIN_RATE_CLASSES.includes('reads'); // true\n */\nexport const BUILTIN_RATE_CLASSES: readonly string[] = ['reads', 'writes'];\n\n/** Guard: an interpolated SSM path segment must be non-empty and slash-free. */\nfunction assertPathSegment(value: string, label: string): string {\n if (!value || value.includes('/')) {\n throw new Error(\n `api-registry: invalid ${label} ${JSON.stringify(value)} \u2014 ` +\n 'SSM path segments must be non-empty and must not contain \"/\"',\n );\n }\n return value;\n}\n\n/** SSM path of the apis registry root (for GetParametersByPath traversal). */\nexport function apisRegistryRoot(tenant: string, env: string): string {\n return `/mesh-platform/${assertPathSegment(tenant, 'tenant')}/${assertPathSegment(env, 'env')}/apis`;\n}\n\n/** SSM path of one Integration's per-owner entry. */\nexport function apiEntryPath(tenant: string, env: string, name: string): string {\n return `${apisRegistryRoot(tenant, env)}/${assertPathSegment(name, 'name')}`;\n}\n\n/**\n * Overlay availability onto doc entries: an entry is `available` iff its `id`\n * (the operationId) is in the deployment's enabled set. Pure; the full doc is\n * never filtered \u2014 availability is computed, not baked.\n */\nexport function markAvailability<T extends { id: string }>(\n entries: readonly T[],\n enabledOps: Iterable<string>,\n): Array<T & { available: boolean }> {\n const set = new Set(enabledOps);\n return entries.map((e) => ({ ...e, available: set.has(e.id) }));\n}\n\n// Re-exported at the root so consumers whose packaging only supports root\n// imports (the published mesh-cli bundles its workspace deps inline \u2014\n// build-for-publish aliases resolve the root entry file only) can still reach\n// the Hub role vocabulary. The subpath export stays; both are the same module.\nexport * from \"./hub-roles.js\";\n", "/**\n * Zitadel seeding for the `mesh start` local platform \u2014 the `mesh login local`\n * path (P1 critical path, design \u00A72.1 seeding).\n *\n * Mirrors what ZitadelPlatformIdentity provisions in the cloud, via the\n * management REST API using the first-instance machine user's PAT (written to\n * the zitadel_machinekey volume by the compose bootstrap). Org structure\n * follows the approved Zitadel tenant-auth pattern: everything here lives in\n * the PLATFORM org (`mesh` \u2014 platform-layer creds home, named by the\n * compose first-instance config); app tenants get their own orgs, created by\n * `mesh dev --local` (see auth-provision.ts):\n * - project \"mesh-cli\" (pattern naming: project per app \u2014 the CLI is a\n * platform-level client feature)\n * - native OIDC application \"cli\" inside it (PKCE + device code, no\n * secret \u2014 the same shape as ZitadelPlatformIdentity's cliApp, plus\n * devMode for http)\n * - two human test users (dev@local.mesh, ops@local.mesh)\n * - registry parity: /mesh-platform/local/dev/platform/zitadel in the local\n * AWS fabric ({endpoint, cliClientId} \u2014 what `mesh login` SSM discovery\n * reads) and temporal/test-users entries (what `mesh dev test-user` reads)\n * - the `local` CLI login context in ~/.config/mesh/config.json, so\n * `mesh login local` works with zero discovery.\n *\n * Idempotent: search-before-create for project/app, \"already exists\" is\n * success for users, SSM params overwrite.\n */\n\nimport * as fs from \"fs\";\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport { hubOperatorRoleKeys, hubRoleKeyTenant, restrictedRoleBase } from \"@mesh-tech/api-registry\";\nimport { logInfo, logSuccess, logWarn } from \"../../utils/log.js\";\nimport { MeshCliError } from \"../../utils/errors.js\";\nimport { writeContextConfig } from \"../login.js\";\nimport { compose, hubPort, DEFAULT_HUB_PORT } from \"./stack.js\";\nimport { LOCAL_TENANT, LOCAL_ENV } from \"./seed.js\";\nimport { meshCacheDir } from \"../../utils/cache-home.js\";\n\nexport const ZITADEL_ISSUER = \"http://localhost:8080\";\nexport const LOGIN_CONTEXT = \"local\";\n/** The platform org \u2014 home of platform-layer creds, same name as the cloud\n * platform tenant. Set by ZITADEL_FIRSTINSTANCE_ORG_NAME in the compose;\n * app tenants (incl. the default `local`) get their own orgs on demand. */\nexport const PLATFORM_ORG = \"mesh\";\n/**\n * Approved-pattern naming inside the platform org: project per app\n * (lowercase), application per service/client inside it. The CLI is a\n * platform-level client feature \u2192 project `mesh-cli`, application `cli`.\n * (The cloud's legacy catch-all \"Platform\" project is being retired by the\n * pattern doc's spec items; local starts pattern-true.)\n */\nexport const CLI_PROJECT_NAME = \"mesh-cli\";\nexport const CLI_APP_NAME = \"cli\";\nexport const CLI_REDIRECT_URI = \"http://localhost:9876/callback\";\n/**\n * The Hub's own auth config (pattern-true: the Hub is a platform app \u2192\n * project `hub` in the platform org, application per service). The `ui`\n * application backs the oauth2-proxy that fronts the Hub UI \u2014 the same\n * sidecar topology `mesh.apps.Service` deploys with `auth.provider: \"mesh\"`.\n * Roles ARE the Hub's vocabulary \u2014 imported from the module the deployed Hub's\n * infra declares them from, so a role that exists in the cloud is grantable\n * locally too (MESH-2504), including the tenant-keyed restricted keys\n * (`vendor:<tenant>`, `auditor:<tenant>`) that carry the only scope the policy\n * actually reads. The bare keys alone are not a usable local vocabulary: a bare\n * restricted grant names no tenant, so `checkPolicy` matches it against nothing\n * and it authorizes no tenant-scoped read.\n */\nexport const HUB_PROJECT_NAME = \"hub\";\nexport const HUB_APP_NAME = \"ui\";\n/** Registered on the seeded Hub app; the default-port URI is always included\n * so a later port switch never strands the app config. Zitadel's devMode only\n * relaxes the https requirement \u2014 every redirect URI must still be registered\n * exactly, so an existing app is RECONCILED (union-merged) on each seed run.\n * Functions, not constants: hubPort() must be read at call time so tests and\n * programmatic callers that set MESH_HUB_PORT after import see the override. */\nexport const hubRedirectUri = (): string => `http://localhost:${hubPort()}/oauth2/callback`;\nexport const HUB_DEFAULT_REDIRECT_URI = `http://localhost:${DEFAULT_HUB_PORT}/oauth2/callback`;\nexport const HUB_ROLES = hubOperatorRoleKeys([LOCAL_TENANT]);\nexport const ZITADEL_SSM_PARAM = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/platform/zitadel`;\nexport const TEST_USERS_SSM_PREFIX = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/temporal/test-users`;\n\nexport interface LocalTestUser {\n name: string;\n email: string;\n firstName: string;\n lastName: string;\n password: string;\n}\n\n/** Seeded human test users \u2014 plainly-labeled dev-grade credentials. */\nexport const TEST_USERS: LocalTestUser[] = [\n {\n name: \"dev\",\n email: \"dev@local.mesh\",\n firstName: \"Dev\",\n lastName: \"User\",\n password: \"LocalDev1!\",\n },\n {\n name: \"ops\",\n email: \"ops@local.mesh\",\n firstName: \"Ops\",\n lastName: \"User\",\n password: \"LocalDev1!\",\n },\n];\n\n/**\n * The CLI OIDC app payload \u2014 the same shape ZitadelPlatformIdentity creates in\n * the cloud (native, PKCE, device code, JWT access tokens, role assertions),\n * plus devMode so Zitadel accepts the http:// redirect locally.\n */\nexport function buildCliAppPayload() {\n return {\n name: CLI_APP_NAME,\n redirectUris: [CLI_REDIRECT_URI],\n responseTypes: [\"OIDC_RESPONSE_TYPE_CODE\"],\n grantTypes: [\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\", \"OIDC_GRANT_TYPE_DEVICE_CODE\"],\n appType: \"OIDC_APP_TYPE_NATIVE\",\n authMethodType: \"OIDC_AUTH_METHOD_TYPE_NONE\",\n accessTokenType: \"OIDC_TOKEN_TYPE_JWT\",\n accessTokenRoleAssertion: true,\n idTokenRoleAssertion: true,\n idTokenUserinfoAssertion: true,\n devMode: true,\n };\n}\n\n/** Read the seeder PAT the compose bootstrap wrote to the machinekey volume. */\nexport function readSeederPat(): string {\n const tmp = path.join(fs.mkdtempSync(path.join(os.tmpdir(), \"mesh-zitadel-\")), \"pat.txt\");\n try {\n compose([\"cp\", \"zitadel:/machinekey/pat.txt\", tmp]);\n const pat = fs.readFileSync(tmp, \"utf-8\").trim();\n if (!pat) throw new Error(\"empty PAT file\");\n return pat;\n } catch {\n throw new MeshCliError(\n \"Zitadel seeder PAT not found \u2014 the zitadel volume predates the bootstrap machine user (first-instance settings only apply on the first init).\",\n { remediation: { command: \"mesh stop --destroy && mesh start\" } },\n );\n } finally {\n fs.rmSync(path.dirname(tmp), { recursive: true, force: true });\n }\n}\n\n/** Call the local Zitadel REST API; `orgId` sets the org context header. */\nexport async function api(\n pat: string,\n method: string,\n apiPath: string,\n body?: unknown,\n orgId?: string,\n): Promise<any> {\n const res = await fetch(`${ZITADEL_ISSUER}${apiPath}`, {\n method,\n headers: {\n Authorization: `Bearer ${pat}`,\n \"Content-Type\": \"application/json\",\n ...(orgId ? { \"x-zitadel-orgid\": orgId } : {}),\n },\n body: body === undefined ? undefined : JSON.stringify(body),\n signal: AbortSignal.timeout(15_000),\n });\n const text = await res.text();\n const data = text ? JSON.parse(text) : {};\n if (!res.ok) {\n const err: any = new Error(\n `Zitadel ${method} ${apiPath} \u2192 ${res.status}: ${data?.message ?? text}`,\n );\n err.status = res.status;\n err.zitadelCode = data?.code;\n throw err;\n }\n return data;\n}\n\nexport const isAlreadyExists = (err: any) =>\n err?.status === 409 || err?.zitadelCode === 6 || /already exists/i.test(err?.message ?? \"\");\n\n/**\n * Shared search-before-create for Zitadel projects: return the existing\n * project's id, else create it. Every ensure*Project in the local seeding\n * and auth-provisioning goes through this.\n */\nexport async function ensureZitadelProject(\n pat: string,\n name: string,\n opts: { orgId?: string; describe?: string; logExisting?: boolean } = {},\n): Promise<string> {\n const search = await api(\n pat,\n \"POST\",\n \"/management/v1/projects/_search\",\n { queries: [{ nameQuery: { name, method: \"TEXT_QUERY_METHOD_EQUALS\" } }] },\n opts.orgId,\n );\n const existing = search?.result?.[0]?.id;\n if (existing) {\n if (opts.logExisting) logInfo(`Zitadel project '${name}' already exists`);\n return existing;\n }\n const created = await api(\n pat,\n \"POST\",\n \"/management/v1/projects\",\n { name, projectRoleAssertion: true },\n opts.orgId,\n );\n logSuccess(`Created Zitadel project '${name}'${opts.describe ? ` (${opts.describe})` : \"\"}`);\n return created.id;\n}\n\n/** Shared name-equals application search; returns the raw app or undefined. */\nexport async function searchZitadelApp(\n pat: string,\n projectId: string,\n name: string,\n orgId?: string,\n): Promise<any | undefined> {\n const search = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/apps/_search`,\n { queries: [{ nameQuery: { name, method: \"TEXT_QUERY_METHOD_EQUALS\" } }] },\n orgId,\n );\n return search?.result?.[0];\n}\n\nconst ensureCliProject = (pat: string): Promise<string> =>\n ensureZitadelProject(pat, CLI_PROJECT_NAME, {\n describe: \"platform client feature\",\n logExisting: true,\n });\n\nasync function ensureCliApp(pat: string, projectId: string): Promise<string> {\n const existing = (await searchZitadelApp(pat, projectId, CLI_APP_NAME))?.oidcConfig?.clientId;\n if (existing) {\n logInfo(`Zitadel app '${CLI_APP_NAME}' already exists`);\n return existing;\n }\n const created = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/apps/oidc`,\n buildCliAppPayload(),\n );\n logSuccess(`Created Zitadel application '${CLI_APP_NAME}' (PKCE + device code)`);\n return created.clientId;\n}\n\nasync function ensureTestUsers(pat: string): Promise<void> {\n for (const user of TEST_USERS) {\n try {\n await api(pat, \"POST\", \"/management/v1/users/human/_import\", {\n userName: user.email,\n profile: {\n firstName: user.firstName,\n lastName: user.lastName,\n displayName: `${user.firstName} ${user.lastName}`,\n },\n email: { email: user.email, isEmailVerified: true },\n password: user.password,\n passwordChangeRequired: false,\n });\n logSuccess(`Created test user ${user.email}`);\n } catch (err) {\n if (isAlreadyExists(err)) {\n logInfo(`Test user ${user.email} already exists`);\n } else {\n throw err;\n }\n }\n }\n}\n\n// \u2500\u2500 Hub auth (oauth2-proxy client) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface HubAuthConfig {\n clientId: string;\n clientSecret: string;\n cookieSecret: string;\n /** The Hub's own Zitadel project \u2014 what its authz pointer names (\u00A77.1). */\n projectId: string;\n}\n\n/** Where the Hub's oauth2-proxy credentials persist between runs (the local\n * analog of the K8s Secret the Service component creates for the sidecar). */\nexport function hubAuthPath(): string {\n return path.join(meshCacheDir(\"mesh-local\"), \"hub-auth.json\");\n}\n\nexport function readHubAuth(): HubAuthConfig | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(hubAuthPath(), \"utf-8\"));\n if (parsed?.clientId && parsed?.clientSecret && parsed?.cookieSecret) return parsed;\n return null;\n } catch {\n return null;\n }\n}\n\nconst ensureHubProject = (pat: string): Promise<string> =>\n ensureZitadelProject(pat, HUB_PROJECT_NAME, { describe: \"Hub platform app\" });\n\n/**\n * What a role key is called in Zitadel's own UI and in the Hub's role picker.\n *\n * A tenant-keyed key reads as the base role plus the tenant it is scoped to, so\n * that `vendor:local` is not offered to an administrator as an opaque string\n * sitting next to a bare `vendor` that grants something entirely different.\n */\nfunction hubRoleDisplayName(roleKey: string): string {\n const tenant = hubRoleKeyTenant(roleKey);\n return tenant === null ? roleKey : `${restrictedRoleBase(roleKey) ?? roleKey} (${tenant})`;\n}\n\nasync function ensureHubRoles(pat: string, projectId: string): Promise<void> {\n for (const role of HUB_ROLES) {\n try {\n await api(pat, \"POST\", `/management/v1/projects/${projectId}/roles`, {\n roleKey: role,\n displayName: hubRoleDisplayName(role),\n });\n logSuccess(`Created Hub role '${role}'`);\n } catch (err) {\n if (!isAlreadyExists(err)) throw err;\n }\n }\n}\n\n/** The slice of Zitadel's OIDC app shape the reconcile below reads. */\ninterface HubOidcApp {\n id: string;\n oidcConfig?: {\n clientId?: string;\n redirectUris?: string[];\n postLogoutRedirectUris?: string[];\n responseTypes?: string[];\n grantTypes?: string[];\n accessTokenType?: string;\n accessTokenRoleAssertion?: boolean;\n idTokenRoleAssertion?: boolean;\n idTokenUserinfoAssertion?: boolean;\n devMode?: boolean;\n };\n}\n\n/** Union-merge the current-port redirect URIs into an existing Hub app's OIDC\n * config. Needed whenever the app was seeded under a different MESH_HUB_PORT:\n * devMode does NOT exempt an unregistered redirect_uri (Zitadel rejects the\n * authorize call with \"redirect_uri is missing in the client configuration\"),\n * it only permits http://. No-op when everything is already registered.\n * Exported for tests. */\nexport async function reconcileHubRedirectUris(\n pat: string,\n projectId: string,\n app: HubOidcApp,\n): Promise<void> {\n const cfg = app?.oidcConfig ?? {};\n const redirectUris = [\n ...new Set([...(cfg.redirectUris ?? []), hubRedirectUri(), HUB_DEFAULT_REDIRECT_URI]),\n ];\n // Both slash forms: the Hub's /logout sends `${origin}/` (trailing slash \u2014\n // matching the deployed platform, which registers `${baseUrl}/`), and\n // Zitadel exact-matches post_logout_redirect_uri. Seeding only the bare\n // form made local sign-out fail with \"post_logout_redirect_uri invalid\".\n const postLogoutRedirectUris = [\n ...new Set([\n ...(cfg.postLogoutRedirectUris ?? []),\n `http://localhost:${hubPort()}`,\n `http://localhost:${hubPort()}/`,\n `http://localhost:${DEFAULT_HUB_PORT}`,\n `http://localhost:${DEFAULT_HUB_PORT}/`,\n ]),\n ];\n if (\n redirectUris.length === (cfg.redirectUris ?? []).length &&\n postLogoutRedirectUris.length === (cfg.postLogoutRedirectUris ?? []).length\n ) {\n return;\n }\n // UpdateOIDCAppConfig replaces the whole config \u2014 carry the fields we seed\n // (falling back to the create-time shape; appType/authMethodType are\n // re-asserted to the seeded values). Fields we never seed \u2014 e.g.\n // additionalOrigins, clockSkew, backChannelLogoutUri \u2014 are NOT carried, so\n // anything set out-of-band in the Zitadel console is dropped here. Fine for\n // the local dev app; revisit before reusing against a shared instance.\n await api(pat, \"PUT\", `/management/v1/projects/${projectId}/apps/${app.id}/oidc_config`, {\n redirectUris,\n postLogoutRedirectUris,\n responseTypes: cfg.responseTypes ?? [\"OIDC_RESPONSE_TYPE_CODE\"],\n grantTypes: cfg.grantTypes ?? [\n \"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\",\n \"OIDC_GRANT_TYPE_REFRESH_TOKEN\",\n ],\n appType: \"OIDC_APP_TYPE_WEB\",\n authMethodType: \"OIDC_AUTH_METHOD_TYPE_BASIC\",\n accessTokenType: cfg.accessTokenType ?? \"OIDC_TOKEN_TYPE_JWT\",\n accessTokenRoleAssertion: cfg.accessTokenRoleAssertion ?? true,\n idTokenRoleAssertion: cfg.idTokenRoleAssertion ?? true,\n idTokenUserinfoAssertion: cfg.idTokenUserinfoAssertion ?? true,\n devMode: cfg.devMode ?? true,\n });\n logSuccess(\n `Registered Hub redirect URI http://localhost:${hubPort()}/oauth2/callback (MESH_HUB_PORT)`,\n );\n}\n\n/** Ensure the Hub `ui` web application; heals a lost client secret (Zitadel\n * returns it once \u2014 regenerate when the persisted copy is gone) and keeps the\n * redirect URIs in sync with the configured Hub port. */\nasync function ensureHubApp(\n pat: string,\n projectId: string,\n): Promise<{ clientId: string; clientSecret?: string }> {\n const existing = await searchZitadelApp(pat, projectId, HUB_APP_NAME);\n if (existing) {\n await reconcileHubRedirectUris(pat, projectId, existing);\n const clientId = existing?.oidcConfig?.clientId;\n const persisted = readHubAuth();\n if (persisted && persisted.clientId === clientId) return { clientId };\n const regenerated = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/apps/${existing.id}/oidc_config/_generate_client_secret`,\n {},\n );\n logSuccess(`Regenerated Hub UI client secret (local copy was missing)`);\n return { clientId, clientSecret: regenerated.clientSecret };\n }\n const created = await api(pat, \"POST\", `/management/v1/projects/${projectId}/apps/oidc`, {\n name: HUB_APP_NAME,\n redirectUris: [...new Set([hubRedirectUri(), HUB_DEFAULT_REDIRECT_URI])],\n postLogoutRedirectUris: [\n // Both slash forms \u2014 see reconcileHubRedirectUris; Zitadel exact-matches\n // and the Hub's /logout sends the trailing-slash form.\n ...new Set([\n `http://localhost:${hubPort()}`,\n `http://localhost:${hubPort()}/`,\n `http://localhost:${DEFAULT_HUB_PORT}`,\n `http://localhost:${DEFAULT_HUB_PORT}/`,\n ]),\n ],\n responseTypes: [\"OIDC_RESPONSE_TYPE_CODE\"],\n grantTypes: [\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\", \"OIDC_GRANT_TYPE_REFRESH_TOKEN\"],\n appType: \"OIDC_APP_TYPE_WEB\",\n authMethodType: \"OIDC_AUTH_METHOD_TYPE_BASIC\",\n accessTokenType: \"OIDC_TOKEN_TYPE_JWT\",\n accessTokenRoleAssertion: true,\n idTokenRoleAssertion: true,\n idTokenUserinfoAssertion: true,\n devMode: true,\n });\n logSuccess(`Created Zitadel application '${HUB_APP_NAME}' (Hub UI web client, code flow)`);\n return { clientId: created.clientId, clientSecret: created.clientSecret };\n}\n\n/** Grant Hub roles: admin@local.mesh + dev get `ops` (full dashboard),\n * ops@local.mesh gets `developer` \u2014 proves per-user role claims. */\nasync function ensureHubGrants(pat: string, projectId: string): Promise<void> {\n const grants: Array<{ email: string; roles: string[] }> = [\n { email: \"admin@local.mesh\", roles: [\"ops\"] },\n { email: \"dev@local.mesh\", roles: [\"ops\"] },\n { email: \"ops@local.mesh\", roles: [\"developer\"] },\n ];\n for (const grant of grants) {\n const search = await api(pat, \"POST\", \"/management/v1/users/_search\", {\n queries: [{ userNameQuery: { userName: grant.email, method: \"TEXT_QUERY_METHOD_EQUALS\" } }],\n });\n const userId = search?.result?.[0]?.id;\n if (!userId) continue;\n try {\n await api(pat, \"POST\", `/management/v1/users/${userId}/grants`, {\n projectId,\n roleKeys: grant.roles,\n });\n logSuccess(`Granted Hub role(s) ${grant.roles.join(\", \")} \u2192 ${grant.email}`);\n } catch (err) {\n if (!isAlreadyExists(err)) throw err;\n }\n }\n}\n\n/** Seed the Hub's auth config; persists oauth2-proxy credentials locally. */\nexport async function seedHubAuth(pat: string): Promise<HubAuthConfig> {\n const projectId = await ensureHubProject(pat);\n await ensureHubRoles(pat, projectId);\n const app = await ensureHubApp(pat, projectId);\n await ensureHubGrants(pat, projectId);\n\n const persisted = readHubAuth();\n // oauth2-proxy wants a RAW 16/24/32-byte cookie secret \u2014 the hosted\n // sidecar uses a 32-char RandomPassword; mirror that.\n const persistedCookie =\n persisted?.cookieSecret && [16, 24, 32].includes(persisted.cookieSecret.length)\n ? persisted.cookieSecret\n : undefined;\n const config: HubAuthConfig = {\n clientId: app.clientId,\n projectId,\n clientSecret: app.clientSecret ?? persisted?.clientSecret ?? \"\",\n cookieSecret: persistedCookie ?? (await import(\"crypto\")).randomBytes(16).toString(\"hex\"),\n };\n fs.writeFileSync(hubAuthPath(), JSON.stringify(config, null, 2), { mode: 0o600 });\n return config;\n}\n\n/**\n * Publish the Hub's OWN authz pointer \u2014 the local stand-in for what the platform\n * Pulumi program publishes (Hub RBAC design \u00A77.1).\n *\n * The Hub administers every app's identity through\n * `identity/apps/{app}/users`, which resolves the app's Zitadel project and org\n * from its published pointer. Without a pointer of its own the Hub is the one\n * app it cannot administer: its Users page has no project to create people in\n * and no role vocabulary to grant, which is why that page could only ever be a\n * mock. Locally there is no Pulumi run to publish it, so seeding does.\n *\n * Read-modify-write: the compiled-schema keys (`opsHubMetadataRef`, `mode`) are\n * published by the Hub itself and must survive this.\n */\nasync function publishHubAuthzPointer(\n projectId: string,\n orgId: string,\n awsConfig: object,\n): Promise<void> {\n const { SSMClient, GetParameterCommand, PutParameterCommand } =\n await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(awsConfig);\n const name = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/apps/hub/stacks/local/authz`;\n\n let current: Record<string, unknown> = {};\n try {\n const existing = await ssm.send(new GetParameterCommand({ Name: name }));\n current = JSON.parse(existing.Parameter?.Value ?? \"{}\") as Record<string, unknown>;\n } catch {\n // First run \u2014 nothing published yet.\n }\n\n const value = {\n spicedb: { instanceRefs: [] },\n ...current,\n zitadel: { projectId, orgId, issuer: ZITADEL_ISSUER },\n };\n await ssm.send(\n new PutParameterCommand({\n Name: name,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify(value),\n Description: \"Hub authz pointer (local analog of the platform Pulumi program)\",\n }),\n );\n logSuccess(`Hub authz pointer published \u2192 project ${projectId} in org ${orgId}`);\n}\n\n/** Registry parity: what SSM discovery and `mesh dev test-user` read in the cloud. */\nasync function writeRegistryParams(cliClientId: string, awsConfig: object): Promise<void> {\n const { SSMClient, PutParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(awsConfig);\n await ssm.send(\n new PutParameterCommand({\n Name: ZITADEL_SSM_PARAM,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({ endpoint: ZITADEL_ISSUER, cliClientId }),\n Description: \"Zitadel platform export (seeded by mesh start)\",\n }),\n );\n for (const user of TEST_USERS) {\n await ssm.send(\n new PutParameterCommand({\n Name: `${TEST_USERS_SSM_PREFIX}/${user.name}`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({ email: user.email, password: user.password, roles: [] }),\n Description: `Local test user '${user.name}' (dev-grade, seeded by mesh start)`,\n }),\n );\n }\n}\n\n/** Secrets Manager id the Hub reads its Zitadel admin key from (opsHubSecretName). */\nexport const OPS_HUB_SECRET_ID = \"mesh/local/dev/zitadel/ops-hub\";\n\n/**\n * Give the local Hub an ADMIN plane.\n *\n * The Hub verifies tokens with `ZITADEL_ISSUER`, but every WRITE \u2014 creating a\n * user, minting an API key \u2014 goes through a machine key it loads from Secrets\n * Manager. Without that key the local Hub silently offers no create affordance\n * and nothing reaches the IdP, which reads as \"the feature is broken\" rather\n * than \"the credential was never provisioned\".\n *\n * The key is a Zitadel machine key (private_key_jwt): `{ keyId, key, userId }`.\n * Idempotent \u2014 an existing user is reused, and a fresh key is minted only when\n * the secret is absent, because Zitadel returns the private half exactly once.\n */\nasync function ensureOpsHubAdmin(pat: string, awsConfig: object): Promise<void> {\n const {\n SecretsManagerClient,\n GetSecretValueCommand,\n CreateSecretCommand,\n PutSecretValueCommand,\n } = await import(\"@aws-sdk/client-secrets-manager\");\n const sm = new SecretsManagerClient(awsConfig);\n\n const existing = await sm\n .send(new GetSecretValueCommand({ SecretId: OPS_HUB_SECRET_ID }))\n .catch(() => null);\n if (existing?.SecretString) {\n logSuccess(\"Hub admin key already provisioned (Zitadel writes enabled)\");\n return;\n }\n\n const userName = \"hub-opshub\";\n const found = await api(pat, \"POST\", \"/management/v1/users/_search\", {\n queries: [{ userNameQuery: { userName, method: \"TEXT_QUERY_METHOD_EQUALS\" } }],\n });\n let userId: string | undefined = found?.result?.[0]?.id;\n if (!userId) {\n const created = await api(pat, \"POST\", \"/management/v1/users/machine\", {\n userName,\n name: \"Hub Ops Hub admin\",\n description: \"Local Hub Zitadel admin plane (seeded by mesh start)\",\n });\n userId = created?.userId;\n }\n if (!userId) throw new MeshCliError(\"could not create the Hub's Zitadel admin user\");\n\n // IAM_OWNER because the Hub administers every org on the instance \u2014 each app\n // tenant gets its own org, and a key is minted in the app's org, not the Hub's.\n await api(pat, \"POST\", \"/admin/v1/members\", { userId, roles: [\"IAM_OWNER\"] }).catch(\n () => undefined,\n );\n\n const key = await api(pat, \"POST\", `/management/v1/users/${userId}/keys`, {\n type: \"KEY_TYPE_JSON\",\n });\n if (!key?.keyDetails)\n throw new MeshCliError(\"Zitadel did not return a machine key for the Hub admin user\");\n const raw = JSON.parse(Buffer.from(key.keyDetails, \"base64\").toString(\"utf8\")) as {\n keyId: string;\n key: string;\n userId: string;\n };\n const secretString = JSON.stringify({ keyId: raw.keyId, key: raw.key, userId: raw.userId });\n await sm\n .send(new CreateSecretCommand({ Name: OPS_HUB_SECRET_ID, SecretString: secretString }))\n .catch(async () => {\n await sm.send(\n new PutSecretValueCommand({ SecretId: OPS_HUB_SECRET_ID, SecretString: secretString }),\n );\n });\n logSuccess(`Hub admin key provisioned \u2192 ${OPS_HUB_SECRET_ID} (Zitadel writes enabled)`);\n}\n\n/** Where the local stack's mail goes \u2014 the `mailpit` service in docker-compose. */\nconst LOCAL_SMTP = {\n host: \"mailpit:1025\",\n from: \"no-reply@local.mesh\",\n fromName: \"Mesh (local)\",\n} as const;\n\n/**\n * Point the instance's mail at the local mailbox, on every start.\n *\n * The compose file sets the same thing via `ZITADEL_DEFAULTINSTANCE_SMTP\u2026`,\n * but those apply at FIRST INIT only \u2014 so every stack that existed before the\n * mailbox was added would keep no SMTP config at all, and activation and\n * password-reset mail would go on silently going nowhere. Reconciling here\n * covers those without asking anyone to `mesh stop --destroy`.\n *\n * Idempotent: reads the configured providers first and only writes when the\n * host differs. Best-effort \u2014 a stack whose mail is misconfigured is worth a\n * warning, not a failed `mesh start`.\n */\nasync function ensureLocalSmtp(pat: string): Promise<void> {\n try {\n const existing = await api(pat, \"POST\", \"/admin/v1/smtp/_search\", {}).catch(() => null);\n const configs: Array<{ id?: string; senderAddress?: string; host?: string; state?: string }> =\n existing?.result ?? [];\n const match = configs.find((c) => c.host === LOCAL_SMTP.host);\n\n if (!match) {\n const created = await api(pat, \"POST\", \"/admin/v1/smtp\", {\n senderAddress: LOCAL_SMTP.from,\n senderName: LOCAL_SMTP.fromName,\n tls: false,\n host: LOCAL_SMTP.host,\n user: \"\",\n password: \"\",\n });\n // A provider that exists but is not active still sends nothing.\n if (created?.id) await api(pat, \"POST\", `/admin/v1/smtp/${created.id}/_activate`, {});\n logSuccess(\n `Local mailbox wired to Zitadel \u2192 ${LOCAL_SMTP.host} (view at http://localhost:8025)`,\n );\n return;\n }\n\n if (match.state !== \"SMTP_CONFIG_ACTIVE\" && match.id) {\n await api(pat, \"POST\", `/admin/v1/smtp/${match.id}/_activate`, {});\n }\n } catch (err) {\n logWarn(\n `Could not wire the local mailbox to Zitadel (${(err as Error).message}). ` +\n \"Activation and password-reset mail will not be delivered locally.\",\n );\n }\n}\n\nexport interface ZitadelSeedResult {\n projectId: string;\n cliClientId: string;\n hubAuth: HubAuthConfig;\n}\n\nexport async function seedZitadel(awsConfig: object): Promise<ZitadelSeedResult> {\n const pat = readSeederPat();\n const org = await api(pat, \"POST\", \"/admin/v1/orgs/_search\", {\n queries: [{ nameQuery: { name: PLATFORM_ORG, method: \"TEXT_QUERY_METHOD_EQUALS\" } }],\n });\n if (!org?.result?.[0]) {\n throw new MeshCliError(\n `Platform org '${PLATFORM_ORG}' not found \u2014 the zitadel volume predates the org naming.`,\n { remediation: { command: \"mesh stop --destroy && mesh start\" } },\n );\n }\n logSuccess(\n `Platform org '${PLATFORM_ORG}' ready (platform tenant root \u2014 holds platform-service auth config)`,\n );\n // Zitadel v4 defaults new instances to the login-v2 UI \u2014 a separate\n // container this stack doesn't run. Pin the built-in v1 login so the\n // authorize flow has a page to land on (idempotent; compose sets the\n // same default at first-instance time).\n try {\n await api(pat, \"PUT\", \"/v2/features/instance\", { loginV2: { required: false } });\n } catch {\n // pre-v4 instance or endpoint drift \u2014 v1 login is already the default\n }\n await ensureLocalSmtp(pat);\n const projectId = await ensureCliProject(pat);\n const cliClientId = await ensureCliApp(pat, projectId);\n await ensureTestUsers(pat);\n // Hub auth AFTER test users exist (role grants target them).\n const hubAuth = await seedHubAuth(pat);\n await publishHubAuthzPointer(hubAuth.projectId, org.result[0].id, awsConfig);\n await writeRegistryParams(cliClientId, awsConfig);\n await ensureOpsHubAdmin(pat, awsConfig);\n writeContextConfig(LOGIN_CONTEXT, { issuer: ZITADEL_ISSUER, clientId: cliClientId });\n logSuccess(`Login context '${LOGIN_CONTEXT}' configured \u2192 try: mesh login ${LOGIN_CONTEXT}`);\n return { projectId, cliClientId, hubAuth };\n}\n", "/**\n * App-tenant auth auto-provisioning for the local platform \u2014 the local analog\n * of what the Pulumi components do in the cloud, per the approved Zitadel\n * tenant-auth pattern (Org / Project / Application / Service User):\n *\n * Org = app tenant (ZitadelTenantIdentity parity)\n * Project = one per app, (ZitadelAppIdentity parity \u2014\n * name = authz schema namespace the app IS the authz boundary)\n * Application = one per service inside it (API app, Basic creds)\n * Secret = mesh/{tenant}/{env}/apps/{app}/zitadel/{service}\n * (ministack Secrets Manager \u2014 same canonical path as cloud)\n *\n * `mesh dev --local` calls this on startup so scaffolding + running an app\n * auto-creates its auth config \u2014 no Pulumi deploy, no manual Zitadel steps.\n * Idempotent: search-before-create everywhere; the credential secret is only\n * written when the application is first created (Zitadel returns the secret\n * once).\n */\n\nimport { logInfo, logSuccess } from \"../../utils/log.js\";\nimport { LOCAL_ENV, LOCAL_AWS_CONFIG, registerTenantEnv } from \"./seed.js\";\nimport { api, readSeederPat, PLATFORM_ORG, ensureZitadelProject, searchZitadelApp } from \"./seed-zitadel.js\";\nimport { upsertLocalSecret } from \"./helpers.js\";\n\nexport interface AppTenantAuthArgs {\n /** App tenant (org name), e.g. \"acme\" */\n tenant: string;\n /** App name = project name = authz schema namespace */\n app: string;\n /** Service names \u2192 one API application each (api, worker, \u2026) */\n services: string[];\n /**\n * App-declared project roles (package.json \u2192 mesh.auth.roles). Created on\n * the app's Zitadel project and granted to the local `{app}-m2m` caller so\n * role-scoped services (roles claim \u2192 scopes) accept its tokens.\n */\n roles?: string[];\n}\n\nexport interface ProvisionedAuth {\n orgId: string;\n projectId: string;\n /** Services whose application (and secret) was created this run */\n created: string[];\n}\n\nexport function authSecretPath(tenant: string, app: string, service: string): string {\n return `mesh/${tenant}/${LOCAL_ENV}/apps/${app}/zitadel/${service}`;\n}\n\nasync function ensureOrg(pat: string, name: string): Promise<string> {\n const search = await api(pat, \"POST\", \"/admin/v1/orgs/_search\", {\n queries: [{ nameQuery: { name, method: \"TEXT_QUERY_METHOD_EQUALS\" } }],\n });\n const existing = search?.result?.[0]?.id;\n if (existing) return existing;\n const created = await api(pat, \"POST\", \"/v2/organizations\", { name });\n logSuccess(`Created Zitadel org '${name}' (app tenant)`);\n return created.organizationId;\n}\n\nconst ensureProject = (pat: string, orgId: string, name: string): Promise<string> =>\n ensureZitadelProject(pat, name, { orgId, describe: \"app\" });\n\ntype ServiceAppResult =\n | { created: true; clientId: string; clientSecret: string }\n | { created: false; appId: string; clientId?: string };\n\n/** Ensure the service's API application exists; returns fresh credentials\n * when it was created this run. */\nasync function ensureServiceApp(\n pat: string,\n orgId: string,\n projectId: string,\n service: string,\n): Promise<ServiceAppResult> {\n const existing = await searchZitadelApp(pat, projectId, service, orgId);\n if (existing) {\n return { created: false, appId: existing.id, clientId: existing?.apiConfig?.clientId };\n }\n const created = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/apps/api`,\n { name: service, authMethodType: \"API_AUTH_METHOD_TYPE_BASIC\" },\n orgId,\n );\n logSuccess(`Created Zitadel application '${service}' (service)`);\n return { created: true, clientId: created.clientId, clientSecret: created.clientSecret };\n}\n\n/** Ensure the app-declared project roles exist (idempotent). */\nasync function ensureProjectRoles(\n pat: string,\n orgId: string,\n projectId: string,\n roles: string[],\n): Promise<void> {\n if (roles.length === 0) return;\n const existing = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/roles/_search`,\n {},\n orgId,\n );\n const have = new Set((existing?.result ?? []).map((r: { key: string }) => r.key));\n for (const key of roles) {\n if (have.has(key)) continue;\n await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/roles`,\n { roleKey: key, displayName: key },\n orgId,\n );\n logSuccess(`Created project role '${key}'`);\n }\n}\n\n/**\n * Ensure the app's LOCAL M2M caller: a machine user (`{app}-m2m`) with a\n * client-credentials secret, granted the app project (+ declared roles).\n * API applications are resource servers \u2014 they cannot obtain tokens \u2014 so\n * without this there is no way to mint a caller token against the local\n * Zitadel at all. Secret stored at mesh/{tenant}/dev/apps/{app}/zitadel/m2m.\n */\nasync function ensureM2mCaller(\n pat: string,\n tenant: string,\n app: string,\n orgId: string,\n projectId: string,\n roles: string[],\n): Promise<boolean> {\n const userName = `${app}-m2m`;\n const search = await api(\n pat,\n \"POST\",\n \"/management/v1/users/_search\",\n { queries: [{ userNameQuery: { userName, method: \"TEXT_QUERY_METHOD_EQUALS\" } }] },\n orgId,\n );\n let userId: string | undefined = search?.result?.[0]?.id;\n let createdUser = false;\n if (!userId) {\n const created = await api(\n pat,\n \"POST\",\n \"/management/v1/users/machine\",\n { userName, name: `${app} local M2M caller`, accessTokenType: \"ACCESS_TOKEN_TYPE_BEARER\" },\n orgId,\n );\n userId = created.userId as string;\n createdUser = true;\n logSuccess(`Created machine user '${userName}' (local M2M caller)`);\n }\n\n // Grant the project (+ declared roles); on re-runs update the existing\n // grant so newly-declared roles apply.\n try {\n await api(pat, \"POST\", `/management/v1/users/${userId}/grants`, { projectId, roleKeys: roles }, orgId);\n } catch (err) {\n const e = err as { status?: number; message?: string };\n if (e.status !== 409 && !/already exists/i.test(String(e.message))) throw err;\n const grants = await api(\n pat,\n \"POST\",\n \"/management/v1/users/grants/_search\",\n { queries: [{ userIdQuery: { userId } }, { projectIdQuery: { projectId } }] },\n orgId,\n );\n const grantId = grants?.result?.[0]?.id;\n if (grantId && roles.length > 0) {\n try {\n await api(pat, \"PUT\", `/management/v1/users/${userId}/grants/${grantId}`, { roleKeys: roles }, orgId);\n } catch (updateErr) {\n // Zitadel 400s a no-op update (\"User grant has not been changed\",\n // COMMAND-Rs8fy) \u2014 the grant already carries the declared roles.\n // Anything else is real. Must not throw here: aborting mid-provision\n // skips the secret (re-)mint below and leaves callers without creds.\n const ue = updateErr as { message?: string };\n if (!/has not been changed/i.test(String(ue.message))) throw updateErr;\n }\n }\n }\n\n if (createdUser || !(await authSecretExists(tenant, app, \"m2m\"))) {\n const secret = await api(pat, \"PUT\", `/management/v1/users/${userId}/secret`, {}, orgId);\n await writeAuthSecret(tenant, app, \"m2m\", {\n clientId: secret.clientId,\n clientSecret: secret.clientSecret,\n issuer: \"http://localhost:8080\",\n orgId,\n projectId,\n });\n return true;\n }\n return false;\n}\n\nasync function authSecretExists(tenant: string, app: string, service: string): Promise<boolean> {\n const { SecretsManagerClient, GetSecretValueCommand } = await import(\"@aws-sdk/client-secrets-manager\");\n const sm = new SecretsManagerClient(LOCAL_AWS_CONFIG);\n return sm.send(new GetSecretValueCommand({ SecretId: authSecretPath(tenant, app, service) })).then(\n () => true,\n () => false,\n );\n}\n\nasync function writeAuthSecret(\n tenant: string,\n app: string,\n service: string,\n value: Record<string, string>,\n): Promise<void> {\n const name = authSecretPath(tenant, app, service);\n await upsertLocalSecret(name, value);\n logSuccess(`Stored service credentials \u2192 ${name}`);\n}\n\n/**\n * Register the running app in the local platform registry \u2014 the same\n * self-registration path deployed apps use (/mesh-platform/{tenant}/{env}/\n * apps/{app}/meta), so StackPort (and the local Hub once its images land)\n * can visualize local apps.\n */\nexport async function registerLocalApp(\n args: AppTenantAuthArgs & {\n ports?: Record<string, number>;\n /** ExternalService names the app's services consume (ExternalService.link()\n * parity) \u2014 the Hub derives Integrations consumers + uptime from these. */\n links?: string[];\n /** The app's own version (its package.json), stamped like a deploy. */\n version?: string;\n },\n): Promise<string> {\n const { SSMClient, PutParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(LOCAL_AWS_CONFIG);\n const put = (name: string, value: unknown, description: string) =>\n ssm.send(\n new PutParameterCommand({ Name: name, Type: \"String\", Overwrite: true, Value: JSON.stringify(value), Description: description }),\n );\n\n // The app's tenant + env must be registered for the Hub to list the app.\n await registerTenantEnv(args.tenant);\n\n // Per-stack registration in the exact shape hub-api's SSM assembler reads\n // (stack \"local\" = the mesh dev --local runtime).\n const namespace = `${args.tenant}-${LOCAL_ENV}-${args.app}`;\n const base = `/mesh-platform/${args.tenant}/${LOCAL_ENV}/apps/${args.app}/stacks/local`;\n await put(\n base,\n {\n name: args.app,\n tenant: args.tenant,\n stack: \"local\",\n namespace,\n platform: { name: \"local\", env: LOCAL_ENV },\n },\n `Local app registration for ${args.tenant}/${args.app} (mesh dev --local)`,\n );\n for (const service of args.services) {\n const port = args.ports?.[service];\n const isMock = service.startsWith(\"mock-\");\n await put(\n `${base}/services/${service}`,\n {\n ...(port ? { url: `http://localhost:${port}`, port } : {}),\n replicas: 1,\n tier: \"core\",\n ...(!isMock && args.links?.length ? { links: args.links } : {}),\n },\n `Local service registration (${service})`,\n );\n }\n // Worker registration \u2014 the shape deployed TemporalWorker components\n // write; hub-api discovers a tenant's Temporal namespaces from these, so\n // the tenant-wide workflows view finds local runs without an explicit\n // namespace filter. Template convention: worker services are named so.\n for (const service of args.services) {\n if (!/worker/i.test(service)) continue;\n await put(\n `${base}/workers/${service}`,\n { temporalNamespace: namespace, taskQueue: args.app },\n `Local worker registration (${service})`,\n );\n }\n await put(\n `${base}/meta`,\n {\n runtime: \"mesh-dev-local\",\n services: args.services,\n ports: args.ports ?? {},\n // The same two fields a deployed stack stamps, so the Hub's app list and\n // its activity timeline read a local run exactly as they read a deploy \u2014\n // which version of this app is running here, and since when. Without them\n // a local app shows a blank version and never appears on the timeline,\n // and \"deployments aren't tracked\" is indistinguishable from \"no deploys\".\n ...(args.version ? { version: args.version } : {}),\n deployedAt: new Date().toISOString(),\n },\n \"Local run metadata (mesh dev --local)\",\n );\n logSuccess(`Registered app in the local registry \u2192 ${base}`);\n return base;\n}\n\n/**\n * Reconcile the platform registry from Zitadel \u2014 every app-tenant org (and\n * its projects = apps, applications = services) provisioned in Zitadel gets\n * a Hub-readable registration, even if the app hasn't been run since it was\n * provisioned. Zitadel is the auth source of truth; the registry mirrors it,\n * exactly like the cloud where ZitadelTenantIdentity/ZitadelAppIdentity and\n * the app's self-registration both flow from the same deploy. Idempotent:\n * existing per-stack registrations (which carry live ports/URLs from\n * `mesh dev --local`) are never overwritten.\n */\nexport async function reconcileRegistryFromZitadel(): Promise<{ tenants: string[]; apps: number }> {\n const pat = readSeederPat();\n const { SSMClient, GetParameterCommand, PutParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const ssm = new SSMClient(LOCAL_AWS_CONFIG);\n\n const orgs = await api(pat, \"POST\", \"/admin/v1/orgs/_search\", { query: { limit: 200 } });\n const tenants: string[] = [];\n let apps = 0;\n for (const org of orgs?.result ?? []) {\n const tenant = org?.name;\n // The platform org holds platform-service auth config, not an app tenant.\n if (!tenant || tenant === PLATFORM_ORG) continue;\n await registerTenantEnv(tenant);\n tenants.push(tenant);\n\n const projects = await api(\n pat,\n \"POST\",\n \"/management/v1/projects/_search\",\n { query: { limit: 200 } },\n org.id,\n );\n for (const project of projects?.result ?? []) {\n const app = project?.name;\n if (!app) continue;\n apps++;\n const base = `/mesh-platform/${tenant}/${LOCAL_ENV}/apps/${app}/stacks/local`;\n const exists = await ssm.send(new GetParameterCommand({ Name: base })).then(\n () => true,\n () => false,\n );\n if (exists) continue;\n await ssm.send(\n new PutParameterCommand({\n Name: base,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({\n name: app,\n tenant,\n stack: \"local\",\n namespace: `${tenant}-${LOCAL_ENV}-${app}`,\n platform: { name: \"local\", env: LOCAL_ENV },\n }),\n Description: `App registration reconciled from Zitadel (${tenant}/${app})`,\n }),\n );\n const applications = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${project.id}/apps/_search`,\n { query: { limit: 100 } },\n org.id,\n );\n for (const application of applications?.result ?? []) {\n const service = application?.name;\n if (!service) continue;\n await ssm.send(\n new PutParameterCommand({\n Name: `${base}/services/${service}`,\n Type: \"String\",\n Overwrite: true,\n Value: JSON.stringify({ replicas: 1, tier: \"core\" }),\n Description: `Service registration reconciled from Zitadel (${service})`,\n }),\n );\n }\n }\n }\n return { tenants, apps };\n}\n\n/**\n * Ensure a service has a BROWSER sign-in, not just an API client.\n *\n * `ensureServiceApp` gives every service a Zitadel **API** application, which is\n * what a machine caller authenticates with. A person cannot sign in to one:\n * there is no authorization endpoint and no redirect URI, so the Hub's\n * Access \u2192 Sign-in tab reports the app has no browser login and refuses to\n * create a user \u2014 correctly, since the account would have nowhere to log in.\n *\n * An app declares which of its services carry a login in `package.json`:\n *\n * ```json\n * { \"mesh\": { \"auth\": { \"signIn\": [\"ui\"] } } }\n * ```\n *\n * which mirrors what the Pulumi program expresses as a `web` OIDC application\n * on `ZitadelAppIdentity`. Deployed, the redirect URI comes from the service's\n * public hostname; locally it is the port `mesh dev` allocated, so this runs\n * AFTER port allocation and re-asserts the URIs on every launch \u2014 a session\n * that lands on a different port must not leave the previous one registered as\n * the only valid callback.\n *\n * Idempotent, and best-effort at the call site: a missing sign-in app degrades\n * the Access tab, it does not stop the dev loop.\n */\nexport async function ensureSignInApp(args: {\n tenant: string;\n app: string;\n service: string;\n /** Origin the service is reachable at, e.g. `http://localhost:40881`. */\n baseUrl: string;\n}): Promise<void> {\n const pat = readSeederPat();\n const orgId = await ensureOrg(pat, args.tenant);\n const projectId = await ensureProject(pat, orgId, args.app);\n const name = `${args.service}-web`;\n const base = args.baseUrl.replace(/\\/+$/, \"\");\n // oauth2-proxy's callback, and both slash forms for post-logout \u2014 Zitadel\n // exact-matches the latter, and proxies differ on the trailing slash.\n const redirectUris = [`${base}/oauth2/callback`];\n const postLogoutRedirectUris = [base, `${base}/`];\n\n const config = {\n redirectUris,\n postLogoutRedirectUris,\n responseTypes: [\"OIDC_RESPONSE_TYPE_CODE\"],\n grantTypes: [\"OIDC_GRANT_TYPE_AUTHORIZATION_CODE\", \"OIDC_GRANT_TYPE_REFRESH_TOKEN\"],\n appType: \"OIDC_APP_TYPE_WEB\",\n authMethodType: \"OIDC_AUTH_METHOD_TYPE_BASIC\",\n // Roles must ride in the token: the app authorizes on them, and the Hub\n // shows which roles a person holds by reading the same grants.\n accessTokenType: \"OIDC_TOKEN_TYPE_JWT\",\n accessTokenRoleAssertion: true,\n idTokenRoleAssertion: true,\n idTokenUserinfoAssertion: true,\n // http:// callbacks are only permitted in dev mode.\n devMode: true,\n };\n\n const existing = await searchZitadelApp(pat, projectId, name, orgId);\n if (existing) {\n await api(\n pat,\n \"PUT\",\n `/management/v1/projects/${projectId}/apps/${existing.id}/oidc_config`,\n config,\n orgId,\n );\n return;\n }\n\n const created = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/apps/oidc`,\n { name, ...config },\n orgId,\n );\n await writeAuthSecret(args.tenant, args.app, name, {\n clientId: created.clientId,\n clientSecret: created.clientSecret,\n issuer: \"http://localhost:8080\",\n orgId,\n projectId,\n });\n logSuccess(`Created Zitadel application '${name}' (browser sign-in \u2192 ${base}/oauth2/callback)`);\n}\n\n/**\n * Ensure the app tenant's full auth config exists (org \u2192 project \u2192\n * application-per-service \u2192 credential secrets).\n */\nexport async function ensureAppTenantAuth(args: AppTenantAuthArgs): Promise<ProvisionedAuth> {\n const pat = readSeederPat();\n const orgId = await ensureOrg(pat, args.tenant);\n const projectId = await ensureProject(pat, orgId, args.app);\n const roles = args.roles ?? [];\n await ensureProjectRoles(pat, orgId, projectId, roles);\n const created: string[] = [];\n for (const service of args.services) {\n const result = await ensureServiceApp(pat, orgId, projectId, service);\n if (result.created) {\n await writeAuthSecret(args.tenant, args.app, service, {\n clientId: result.clientId,\n clientSecret: result.clientSecret,\n issuer: \"http://localhost:8080\",\n orgId,\n projectId,\n });\n created.push(service);\n } else if (!(await authSecretExists(args.tenant, args.app, service))) {\n // Zitadel persists across stack restarts but the local secret store is\n // in-memory \u2014 heal the gap by regenerating the application's secret\n // (Zitadel returns it only once, so a lost secret can't be re-read).\n const regenerated = await api(\n pat,\n \"POST\",\n `/management/v1/projects/${projectId}/apps/${result.appId}/api_config/_generate_client_secret`,\n {},\n orgId,\n );\n await writeAuthSecret(args.tenant, args.app, service, {\n clientId: result.clientId ?? \"\",\n clientSecret: regenerated.clientSecret,\n issuer: \"http://localhost:8080\",\n orgId,\n projectId,\n });\n logSuccess(`Regenerated credentials for '${service}' (local secret store was reset)`);\n created.push(service);\n }\n }\n if (await ensureM2mCaller(pat, args.tenant, args.app, orgId, projectId, roles)) {\n created.push(\"m2m\");\n }\n if (created.length === 0) {\n logInfo(`Auth config for ${args.tenant}/${args.app} already provisioned`);\n }\n return { orgId, projectId, created };\n}\n", "/**\n * `mesh.json` \u2014 the repo-level record of which tenant a checkout belongs to\n * and where that tenant runs.\n *\n * Written by the `mesh init` wizard at the repo root, read by anything that\n * wants a default `--tenant` (`mesh create-app`, `mesh dev`'s local mode) and\n * by the wizard itself so a re-run pre-fills its answers. It is deliberately\n * tiny and app-agnostic: per-app identity stays in each app's Pulumi config,\n * which wins when both are present.\n *\n * @example\n * // mesh.json\n * { \"tenant\": \"acme\", \"platform\": \"local\" }\n */\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\n\n/** The file's name at the repo root. */\nexport const MESH_JSON = \"mesh.json\";\n\n/** The record `mesh.json` holds. */\nexport interface MeshJson {\n /** The app tenant this repo belongs to (`^[a-z][a-z0-9-]*$`). */\n tenant: string;\n /**\n * Where the tenant runs: `local` (the `mesh start` platform, no deployed\n * platform yet) or the environment name of a deployed platform (`dev`,\n * `uat`, \u2026), which with the tenant forms the `<tenant>.<env>` login context.\n */\n platform: string;\n}\n\n/** True when `value` is a tenant name every Mesh command accepts. */\nexport function isValidTenantName(value: string): boolean {\n return /^[a-z][a-z0-9-]*$/.test(value);\n}\n\n/**\n * Parse `mesh.json` content. Null when the document is not the expected\n * shape \u2014 a malformed file is treated as absent, never as an error, because\n * nothing that reads it can do better than \"no default\".\n *\n * @example\n * parseMeshJson('{\"tenant\":\"acme\",\"platform\":\"local\"}'); // \u2192 { tenant: \"acme\", platform: \"local\" }\n */\nexport function parseMeshJson(content: string): MeshJson | null {\n try {\n const data = JSON.parse(content) as Record<string, unknown>;\n if (typeof data?.tenant !== \"string\" || !isValidTenantName(data.tenant)) return null;\n const platform = typeof data.platform === \"string\" && data.platform ? data.platform : \"local\";\n return { tenant: data.tenant, platform };\n } catch {\n return null;\n }\n}\n\n/**\n * Find the nearest `mesh.json` at or above `startDir`. Stops at the first\n * match, at a `.git` directory (a repo boundary), or at the filesystem root.\n *\n * @example\n * findMeshJson(\"/work/acme-mesh-apps/apps/billing\"); // \u2192 { path: \"/work/acme-mesh-apps/mesh.json\", data }\n */\nexport function findMeshJson(startDir: string): { path: string; data: MeshJson } | null {\n let dir = path.resolve(startDir);\n while (true) {\n const candidate = path.join(dir, MESH_JSON);\n if (fs.existsSync(candidate)) {\n let content = \"\";\n try {\n content = fs.readFileSync(candidate, \"utf-8\");\n } catch {\n return null;\n }\n const data = parseMeshJson(content);\n return data ? { path: candidate, data } : null;\n }\n if (fs.existsSync(path.join(dir, \".git\"))) return null;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n}\n\n/**\n * Write `mesh.json` under `rootDir`, replacing an existing one. Returns the\n * path written.\n *\n * @example\n * writeMeshJson(\"/work/acme-mesh-apps\", { tenant: \"acme\", platform: \"dev\" });\n */\nexport function writeMeshJson(rootDir: string, data: MeshJson): string {\n const target = path.join(rootDir, MESH_JSON);\n fs.writeFileSync(target, `${JSON.stringify({ tenant: data.tenant, platform: data.platform }, null, 2)}\\n`);\n return target;\n}\n", "/**\n * Local DevOutput source for `mesh dev` (design \u00A75.2, AC C1/C3).\n *\n * Instead of reading a deployed Pulumi stack, synthesize the same DevOutput\n * shape from repo conventions and point every service at the running\n * `mesh start` platform. Zero AWS credentials, zero VPN, zero Pulumi state \u2014\n * the app's code never knows it's local:\n * - Temporal \u2192 localhost:7233, namespace local-dev (seeded)\n * - Postgres \u2192 the local `app` database\n * - Zitadel \u2192 http://localhost:8080 (seeded org/app/users)\n * - SpiceDB \u2192 localhost:50051 (preshared key)\n * - AWS SDK \u2192 ministack via AWS_ENDPOINT_URL (SSM/Secrets/S3 registry)\n *\n * Detection convention = what `mesh create-app` templates emit: immediate\n * subdirectories of the app root whose package.json has a `dev` script\n * (api/, worker/, web/, \u2026); a single-package app root with its own `dev`\n * script becomes one service.\n */\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport { isServiceMode, openapiMockCommand } from \"./mocks.js\";\nimport { probeTcp } from \"./helpers.js\";\nimport { MeshCliError } from \"../../utils/errors.js\";\nimport { localAwsEnv, LOCAL_ENV } from \"./seed.js\";\nimport { TEMPORAL_ADDRESS } from \"./seed.js\";\nimport { LOGIN_CONTEXT, ZITADEL_ISSUER } from \"./seed-zitadel.js\";\nimport { authSecretPath } from \"./auth-provision.js\";\nimport { findPackageRoot } from \"./stack.js\";\nimport { getContextConfig } from \"../login.js\";\nimport { findMeshJson } from \"../../utils/mesh-json.js\";\n\n/** Matches dev.ts's DevEnvVar/DevService/DevOutput shapes (kept structural \u2014\n * dev.ts owns the canonical interfaces). */\ninterface LocalDevEnvVar {\n value: string;\n}\ninterface LocalDevService {\n src: string;\n port: number;\n command: string[];\n env?: Record<string, LocalDevEnvVar>;\n}\nexport interface LocalDevOutput {\n platform: { tenant: string; env: string };\n app?: string;\n stack: string;\n services: Record<string, LocalDevService>;\n tunnels: Record<string, never>;\n}\n\nexport const LOCAL_STACK_NAME = \"local\";\nconst BASE_PORT = 3000;\n\n/**\n * The app's namespace, exactly the name the cloud gives it in K8s AND\n * Temporal ({tenant}-{env}-{app}) \u2014 the Hub resolves both from this one\n * convention, so local runs are addressable with zero translation.\n */\nexport function localAppNamespace(tenant: string, appName: string): string {\n return `${tenant}-${LOCAL_ENV}-${appName}`;\n}\n\n/** The OTLP log shipper (assets/log-shipper.mjs) each local service's\n * launch pipeline pipes through \u2014 ships stdout to the stack's collector\n * with the hosted k8s_* attribution. */\nexport function logShipperPath(): string {\n return path.join(findPackageRoot(), \"assets\", \"log-shipper.mjs\");\n}\n\n/**\n * OTel SDK bootstrap \u2014 the local analog of the OTel Operator's pod\n * injection: when the service ships `@opentelemetry/auto-instrumentations-\n * node` (the templates declare it), the platform injects the `--import`\n * register hook + exporter env, and HTTP/fetch/gRPC spans flow to the\n * stack's collector \u2192 Tempo \u2192 the Hub's Traces views. Apps without the\n * package run untouched.\n */\nexport function autoInstrumentationEnv(appRoot: string, src: string): Record<string, LocalDevEnvVar> {\n const pkgDir = path.join(\"node_modules\", \"@opentelemetry\", \"auto-instrumentations-node\");\n const present =\n fs.existsSync(path.join(appRoot, src, pkgDir)) || fs.existsSync(path.join(appRoot, pkgDir));\n if (!present) return {};\n return {\n NODE_OPTIONS: { value: \"--import @opentelemetry/auto-instrumentations-node/register\" },\n OTEL_TRACES_EXPORTER: { value: \"otlp\" },\n // Logs ship via the launch pipeline; metrics locally come from\n // Prometheus scrapes \u2014 keep the SDK to traces.\n OTEL_METRICS_EXPORTER: { value: \"none\" },\n OTEL_LOGS_EXPORTER: { value: \"none\" },\n OTEL_NODE_DISABLED_INSTRUMENTATIONS: { value: \"fs,dns,net\" },\n };\n}\n\n/** Hosted-parity resource attribution for a local service \u2014 what the\n * in-cluster k8sattributes processor stamps. */\nexport function otelEnv(\n tenant: string,\n appName: string,\n serviceName: string,\n): Record<string, LocalDevEnvVar> {\n const namespace = localAppNamespace(tenant, appName);\n return {\n OTEL_EXPORTER_OTLP_ENDPOINT: { value: \"http://localhost:4318\" },\n OTEL_SERVICE_NAME: { value: serviceName },\n OTEL_RESOURCE_ATTRIBUTES: {\n value: `k8s.namespace.name=${namespace},k8s.deployment.name=${serviceName},k8s.container.name=${serviceName},service.namespace=${appName}`,\n },\n };\n}\n\n/** Endpoints of the running `mesh start` platform, as service env. */\nexport function localPlatformEnv(\n tenant: string,\n appName: string,\n serviceName: string,\n): Record<string, LocalDevEnvVar> {\n const namespace = localAppNamespace(tenant, appName);\n const env: Record<string, LocalDevEnvVar> = {\n TEMPORAL_ADDRESS: { value: TEMPORAL_ADDRESS },\n TEMPORAL_NAMESPACE: { value: namespace },\n TEMPORAL_TASK_QUEUE: { value: appName },\n // OTel wiring, two consumers: instrumented services ship traces/metrics\n // themselves, and the launch pipeline's log shipper reads these to ship\n // stdout logs \u2014 both with the in-cluster k8sattributes attribution.\n ...otelEnv(tenant, appName, serviceName),\n DATABASE_URL: { value: \"postgres://postgres:postgres@localhost:5433/app\" },\n ZITADEL_ISSUER: { value: ZITADEL_ISSUER },\n // The service's own Zitadel M2M credentials (created by ensureAppTenantAuth\n // on session start) \u2014 resolveCredentials(\"zitadel\") picks this up, so apps\n // can hydrate introspection/M2M config the same way they resolve\n // ExternalService links instead of hand-carrying client ids in env files.\n ZITADEL_SECRET_PREFIX: { value: authSecretPath(tenant, appName, serviceName) },\n SPICEDB_ENDPOINT: { value: \"localhost:50051\" },\n SPICEDB_HTTP_ENDPOINT: { value: \"http://localhost:8443\" },\n SPICEDB_PRESHARED_KEY: { value: \"local-dev-key\" },\n MEMCACHED_SERVERS: { value: \"localhost:11211\" },\n MESH_LOCAL: { value: \"1\" },\n MESH_SERVICE: { value: serviceName },\n };\n // Local tokens come from `mesh login local` (the seeded CLI app), so that\n // app's clientId is the audience local APIs should verify. In the cloud,\n // consumers request the API project's audience scope instead.\n const cliClientId = getContextConfig(LOGIN_CONTEXT)?.clientId;\n if (cliClientId) {\n env.AUTH_AUDIENCE = { value: cliClientId };\n }\n for (const [key, value] of Object.entries(localAwsEnv())) {\n env[key] = { value };\n }\n return env;\n}\n\nfunction hasDevScript(dir: string): boolean {\n const pkgPath = path.join(dir, \"package.json\");\n if (!fs.existsSync(pkgPath)) return false;\n try {\n const pkg = JSON.parse(fs.readFileSync(pkgPath, \"utf-8\"));\n return typeof pkg?.scripts?.dev === \"string\";\n } catch {\n return false;\n }\n}\n\n/**\n * Detect runnable services by template convention: immediate subdirectories\n * with a package.json `dev` script; falls back to the app root itself when\n * it is a single-package app.\n */\nexport function detectLocalServices(appRoot: string): Record<string, { src: string }> {\n const services: Record<string, { src: string }> = {};\n for (const entry of fs.readdirSync(appRoot, { withFileTypes: true })) {\n if (!entry.isDirectory() || entry.name.startsWith(\".\") || entry.name === \"node_modules\") continue;\n if (hasDevScript(path.join(appRoot, entry.name))) {\n services[entry.name] = { src: entry.name };\n }\n }\n if (Object.keys(services).length === 0 && hasDevScript(appRoot)) {\n services[path.basename(appRoot)] = { src: \".\" };\n }\n return services;\n}\n\n/** Synthesize the DevOutput `mesh dev` would otherwise read from Pulumi.\n * `tenant` is the app tenant (detectLocalTenant) \u2014 it scopes the app's\n * namespace ({tenant}-{env}-{app}) for Temporal, logs, and traces.\n * `opts.mocks` (mesh dev --externals) appends declared mock services and injects\n * their ExternalService credential prefixes into every app service. */\nexport function buildLocalDevOutput(\n appRoot: string,\n tenant: string,\n opts: { mocks?: Record<string, import(\"./mocks.js\").LocalMockDecl> } = {},\n): LocalDevOutput {\n const detected = detectLocalServices(appRoot);\n const names = Object.keys(detected);\n if (names.length === 0) {\n throw new MeshCliError(\n `No runnable services found under ${appRoot} \u2014 expected subdirectories (api/, worker/, \u2026) with a package.json 'dev' script.`,\n { remediation: { command: \"mesh create-app\" } },\n );\n }\n // App name = project name = authz namespace. Pulumi.yaml `name:` wins\n // (matches getProjectName \u2014 a stack-backed app run with --local must keep\n // its deployed identity), then package.json name, then the basename.\n // Scoped package names (@scu-apps/plaid) are unusable as Temporal/K8s\n // namespaces \u2014 drop the scope.\n let appName = path.basename(appRoot);\n try {\n const pkg = JSON.parse(fs.readFileSync(path.join(appRoot, \"package.json\"), \"utf-8\"));\n if (typeof pkg?.name === \"string\" && pkg.name) appName = pkg.name.replace(/^@[^/]+\\//, \"\");\n } catch {\n // no root package.json \u2014 keep the basename\n }\n const pulumiYaml = path.join(appRoot, \"Pulumi.yaml\");\n if (fs.existsSync(pulumiYaml)) {\n const match = fs.readFileSync(pulumiYaml, \"utf-8\").match(/^name:\\s*(.+)$/m);\n if (match?.[1]?.trim()) appName = match[1].trim();\n }\n const services: Record<string, LocalDevService> = {};\n const mocks = opts.mocks ?? {};\n // Only process/openapi declarations run as mock-{name} services in this\n // orchestration; compose externals are docker-managed and sandbox\n // declarations spawn nothing (dev.ts seeds their credentials directly).\n const mockNames = Object.keys(mocks).filter((name) => isServiceMode(name, mocks[name]!));\n\n // The ExternalService link() contract, synthesized: every app service gets\n // {EXT}_SECRET_PREFIX for each mocked external, so resolveCredentials()\n // runs the exact deployed code path against the locally seeded secret.\n const externalEnv: Record<string, LocalDevEnvVar> = {};\n for (const decl of Object.values(mocks)) {\n if (!decl.external) continue;\n const prefix = decl.external.toUpperCase().replace(/-/g, \"_\");\n externalEnv[`${prefix}_SECRET_PREFIX`] = {\n value: `mesh/${tenant}/${LOCAL_ENV}/external/${decl.external}`,\n };\n }\n\n names.forEach((name, i) => {\n const port = BASE_PORT + i;\n services[name] = {\n src: detected[name]!.src,\n port,\n command: [\"pnpm\", \"dev\"],\n env: {\n // allocatePorts keeps service.port and env.PORT in sync on reallocation\n PORT: { value: String(port) },\n ...localPlatformEnv(tenant, appName, name),\n ...autoInstrumentationEnv(appRoot, detected[name]!.src),\n ...externalEnv,\n },\n };\n });\n\n // Sibling-service URLs \u2014 the local analog of the deployed cross-service\n // wiring (http://{svc}.{namespace}.svc.cluster.local:{port} in-cluster):\n // every app service gets SERVICE_{NAME}_URL for each app service, so code\n // can wire cross-service clients locally without knowing allocated ports.\n // allocatePorts' cross-service localhost:{port} rewrite keeps them in sync\n // when ports are reallocated.\n //\n // This is a PUBLIC, app-facing env contract \u2014 {NAME} is the service's\n // directory name upper-cased, non-[A-Z0-9] \u2192 \"_\". Apps that name their\n // link the same way (events.link({ environmentPrefix: \"SERVICE_EVENTS\" }))\n // read ONE variable deployed and locally, with no isLocal branch.\n // See docs/guides/mesh-dev-local-env.md.\n for (const name of names) {\n const key = `SERVICE_${name.toUpperCase().replace(/[^A-Z0-9]/g, \"_\")}_URL`;\n const url = `http://localhost:${services[name]!.port}`;\n for (const other of names) {\n services[other]!.env![key] = { value: url };\n }\n }\n\n // Declared mocks join the same orchestration (ports, env file, tmux\n // window, log pipeline) as first-class services named mock-{name}.\n mockNames.forEach((name, i) => {\n const decl = mocks[name]!;\n const serviceName = `mock-${name}`;\n const port = decl.port ?? BASE_PORT + names.length + i;\n services[serviceName] = {\n // openapi mode runs Prism from the app root against the spec path.\n src: decl.src ?? \".\",\n port,\n command: decl.openapi ? openapiMockCommand(decl) : (decl.command ?? [\"pnpm\", \"dev\"]),\n env: {\n PORT: { value: String(port) },\n ...otelEnv(tenant, appName, serviceName),\n ...Object.fromEntries(\n Object.entries(decl.env ?? {}).map(([key, value]) => [key, { value }]),\n ),\n },\n };\n });\n\n return {\n platform: { tenant: \"local\", env: \"dev\" },\n app: appName,\n stack: LOCAL_STACK_NAME,\n services,\n tunnels: {},\n };\n}\n\n/**\n * Whether the app is backed by a Pulumi program: its own (Pulumi.yaml or a\n * Pulumi.<stack>.yaml stack config in the app dir), or a monorepo ROOT\n * program above it (walk up to the repo boundary \u2014 .git/pnpm-workspace).\n * Apps like apps/hub deploy via a root program and have no per-app\n * Pulumi.yaml; they must NOT default into `mesh dev` local mode.\n */\nexport function hasStackBacking(appRoot: string): boolean {\n const hasOwn =\n fs.existsSync(path.join(appRoot, \"Pulumi.yaml\")) ||\n fs.readdirSync(appRoot).some((f) => /^Pulumi\\..+\\.yaml$/.test(f));\n if (hasOwn) return true;\n let dir = path.dirname(appRoot);\n while (true) {\n if (fs.existsSync(path.join(dir, \"Pulumi.yaml\"))) return true;\n if (fs.existsSync(path.join(dir, \".git\")) || fs.existsSync(path.join(dir, \"pnpm-workspace.yaml\"))) {\n return fs.existsSync(path.join(dir, \"Pulumi.yaml\"));\n }\n const parent = path.dirname(dir);\n if (parent === dir) return false;\n dir = parent;\n }\n}\n\n/**\n * The app's tenant, for auth provisioning: `mesh:tenant` from the app's\n * Pulumi stack config when present (templates write it), else \"local\".\n */\nexport function detectLocalTenant(appRoot: string): string {\n for (const file of fs.readdirSync(appRoot)) {\n if (!/^Pulumi\\..*\\.yaml$/.test(file)) continue;\n const match = fs\n .readFileSync(path.join(appRoot, file), \"utf-8\")\n .match(/^\\s*mesh:tenant:\\s*[\"']?([A-Za-z0-9-]+)[\"']?\\s*$/m);\n if (match) return match[1]!;\n }\n // No per-app stack config names a tenant: fall back to the repo-level\n // record `mesh init` writes, then to the local default.\n const recorded = findMeshJson(appRoot);\n if (recorded) return recorded.data.tenant;\n return \"local\";\n}\n\n/** The local platform must be up before `mesh dev` can wire services to it. */\nexport async function ensureLocalPlatformRunning(): Promise<void> {\n const [temporalUp, fabricUp] = await Promise.all([probeTcp(7233), probeTcp(4566)]);\n if (!temporalUp || !fabricUp) {\n throw new MeshCliError(\n \"The local Mesh platform is not running (mesh dev local mode wires services to it).\",\n { remediation: { command: \"mesh start\" } },\n );\n }\n}\n", "/**\n * Docker runner for `mesh dev --local` (design \u00A75.3, AC C2).\n *\n * The tmux runner stays the default on dev machines; `--runner docker` is\n * for environments where tmux isn't sensible \u2014 CI (the golden-path\n * workflow), headless agents, non-macOS boxes. It renders the SAME\n * synthesized DevOutput into a docker compose project:\n *\n * - one service per DevOutput service, `node:22-bookworm`, the app\n * bind-mounted at /workspace, `pnpm dev` in the service dir\n * - `network_mode: host` \u2014 services reach the `mesh start` platform on\n * localhost exactly like the tmux runner (Linux/CI semantics; on macOS\n * Docker Desktop host networking must be enabled, and host-installed\n * node_modules are platform-specific \u2014 tmux is the right runner there)\n * - per-service env files carrying the identical env contract\n * - logs ship through the same OTLP shipper (mounted from the CLI)\n *\n * Parity: `mesh dev --status|--kill|logs|restart` map to\n * `docker compose ps|down|logs|restart` via the session state's runner.\n */\n\nimport { execFileSync } from \"child_process\";\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport { logShipperPath } from \"./dev-local.js\";\nimport { meshCacheDir } from \"../../utils/cache-home.js\";\n\n// Digest-pinned so dev-runner containers are reproducible (matches the\n// pinning of the stack's third-party images).\nconst DEV_RUNNER_IMAGE =\n \"node:22-bookworm@sha256:5647be709086c696ff32edaaf1c70cd26d1da6ab2b39c32f3c7b4c4a31957e37\";\n\ninterface RunnerService {\n src: string;\n port: number;\n command: string[];\n env?: Record<string, { value: string }>;\n}\n\nfunction sessionDir(sessionName: string): string {\n return meshCacheDir(\"mesh-local\", \"dev\", sessionName);\n}\n\n/** docker env-file format: KEY=VALUE, no quoting, single-line values only. */\nfunction renderDockerEnvFile(env: Record<string, { value: string }>): string {\n const lines: string[] = [];\n for (const [key, entry] of Object.entries(env)) {\n if (entry.value.includes(\"\\n\")) continue; // multi-line values unsupported by env_file\n lines.push(`${key}=${entry.value}`);\n }\n return lines.join(\"\\n\") + \"\\n\";\n}\n\n/** Write the compose project for a dev session; returns the compose file path. */\nexport function writeDevCompose(\n sessionName: string,\n appRoot: string,\n services: Record<string, RunnerService>,\n): string {\n const dir = sessionDir(sessionName);\n const shipper = logShipperPath();\n\n const blocks: string[] = [\n `# Generated by \\`mesh dev --local --runner docker\\` \u2014 do not edit.`,\n `name: ${sessionName}`,\n `services:`,\n ];\n for (const [name, service] of Object.entries(services)) {\n const env = service.env ?? {};\n fs.writeFileSync(path.join(dir, `${name}.env`), renderDockerEnvFile(env), { mode: 0o600 });\n const workdir = path.posix.join(\"/workspace\", service.src === \".\" ? \"\" : service.src);\n const ship = env.OTEL_RESOURCE_ATTRIBUTES\n ? ` 2>&1 | NODE_OPTIONS= node /mesh-assets/log-shipper.mjs`\n : \"\";\n blocks.push(\n ` ${name}:`,\n ` image: ${DEV_RUNNER_IMAGE}`,\n ` network_mode: host`,\n ` working_dir: ${workdir}`,\n ` volumes:`,\n ` - '${appRoot}:/workspace'`,\n ` - '${path.dirname(shipper)}:/mesh-assets:ro'`,\n ` environment:`,\n // node_modules is the HOST's bind-mounted install \u2014 pnpm inside the\n // container must never \"verify\" it and try a purge/reinstall (pnpm\n // \u226510.9's verify-deps-before-run prompts and dies headless; worse, a\n // purge would clobber the host install).\n ` CI: 'true'`,\n ` npm_config_verify_deps_before_run: 'false'`,\n ` env_file:`,\n ` - './${name}.env'`,\n // pipefail: the container's exit code must be the SERVICE's, not the\n // log-shipper's \u2014 otherwise a crashed service reports success (CI).\n ` command: [\"bash\", \"-lc\", \"set -o pipefail; corepack enable >/dev/null 2>&1; { ${service.command.join(\" \")}; }${ship}\"]`,\n ` restart: unless-stopped`,\n );\n }\n const composePath = path.join(dir, \"compose.yml\");\n fs.writeFileSync(composePath, blocks.join(\"\\n\") + \"\\n\");\n return composePath;\n}\n\nfunction composeArgs(sessionName: string, args: string[]): string[] {\n return [\"compose\", \"-p\", sessionName, \"-f\", path.join(sessionDir(sessionName), \"compose.yml\"), ...args];\n}\n\nexport function dockerDevUp(sessionName: string): void {\n execFileSync(\"docker\", composeArgs(sessionName, [\"up\", \"-d\"]), {\n stdio: [\"ignore\", \"inherit\", \"inherit\"],\n });\n}\n\nexport function dockerDevDown(sessionName: string): void {\n execFileSync(\"docker\", composeArgs(sessionName, [\"down\", \"--remove-orphans\"]), {\n stdio: [\"ignore\", \"inherit\", \"inherit\"],\n });\n}\n\nexport function dockerDevPs(sessionName: string): string {\n return execFileSync(\"docker\", composeArgs(sessionName, [\"ps\", \"--format\", \"table {{.Service}}\\t{{.State}}\\t{{.Status}}\"]), {\n encoding: \"utf-8\",\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n }) as unknown as string;\n}\n\nexport function dockerDevLogs(sessionName: string, service: string, tail: number): string {\n return execFileSync(\"docker\", composeArgs(sessionName, [\"logs\", \"--tail\", String(tail), service]), {\n encoding: \"utf-8\",\n stdio: [\"ignore\", \"pipe\", \"pipe\"],\n }) as unknown as string;\n}\n\nexport function dockerDevRestart(sessionName: string, service: string): void {\n execFileSync(\"docker\", composeArgs(sessionName, [\"restart\", service]), {\n stdio: [\"ignore\", \"inherit\", \"inherit\"],\n });\n}\n", "/**\n * Stack-selection flag resolution (MESH-2154).\n *\n * `mesh` subcommands historically disagreed on the stack-selection flag:\n * `deploy`/`cluster` use `--stack` (Pulumi-native), while `dev`/`secrets` used\n * `--stage` (the SST-era term the platform has moved off). So `mesh kubectl\n * --stage dev` and `mesh dev --stack \u2026` each errored on muscle memory. We\n * consolidate on `--stack` everywhere and keep `--stage` as a HIDDEN, deprecated\n * alias that still works but warns \u2014 removable after a transition window.\n */\nimport { logWarn } from './log.js';\n\nexport interface StackSelector {\n /** Canonical Pulumi stack name. */\n stack?: string;\n /** @deprecated SST-era alias for {@link StackSelector.stack}; still accepted. */\n stage?: string;\n}\n\n/**\n * Resolve the stack selector, preferring the canonical `--stack` over the\n * deprecated `--stage` alias. Pure: reports whether the deprecated alias supplied\n * the value so the caller can warn. `--stack` wins when both are present.\n */\nexport function resolveStackSelector(opts: StackSelector): {\n stack: string | undefined;\n usedDeprecatedStage: boolean;\n} {\n if (opts.stack != null) return { stack: opts.stack, usedDeprecatedStage: false };\n if (opts.stage != null) return { stack: opts.stage, usedDeprecatedStage: true };\n return { stack: undefined, usedDeprecatedStage: false };\n}\n\nlet warnedOnce = false;\n\n/** Test-only: reset the once-per-process deprecation-warning latch. */\nexport function resetStackFlagWarning(): void {\n warnedOnce = false;\n}\n\n/**\n * Resolve `--stack`/`--stage` to the effective stack for use at a consumption\n * site, warning ONCE per process when the deprecated `--stage` alias supplied it.\n */\nexport function resolveStackOption(opts: StackSelector): string | undefined {\n const { stack, usedDeprecatedStage } = resolveStackSelector(opts);\n if (usedDeprecatedStage && !warnedOnce) {\n warnedOnce = true;\n logWarn(\n '--stage is deprecated; use --stack (the Pulumi-native name). --stage still works for now.',\n );\n }\n return stack;\n}\n", "/**\n * How peers address each other inside a `mesh dev` session (MESH-2597).\n *\n * `mesh dev` allocates local ports and rewrites `localhost:<oldPort>` references\n * across every service's env, which handles the PORT half of the question. The\n * HOST half was never handled: an env var derived from a `link()` renders as\n * `http://<svc>.<ns>.svc.cluster.local:8080`, and that survived untouched even\n * when the service it names was one of the ones just started on this laptop.\n *\n * Observed 2026-08-01: after a relaunch, `studio-ui` held\n * `AGENT_API_INTERNAL_URL=http://studio-api.fub-dev-studio.svc.cluster.local:8080`\n * while `studio-api` was running locally on :40161. The UI's proxy dialled the\n * cluster from the laptop, every request failed `getaddrinfo ENOTFOUND`, and the\n * whole app returned `502 upstream unavailable`.\n *\n * Its own module rather than more of `dev.ts` (already ~3.7k lines) so the\n * rewriting rule is testable without importing the launch path.\n */\n\n/** The subset of a dev service this module reads. */\nexport interface AddressableService {\n port: number;\n /** Empty for a DEPLOYED service \u2014 it is not running here. */\n command?: string[];\n env?: Record<string, { value: string }>;\n}\n\nexport interface AddressableOutput {\n services?: Record<string, AddressableService>;\n}\n\n/**\n * `http(s)://<name>.<namespace>.svc.cluster.local[:port]`.\n *\n * Anchored on the full service name so `studio-api` cannot match\n * `studio-api-worker`, and the name is escaped because it reaches this as data.\n */\nexport function clusterHostPattern(serviceName: string): RegExp {\n const name = serviceName.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n return new RegExp(`https?://${name}\\\\.[a-z0-9-]+\\\\.svc\\\\.cluster\\\\.local(?::\\\\d+)?`, \"g\");\n}\n\n/**\n * Point every reference to a LOCALLY-RUN service at its local port.\n *\n * Only services this session actually runs are rewritten. A deployed-only service\n * (no dev command) keeps its cluster address, because reaching that is the\n * tunnels' job, not this one's \u2014 rewriting it would break the one case that\n * currently works.\n *\n * The namespace is matched rather than resolved: the session already knows which\n * services it started and on which ports, and re-deriving the namespace here\n * would be a second source of truth for something the URL already carries.\n */\nexport function rewriteClusterHostsToLocal(devOutput: AddressableOutput): void {\n const localPorts = new Map<string, number>();\n for (const [name, service] of Object.entries(devOutput.services ?? {})) {\n if (!service.command || service.command.length === 0) continue;\n if (typeof service.port !== \"number\" || service.port <= 0) continue;\n localPorts.set(name, service.port);\n }\n if (localPorts.size === 0) return;\n\n for (const service of Object.values(devOutput.services ?? {})) {\n if (!service.env) continue;\n for (const envVar of Object.values(service.env)) {\n if (typeof envVar.value !== \"string\" || !envVar.value.includes(\".svc.cluster.local\")) continue;\n for (const [name, port] of localPorts) {\n envVar.value = envVar.value.replace(clusterHostPattern(name), `http://localhost:${port}`);\n }\n }\n }\n}\n", "/**\n * `mesh dev doctor` \u2014 preflight + on-demand diagnosis for `mesh dev`.\n *\n * Three layers kept deliberately separate so the bug-prone logic stays pure and\n * unit-testable without network/AWS:\n * 1. Engine + renderers (this section): `aggregateStatus`, `runChecks`,\n * `renderHuman`, `renderJson`.\n * 2. `DoctorContext` builder (resolves role/context/session once).\n * 3. The individual `Check`s (creds, tunnels, ports, \u2026), each returning data.\n *\n * Both `mesh dev doctor` and the `mesh dev` launch preflight call `runChecks`;\n * see MESH-2015 (docs/superpowers/specs/2026-07-08-mesh-dev-doctor-design.md).\n */\n\nimport { execFileSync } from \"node:child_process\";\nimport * as fs from \"node:fs\";\nimport * as net from \"node:net\";\nimport * as path from \"node:path\";\nimport type { SessionState } from \"./dev.js\";\nimport { isLinkedDependencyDir, isPortFree } from \"./dev.js\";\nimport type { ResolvedAwsCreds } from \"../utils/aws-auth.js\";\nimport {\n appUsesMeshPackages,\n probeRegistryToken,\n registryLoginFix,\n} from \"../utils/auth-preflight.js\";\nimport { probeCredentials, isRemoteEnvironment, type CredProbe } from \"./login.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport type CheckStatus = \"ok\" | \"warn\" | \"error\";\nexport type CheckPhase = \"preflight\" | \"ondemand\";\n\nexport interface CheckResult {\n status: CheckStatus;\n /** One line, present tense. */\n summary: string;\n /** Exact command that fixes it (rendered indented under the summary). */\n remediation?: string;\n /** Optional extra context (PID, TTL, path, \u2026). */\n detail?: string;\n}\n\n/**\n * One diagnostic. `C` is the context the check reads: `DoctorContext` for\n * `mesh dev doctor` (the default), `AppCheckContext` for `mesh app check` \u2014\n * the engine below is shared, only the context differs.\n */\nexport interface Check<C = DoctorContext> {\n /** Stable slug, e.g. `creds`, `tunnels`. */\n id: string;\n /** Human label. */\n title: string;\n /** Which contexts this check runs in. */\n phases: CheckPhase[];\n /** `phase` is the context of THIS run \u2014 checks may bound work tighter in\n * `preflight` (launch path) than in on-demand `mesh dev doctor`. */\n run(ctx: C, phase?: CheckPhase): Promise<CheckResult>;\n}\n\n/** What the renderers need from a check: its identity, not its `run`. */\nexport type CheckOutcome = { check: Pick<Check<unknown>, \"id\" | \"title\">; result: CheckResult };\n\nexport interface DoctorContext {\n appRoot: string;\n stack: string;\n sessionName: string;\n /** Resolved deployer role ARN (incl. admin selection), or null if none. */\n deployerRole: string | null;\n /** `derivePlatformContext()` result (the Zitadel context), or null. */\n platformContext: string | null;\n /** How creds were obtained at launch; null when the probe determines it. */\n credMethod: ResolvedAwsCreds[\"method\"] | \"ambient\" | null;\n /** Live session state (allocated ports, tunnels); null pre-launch. */\n sessionState: SessionState | null;\n}\n\n// ============================================================================\n// Engine + renderers (pure)\n// ============================================================================\n\n/** `error` if any error, else `warn` if any warn, else `ok` (empty \u2192 `ok`). */\nexport function aggregateStatus(results: CheckResult[]): CheckStatus {\n if (results.some((r) => r.status === \"error\")) return \"error\";\n if (results.some((r) => r.status === \"warn\")) return \"warn\";\n return \"ok\";\n}\n\n/**\n * Run every check whose `phases` includes `phase`, concurrently. A check that\n * throws is captured as an `error` result rather than aborting the run \u2014 the\n * whole point of doctor is to report, never to crash.\n */\nexport async function runChecks<C = DoctorContext>(\n ctx: C,\n phase: CheckPhase,\n checks: Check<C>[],\n): Promise<Array<{ check: Check<C>; result: CheckResult }>> {\n const applicable = checks.filter((c) => c.phases.includes(phase));\n return Promise.all(\n applicable.map(async (check) => {\n try {\n return { check, result: await check.run(ctx, phase) };\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n return {\n check,\n result: {\n status: \"error\" as const,\n summary: `check '${check.id}' threw: ${msg}`,\n },\n };\n }\n }),\n );\n}\n\nexport const ICONS: Record<CheckStatus, string> = { ok: \"\u2713\", warn: \"\u26A0\", error: \"\u2717\" };\n\n/** Human-readable: one line per check + indented remediation when present. */\nexport function renderHuman(results: CheckOutcome[]): string {\n const lines: string[] = [];\n for (const { check, result } of results) {\n lines.push(`${ICONS[result.status]} ${check.title} \u2014 ${result.summary}`);\n if (result.detail) lines.push(` ${result.detail}`);\n if (result.remediation) lines.push(` Fix: ${result.remediation}`);\n }\n return lines.join(\"\\n\");\n}\n\n/** The JSON report as data \u2014 `renderJson` serialises it; `mesh app check` nests one per app. */\nexport function jsonReport(results: CheckOutcome[]): {\n status: CheckStatus;\n checks: Array<{ id: string; status: CheckStatus; summary: string; remediation: string | null; detail: string | null }>;\n} {\n return {\n status: aggregateStatus(results.map((r) => r.result)),\n checks: results.map(({ check, result }) => ({\n id: check.id,\n status: result.status,\n summary: result.summary,\n // Stable keys for tooling: always present, `null` when absent\n // (JSON.stringify would otherwise drop `undefined`).\n remediation: result.remediation ?? null,\n detail: result.detail ?? null,\n })),\n };\n}\n\n/** Machine-readable: stable key shape for tooling / future CI. */\nexport function renderJson(results: CheckOutcome[]): string {\n return JSON.stringify(jsonReport(results), null, 2);\n}\n\n// ============================================================================\n// Check: credentials + TTL (creds)\n// ============================================================================\n\n/** Render a TTL in seconds as a compact human string (\"47m\", \"1h 10m\"). */\nfunction fmtTtl(seconds: number): string {\n if (seconds <= 0) return \"expired\";\n // Round to whole minutes first, then split \u2014 so 11h59.5m carries to \"12h\"\n // rather than rendering \"11h 60m\".\n const totalMin = Math.round(seconds / 60);\n const h = Math.floor(totalMin / 60);\n const m = totalMin % 60;\n if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;\n return `${m}m`;\n}\n\n/**\n * Pure mapper: a `CredProbe` \u2192 a `CheckResult` with the exact remediation.\n * `headless` selects the device-flow `mesh login` variant (browser flow times\n * out over SSH/tailscale \u2014 see the credential runbook).\n */\nexport function credProbeToResult(\n probe: CredProbe,\n context: string,\n headless: boolean,\n): CheckResult {\n const loginCmd = `mesh login ${context}${headless ? \" --device\" : \"\"}`;\n switch (probe.state) {\n case \"ok\":\n return {\n status: probe.ttlSeconds < 300 ? \"warn\" : \"ok\",\n summary: `deployer creds valid ${fmtTtl(probe.ttlSeconds)}${probe.email ? ` (${probe.email})` : \"\"}`,\n detail: `expires ${probe.expiresAt}`,\n };\n case \"no-session\":\n return {\n status: \"error\",\n summary: `no Zitadel session for ${context}`,\n remediation: loginCmd,\n };\n case \"expired-session\":\n return {\n status: \"error\",\n summary: `Zitadel session for ${context} expired and could not refresh`,\n remediation: loginCmd,\n };\n case \"assume-denied\":\n return {\n status: \"error\",\n summary: \"session valid but AssumeRole denied \u2014 wrong IAM role/policy\",\n detail: probe.detail,\n remediation: `check mesh:deployerRole in the stack config and the role's trust/permissions; re-login if role changed: ${loginCmd}`,\n };\n case \"stale-env-override\":\n return {\n status: \"error\",\n summary:\n \"stale AWS_* env vars would override the self-refreshing login profile\",\n remediation:\n \"unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN\",\n };\n }\n}\n\nexport const credsCheck: Check = {\n id: \"creds\",\n title: \"Credentials\",\n phases: [\"preflight\", \"ondemand\"],\n async run(ctx) {\n // When creds were already resolved (launch preflight passes the method),\n // trust that result \u2014 avoids a second AssumeRole call, and correctly\n // reports the SSO/environment paths that `probeCredentials` (Zitadel-only)\n // would otherwise misdiagnose.\n switch (ctx.credMethod) {\n case \"sso\":\n return {\n status: \"ok\",\n summary: \"using AWS SSO credentials (auto-refresh from SSO cache)\",\n };\n case \"environment\":\n return { status: \"ok\", summary: \"using AWS credentials from environment\" };\n case \"zitadel\":\n return {\n status: \"ok\",\n summary:\n \"using Zitadel deployer credentials (credential_process auto-refresh)\",\n };\n }\n // credMethod null (on-demand doctor, or a failed launch resolution) or\n // 'ambient': determine the real state by probing.\n if (!ctx.platformContext || !ctx.deployerRole) {\n return {\n status: \"warn\",\n summary:\n \"no deployer role/context resolved \u2014 using ambient AWS credentials\",\n detail:\n \"add mesh:deployerRole to the Pulumi stack config to use mesh login credentials\",\n };\n }\n const probe = await probeCredentials(ctx.platformContext, ctx.deployerRole);\n return credProbeToResult(probe, ctx.platformContext, isRemoteEnvironment());\n },\n};\n\n// ============================================================================\n// Check: CodeArtifact registry token (registry) \u2014 MESH-2479\n// ============================================================================\n\n/**\n * Verify the @mesh-tech CodeArtifact token actually works BEFORE long work \u2014\n * an expired token otherwise surfaces minutes later as npm's generic E401\n * (\"try npm login\", which cannot fix a CodeArtifact registry). Warn-level:\n * `mesh dev` itself runs installed code, so a stale token shouldn't block\n * launch \u2014 it should name the fix.\n */\nexport const registryCheck: Check = {\n id: \"registry\",\n title: \"Registry auth\",\n phases: [\"preflight\", \"ondemand\"],\n async run(ctx, phase) {\n // The other preflight checks are fast \u2014 don't let an unreachable\n // registry (offline / VPN off) hold the whole launch for the full\n // on-demand bound. The zero-access local loop must stay snappy.\n const probe = await probeRegistryToken(phase === \"preflight\" ? { timeoutMs: 3_000 } : undefined);\n switch (probe.state) {\n case \"fresh\":\n return { status: \"ok\", summary: \"CodeArtifact token accepted by the @mesh-tech registry\" };\n case \"expired\":\n return {\n status: \"warn\",\n summary:\n \"CodeArtifact token expired/rejected \u2014 installing @mesh-tech packages will fail with E401\",\n detail: probe.detail,\n remediation: registryLoginFix(),\n };\n case \"unreachable\":\n return {\n status: \"ok\",\n summary: \"registry unreachable \u2014 CodeArtifact token not verified (offline?)\",\n detail: probe.detail,\n };\n case \"missing\":\n return appUsesMeshPackages(ctx.appRoot)\n ? {\n status: \"warn\",\n summary:\n \"no CodeArtifact auth in ~/.npmrc, but this app depends on @mesh-tech packages \u2014 pnpm install will fail with E401\",\n remediation: registryLoginFix(),\n }\n : {\n status: \"ok\",\n summary: \"no CodeArtifact auth configured (needed only to install @mesh-tech packages)\",\n };\n }\n },\n};\n\n// ============================================================================\n// Checks: tmux, ports, worktree\n// ============================================================================\n\n/**\n * Parse `lsof -ti :PORT` + `ps -o comm= -p PID` output into the squatting\n * process. `lsof` may list several PIDs (one per line); we name the first.\n */\nexport function parsePortSquatter(\n lsofOut: string,\n psOut: string,\n): { pid: number; command: string } | null {\n const firstLine = lsofOut.trim().split(\"\\n\")[0]?.trim();\n const pid = Number(firstLine);\n if (!firstLine || !Number.isInteger(pid)) return null;\n const command = psOut.trim().split(\"\\n\")[0]?.trim() || \"unknown\";\n return { pid, command };\n}\n\n/** True when `serviceSrc` lives under (or equals) `repoRoot`. */\nexport function sharesWorktreeRoot(\n serviceSrc: string,\n repoRoot: string,\n): boolean {\n const src = path.resolve(serviceSrc);\n const root = path.resolve(repoRoot);\n return src === root || src.startsWith(root + path.sep);\n}\n\n/** How a service port relates to the live session that should own it. */\nexport type PortOwnership = \"owned\" | \"foreign\" | \"dead\";\n\n/**\n * Pure classification for the on-demand ports check. Given the PID currently\n * listening on a service port (`null` when nothing is) and the set of PIDs in\n * the live session's tmux process tree:\n * - `null` listener \u2192 `dead` (the service isn't running)\n * - listener in the session \u2192 `owned` (healthy \u2014 the session's own svc)\n * - listener outside it \u2192 `foreign` (a real squatter)\n * Mid-session the session's services are *supposed* to hold their ports, so a\n * naive \"occupied \u21D2 squatted\" test misreports a healthy session (MESH-2015).\n */\nexport function classifyPortListener(\n listenerPid: number | null,\n sessionPids: ReadonlySet<number>,\n): PortOwnership {\n if (listenerPid === null) return \"dead\";\n return sessionPids.has(listenerPid) ? \"owned\" : \"foreign\";\n}\n\n/**\n * Pure: expand tmux pane PIDs to the full set of PIDs in their process\n * subtrees, using the output of `ps -eo pid=,ppid=` (each line \"PID PPID\").\n * The roots themselves are included; unparseable lines are skipped.\n */\nexport function collectSessionPids(\n panePids: number[],\n psOut: string,\n): Set<number> {\n const children = new Map<number, number[]>();\n for (const line of psOut.trim().split(\"\\n\")) {\n const [pidStr, ppidStr] = line.trim().split(/\\s+/);\n const pid = Number(pidStr);\n const ppid = Number(ppidStr);\n if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue;\n const kids = children.get(ppid) ?? [];\n kids.push(pid);\n children.set(ppid, kids);\n }\n const result = new Set<number>();\n const queue = panePids.filter((p) => Number.isInteger(p));\n while (queue.length > 0) {\n const pid = queue.shift() as number;\n if (result.has(pid)) continue;\n result.add(pid);\n for (const child of children.get(pid) ?? []) queue.push(child);\n }\n return result;\n}\n\n/** Identify the process squatting a port; null if it can't be determined. */\nfunction whoHasPort(port: number): { pid: number; command: string } | null {\n try {\n const lsof = execFileSync(\"lsof\", [\"-ti\", `:${port}`], {\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n const pid = lsof.trim().split(\"\\n\")[0]?.trim();\n if (!pid) return null;\n let ps = \"\";\n try {\n ps = execFileSync(\"ps\", [\"-o\", \"comm=\", \"-p\", pid], {\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n } catch {\n /* ps failed \u2014 parsePortSquatter falls back to \"unknown\" */\n }\n return parsePortSquatter(lsof, ps);\n } catch {\n return null;\n }\n}\n\n/**\n * Gather every PID in the live session's tmux process trees (pane PIDs plus\n * their descendants). Used to tell the session's own service processes apart\n * from foreign squatters. Returns an empty set when tmux/ps are unavailable or\n * the session has no panes \u2014 the caller then treats occupied ports as\n * unidentified rather than owned.\n */\nfunction sessionProcessTree(sessionName: string): Set<number> {\n let panePids: number[];\n try {\n const out = execFileSync(\n \"tmux\",\n [\"list-panes\", \"-s\", \"-t\", sessionName, \"-F\", \"#{pane_pid}\"],\n { encoding: \"utf8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] },\n );\n panePids = out\n .trim()\n .split(\"\\n\")\n .map((l) => Number(l.trim()))\n .filter((n) => Number.isInteger(n));\n } catch {\n return new Set();\n }\n if (panePids.length === 0) return new Set();\n let psOut = \"\";\n try {\n psOut = execFileSync(\"ps\", [\"-eo\", \"pid=,ppid=\"], {\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n } catch {\n // No process table \u2014 the pane PIDs themselves are still session-owned.\n return new Set(panePids);\n }\n return collectSessionPids(panePids, psOut);\n}\n\nexport const tmuxCheck: Check = {\n id: \"tmux\",\n title: \"tmux\",\n phases: [\"preflight\", \"ondemand\"],\n async run() {\n try {\n execFileSync(\"which\", [\"tmux\"], { stdio: \"ignore\" });\n return { status: \"ok\", summary: \"installed\" };\n } catch {\n return {\n status: \"error\",\n summary: \"tmux is not installed\",\n remediation: \"brew install tmux\",\n };\n }\n },\n};\n\nexport const portsCheck: Check = {\n id: \"ports\",\n title: \"Ports\",\n phases: [\"preflight\", \"ondemand\"],\n async run(ctx) {\n const services = ctx.sessionState?.devOutput.services;\n if (!services || Object.keys(services).length === 0) {\n // Preflight (no session): ports are validated at allocation/launch time.\n return {\n status: \"ok\",\n summary: \"no session \u2014 ports are checked at allocation time\",\n };\n }\n // Mid-session the session's OWN services should be listening on their\n // allocated ports. Classify each port against the session's process tree so\n // healthy self-owned ports don't read as squatters (MESH-2015).\n const sessionPids = sessionProcessTree(ctx.sessionName);\n const foreign: string[] = []; // held by a process outside the session\n const dead: string[] = []; // nothing listening \u2014 service died/isn't up\n const unknown: string[] = []; // occupied but the owner couldn't be resolved\n for (const [name, svc] of Object.entries(services)) {\n const free = await isPortFree(svc.port);\n const who = free ? null : whoHasPort(svc.port);\n const ownership = classifyPortListener(who?.pid ?? null, sessionPids);\n if (ownership === \"owned\") continue;\n if (ownership === \"foreign\") {\n foreign.push(`${name} :${svc.port} held by ${who!.command} (pid ${who!.pid})`);\n continue;\n }\n // `dead` from the helper (null listener pid): either the port is\n // genuinely free (service down) or it's occupied but the owner couldn't\n // be resolved via lsof.\n if (free) {\n dead.push(`${name} :${svc.port} not listening`);\n } else {\n unknown.push(`${name} :${svc.port} in use (owner unknown)`);\n }\n }\n const total = Object.keys(services).length;\n if (foreign.length === 0 && dead.length === 0 && unknown.length === 0) {\n return {\n status: \"ok\",\n summary: `all ${total} service port(s) owned by this session`,\n };\n }\n const parts: string[] = [];\n if (foreign.length) parts.push(`${foreign.length} squatted by another process`);\n if (dead.length) parts.push(`${dead.length} not listening (service down)`);\n if (unknown.length) parts.push(`${unknown.length} in use by an unidentified process`);\n const remediation =\n foreign.length > 0\n ? \"stop the squatting process (or its SSH tunnel), then `mesh dev restart <svc>`\"\n : dead.length > 0\n ? \"restart the stopped service: `mesh dev restart <svc>`\"\n : \"identify the port owner (`lsof -i :<port>`), then `mesh dev restart <svc>`\";\n return {\n // A real squatter or a down service is actionable \u2192 error; an\n // unidentifiable occupant alone is only a warning.\n status: foreign.length > 0 || dead.length > 0 ? \"error\" : \"warn\",\n summary: `service ports: ${parts.join(\", \")}`,\n detail: [...foreign, ...dead, ...unknown].join(\"; \"),\n remediation,\n };\n },\n};\n\n// ============================================================================\n// Check: config staleness (config-staleness)\n// ============================================================================\n\n/** True when the stack config file changed after the session was launched. */\nexport function isConfigStale(\n configMtimeMs: number,\n startedAtIso: string,\n): boolean {\n return configMtimeMs > Date.parse(startedAtIso);\n}\n\nexport const configStalenessCheck: Check = {\n // Preflight runs *at* launch, so nothing can be stale-relative-to-launch yet\n // \u2014 this only makes sense mid-session.\n id: \"config-staleness\",\n title: \"Config freshness\",\n phases: [\"ondemand\"],\n async run(ctx) {\n const state = ctx.sessionState;\n if (!state) {\n return { status: \"ok\", summary: \"no session \u2014 config read fresh at launch\" };\n }\n const configPath = path.join(ctx.appRoot, `Pulumi.${ctx.stack}.yaml`);\n if (!fs.existsSync(configPath)) {\n return {\n status: \"ok\",\n summary: `no Pulumi.${ctx.stack}.yaml to compare`,\n };\n }\n const mtimeMs = fs.statSync(configPath).mtimeMs;\n if (isConfigStale(mtimeMs, state.startedAt)) {\n return {\n status: \"warn\",\n summary: `Pulumi.${ctx.stack}.yaml changed since launch \u2014 a restart won't pick this up`,\n detail: `config mtime ${new Date(mtimeMs).toISOString()} > session start ${state.startedAt}`,\n remediation: `mesh deploy up --stack ${ctx.stack} && mesh dev`,\n };\n }\n return { status: \"ok\", summary: \"stack config unchanged since launch\" };\n },\n};\n\nexport const worktreeCheck: Check = {\n id: \"worktree\",\n title: \"Worktree\",\n phases: [\"preflight\", \"ondemand\"],\n async run(ctx) {\n const services = ctx.sessionState?.devOutput.services;\n if (!services) {\n return { status: \"ok\", summary: \"no session \u2014 worktree checked at launch\" };\n }\n let repoRoot: string;\n try {\n repoRoot = execFileSync(\"git\", [\"rev-parse\", \"--show-toplevel\"], {\n cwd: ctx.appRoot,\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n }).trim();\n } catch {\n return {\n status: \"warn\",\n summary: \"could not resolve git worktree root for the app\",\n };\n }\n // A dev-LINKED package is outside the worktree by construction and is what\n // the app runs either way \u2014 the same exemption `mesh dev` itself applies, so\n // the doctor does not warn about something the launcher deliberately allows.\n const foreign = Object.entries(services)\n .filter(\n ([, svc]) =>\n svc.src &&\n !sharesWorktreeRoot(svc.src, repoRoot) &&\n !isLinkedDependencyDir(svc.src, ctx.appRoot),\n )\n .map(([name, svc]) => `${name} \u2192 ${svc.src}`);\n if (foreign.length === 0) {\n return {\n status: \"ok\",\n summary: `all services resolve under ${repoRoot}`,\n };\n }\n return {\n status: \"warn\",\n summary: `${foreign.length} service(s) resolve outside this worktree \u2014 you may be running another worktree's code`,\n detail: foreign.join(\"; \"),\n remediation: \"relaunch from this worktree: `mesh dev` (re-reads stack output paths)\",\n };\n },\n};\n\n// ============================================================================\n// Checks: tunnels + temporal (connectivity)\n// ============================================================================\n\n/** Resolve `true` iff a TCP connection to host:port succeeds within `timeoutMs`. */\nexport function canConnect(\n host: string,\n port: number,\n timeoutMs = 800,\n): Promise<boolean> {\n return new Promise((resolve) => {\n const socket = new net.Socket();\n let settled = false;\n const done = (ok: boolean) => {\n if (settled) return;\n settled = true;\n socket.destroy();\n resolve(ok);\n };\n socket.setTimeout(timeoutMs);\n socket.once(\"connect\", () => done(true));\n socket.once(\"timeout\", () => done(false));\n socket.once(\"error\", () => done(false));\n socket.connect(port, host);\n });\n}\n\nfunction isLocalHost(host: string): boolean {\n return host === \"localhost\" || host === \"127.0.0.1\" || host === \"::1\";\n}\n\nexport const tunnelsCheck: Check = {\n // On-demand only: pre-launch the tunnels don't exist yet, and dialing the\n // VPC endpoints they'll use would false-positive on an SSM-only machine.\n id: \"tunnels\",\n title: \"Tunnels\",\n phases: [\"ondemand\"],\n async run(ctx) {\n const tunnels = ctx.sessionState?.devOutput.tunnels;\n if (!tunnels || Object.keys(tunnels).length === 0) {\n return { status: \"ok\", summary: \"no tunnels in this session\" };\n }\n // Only dial the SSM-forwarded (localhost) tunnels \u2014 those are the ones that\n // die with \"broken pipe \u2192 ECONNREFUSED\". VPC-direct tunnels are reachable\n // only over VPN and would false-positive here.\n const local = Object.entries(tunnels).filter(([, t]) => isLocalHost(t.host));\n if (local.length === 0) {\n return {\n status: \"ok\",\n summary: `${Object.keys(tunnels).length} tunnel(s), all VPC-direct (not dialed)`,\n };\n }\n const dead: string[] = [];\n for (const [name, t] of local) {\n if (!(await canConnect(t.host, t.port))) dead.push(`${name} (${t.host}:${t.port})`);\n }\n if (dead.length === 0) {\n return { status: \"ok\", summary: `${local.length} SSM tunnel(s) live` };\n }\n return {\n status: \"error\",\n summary: `${dead.length} SSM tunnel(s) down`,\n detail: dead.join(\"; \"),\n remediation: \"relaunch to re-establish tunnels: `mesh dev`\",\n };\n },\n};\n\nexport const temporalCheck: Check = {\n // On-demand only: reachability depends on the session's tunnel being up.\n id: \"temporal\",\n title: \"Temporal\",\n phases: [\"ondemand\"],\n async run(ctx) {\n const state = ctx.sessionState;\n const t = state?.devOutput.tunnels[\"temporal\"];\n if (!state || !t) {\n return { status: \"ok\", summary: \"no temporal tunnel in this session\" };\n }\n const addr = `${t.host}:${t.port}`;\n if (!(await canConnect(t.host, t.port))) {\n return {\n status: \"error\",\n summary: `Temporal frontend unreachable at ${addr}`,\n remediation: \"tunnel likely down \u2014 relaunch: `mesh dev`\",\n };\n }\n const p = state.devOutput.platform;\n const app = state.devOutput.app;\n if (!p || !app) {\n return { status: \"ok\", summary: `frontend reachable at ${addr} (namespace unknown)` };\n }\n const namespace = `${p.tenant}-${p.env}-${app}`;\n try {\n const { Connection } = await import(\"@temporalio/client\");\n const connection = await Connection.connect({\n address: addr,\n connectTimeout: \"3s\",\n });\n try {\n await connection.workflowService.describeNamespace({ namespace });\n return { status: \"ok\", summary: `frontend reachable; namespace ${namespace} present` };\n } finally {\n await connection.close().catch(() => {});\n }\n } catch (err) {\n const code = (err as { code?: number }).code;\n // gRPC status: 5 NOT_FOUND, 7 PERMISSION_DENIED, 16 UNAUTHENTICATED.\n if (code === 5) {\n return {\n status: \"error\",\n summary: `namespace ${namespace} not found on the server`,\n remediation: `verify the app is deployed to this env (mesh deploy up --stack ${ctx.stack})`,\n };\n }\n if (code === 7 || code === 16) {\n return {\n status: \"ok\",\n summary: `frontend reachable; namespace ${namespace} auth-gated (not verified)`,\n };\n }\n return {\n status: \"warn\",\n summary: \"frontend reachable but namespace check errored\",\n detail: err instanceof Error ? err.message : String(err),\n };\n }\n },\n};\n\n// ============================================================================\n// Registry + runner\n// ============================================================================\n\n/** All checks, in display order. */\nexport const ALL_CHECKS: Check[] = [\n tmuxCheck,\n credsCheck,\n registryCheck,\n configStalenessCheck,\n portsCheck,\n worktreeCheck,\n tunnelsCheck,\n temporalCheck,\n];\n\n/**\n * Run the full on-demand doctor against a prebuilt context, print the report,\n * and return the aggregate status. The caller (the `mesh dev doctor`\n * subcommand) owns process exit.\n */\nexport async function runDoctor(\n ctx: DoctorContext,\n opts: { json: boolean },\n): Promise<CheckStatus> {\n const results = await runChecks(ctx, \"ondemand\", ALL_CHECKS);\n const status = aggregateStatus(results.map((r) => r.result));\n if (opts.json) {\n console.log(renderJson(results));\n } else {\n console.log(renderHuman(results));\n console.log(`\\n${ICONS[status]} overall: ${status}`);\n }\n return status;\n}\n", "/**\n * Per-worktree identity for collision-free concurrent `mesh dev` (MESH-2013).\n *\n * Running `mesh dev` in several git worktrees at once collides on ports, tmux\n * session names, tmpdir session state, and env-file dirs \u2014 because every one of\n * those derives from the app's Pulumi project name, which is identical across\n * worktrees. This module derives ONE deterministic token per worktree (from its\n * path) that the dev launcher threads through all of those so worktrees can't\n * step on each other.\n *\n * TASK QUEUES are unique by construction \u2014 `taskQueueSuffix` is `-${token}` and\n * the token carries the 4-hex path hash. SESSION NAMES are not: the tmux name is\n * `${projectName}-${slug}` (slug only), and the state file + env dir derive from\n * it, so two linked worktrees whose basenames sanitize to the same slug (e.g.\n * `.worktrees/MESH-2375` and `~/scratch/mesh-2375`) share all three. PORTS are\n * not either: the block is a hash bucketed into NUM_PORT_BLOCKS-1 slots and is\n * additionally scoped by app \u2014 see `withAppScopedPortBlock` below for what that\n * does and does not guarantee.\n *\n * The PRIMARY checkout (the repo's main working tree) keeps its historical\n * behavior \u2014 empty token, port block 0 (preferred ports), no session suffix \u2014\n * so existing muscle memory is preserved. Only LINKED worktrees get offsets.\n *\n * Pure helpers (`sanitizeSlug`, `worktreeHash`, `portBlockFor`, `blockBasePort`)\n * are git-free and unit-tested; `resolveWorktreeIdentity` shells to git via an\n * injectable runner so it can be tested without a real repo.\n */\nimport { execFileSync } from \"node:child_process\";\nimport * as crypto from \"node:crypto\";\nimport * as path from \"node:path\";\n\n/** Number of distinct port blocks (block 0 reserved for the primary checkout). */\nexport const NUM_PORT_BLOCKS = 64;\n/** First port of block 1; block 0 uses the app's preferred ports, not this range. */\nexport const PORT_BLOCK_BASE = 40000;\n/** Ports reserved per worktree block (split into service + tunnel sub-ranges). */\nexport const PORT_BLOCK_SIZE = 40;\n/** Service ports occupy `[base, base+SERVICE_SUBRANGE)`; tunnels take the rest. */\nexport const PORT_BLOCK_SERVICE_SUBRANGE = 24;\n\nexport interface WorktreeIdentity {\n /** Absolute path to this worktree's root (git top-level). */\n worktreeRoot: string;\n /** True for the repo's primary checkout; false for a linked `git worktree`. */\n isPrimary: boolean;\n /** Human-readable sanitized basename of the worktree root (e.g. \"design-registry\"). */\n slug: string;\n /** 4 hex chars of sha256(worktreeRoot) \u2014 disambiguates same-named worktrees. */\n hash: string;\n /** \"\" for the primary checkout; else `${slug}-${hash}`. */\n token: string;\n /** 0 for primary (preferred ports); 1..NUM_PORT_BLOCKS-1 for linked worktrees. */\n portBlock: number;\n /** \"\" for primary; else `-${token}` \u2014 appended to Temporal/Nexus task queues (Tier 2). */\n taskQueueSuffix: string;\n}\n\n/** Lowercase, hyphenate, strip non-alphanumerics; never empty. */\nexport function sanitizeSlug(name: string): string {\n const s = name\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n return s || \"wt\";\n}\n\n/** Stable 4-hex-char fingerprint of a worktree's absolute path. */\nexport function worktreeHash(worktreeRoot: string): string {\n return crypto.createHash(\"sha256\").update(worktreeRoot).digest(\"hex\").slice(0, 4);\n}\n\n/**\n * Map a worktree hash to a port block. Primary \u2192 0 (preferred ports).\n * Linked \u2192 deterministic 1..NUM_PORT_BLOCKS-1 (stable across runs for the same\n * worktree path, so ports don't drift between launches).\n */\nexport function portBlockFor(hash: string, isPrimary: boolean): number {\n if (isPrimary) return 0;\n const n = parseInt(hash, 16);\n return 1 + (n % (NUM_PORT_BLOCKS - 1));\n}\n\n/** First port of a block (block 0 has no dedicated range \u2014 callers use preferred ports). */\nexport function blockBasePort(block: number): number {\n return PORT_BLOCK_BASE + block * PORT_BLOCK_SIZE;\n}\n\n/**\n * Identity whose PORT BLOCK is derived from (worktree, app) \u2014 every non-port\n * field stays worktree-scoped. A repo can hold several apps whose `mesh dev`\n * sessions run CONCURRENTLY (e.g. scu-mesh-apps' plaid-token-manager +\n * plaid-reporting); with a per-worktree block they draw the same ports, and\n * the availability probe alone races services that are still booting and\n * haven't bound yet \u2014 both sessions allocated 41800 in the wild. Same\n * philosophy as the per-worktree blocks of MESH-2013.\n *\n * NOT disjoint by construction: `portBlockFor` buckets a hash into\n * NUM_PORT_BLOCKS-1 slots, so two (worktree, app) pairs CAN land on the same\n * block \u2014 and app-scoping raises the entity count from #worktrees to\n * #worktrees x #apps, which makes that likelier. Sibling apps in one worktree\n * (the case this fixes) are disjoint by default; the in-block probe and the\n * ephemeral fallback in `allocatePorts` remain the backstop for a collision.\n *\n * Primary checkouts keep preferred-port semantics (block 0) \u2014 sibling apps\n * there still share preferred ports and rely on that probe.\n */\nexport function withAppScopedPortBlock(wt: WorktreeIdentity, appRoot: string): WorktreeIdentity {\n if (wt.isPrimary) return wt;\n const appHash = worktreeHash(`${wt.worktreeRoot}#${path.resolve(appRoot)}`);\n return { ...wt, portBlock: portBlockFor(appHash, false) };\n}\n\n/**\n * Stack name for `mesh stack init --worktree` (MESH-2013 Slice 3): append the\n * worktree token so concurrent worktrees deploy to distinct stacks. The SSM\n * export paths (`apps/{app}/stacks/{stack}/...`), Temporal namespace\n * (`{tenant}-{stack}-{app}`), and Nexus endpoint name all derive from the stack\n * name, so one discriminator isolates all three. The primary checkout has an\n * empty token \u2192 the base name is returned unchanged (it IS the canonical stack).\n */\n/** Max chars of the worktree slug that reach a stack name. Deploy-time surfaces\n * (Temporal namespace `{tenant}-{stack}-{app}`, Nexus endpoint names) have\n * provider length limits, so we cap the human-readable slug here; the 4-hex hash\n * still guarantees uniqueness. (The local-only token \u2014 tmux/ports \u2014 is uncapped.) */\nexport const STACK_SLUG_MAX = 16;\n\nexport function worktreeStackName(baseName: string, wt: WorktreeIdentity): string {\n if (!wt.token) return baseName;\n const cappedSlug = wt.slug.slice(0, STACK_SLUG_MAX).replace(/-+$/, \"\");\n return `${baseName}-${cappedSlug}-${wt.hash}`;\n}\n\n/**\n * True when `stackName` would collide across worktrees: we're in a LINKED\n * worktree (non-empty token) but the stack name doesn't carry that token \u2014 so a\n * concurrent `pulumi up` from another worktree on the same name hits identical\n * SSM export paths / Temporal namespace. Drives the `mesh deploy` warning.\n */\nexport function stackNeedsWorktreeIsolation(stackName: string, wt: WorktreeIdentity): boolean {\n // The 4-hex hash is the stable discriminator present in every worktree stack\n // name (`worktreeStackName` ends with `-${hash}`), and survives the slug cap \u2014\n // so match on it rather than the full (uncapped) token.\n return !!wt.token && !stackName.endsWith(`-${wt.hash}`);\n}\n\ntype GitRunner = (args: string[], cwd: string) => string;\n\nconst defaultGitRunner: GitRunner = (args, cwd) =>\n execFileSync(\"git\", args, { cwd, encoding: \"utf-8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] }).trim();\n\n/**\n * Resolve the worktree identity for a given cwd. Returns a primary-checkout\n * identity (token \"\", block 0) when not inside a git repo, so non-repo/edge\n * cases degrade to today's behavior rather than throwing.\n */\nexport function resolveWorktreeIdentity(\n cwd: string = process.cwd(),\n gitRunner: GitRunner = defaultGitRunner,\n): WorktreeIdentity {\n let worktreeRoot: string;\n let isPrimary = true;\n try {\n worktreeRoot = path.resolve(gitRunner([\"rev-parse\", \"--show-toplevel\"], cwd));\n // The common git dir is `<primary-worktree>/.git`; its parent is the primary\n // checkout. A linked worktree's top-level differs from that parent.\n const commonDir = gitRunner([\"rev-parse\", \"--git-common-dir\"], cwd);\n const primaryRoot = path.resolve(path.dirname(path.resolve(cwd, commonDir)));\n isPrimary = worktreeRoot === primaryRoot;\n } catch {\n // Not a git repo (or git unavailable) \u2014 behave as the primary checkout.\n return {\n worktreeRoot: path.resolve(cwd),\n isPrimary: true,\n slug: sanitizeSlug(path.basename(path.resolve(cwd))),\n hash: worktreeHash(path.resolve(cwd)),\n token: \"\",\n portBlock: 0,\n taskQueueSuffix: \"\",\n };\n }\n\n const slug = sanitizeSlug(path.basename(worktreeRoot));\n const hash = worktreeHash(worktreeRoot);\n const token = isPrimary ? \"\" : `${slug}-${hash}`;\n return {\n worktreeRoot,\n isPrimary,\n slug,\n hash,\n token,\n portBlock: portBlockFor(hash, isPrimary),\n taskQueueSuffix: token ? `-${token}` : \"\",\n };\n}\n", "/**\n * mesh dev \u2014 Pulumi-output-driven local development orchestrator\n *\n * Reads `pulumi stack output dev --json` to discover services and VPC endpoints,\n * then launches each service's dev command in a tmux window.\n * Requires VPN connection for direct access to VPC resources.\n *\n * Usage:\n * mesh dev Start all services, attach tmux\n * mesh dev --headless Start without attaching\n * mesh dev --kill Stop everything\n * mesh dev --status Show service health (--json for structured)\n * mesh dev logs <service> Tail a service's output\n * mesh dev restart <svc> Restart a single service\n */\n\nimport { execFileSync, spawn, spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as net from 'net';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { Command, Option } from 'commander';\nimport { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';\nimport {\n logInfo,\n logSuccess,\n logError,\n logWarn,\n isVpnConnected,\n getTailscaleInfo,\n headscaleDnsConfig,\n registerDnsRecords,\n unregisterDnsRecords,\n readDnsRecords,\n getPlatformBastionInfo,\n} from '../utils/index.js';\nimport type { DnsRecord, HeadscaleDnsConfig } from '../utils/index.js';\nimport {\n derivePlatformContext,\n renderCredentialProcessProfile,\n resolveAwsCredentials,\n resolveStableMeshBin,\n selectRoleForCaller,\n stripBareProfile,\n upsertManagedAwsConfigSection,\n type ResolvedAwsCreds,\n} from '../utils/aws-auth.js';\nimport { atomicWriteFileSync, readCredentials, getContextConfig, probeCredentials, getValidToken } from './login.js';\nimport { resolveVpnJoinBroker, mintPreAuthKey } from '../utils/vpn-join.js';\nimport { credProbeToPreflightError } from '../utils/pulumi-run.js';\nimport {\n startTailscaleTunnels,\n tailscaleAvailable,\n daemonState as tailscaleDaemonState,\n readTunnelState as readTailscaleState,\n readDaemonMeta as readTailscaleDaemonMeta,\n} from '../utils/tailscale.js';\nimport { pulumiStackOutput, readStackConfig } from '../utils/pulumi.js';\nimport {\n ensureKubeconfig,\n resolveHubPlatformName,\n sessionKubeconfigPath,\n} from '../utils/kubeconfig.js';\nimport { resolveTemporalAuth } from '../utils/temporal-auth.js';\nimport { probeTcpReachable, probeConnectionHolds } from '../utils/reachability.js';\nimport { fingerprintWorkflowSource } from '../utils/workflow-fingerprint.js';\nimport { buildLaunchCommand, envFileName, waitForPort, writeEnvFile } from './dev-launch.js';\nimport { buildLocalDevOutput, detectLocalTenant, ensureLocalPlatformRunning, hasStackBacking, localAppNamespace } from './local/dev-local.js';\nimport { ensureAppTenantAuth, ensureSignInApp, registerLocalApp } from './local/auth-provision.js';\nimport { localAwsEnv, ensureTemporalNamespace } from './local/seed.js';\nimport {\n composeExternalUp,\n composeExternalsDown,\n externalMode,\n localProbesRemove,\n parseExternalsSelection,\n readLocalMocks,\n seedLocalMock,\n type ComposeExternalRef,\n type ExternalMode,\n type LocalMockDecl,\n} from './local/mocks.js';\nimport { logShipperPath } from './local/dev-local.js';\nimport { writeAppServiceProbes } from './local/stack.js';\nimport { writeDevCompose, dockerDevUp, dockerDevDown, dockerDevPs, dockerDevLogs, dockerDevRestart } from './local/docker-runner.js';\nimport { MeshCliError } from '../utils/errors.js';\nimport { resolveStackOption } from '../utils/stack-flag.js';\nimport { rewriteClusterHostsToLocal } from './peer-addressing.js';\nimport {\n ALL_CHECKS,\n aggregateStatus,\n renderHuman,\n runChecks,\n runDoctor,\n type DoctorContext,\n} from './dev-doctor.js';\nimport {\n resolveWorktreeIdentity,\n withAppScopedPortBlock,\n blockBasePort,\n PORT_BLOCK_SERVICE_SUBRANGE,\n type WorktreeIdentity,\n} from '../utils/worktree-identity.js';\n\n// ============================================================================\n// Types\n// ============================================================================\n\ninterface DevEnvVar {\n value: string;\n /** If set, env var value uses the named tunnel's host:port */\n tunnel?: string;\n}\n\ninterface DevService {\n src: string;\n port: number;\n command: string[];\n env?: Record<string, DevEnvVar>;\n}\n\n/** VPC-accessible endpoint for VPN direct access */\ninterface DevTunnel {\n /** VPC hostname (RDS endpoint, NLB hostname, etc.) */\n host: string;\n /** Port number */\n port: number;\n}\n\ninterface DevSecret {\n /** Secrets Manager secret name */\n secretName: string;\n /** Maps secret JSON keys \u2192 env var names to inject into services */\n envMapping: Record<string, string>;\n}\n\nexport interface DevOutput {\n /**\n * Platform context. `tenant` is the APP tenant (Temporal namespace auth +\n * encoding key). `name` is the HUB platform name (bastion + headscale DNS,\n * which live in the hub for shared tenants); falls back to \"mesh\".\n */\n platform?: { tenant: string; name?: string; env: string };\n /** App name (e.g., \"hub\") */\n app?: string;\n /** Pulumi stack name (e.g., \"dev\", \"feature-xyz\") */\n stack?: string;\n services: Record<string, DevService>;\n tunnels: Record<string, DevTunnel>;\n /** Secrets to resolve from Secrets Manager at dev startup */\n secrets?: Record<string, DevSecret>;\n}\n\ninterface DevOptions {\n app?: string;\n stage?: string;\n headless?: boolean;\n kill?: boolean;\n status?: boolean;\n json?: boolean;\n session?: string;\n profile?: string;\n local?: boolean;\n /** true = all declared externals; string = comma-separated subset of\n * name[=mode] entries (mode \u2208 mock|local|remote overrides the decl). */\n mock?: boolean | string;\n /** Preferred spelling of --mock (same values). */\n externals?: boolean | string;\n /** local-mode process runner: 'tmux' (default) or 'docker' */\n runner?: string;\n dryRun?: boolean;\n transport?: string;\n force?: boolean;\n}\n\n/**\n * The app's own services, as blackbox probe targets, so local uptime is real.\n *\n * Mock stand-ins are excluded: `--externals` already registers each of those\n * under its own external-service labels, and probing them twice would show one\n * dependency as two rows \u2014 one of which disappears when the mock does.\n *\n * Best-effort. A probe file is monitoring, not the app: failing to write one\n * must never take down a dev session that is otherwise working.\n */\nexport function registerServiceProbes(\n devOutput: DevOutput,\n tenant: string,\n probeFiles: string[],\n): void {\n const services: Record<string, number> = {};\n for (const [name, service] of Object.entries(devOutput.services)) {\n if (name.startsWith(\"mock-\")) continue;\n services[name] = service.port;\n }\n try {\n const file = writeAppServiceProbes({\n tenant,\n env: devOutput.platform?.env ?? \"dev\",\n app: devOutput.app ?? \"\",\n services,\n });\n if (file) probeFiles.push(file);\n } catch (err) {\n logWarn(\n `Could not register uptime probes for this session (${err instanceof Error ? err.message : err}) \u2014 the Hub will show no uptime for these services.`,\n );\n }\n}\n\n/**\n * tmux session name for a dev launch. The primary checkout keeps the historical\n * `${projectName}-dev`; linked worktrees get a `${projectName}-${slug}` so two\n * worktrees of the same app don't share (and kill) each other's session, state,\n * and env-file dir (MESH-2013).\n */\nfunction deriveSessionName(projectName: string, wt: WorktreeIdentity): string {\n return wt.isPrimary ? `${projectName}-dev` : `${projectName}-${wt.slug}`;\n}\n\n/**\n * Render the resolved per-worktree plan (`mesh dev --dry-run`): identity,\n * session/state/env paths, and the port assignment \u2014 everything that must be\n * collision-free across concurrent worktrees, without launching anything.\n */\nfunction printDevPlan(\n sessionName: string,\n appRoot: string,\n wt: WorktreeIdentity,\n devOutput: DevOutput,\n): void {\n const kind = wt.isPrimary ? 'primary checkout' : `linked worktree (block ${wt.portBlock})`;\n console.log(`\\nmesh dev plan \u2014 ${kind}\\n`);\n console.log(` worktree root : ${wt.worktreeRoot}`);\n console.log(` app root : ${appRoot}`);\n console.log(` token : ${wt.token || '(none \u2014 primary)'}`);\n console.log(` tmux session : ${sessionName}`);\n console.log(` session state : ${getSessionStatePath(sessionName)}`);\n console.log(` env dir : ${getSessionEnvDir(sessionName)}`);\n const tq = wt.taskQueueSuffix\n ? `<app-task-queue>${wt.taskQueueSuffix}`\n : '<app-task-queue> (primary \u2014 unchanged)';\n console.log(` task queue : ${tq}`);\n console.log(`\\n services (port \u00B7 resolved source dir):`);\n const locals = Object.entries(devOutput.services).filter(\n ([, s]) => (s.port ?? 0) >= 0 && s.command && s.command.length > 0,\n );\n if (locals.length === 0) {\n console.log(` (none \u2014 all services are deployed/K8s)`);\n } else {\n const monorepoRoot = findMonorepoRoot();\n const wtWithSep = wt.worktreeRoot.endsWith(path.sep) ? wt.worktreeRoot : wt.worktreeRoot + path.sep;\n for (const [name, s] of locals) {\n const dir = path.resolve(appRoot, rebaseServiceSrc(s.src, monorepoRoot));\n const outside = dir !== wt.worktreeRoot && !dir.startsWith(wtWithSep);\n const port = s.port > 0 ? `:${s.port}` : '(no port)';\n console.log(` ${name.padEnd(18)} ${port.padEnd(7)} ${dir}${outside ? ' \u26A0 OUTSIDE worktree' : ''}`);\n }\n }\n console.log('');\n}\n\n// ============================================================================\n// Session state & port allocation\n// ============================================================================\n\nexport interface SessionState {\n appRoot: string;\n stack: string;\n devOutput: DevOutput;\n startedAt: string;\n /** 'tmux' (default) or 'docker' (mesh dev --runner docker, local mode) */\n runner?: 'tmux' | 'docker';\n /** Compose-mode external services (mesh dev --externals) this session brought\n * up \u2014 torn down (volumes kept) on `mesh dev --kill`. */\n composeExternals?: ComposeExternalRef[];\n /** Prometheus file_sd probe targets this session's externals registered \u2014\n * removed on `mesh dev --kill` so the Hub stops showing uptime for a\n * session that isn't running. Recorded as paths so `--kill --session`\n * works without resolving an app root. */\n externalProbeFiles?: string[];\n /**\n * Content fingerprint of the workflow source (agent-sdk / interpreter /\n * agent-contracts) at launch. Compared on relaunch/restart to warn before the\n * worker replays in-flight conversations against changed workflow code\n * (MESH-2017). Absent for sessions launched before this field existed.\n */\n workflowFingerprint?: string;\n}\n\nfunction getSessionStatePath(sessionName: string): string {\n const dir = path.join(os.tmpdir(), 'mesh-dev-sessions');\n if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });\n return path.join(dir, `${sessionName}.json`);\n}\n\nfunction saveSessionState(sessionName: string, state: SessionState): void {\n fs.writeFileSync(getSessionStatePath(sessionName), JSON.stringify(state, null, 2));\n}\n\nfunction loadSessionState(sessionName: string): SessionState | null {\n const filePath = getSessionStatePath(sessionName);\n if (!fs.existsSync(filePath)) return null;\n try {\n return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as SessionState;\n } catch {\n return null;\n }\n}\n\n/**\n * Relaunch safety (MESH-2017): if the workflow source changed since this session\n * launched, restarting the worker replays any in-flight conversations against the\n * NEW code \u2014 a replay-incompatible change strands them. Warn and require `--force`.\n *\n * Silent no-op when there's no prior session, no stored fingerprint (pre-feature\n * session), or nothing changed \u2014 so a restart of unchanged code never nags.\n * Returns `true` if the caller should ABORT (changed && !force).\n */\n/** Pure: did the workflow source change since launch (\u2192 risk of stranding)? */\nexport function workflowChanged(prior: string | undefined, current: string): boolean {\n return Boolean(prior) && Boolean(current) && current !== prior;\n}\n\nfunction workflowChangeWouldStrand(\n priorState: SessionState | null,\n appRoot: string,\n force: boolean,\n): boolean {\n const prior = priorState?.workflowFingerprint;\n if (!prior) return false;\n const current = fingerprintWorkflowSource(appRoot, priorState?.devOutput?.services);\n if (!workflowChanged(prior, current)) return false;\n logWarn(\n 'Workflow code changed since this session started.\\n' +\n ' Restarting the worker replays any in-flight conversations against the NEW code \u2014\\n' +\n ' a replay-incompatible change strands them (they get stuck and become unviewable).\\n' +\n ' Keep it replay-compatible: gate the change with wf.patched() and regenerate the replay\\n' +\n ' goldens (see the temporal-workflow-safety skill), or accept the risk.',\n );\n if (force) {\n logWarn(' Proceeding anyway (--force).');\n return false;\n }\n logError(' Refusing to restart the worker. Re-run with --force once the change is replay-safe.');\n return true;\n}\n\n/**\n * Per-session directory holding one sourceable env file per service\n * (`<service>.env.sh`, mode 0600). Written at launch; `mesh dev restart`\n * sources the same file so a restarted service gets a byte-identical env.\n */\nfunction getSessionEnvDir(sessionName: string): string {\n return path.join(os.tmpdir(), 'mesh-dev-sessions', sessionName);\n}\n\nfunction getServiceEnvFilePath(sessionName: string, serviceName: string): string {\n return path.join(getSessionEnvDir(sessionName), envFileName(serviceName));\n}\n\nfunction removeSessionState(sessionName: string): void {\n try {\n fs.unlinkSync(getSessionStatePath(sessionName));\n } catch {}\n try {\n fs.rmSync(getSessionEnvDir(sessionName), { recursive: true, force: true });\n } catch {}\n}\n\n/**\n * Check whether a TCP port is available \u2014 probing the way services actually bind.\n *\n * This used to bind `127.0.0.1` only, and that made it blind to exactly the\n * processes it exists to find. Node's `listen(port)` (and `@hono/node-server`,\n * which every agent-api uses) binds the WILDCARD address `::` in dual-stack mode,\n * holding the port on every interface \u2014 yet a probe bound to `127.0.0.1` still\n * succeeds against such a holder. So the check returned `true` for a port already\n * held by a prior `mesh dev` service, `allocatePorts` kept the preferred port, and\n * the service then died on `EADDRINUSE` at its real bind.\n *\n * Observed 2026-07-31: the `studio-api` pane died on `EADDRINUSE :::40161` while a\n * stale agent-api from an earlier boot kept serving on the same port, so the Studio\n * UI drove a process from a previous session's configuration and the conversation\n * list came back empty. Reproduced in isolation \u2014 holder on `::`, loopback probe\n * reports free, real bind fails (MESH-2590).\n *\n * Both addresses are probed: the wildcard catches all-interface holders, and the\n * loopback probe still catches a loopback-only squatter. A false \"busy\" is cheap \u2014\n * `allocatePorts` simply picks another port \u2014 while a false \"free\" crashes the\n * service, so this deliberately errs toward busy.\n */\nexport async function isPortFree(port: number): Promise<boolean> {\n const bindSucceeds = (host?: string): Promise<boolean> =>\n new Promise((resolve) => {\n const server = net.createServer();\n server.once('error', () => resolve(false));\n const onListening = () => server.close(() => resolve(true));\n if (host === undefined) server.listen(port, onListening);\n else server.listen(port, host, onListening);\n });\n\n // Wildcard first: it is how the services bind, so it is the probe that matters.\n if (!(await bindSucceeds())) return false;\n return bindSucceeds('127.0.0.1');\n}\n\n/**\n * Ask the OS for an ephemeral free port.\n */\nasync function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const server = net.createServer();\n server.once('error', reject);\n server.listen(0, '127.0.0.1', () => {\n const { port } = server.address() as net.AddressInfo;\n server.close(() => resolve(port));\n });\n });\n}\n\n/** Resolve true if something is accepting connections on 127.0.0.1:port. */\nfunction isPortListening(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const s = new net.Socket();\n let done = false;\n const fin = (ok: boolean) => {\n if (done) return;\n done = true;\n s.destroy();\n resolve(ok);\n };\n s.setTimeout(300);\n s.once('connect', () => fin(true));\n s.once('timeout', () => fin(false));\n s.once('error', () => fin(false));\n s.connect(port, '127.0.0.1');\n });\n}\n\n/**\n * Rewrite a service's OWN listen-port env var(s) from `oldPort` to `newPort`\n * when `allocatePorts` reassigns its port (e.g. a worktree block). Matches any\n * port-ish var (`PORT` or `*_PORT`, e.g. `VCS_PORT`/`AGENT_API_PORT`) whose value\n * is exactly the old port \u2014 so a service that reads its own env var still binds\n * the reassigned port instead of its deploy-time default (which would collide\n * with another worktree/session on that default). The name + exact-value match\n * keeps this from touching cross-service references or unrelated vars.\n * Mutates `service.env`. Pure otherwise \u2014 exported for unit testing. (MESH-2088)\n */\nexport function rewriteOwnServicePort(service: DevService, oldPort: number, newPort: number): void {\n for (const [key, spec] of Object.entries(service.env ?? {})) {\n if ((key === 'PORT' || key.endsWith('_PORT')) && spec.value === String(oldPort)) {\n spec.value = String(newPort);\n }\n }\n}\n\n/**\n * Clone devOutput and assign each service a local port.\n *\n * The PRIMARY checkout keeps the app's preferred ports (falling back to an\n * OS-ephemeral port only if one is busy). LINKED worktrees draw from a reserved\n * port block keyed on (worktree, app) \u2014 MESH-2013 for the worktree axis, #2889\n * for the app axis \u2014 so concurrent sessions don't collide, where the\n * `EADDRINUSE on 8080/8081/9000` class only happened because every worktree\n * reused the same preferred ports.\n *\n * Disjoint by DEFAULT, not by construction: the block is a hash bucket, so two\n * (worktree, app) pairs can share one. The in-block scan below plus the\n * ephemeral fallback are the backstop; MESH-2375 tracks making it deterministic.\n */\n\nasync function allocatePorts(devOutput: DevOutput, worktree: WorktreeIdentity): Promise<DevOutput> {\n const allocated = JSON.parse(JSON.stringify(devOutput)) as DevOutput;\n const usedPorts = new Set<number>();\n const portRemaps = new Map<number, number>();\n\n const blockBase = worktree.isPrimary ? 0 : blockBasePort(worktree.portBlock);\n let blockCursor = 0; // next unused index within the service sub-range\n\n for (const [name, service] of Object.entries(allocated.services)) {\n if (service.port <= 0) continue;\n // Skip deployed services (no dev command) \u2014 they run in K8s, no local port.\n if (!service.command || service.command.length === 0) continue;\n const preferred = service.port;\n\n let target = -1;\n if (worktree.isPrimary) {\n if (!usedPorts.has(preferred) && (await isPortFree(preferred))) {\n target = preferred;\n } else {\n target = await findFreePort();\n logWarn(`Port ${preferred} busy for service '${name}', using ${target}`);\n }\n } else {\n // Reserved block: next free port in [blockBase, blockBase+SERVICE_SUBRANGE).\n for (let i = blockCursor; i < PORT_BLOCK_SERVICE_SUBRANGE; i++) {\n const candidate = blockBase + i;\n if (!usedPorts.has(candidate) && (await isPortFree(candidate))) {\n target = candidate;\n blockCursor = i + 1;\n break;\n }\n }\n if (target < 0) {\n // Block exhausted or contended \u2014 degrade to an OS-ephemeral port.\n target = await findFreePort();\n logWarn(`Worktree port block exhausted for '${name}', using ${target}`);\n }\n }\n\n if (target !== preferred) {\n // Rewrite the service's OWN port env var(s) \u2014 not just a var literally\n // named `PORT`. Without this, a service reading `VCS_PORT`/`AGENT_API_PORT`\n // keeps its deploy-time default and collides across worktrees (MESH-2088).\n rewriteOwnServicePort(service, preferred, target);\n portRemaps.set(preferred, target);\n service.port = target;\n }\n usedPorts.add(target);\n }\n\n // Cross-service URL rewriting: when a service port is reassigned,\n // update any env var in other services that references the old port\n // (e.g., API_URL from a link() pointing to the API's original port).\n rewriteCrossServicePorts(allocated, portRemaps);\n rewriteClusterHostsToLocal(allocated);\n\n return allocated;\n}\n\n/**\n * Rewrite `localhost:${oldPort}` \u2192 `localhost:${newPort}` in EVERY service's env\n * values, for each `oldPort \u2192 newPort` entry in `portRemaps`. Mutates the given\n * `devOutput` in place \u2014 callers pass a clone they own, so this stays safe.\n *\n * Shared by `allocatePorts` (launch-time reallocation) and `mergePinnedPorts`\n * (refresh-time re-pinning) so both apply identical cross-service rewrites.\n */\nfunction rewriteCrossServicePorts(devOutput: DevOutput, portRemaps: Map<number, number>): void {\n if (portRemaps.size === 0) return;\n for (const service of Object.values(devOutput.services)) {\n if (!service.env) continue;\n for (const envVar of Object.values(service.env)) {\n for (const [oldPort, newPort] of portRemaps) {\n if (envVar.value.includes(`localhost:${oldPort}`)) {\n envVar.value = envVar.value.replace(`localhost:${oldPort}`, `localhost:${newPort}`);\n }\n }\n }\n }\n}\n\n/**\n * Merge a session's PINNED ports/tunnels into a freshly-read DevOutput.\n *\n * `mesh dev restart <svc> --refresh-env` re-reads `pulumi stack output` to pick\n * up config/deploy changes, but the running session already allocated local\n * ports (`allocatePorts`) and opened SSM tunnels. Re-reading the raw stack\n * output would carry the DEPLOYED (pre-allocation) ports, so writing those into\n * the env file would point the service \u2014 and its peers \u2014 at the wrong ports and\n * break the live tunnels.\n *\n * This returns `fresh` (so new/changed non-port env values win) but overrides:\n * - EVERY service present in both `fresh` and `pinned` keeps its pinned/\n * allocated PORT (both `service.port` and `service.env.PORT.value`) \u2014 not\n * just the refreshed one, so a regenerated service's peer references land on\n * the session-allocated ports, and\n * - every tunnel present in `pinned` with the pinned host:port (the live SSM\n * endpoints); tunnels that exist only in `fresh` survive untouched.\n *\n * It then re-applies BOTH of the peer-addressing rewrites `allocatePorts` does \u2014\n * there are two now, and naming only one here is how they drift apart:\n * - PORT: for each service whose port differs between `fresh` (preferred) and\n * `pinned` (allocated), `localhost:${freshPort}` \u2192 `localhost:${pinnedPort}`\n * across EVERY service's env (e.g. a `ui`'s `API_URL` pointing at `api`).\n * - HOST: an in-cluster address for a service running LOCALLY becomes its local\n * one. `pulumi stack output` carries cluster hosts verbatim, so a refresh\n * without this restores exactly the value MESH-2597 exists to remove.\n *\n * Pure: deep-clones and never mutates its arguments.\n */\nexport function mergePinnedPorts(fresh: DevOutput, pinned: DevOutput): DevOutput {\n const merged = JSON.parse(JSON.stringify(fresh)) as DevOutput;\n\n // Pinned tunnels win (live SSM/VPC endpoints); fresh-only tunnels survive.\n const pinnedTunnels = JSON.parse(JSON.stringify(pinned.tunnels ?? {})) as Record<\n string,\n DevTunnel\n >;\n merged.tunnels = { ...(merged.tunnels ?? {}), ...pinnedTunnels };\n\n // Pin EVERY service's allocated port (never re-allocate). Track fresh\u2192pinned\n // port remaps so peer references (localhost:<freshPort>) can be rewritten.\n const portRemaps = new Map<number, number>();\n for (const [name, mergedSvc] of Object.entries(merged.services ?? {})) {\n const pinnedSvc = pinned.services?.[name];\n if (!pinnedSvc || typeof pinnedSvc.port !== 'number') continue;\n const freshPort = mergedSvc.port;\n mergedSvc.port = pinnedSvc.port;\n if (mergedSvc.env?.PORT) {\n mergedSvc.env.PORT.value = String(pinnedSvc.port);\n }\n if (typeof freshPort === 'number' && freshPort > 0 && freshPort !== pinnedSvc.port) {\n portRemaps.set(freshPort, pinnedSvc.port);\n }\n }\n\n // Re-apply cross-service URL rewrite (mirrors allocatePorts): peer references\n // to a preferred port become references to the session-allocated port.\n rewriteCrossServicePorts(merged, portRemaps);\n // BOTH rewrites, or `--refresh-env` undoes the other one. This path re-reads\n // `pulumi stack output`, which carries in-cluster hosts verbatim, so without\n // this a refresh restores `AGENT_API_INTERNAL_URL` to\n // `http://studio-api.<ns>.svc.cluster.local:8080` for a service running locally\n // \u2014 reintroducing MESH-2597 on the command whose entire purpose is refreshing\n // env.\n rewriteClusterHostsToLocal(merged);\n\n return merged;\n}\n\n// ============================================================================\n// Pulumi helpers\n// ============================================================================\n\n/**\n * Find the monorepo root by walking up from cwd looking for the root\n * package.json (has \"workspaces\" or the well-known repo markers).\n */\nfunction findMonorepoRoot(): string | null {\n let dir = process.cwd();\n const root = path.parse(dir).root;\n\n while (dir !== root) {\n // Look for pnpm-workspace.yaml (pnpm monorepo) or .git (repo root)\n if (\n fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) ||\n fs.existsSync(path.join(dir, 'turbo.json'))\n ) {\n return dir;\n }\n // .git as fallback \u2014 but only if it's a directory (not a worktree file)\n const gitPath = path.join(dir, '.git');\n if (fs.existsSync(gitPath)) {\n return dir;\n }\n dir = path.dirname(dir);\n }\n return null;\n}\n\n/**\n * Discover Pulumi apps in the monorepo.\n * Scans:\n * {tenants,tests/tenants}/<tenant>/apps/<app>/Pulumi.yaml\n * apps/<app>/Pulumi.yaml (flat layout, tenant from stack config)\n */\nfunction discoverApps(monorepoRoot: string): Array<{\n name: string;\n tenant: string;\n appPath: string;\n relativePath: string;\n stacks: string[];\n}> {\n const apps: Array<{\n name: string;\n tenant: string;\n appPath: string;\n relativePath: string;\n stacks: string[];\n }> = [];\n\n // Helper: extract stacks and add an app entry\n function addApp(appDir: string, tenant: string) {\n const projectName = getProjectName(appDir);\n const stacks = fs\n .readdirSync(appDir)\n .filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml')\n .map((f) => f.replace(/^Pulumi\\./, '').replace(/\\.yaml$/, ''));\n\n apps.push({\n name: projectName,\n tenant,\n appPath: appDir,\n relativePath: path.relative(monorepoRoot, appDir),\n stacks,\n });\n }\n\n // 1. Tenant-scoped: {tenants,tests/tenants}/<tenant>/apps/<app>/\n const searchDirs = ['tenants', 'tests/tenants'];\n\n for (const searchDir of searchDirs) {\n const base = path.join(monorepoRoot, searchDir);\n if (!fs.existsSync(base)) continue;\n\n for (const tenant of fs.readdirSync(base)) {\n const appsDir = path.join(base, tenant, 'apps');\n if (!fs.existsSync(appsDir) || !fs.statSync(appsDir).isDirectory()) continue;\n\n for (const app of fs.readdirSync(appsDir)) {\n const appDir = path.join(appsDir, app);\n if (!fs.existsSync(path.join(appDir, 'Pulumi.yaml'))) continue;\n addApp(appDir, tenant);\n }\n }\n }\n\n // 2. Flat layout: apps/<app>/ (tenant read from first stack config)\n const flatAppsDir = path.join(monorepoRoot, 'apps');\n if (fs.existsSync(flatAppsDir) && fs.statSync(flatAppsDir).isDirectory()) {\n for (const app of fs.readdirSync(flatAppsDir)) {\n const appDir = path.join(flatAppsDir, app);\n if (!fs.statSync(appDir).isDirectory()) continue;\n if (!fs.existsSync(path.join(appDir, 'Pulumi.yaml'))) continue;\n\n // Read tenant from first stack config's mesh:tenant\n let tenant = 'unknown';\n const stackConfigs = fs\n .readdirSync(appDir)\n .filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml');\n if (stackConfigs.length > 0) {\n try {\n const content = fs.readFileSync(path.join(appDir, stackConfigs[0]!), 'utf-8');\n const tenantMatch = content.match(/mesh:tenant:\\s*(\\S+)/);\n if (tenantMatch) tenant = tenantMatch[1]!;\n } catch {}\n }\n\n addApp(appDir, tenant);\n }\n }\n\n return apps;\n}\n\n/**\n * Find the Pulumi app root by walking up from cwd looking for Pulumi.yaml.\n */\nfunction findAppRoot(appPath?: string): string {\n if (appPath) {\n // Resolve relative to cwd or monorepo root\n const resolved = path.resolve(appPath);\n if (fs.existsSync(path.join(resolved, 'Pulumi.yaml'))) return resolved;\n\n // Try relative to monorepo root\n const mono = findMonorepoRoot();\n if (mono) {\n const fromMono = path.resolve(mono, appPath);\n if (fs.existsSync(path.join(fromMono, 'Pulumi.yaml'))) return fromMono;\n }\n\n logError(`No Pulumi.yaml found at: ${appPath}`);\n process.exit(1);\n }\n\n let dir = process.cwd();\n const root = path.parse(dir).root;\n\n while (dir !== root) {\n if (fs.existsSync(path.join(dir, 'Pulumi.yaml'))) {\n return dir;\n }\n dir = path.dirname(dir);\n }\n\n return process.cwd();\n}\n\n/**\n * Read the Pulumi project name from Pulumi.yaml.\n */\nfunction getProjectName(appRoot: string): string {\n const yamlPath = path.join(appRoot, 'Pulumi.yaml');\n if (!fs.existsSync(yamlPath)) return path.basename(appRoot);\n\n const content = fs.readFileSync(yamlPath, 'utf-8');\n const match = content.match(/^name:\\s*(.+)$/m);\n return match?.[1]?.trim() ?? path.basename(appRoot);\n}\n\n/**\n * Detect the current Pulumi stack from:\n * 1. --stage flag\n * 2. MESH_STAGE env var\n * 3. `pulumi stack --show-name` (currently-selected stack)\n * 4. Pulumi.<stack>.yaml files in the app root (if exactly one, use it)\n */\nfunction detectStack(appRoot: string, stageArg?: string): string {\n if (stageArg) return stageArg;\n if (process.env.MESH_STAGE) return process.env.MESH_STAGE;\n\n try {\n const result = execFileSync('pulumi', ['stack', '--show-name'], {\n cwd: appRoot,\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'ignore'],\n }).trim();\n if (result) return result;\n } catch {\n // Fall through\n }\n\n // Infer from Pulumi.<stack>.yaml config files\n try {\n const files = fs\n .readdirSync(appRoot)\n .filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml');\n if (files.length === 1) {\n const match = files[0]!.match(/^Pulumi\\.(.+)\\.yaml$/);\n if (match?.[1]) {\n logInfo(`Auto-detected stack from ${files[0]}`);\n return match[1];\n }\n }\n } catch {\n // Fall through\n }\n\n return 'dev';\n}\n\n/**\n * Build the `--stack <name>` args for Pulumi commands.\n * Newer Pulumi versions require fully-qualified names (organization/project/stack)\n * with `--stack`. If the stack is already selected, we can omit the flag entirely.\n */\nfunction stackArgs(appRoot: string, stack: string): string[] {\n // Check if the requested stack is already the selected one \u2014 if so, skip --stack\n try {\n const selected = execFileSync('pulumi', ['stack', '--show-name'], {\n cwd: appRoot,\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'ignore'],\n }).trim();\n if (selected === stack) return [];\n } catch {\n // ignore\n }\n\n // Try selecting the stack first (works with short names), then omit --stack\n try {\n execFileSync('pulumi', ['stack', 'select', stack], {\n cwd: appRoot,\n encoding: 'utf-8',\n stdio: ['ignore', 'pipe', 'ignore'],\n });\n return [];\n } catch {\n // Fall through \u2014 pass --stack and hope the name is fully qualified\n }\n\n return ['--stack', stack];\n}\n\n/**\n * Read the dev output from Pulumi stack.\n * Tries `app` output first (new env.register() format), falls back to `dev`.\n */\n/**\n * Thrown by `getDevOutput` when no readable Pulumi stack output exists (usually a\n * fresh app that hasn't been `stack init` + `deploy`ed yet). Typed so each caller\n * presents it in its OWN context: only the launch path can conclude \"nothing to\n * run\" and print first-run guidance; the `--refresh-env`/restart handlers must\n * keep the running session's env untouched (MESH-2097). Carries the underlying\n * read error so an auth/network/JSON failure isn't misdiagnosed as \"fresh app\".\n */\nclass MissingStackOutputError extends Error {\n constructor(public readonly stackName: string, cause?: unknown) {\n super(`No Pulumi stack output for '${stackName}'`, { cause });\n this.name = 'MissingStackOutputError';\n }\n}\n\nfunction getDevOutput(appRoot: string, stack: string, awsEnv?: Record<string, string>): DevOutput {\n const sa = stackArgs(appRoot, stack);\n\n // Try new format: export const app = env.register() \u2192 { dev: { ... } }\n try {\n const result = pulumiStackOutput(appRoot, 'app', sa, awsEnv);\n const appOutput = JSON.parse(result) as { dev?: DevOutput };\n if (appOutput.dev) {\n return appOutput.dev;\n }\n } catch {\n // Fall through to legacy format\n }\n\n // Legacy format: export const dev = ...\n try {\n const result = pulumiStackOutput(appRoot, 'dev', sa, awsEnv);\n return JSON.parse(result) as DevOutput;\n } catch (err) {\n // No readable stack output. Throw a TYPED error so each caller decides how to\n // present it (MESH-2097): the launch path prints first-run guidance + exits;\n // the --refresh-env/restart handlers keep the running session untouched.\n // (process.exit() here previously hijacked control flow past those fail-closed\n // catches, and a bare `throw` escaped as an uncaught node stacktrace.)\n throw new MissingStackOutputError(stack, err);\n }\n}\n\n// ============================================================================\n// tmux helpers\n// ============================================================================\n\nfunction hasTmux(): boolean {\n try {\n execFileSync('which', ['tmux'], { stdio: 'ignore' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction sessionExists(name: string): boolean {\n try {\n execFileSync('tmux', ['has-session', '-t', name], { stdio: 'ignore' });\n return true;\n } catch {\n return false;\n }\n}\n\nfunction killSession(name: string): boolean {\n try {\n execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' });\n return true;\n } catch {\n return false;\n }\n}\n\nconst AWS_STATIC_CREDENTIAL_ENV_KEYS = [\n 'AWS_ACCESS_KEY_ID',\n 'AWS_SECRET_ACCESS_KEY',\n 'AWS_SESSION_TOKEN',\n 'AWS_WEB_IDENTITY_TOKEN_FILE',\n 'AWS_ROLE_ARN',\n] as const;\n\nfunction applyAwsProfileOverride(profile: string): void {\n process.env.AWS_PROFILE = profile;\n for (const key of AWS_STATIC_CREDENTIAL_ENV_KEYS) {\n delete process.env[key];\n }\n}\n\n/**\n * Collect AWS credentials from the current environment.\n */\nfunction getAwsEnvVars(): Record<string, string> {\n const result: Record<string, string> = {};\n const vars = [\n 'AWS_PROFILE',\n 'AWS_ACCESS_KEY_ID',\n 'AWS_SECRET_ACCESS_KEY',\n 'AWS_SESSION_TOKEN',\n 'AWS_REGION',\n 'AWS_DEFAULT_REGION',\n ];\n\n for (const name of vars) {\n const value = process.env[name];\n if (value) result[name] = value;\n }\n return result;\n}\n\n/**\n * Build the AWS env vars to hand to long-lived child services (ui, api,\n * ai-assistant) and SSM tunnels.\n *\n * For the AWS SSO credential path, returns AWS_PROFILE + AWS_REGION only so\n * the AWS SDK's default provider chain can auto-refresh credentials from the\n * SSO cache (~8h session) as short-lived STS tokens expire. Without this,\n * child processes inherit a frozen STS triple and start throwing\n * ExpiredTokenException ~1h into a dev session.\n *\n * For the Zitadel web-identity path, when `opts` is supplied we write an AWS\n * config file whose `credential_process` re-invokes `mesh login \u2026 --credential-process`,\n * so the AWS SDK's default provider chain auto-refreshes the underlying STS\n * triple (the `mesh login` process re-issues the Zitadel token as needed).\n * This removes the ~1h forced `mesh dev` restart. When `opts` is absent we\n * fall back to the frozen STS triple (used by CI/`environment` and any caller\n * that doesn't thread the context/role through).\n *\n * For the remaining non-SSO paths (CI/CD env keys, or no deployer role),\n * returns the explicit STS triple \u2014 there is no profile to refresh from. The\n * user must restart `mesh dev` when those tokens expire.\n */\nexport function buildChildAwsEnv(\n awsEnv: Record<string, string>,\n method: ResolvedAwsCreds['method'] | 'ambient',\n profile: string | undefined,\n opts?: { context: string; roleArn: string; sessionScratchDir: string; meshBin: string },\n): Record<string, string> {\n const result: Record<string, string> = {};\n if (awsEnv.AWS_REGION) result.AWS_REGION = awsEnv.AWS_REGION;\n if (awsEnv.AWS_DEFAULT_REGION) result.AWS_DEFAULT_REGION = awsEnv.AWS_DEFAULT_REGION;\n\n if (method === 'sso' && profile) {\n result.AWS_PROFILE = profile;\n return result;\n }\n\n // Zitadel path with a credential_process profile: hand children a profile\n // that self-refreshes via `mesh login`, never a frozen triple.\n if (method === 'zitadel' && opts) {\n const sanitizedContext = opts.context.replace(/[^A-Za-z0-9_-]/g, '-');\n const profileName = `mesh-dev-${sanitizedContext}`;\n const configPath = path.join(opts.sessionScratchDir, 'aws-config');\n const region = awsEnv.AWS_REGION ?? awsEnv.AWS_DEFAULT_REGION ?? '';\n fs.mkdirSync(opts.sessionScratchDir, { recursive: true });\n const profileBlock = renderCredentialProcessProfile({\n profileName,\n context: opts.context,\n roleArn: opts.roleArn,\n region,\n meshBin: opts.meshBin,\n });\n // UPSERT a managed section; never rewrite the file. This file has a second\n // writer \u2014 `mesh login --export` (the worker-start recipe evals it) \u2014 and\n // the AWS SDK reads it ONCE per process, so a wholesale write here deletes\n // that profile under a running worker, which then fails every LLM call until\n // it is restarted. The key carries \"(mesh dev)\" so the two writers own two\n // sections rather than replacing each other's.\n const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';\n const next = upsertManagedAwsConfigSection(\n stripBareProfile(existing, profileName),\n `${opts.context} (mesh dev)`,\n profileBlock,\n );\n if (next !== existing) atomicWriteFileSync(configPath, next, 0o600);\n const out: Record<string, string> = {\n AWS_PROFILE: profileName,\n AWS_CONFIG_FILE: configPath,\n };\n if (region) out.AWS_REGION = region;\n return out;\n }\n\n for (const key of ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN'] as const) {\n if (awsEnv[key]) result[key] = awsEnv[key]!;\n }\n if (profile && !result.AWS_ACCESS_KEY_ID) {\n result.AWS_PROFILE = profile;\n }\n return result;\n}\n\n/**\n * Resolve a service's source dir onto the CURRENT worktree (MESH-2013).\n *\n * `mesh dev` reads the service `src` from `pulumi stack output`. Two shapes exist:\n * - New (post relative-src fix in infra-components): a monorepo-RELATIVE dir\n * like `packages/agent-sdk` \u2192 just resolve it against this worktree's root.\n * - Legacy: an ABSOLUTE path baked at the deploying `pulumi up`, e.g.\n * `/\u2026/mesh-platform/.worktrees/streaming-ui-builder/packages/agent-sdk`. The\n * old logic stripped only up to `/mesh-platform/`, keeping the\n * `.worktrees/<name>/` segment \u2014 so it re-rooted to a *sibling worktree* that\n * also exists, silently running the wrong worktree's code. We now reduce to\n * the monorepo-relative portion (dropping any `.worktrees/<name>/`) and\n * re-root here, so a stack deployed from any worktree runs from THIS one.\n */\nfunction rebaseServiceSrc(src: string, monorepoRoot: string | null): string {\n if (!monorepoRoot) return src;\n const rel = monorepoRelativeSrc(src);\n if (rel === null) return src;\n const rebased = path.join(monorepoRoot, rel);\n return fs.existsSync(rebased) ? rebased : src;\n}\n\n/**\n * Is `serviceDir` a package this app DEV-LINKED, rather than a stale sibling\n * worktree?\n *\n * Both look identical to the wrong-worktree guard \u2014 a source directory outside\n * the app's worktree \u2014 but only one is a mistake. A module that contributes a\n * dev service (`InfraContribution.devServices`) reports `resolvePackageRoot()`,\n * which is the package's REAL path; when that package is dev-linked from another\n * repo it legitimately lives there. Refusing it means a devService can run only\n * while its package is published \u2014 i.e. never while you are developing it, which\n * is the only time you need it.\n *\n * The test is proof, not a heuristic: read the directory's own package name and\n * ask whether THIS app resolves that name to THIS directory. A stale sibling\n * worktree cannot satisfy that \u2014 nothing in the app's `node_modules` points at\n * it \u2014 so the wrong-worktree guard keeps its teeth.\n *\n * @example\n * isLinkedDependencyDir(\"/repo/libs/infra-components/src/components/ai/redis\", \"/repo/apps/demo\");\n * // \u2192 true when the app resolves @mesh-tech/infra-components to that directory\n */\nexport function isLinkedDependencyDir(serviceDir: string, appRoot: string): boolean {\n // The service source is often INSIDE a package rather than its root \u2014 a redis\n // component ships as `<infra-components>/src/components/ai/redis`. So find the\n // enclosing package first (nearest ancestor with a named manifest), then ask\n // whether the app resolves THAT name to THAT directory.\n let pkgRoot: string | null = null;\n let name = '';\n for (let dir = serviceDir; ; dir = path.dirname(dir)) {\n try {\n const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));\n if (typeof manifest?.name === 'string' && manifest.name) {\n pkgRoot = dir;\n name = manifest.name;\n break;\n }\n } catch {\n /* no manifest here; keep walking up */\n }\n const parent = path.dirname(dir);\n if (parent === dir) break;\n }\n if (!pkgRoot) return false;\n\n // Resolve from the app outward, so a package linked for a DIFFERENT app in the\n // same repo does not vouch for this one.\n for (let dir = appRoot; ; dir = path.dirname(dir)) {\n try {\n if (fs.realpathSync(path.join(dir, 'node_modules', name)) === pkgRoot) return true;\n } catch {\n /* not resolvable here; keep walking up */\n }\n const parent = path.dirname(dir);\n if (parent === dir) return false;\n }\n}\n\n/**\n * Reduce a service `src` to its monorepo-relative form (pure; no filesystem).\n * - relative (`./packages/x` or `packages/x`) \u2192 normalized `packages/x`\n * - legacy absolute (`/\u2026/mesh-platform[/.worktrees/<name>]/packages/x`) \u2192\n * `packages/x` (drops the repo root AND any deploying-worktree segment)\n * Returns null when an absolute path has no recognizable monorepo marker.\n */\nexport function monorepoRelativeSrc(src: string): string | null {\n if (!path.isAbsolute(src)) return src.replace(/^\\.\\//, '') || '.';\n // Match the LAST `/mesh-platform` at a segment boundary \u2014 covers the repo root\n // itself (nothing follows \u2192 '.'), a nested `.worktrees/<name>/`, and normal\n // subpaths. A path ending at the repo root (e.g. a service whose src IS the\n // monorepo root) previously slipped through and stayed absolute (MESH-2069).\n const m = src.match(/^.*\\/mesh-platform(?:\\/(.*))?$/);\n if (!m) return null;\n const rest = (m[1] ?? '').replace(/^\\.worktrees\\/[^/]+(?:\\/|$)/, '');\n return rest === '' ? '.' : rest;\n}\n\n/**\n * Resolve env vars for a service, rewriting tunnel references to VPC endpoints.\n */\nfunction getServiceEnvVars(\n service: DevService,\n tunnels: Record<string, DevTunnel>,\n): Record<string, string> {\n const result: Record<string, string> = {};\n\n if (service.env) {\n for (const [key, spec] of Object.entries(service.env)) {\n let value = spec.value;\n\n if (spec.tunnel) {\n const tunnel = tunnels[spec.tunnel];\n if (tunnel) {\n // Rewrite to VPC endpoint (VPN provides direct access)\n try {\n const url = new URL(value);\n if (url.hostname) {\n url.hostname = tunnel.host;\n url.port = String(tunnel.port);\n value = url.toString();\n } else {\n value = `${tunnel.host}:${tunnel.port}`;\n }\n } catch {\n value = `${tunnel.host}:${tunnel.port}`;\n }\n }\n }\n\n result[key] = value;\n }\n }\n return result;\n}\n\n/**\n * Set env vars on a tmux session. New windows inherit them.\n *\n * Use for truly shared vars (AWS creds, secrets). For per-service\n * vars like PORT, use envPrefix() instead to avoid cross-service leaks.\n */\nfunction setTmuxEnv(sessionName: string, vars: Record<string, string>): void {\n for (const [key, value] of Object.entries(vars)) {\n execFileSync('tmux', ['set-environment', '-t', sessionName, key, value], { stdio: 'ignore' });\n }\n}\n\n/**\n * Build an `env K=V \u2026` prefix string for per-service env vars.\n *\n * This scopes variables to the process instead of polluting the\n * tmux session environment (where the last service's PORT would\n * leak to every other service on restart).\n */\nfunction envPrefix(vars: Record<string, string>): string {\n const entries = Object.entries(vars);\n if (entries.length === 0) return '';\n const parts = entries.map(([k, v]) => `${k}=${shellEscape(v)}`);\n return `env ${parts.join(' ')} `;\n}\n\n/** Escape a value for shell interpolation. */\nfunction shellEscape(s: string): string {\n // If the value is safe, return as-is\n if (/^[a-zA-Z0-9_./:@=+,-]+$/.test(s)) return s;\n // Otherwise single-quote it, escaping any embedded single quotes\n return `'${s.replace(/'/g, \"'\\\\''\")}'`;\n}\n\n// ============================================================================\n// Secret resolution\n// ============================================================================\n\n/**\n * Resolve secrets from Secrets Manager and return env var key\u2192value pairs.\n * VPN provides direct access \u2014 no URL rewriting needed.\n */\nasync function resolveSecrets(secrets: Record<string, DevSecret>): Promise<Record<string, string>> {\n const client = new SecretsManagerClient({});\n const resolved: Record<string, string> = {};\n\n for (const [name, secret] of Object.entries(secrets)) {\n try {\n const response = await client.send(\n new GetSecretValueCommand({ SecretId: secret.secretName }),\n );\n if (!response.SecretString) continue;\n\n const values = JSON.parse(response.SecretString) as Record<string, string>;\n\n for (const [secretKey, envVar] of Object.entries(secret.envMapping)) {\n const value = values[secretKey];\n if (value === undefined) continue;\n resolved[envVar] = value;\n }\n\n logSuccess(`Secret resolved: ${name} (${secret.secretName})`);\n } catch (err) {\n logWarn(\n `Could not resolve secret '${name}' (${secret.secretName}): ${err instanceof Error ? err.message : String(err)}`,\n );\n }\n }\n\n return resolved;\n}\n\n\n/**\n * Resolve the Temporal encoding key from the K8s secret in the app namespace.\n *\n * The Pulumi-generated devEncodingKey is a placeholder that differs from the\n * deployed key, causing decryption failures when the local worker encounters\n * existing Temporal payloads. Reading the real key from K8s ensures local dev\n * can decrypt data created by deployed workers.\n *\n * Returns the encoding key value, or undefined if not available.\n */\nfunction resolveTemporalEncodingKey(\n tenant: string,\n env: string,\n appName: string,\n): string | undefined {\n const namespace = `${tenant}-${env}-${appName}`;\n const secretName = `${namespace}-temporal-encoding-key`;\n try {\n const b64 = execFileSync(\n 'kubectl',\n [\n 'get',\n 'secret',\n secretName,\n '-n',\n namespace,\n '-o',\n 'jsonpath={.data.TEMPORAL_ENCODING_KEY}',\n ],\n { encoding: 'utf-8', timeout: 10_000, stdio: ['pipe', 'pipe', 'pipe'] },\n ).trim();\n\n if (b64) {\n const key = Buffer.from(b64, 'base64').toString('utf-8');\n logSuccess(`Temporal encoding key resolved from K8s secret (${secretName})`);\n return key;\n }\n } catch {\n // Non-fatal \u2014 secret may not exist yet or kubectl may not have access\n }\n return undefined;\n}\n\n/**\n * Re-resolve the session-level Temporal auth vars the SAME way launch does\n * (see startServices): kubeconfig from SSM, M2M auth, encoding key from the\n * K8s secret, and TEMPORAL_ADDRESS from the (pinned) tunnel. Used by\n * `mesh dev restart --refresh-env` so a regenerated env file carries the live\n * resolved values, not the dev-output placeholders (which would desync payload\n * encryption between services).\n */\nasync function resolveTemporalAuthVars(\n sessionName: string,\n devOutput: DevOutput,\n): Promise<Record<string, string>> {\n const tenant = devOutput.platform?.tenant ?? 'mesh';\n const platformEnv = devOutput.platform?.env ?? 'dev';\n // Hub platform name \u2014 the EKS export lives under it, not under a hardcoded\n // \"mesh\" (MESH-2515); same axis as the bastion resolution.\n const platformName = resolveHubPlatformName(devOutput.platform);\n\n const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {\n onError: ({ parameter, error }) =>\n logWarn(\n `EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`,\n ),\n });\n if (kubeconfigPath) {\n process.env.KUBECONFIG = kubeconfigPath;\n setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });\n } else if (!process.env.KUBECONFIG) {\n // ensureKubeconfig couldn't re-resolve the cluster from SSM (transient /\n // stale creds). Fall back to the session kubeconfig written at launch so the\n // kubectl calls below don't default to ~/.kube/config \u2014 which macOS TCC\n // denies for shells without Full Disk Access (\"operation not permitted\").\n const existing = sessionKubeconfigPath(sessionName);\n if (fs.existsSync(existing)) {\n // Set only this process's env \u2014 deliberately NOT setTmuxEnv: this is a\n // best-effort fallback to a possibly-stale last-good kubeconfig, so we use\n // it for the kubectl calls below without persisting it into the session\n // (a later successful re-resolve is the one that should win the session).\n process.env.KUBECONFIG = existing;\n }\n }\n\n let temporalAuthVars: Record<string, string> = {};\n if (devOutput.tunnels['temporal']) {\n // Deliberately NOT `platformName` from above: the Temporal-auth fallback is\n // `?? tenant` (dedicated tenants may run their own Temporal), while the EKS\n // fallback is `?? 'mesh'`. Whether that difference is correct is MESH-2551;\n // don't \"fix\" either default to match the other without reading it.\n temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);\n\n const appName = devOutput.app ?? '';\n if (appName) {\n const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);\n if (encodingKey) {\n temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;\n }\n }\n\n const temporalTunnel = devOutput.tunnels['temporal'];\n temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);\n }\n return temporalAuthVars;\n}\n\n/**\n * Resolve the dev-user identity vars (DEV_USER_*) from cached `mesh login`\n * credentials for the platform context (e.g. 'mesh.dev'). Credentials are\n * stored under the platform context, not the tenant name \u2014 all tenants share\n * the same Zitadel instance.\n *\n * Services (e.g., Hub UI) use these as a fallback when the real oauth2-proxy\n * `X-Forwarded-*` request headers are absent:\n * DEV_USER_ID - subject from idToken, matches conversation ownership\n * DEV_USER_EMAIL - user email from idToken\n * DEV_USER_ID_TOKEN - full idToken JWT (profile claims, authorization hint)\n * DEV_USER_ACCESS_TOKEN - full accessToken JWT (has project:roles claim)\n *\n * Returned vars must be overlaid onto each service's env FILE (not just the\n * tmux session env): the dev output may carry a placeholder value (e.g. hub\n * ui's DEV_USER_ACCESS_TOKEN=\"dev-local\"), and the sourced per-service env\n * file wins over the session env, so a session-env-only injection gets\n * clobbered and every tenant-scoped Hub view silently 401s.\n */\n/**\n * The `mesh login` context that issues this app's dev-user identity.\n *\n * It is the PLATFORM's name and env \u2014 `trabian.dev`, `mesh.dev` \u2014 because the\n * platform owns the Zitadel the app's services verify against, so a token from\n * any other issuer is well-formed and rejected. Not every app runs on the `mesh`\n * platform: a shared tenant on the `trabian` platform whose dev user came from\n * `identity.dev.mesh-platform.trabian.com` would be verified against\n * `identity.dev.trabian.meshtech.io`.\n *\n * That failure is invisible at injection time and only surfaces at the far end\n * of a cross-app call, as `Missing or invalid Authorization header \u2014 expected\n * Bearer token`: the token is well-formed and unexpired, just from the wrong\n * issuer.\n *\n * `name` is optional on the dev output, so fall back to `mesh`, which keeps\n * mesh-platform apps on the context they already use.\n */\nfunction loginContextFor(platform: { name?: string; env: string }): string {\n return `${platform.name ?? 'mesh'}.${platform.env}`;\n}\n\nfunction resolveDevUserVars(devOutput: DevOutput): Record<string, string> {\n const credContext = devOutput.platform\n ? devOutput.platform.tenant === 'local'\n ? 'local'\n : loginContextFor(devOutput.platform)\n : null;\n if (!credContext) return {};\n const meshCreds = readCredentials(credContext);\n if (!meshCreds?.idToken) return {};\n const vars: Record<string, string> = { DEV_USER_ID_TOKEN: meshCreds.idToken };\n try {\n const payload = JSON.parse(\n Buffer.from(meshCreds.idToken.split('.')[1]!, 'base64url').toString(),\n );\n if (payload.sub) vars.DEV_USER_ID = payload.sub;\n if (payload.email) vars.DEV_USER_EMAIL = payload.email;\n } catch {\n /* ignore decode errors */\n }\n if (meshCreds.accessToken) {\n vars.DEV_USER_ACCESS_TOKEN = meshCreds.accessToken;\n }\n return vars;\n}\n\n// ============================================================================\n// SSM tunnel fallback (when VPN is not connected)\n// ============================================================================\n\n/**\n * Base port for SSM tunnels. Preferred ports use 20000 + original_port to\n * avoid collisions with dev services (1000-9999 range). If the preferred\n * port is already reserved or occupied, mesh dev walks upward until it finds\n * an available port.\n *\n * Examples:\n * PostgreSQL 5432 \u2192 25432\n * Temporal 7233 \u2192 27233\n * Temporal UI 8080 \u2192 28080\n */\nconst SSM_TUNNEL_BASE_PORT = 20000;\n\n/** How long to wait for an SSM tunnel's local port to accept TCP before failing. */\nconst TUNNEL_READY_TIMEOUT_MS = 60_000;\n\n/**\n * Build the client-facing `host:port` for an SSM tunnel.\n *\n * The `aws ssm start-session` port-forwarder binds IPv4 `127.0.0.1` only.\n * Advertising `localhost` lets Node/grpc-js resolve to IPv6 `::1` first, which\n * the tunnel isn't listening on \u2014 so e.g. the Temporal gRPC client fails with\n * `ECONNREFUSED ::1:<port>` and the Hub's Automations/workflow lists never load.\n * Maps the literal `localhost` (how tunnels are advertised today) to `127.0.0.1`\n * so clients hit the bound listener. Any other host \u2014 VPN-direct VPC endpoints,\n * or a different loopback literal like `::1`/`127.0.0.1` \u2014 passes through unchanged.\n */\nexport function tunnelClientAddress(tunnel: { host: string; port: number }): string {\n const host = tunnel.host === 'localhost' ? '127.0.0.1' : tunnel.host;\n return `${host}:${tunnel.port}`;\n}\n\n/** One tunnel's live health, resolved by a TCP dial of its client address. */\nexport interface TunnelHealth {\n name: string;\n /** Client-facing `host:port` (see {@link tunnelClientAddress}). */\n address: string;\n reachable: boolean;\n}\n\n/**\n * Render the Connections section (live tunnel health) as status-window lines.\n *\n * The status window refreshes every ~2s, so this is where a mid-session tunnel\n * death surfaces (MESH-2016): a dead tunnel shows `\u26A0 \u2026 DOWN` and a SINGLE\n * remediation line explains the `ECONNREFUSED` retry spam the dependent service\n * windows emit \u2014 instead of leaving the operator to decode that spam themselves.\n * Returns `[]` when there are no tunnels (nothing to show).\n */\nexport function formatTunnelHealth(health: TunnelHealth[]): string[] {\n if (health.length === 0) return [];\n const lines = ['Connections:'];\n for (const h of health) {\n const icon = h.reachable ? '\u2713' : '\u26A0';\n const suffix = h.reachable ? '' : ' DOWN';\n lines.push(` ${icon} ${h.name.padEnd(16)} ${h.address}${suffix}`);\n }\n const down = health.filter((h) => !h.reachable).map((h) => h.name);\n if (down.length > 0) {\n const noun = down.length === 1 ? 'tunnel' : 'tunnels';\n lines.push('');\n lines.push(\n ` \u26A0 ${down.length} ${noun} down (${down.join(', ')}) \u2014 dependent services are ` +\n `retrying (ECONNREFUSED spam is expected). Relaunch: mesh dev`,\n );\n }\n return lines;\n}\n\n/** Probe every tunnel's client address in parallel and resolve its health. */\nasync function probeTunnelHealth(\n tunnels: Record<string, { host: string; port: number }>,\n): Promise<TunnelHealth[]> {\n return Promise.all(\n Object.entries(tunnels).map(async ([name, tun]) => ({\n name,\n address: tunnelClientAddress(tun),\n // Dial the SAME address the client uses (localhost\u2192127.0.0.1) so the probe\n // sees exactly what a dependent service would. Use the connection-HOLDS\n // probe, not a bare accept: a forwarder binds its local port even when its\n // upstream leg is dead (a dropped SSM session, or a Tailscale SOCKS bridge\n // dialing a stale port \u2014 MESH-2169), so \"port accepts\" would report a dead\n // tunnel as healthy. Holding briefly catches the accept-then-close teardown.\n reachable: await probeConnectionHolds(\n tun.host === 'localhost' ? '127.0.0.1' : tun.host,\n tun.port,\n 800,\n ),\n })),\n );\n}\n\n/**\n * Probe the routing-critical tunnel of a (Tailscale-rewritten) devOutput and\n * return its name if the forwarder binds but does not ROUTE, else null.\n *\n * Only `temporal` is gated \u2014 it's the load-bearing VPC dependency (services die\n * on a gRPC deadline when it's dead), and keying on it mirrors the vpn-direct\n * reachability gate ({@link gateVpnDirectReachability}). A Tailscale forwarder\n * accepts its local port even when its SOCKS upstream is dead or the joined\n * tailnet doesn't route the VPC; such a forwarder tears the connection down\n * within the grace window, so it fails the connection-HOLDS probe that a bare\n * accept (the readiness gate's `waitForPort`) would pass (MESH-2169/MESH-2268).\n */\nexport async function firstUnroutableTunnel(\n tunnels: Record<string, { host: string; port: number }>,\n): Promise<string | null> {\n const temporal = tunnels['temporal'];\n if (!temporal || temporal.host !== 'localhost') return null;\n const holds = await probeConnectionHolds('127.0.0.1', temporal.port, 800);\n return holds ? null : 'temporal';\n}\n\n/** Compute the preferred local port for an SSM tunnel. */\n/** Tunnel backing for a dev session. */\nexport type Transport = \"ssm\" | \"tailscale\" | \"vpn-direct\";\n\n/** What `startServices` should do for VPC tunnels. */\nexport interface TunnelPlan {\n transport: Transport;\n tailscale?: { tenant: string; env: string; region: string; loginServer: string; socksPort: number; preAuthKey?: string };\n /** True when the user EXPLICITLY asked for this transport (`--transport <x>`),\n * vs an auto-resolved default. An explicit tailscale choice must NOT silently\n * fall back to SSM \u2014 surface the failure so the user can act (MESH-2098). */\n explicit?: boolean;\n}\n\n/**\n * Resolve which tunnel backing `mesh dev` should use.\n *\n * An explicit `--transport` flag wins. Both `auto` and the default (unset)\n * prefer Tailscale when it's available (brew `tailscaled` present), else fall\n * back to the VPN-direct/SSM choice: VPN-direct when the VPN is connected, else\n * SSM when the session-manager plugin is present. Where Tailscale isn't\n * available the behavior is unchanged from before this feature.\n */\nexport function resolveTransport(\n flag: string | undefined,\n ctx: { vpnConnected: boolean; hasSsmPlugin: boolean; tailscaleAvailable: boolean },\n): Transport {\n if (flag === \"tailscale\" || flag === \"ssm\") return flag;\n if (flag !== undefined && flag !== \"auto\") {\n throw new Error(`Invalid --transport '${flag}'. Valid values: auto (default), tailscale, ssm.`);\n }\n const legacy: Transport = ctx.vpnConnected ? \"vpn-direct\" : ctx.hasSsmPlugin ? \"ssm\" : \"vpn-direct\";\n return ctx.tailscaleAvailable ? \"tailscale\" : legacy;\n}\n\n/** Outcome of the reachability gate \u2014 `transport` is what to use, `reason` drives the log. */\nexport type ReachabilityDecision = {\n transport: Transport;\n reason: \"not-vpn-direct\" | \"reachable\" | \"fallback-ssm\" | \"unreachable-no-plugin\";\n};\n\n/**\n * Correct an auto-selected `vpn-direct` transport against ACTUAL reachability of\n * the Temporal VPC endpoint (MESH-2016). `resolveTransport` uses a presence check\n * (any Tailscale/VPN up) that's tailnet-blind \u2014 Tailscale-for-SSH looks connected\n * but doesn't route the mesh VPC. Only `vpn-direct` is reachability-gated (it's\n * never an explicit choice); every other transport passes through unchanged.\n *\n * Pure so the fallback logic is testable independently of the TCP probe.\n */\nexport function reachabilityFallbackTransport(\n transport: Transport,\n temporalReachable: boolean,\n hasSsmPlugin: boolean,\n): ReachabilityDecision {\n if (transport !== \"vpn-direct\") return { transport, reason: \"not-vpn-direct\" };\n if (temporalReachable) return { transport, reason: \"reachable\" };\n if (hasSsmPlugin) return { transport: \"ssm\", reason: \"fallback-ssm\" };\n return { transport, reason: \"unreachable-no-plugin\" };\n}\n\n/**\n * Apply the reachability gate to a (possibly auto-selected) transport: if it's\n * `vpn-direct`, TCP-dial the Temporal VPC endpoint and downgrade to SSM when it's\n * actually unreachable, logging the decision. Returns the effective transport;\n * a no-op for any non-`vpn-direct` transport.\n *\n * Called on BOTH auto-select paths \u2014 the normal resolve result AND the\n * Tailscale\u2192legacy fallback (when no mesh login config exists for the derived\n * context) \u2014 so a `vpn-direct` can never reach `startServices` unprobed\n * (MESH-2016).\n */\nasync function gateVpnDirectReachability(\n transport: Transport,\n devOutput: DevOutput,\n): Promise<Transport> {\n if (transport !== \"vpn-direct\") return transport;\n const temporalTunnel = devOutput.tunnels?.[\"temporal\"];\n if (!temporalTunnel) return transport;\n const { host, port } = temporalTunnel;\n const reachable = await probeTcpReachable(host, port, 1500);\n const decision = reachabilityFallbackTransport(transport, reachable, hasSessionManagerPlugin());\n if (decision.reason === \"reachable\") {\n logInfo(`Transport: VPN-direct \u2014 Temporal VPC endpoint ${host}:${port} is reachable.`);\n } else if (decision.reason === \"fallback-ssm\") {\n logWarn(\n `Transport: VPN reports connected but the Temporal VPC endpoint ${host}:${port} is unreachable ` +\n `(tailnet-blind presence check) \u2014 falling back to SSM tunnels (2XXXX port range; VPN-only features ` +\n `like in-cluster kubectl are unavailable). Pin with --transport=ssm to skip this probe.`,\n );\n } else if (decision.reason === \"unreachable-no-plugin\") {\n logWarn(\n `Transport: Temporal VPC endpoint ${host}:${port} is unreachable and the SSM session-manager ` +\n `plugin is missing \u2014 VPN-direct will likely fail. Fix VPN routing or install the plugin ` +\n `(see: mesh dev doctor).`,\n );\n }\n return decision.transport;\n}\n\n/**\n * Derive the Headscale login-server URL from a login context's issuer\n * (`identity.<env>.<domain>` \u2192 `vpn.<env>.<domain>`). Used for the Tailscale\n * transport's OIDC join. Returns null if the context has no cached login config.\n */\nfunction deriveLoginServer(context: string): string | null {\n const cfg = getContextConfig(context);\n if (!cfg?.issuer) return null;\n try {\n const u = new URL(cfg.issuer);\n const parts = u.hostname.split(\".\");\n parts[0] = \"vpn\";\n return `https://${parts.join(\".\")}`;\n } catch {\n return null;\n }\n}\n\nexport function preferredSsmLocalPort(remotePort: number): number {\n const preferred = SSM_TUNNEL_BASE_PORT + remotePort;\n if (preferred > 65535) {\n throw new Error(\n `Cannot allocate SSM tunnel port for remote port ${remotePort}: preferred local port ${preferred} exceeds 65535`,\n );\n }\n return preferred;\n}\n\n/**\n * Reserve a deterministic local-port candidate for an SSM tunnel.\n *\n * This prevents two tunnels with the same remote port (common for HTTPS\n * endpoints) from being assigned the same localhost port before the AWS CLI\n * has a chance to bind it.\n */\nexport function reserveSsmLocalPortCandidate(\n remotePort: number,\n reservedPorts: Set<number>,\n startAt = preferredSsmLocalPort(remotePort),\n): number {\n for (let port = startAt; port <= 65535; port += 1) {\n if (!reservedPorts.has(port)) {\n reservedPorts.add(port);\n return port;\n }\n }\n throw new Error(\n `Cannot allocate SSM tunnel port for remote port ${remotePort}: no free candidate ports remain`,\n );\n}\n\nasync function allocateSsmLocalPort(\n remotePort: number,\n reservedPorts: Set<number>,\n): Promise<number> {\n let nextCandidate = preferredSsmLocalPort(remotePort);\n\n while (nextCandidate <= 65535) {\n const candidate = reserveSsmLocalPortCandidate(remotePort, reservedPorts, nextCandidate);\n if (await isPortFree(candidate)) {\n return candidate;\n }\n nextCandidate = candidate + 1;\n }\n\n throw new Error(\n `Cannot allocate SSM tunnel port for remote port ${remotePort}: no local ports are available`,\n );\n}\n\n/**\n * Check if the session-manager-plugin is installed (required for SSM tunnels).\n */\nfunction hasSessionManagerPlugin(): boolean {\n try {\n execFileSync('which', ['session-manager-plugin'], { stdio: 'ignore' });\n return true;\n } catch {\n return false;\n }\n}\n\ninterface PlannedSsmTunnel {\n tunnelName: string;\n endpoint: DevTunnel;\n localPort: number;\n}\n\n/**\n * Start SSM port-forwarding tunnels as tmux windows within the dev session.\n *\n * For each tunnel defined in devOutput (e.g., temporal, rds), this:\n * 1. Reads the bastion instance ID and service endpoints from SSM\n * 2. Spawns `aws ssm start-session` in a dedicated tmux window\n * 3. Rewrites devOutput.tunnels to point at localhost:2XXXX\n *\n * Returns the rewritten devOutput with tunnels pointing to localhost.\n */\nasync function startSsmTunnels(sessionName: string, devOutput: DevOutput): Promise<DevOutput> {\n const platformEnv = devOutput.platform?.env ?? 'dev';\n // The bastion is a HUB platform resource (/mesh-platform/<hub>/<env>/platform-bastion),\n // not an app-tenant resource \u2014 devOutput.platform.tenant is the APP tenant (e.g. acme),\n // which for shared tenants has no bastion. Resolve under the platform/hub name; default\n // to \"mesh\" (matching how resolveTemporalAuth reads platform-level params).\n const bastionTenant = devOutput.platform?.name ?? 'mesh';\n\n // Read bastion info from SSM\n const bastion = await getPlatformBastionInfo(bastionTenant, platformEnv).catch(() => null);\n if (!bastion) {\n logError('Could not read platform bastion info from SSM.');\n logInfo('Make sure PlatformBastion is deployed in your platform stack.');\n logInfo(\"If you don't have a bastion, connect via VPN instead: mesh vpn connect\");\n throw new Error('SSM tunnel fallback unavailable: no bastion found');\n }\n\n const rewritten = JSON.parse(JSON.stringify(devOutput)) as DevOutput;\n const tunnelCount = Object.keys(devOutput.tunnels).length;\n\n if (tunnelCount === 0) {\n logInfo('No tunnels defined \u2014 SSM fallback not needed.');\n return rewritten;\n }\n\n logInfo(`Starting ${tunnelCount} SSM tunnel(s) via bastion ${bastion.instanceId}...`);\n\n // Map devOutput tunnel names to bastion service keys\n // devOutput uses names like \"temporal\", \"rds\"; bastion uses \"temporal-frontend\", \"rds\"\n const tunnelToBastionKey: Record<string, string> = {\n temporal: 'temporal-frontend',\n 'temporal-ui': 'temporal-ui',\n rds: 'rds',\n db: 'rds',\n database: 'rds',\n };\n\n // Observability tunnels are non-fatal under SSM fallback \u2014 skip with a warning\n // rather than aborting the dev loop when the bastion doesn't expose them.\n const OPTIONAL_TUNNEL_NAMES = new Set([\n 'loki',\n 'prometheus',\n 'tempo',\n 'pushgateway',\n 'grafana',\n ]);\n\n const missingEndpoints: string[] = [];\n const skippedOptional: string[] = [];\n const reservedPorts = new Set<number>();\n const plannedTunnels: PlannedSsmTunnel[] = [];\n\n for (const [tunnelName] of Object.entries(devOutput.tunnels)) {\n const bastionKey = tunnelToBastionKey[tunnelName] ?? tunnelName;\n const endpoint = bastion.services[bastionKey];\n\n if (!endpoint) {\n // Observability tunnels degrade gracefully under SSM fallback: without\n // them the consuming app's monitoring views (logs/metrics/traces) won't\n // work, but they must not block the core dev loop. Only critical tunnels\n // (temporal, rds, ...) are fatal when the bastion can't reach them.\n if (OPTIONAL_TUNNEL_NAMES.has(tunnelName)) {\n skippedOptional.push(`${tunnelName} (bastion key: ${bastionKey})`);\n } else {\n missingEndpoints.push(`${tunnelName} (bastion key: ${bastionKey})`);\n }\n continue;\n }\n\n plannedTunnels.push({\n tunnelName,\n endpoint,\n localPort: await allocateSsmLocalPort(endpoint.port, reservedPorts),\n });\n }\n\n if (skippedOptional.length > 0) {\n logWarn(\n 'Skipping optional observability tunnel(s) not exposed by the bastion (SSM fallback):',\n );\n for (const skipped of skippedOptional) {\n logInfo(` Skipped: ${skipped}`);\n }\n logInfo(\n ' Monitoring views (logs/metrics/traces) stay unavailable until VPN is connected.',\n );\n }\n\n if (missingEndpoints.length > 0) {\n logError('SSM tunnel fallback cannot cover every required dev tunnel.');\n for (const missing of missingEndpoints) {\n logInfo(` Missing: ${missing}`);\n }\n logInfo(\n ` Available bastion services: ${Object.keys(bastion.services).join(', ') || '(none)'}`,\n );\n logInfo(\n 'Connect VPN instead or deploy/update PlatformBastion with the missing service endpoints.',\n );\n throw new Error('SSM tunnel fallback unavailable: missing bastion endpoints');\n }\n\n for (const { tunnelName, endpoint, localPort } of plannedTunnels) {\n // Create a tmux window for this tunnel\n const windowName = `tunnel-${tunnelName}`;\n const ssmCmd = [\n 'aws',\n 'ssm',\n 'start-session',\n '--target',\n bastion.instanceId,\n '--document-name',\n 'AWS-StartPortForwardingSessionToRemoteHost',\n '--parameters',\n `'${JSON.stringify({\n host: [endpoint.host],\n portNumber: [String(endpoint.port)],\n localPortNumber: [String(localPort)],\n })}'`,\n ].join(' ');\n\n execFileSync('tmux', ['new-window', '-t', sessionName, '-n', windowName]);\n\n // Keep window alive so tunnel errors are visible\n execFileSync(\n 'tmux',\n ['set-option', '-t', `${sessionName}:${windowName}`, 'remain-on-exit', 'on'],\n { stdio: 'ignore' },\n );\n\n execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${windowName}`, ssmCmd, 'Enter']);\n\n // Rewrite the tunnel to point at localhost\n rewritten.tunnels[tunnelName] = {\n host: 'localhost',\n port: localPort,\n };\n\n logSuccess(\n ` ${tunnelName}: localhost:${localPort} \u2192 ${endpoint.host}:${endpoint.port} (via SSM)`,\n );\n }\n\n return rewritten;\n}\n\n/**\n * Update the DevBox host's Caddy reverse proxy config.\n *\n * Generates a Caddyfile so services are accessible at\n * http://{service}.{stack}.{app}.{tenant}-{env}.mesh.local\n * from the developer's Mac (via Tailscale + dnsmasq).\n */\nasync function updateDevboxProxy(devOutput: DevOutput, appRoot: string): Promise<void> {\n const appName = devOutput.app ?? path.basename(appRoot);\n const tenant = devOutput.platform?.tenant ?? 'mesh';\n const env = devOutput.platform?.env ?? 'dev';\n const stack = devOutput.stack ?? 'dev';\n const baseDomain = `${tenant}-${env}.mesh.local`;\n\n // Build hostname \u2192 port map\n const services: Record<string, number> = {};\n for (const [name, service] of Object.entries(devOutput.services)) {\n if (service.port) {\n services[`${name}.${stack}.${appName}.${baseDomain}`] = service.port;\n }\n }\n for (const [name, tunnel] of Object.entries(devOutput.tunnels)) {\n if (tunnel.port) {\n services[`${name}.${stack}.${appName}.${baseDomain}`] = tunnel.port;\n }\n }\n\n if (Object.keys(services).length === 0) return;\n\n // Generate Caddyfile\n const lines = [\n '# Auto-generated by mesh dev \u2014 do not edit manually.',\n '',\n ':8080 {',\n ' respond /health \"OK\" 200',\n '}',\n '',\n ];\n for (const [hostname, port] of Object.entries(services)) {\n lines.push(`http://${hostname} {`);\n lines.push(` reverse_proxy localhost:${port}`);\n lines.push('}');\n lines.push('');\n }\n\n const caddyfile = lines.join('\\n');\n\n // Write to host's /etc/caddy via a throwaway container with a host volume mount.\n // The Docker socket is the HOST's, so -v /etc/caddy mounts the HOST path.\n execFileSync(\n 'docker',\n [\n 'run',\n '--rm',\n '-v',\n '/etc/caddy:/etc/caddy',\n 'busybox',\n 'sh',\n '-c',\n `cat > /etc/caddy/Caddyfile << 'CADDYEOF'\\n${caddyfile}\\nCADDYEOF`,\n ],\n { stdio: 'pipe' },\n );\n\n // Reload Caddy on the host via nsenter into PID 1's mount namespace\n execFileSync(\n 'docker',\n [\n 'run',\n '--rm',\n '--pid=host',\n '--privileged',\n 'busybox',\n 'nsenter',\n '-t',\n '1',\n '-m',\n '--',\n 'caddy',\n 'reload',\n '--config',\n '/etc/caddy/Caddyfile',\n '--adapter',\n 'caddyfile',\n ],\n { stdio: 'pipe' },\n );\n\n logSuccess('DevBox proxy updated:');\n for (const [hostname, port] of Object.entries(services)) {\n logInfo(` http://${hostname} \u2192 localhost:${port}`);\n }\n}\n\n// ============================================================================\n// Subdomain routing (Headscale MagicDNS + Caddy)\n// ============================================================================\n\ninterface SubdomainContext {\n tsHostname: string;\n tsIp: string;\n baseDomain: string; // e.g., \"hub.dev.devcontainer-test.vpn.internal\"\n dnsConfig: HeadscaleDnsConfig;\n}\n\nasync function setupSubdomainRouting(\n devOutput: DevOutput,\n appRoot: string,\n): Promise<SubdomainContext | null> {\n // 1. Get Tailscale info\n const tsInfo = await getTailscaleInfo();\n if (!tsInfo) return null;\n\n const appName = devOutput.app ?? path.basename(appRoot);\n const stack = devOutput.stack ?? 'dev';\n const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;\n\n // 2. Derive Headscale DNS config from platform context (same convention as `mesh vpn`)\n const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };\n const dnsConfig = headscaleDnsConfig(platform.name ?? platform.tenant, platform.env);\n\n // 3. Build DNS records for all services + tunnels\n const records: DnsRecord[] = [];\n for (const [name, service] of Object.entries(devOutput.services)) {\n if (service.port) {\n records.push({ name: `${name}.${baseDomain}`, type: 'A', value: tsInfo.ip });\n }\n }\n for (const [name] of Object.entries(devOutput.tunnels)) {\n records.push({ name: `${name}.${baseDomain}`, type: 'A', value: tsInfo.ip });\n }\n\n // 4. Remove stale DNS records (best-effort)\n try {\n unregisterDnsRecords(dnsConfig, baseDomain);\n } catch {\n /* ignore */\n }\n\n // 5. Generate Caddyfile\n const caddyDir = '/tmp/mesh-dev-caddy';\n const caddyfile = path.join(caddyDir, 'Caddyfile');\n if (!fs.existsSync(caddyDir)) fs.mkdirSync(caddyDir, { recursive: true });\n\n // Build Caddy config \u2014 bind directly to the Tailscale IP on port 80.\n // In kernel tun mode, tailscale0 is a real interface so Caddy can listen\n // on it without any tailscale serve layer in between.\n const caddyLines: string[] = [`# mesh-dev: ${appName}/${stack} (${tsInfo.hostname})`, ''];\n for (const [name, service] of Object.entries(devOutput.services)) {\n if (service.port) {\n caddyLines.push(`http://${name}.${baseDomain} {`);\n caddyLines.push(` bind ${tsInfo.ip}`);\n caddyLines.push(` reverse_proxy localhost:${service.port}`);\n caddyLines.push('}');\n caddyLines.push('');\n }\n }\n for (const [name, tunnel] of Object.entries(devOutput.tunnels)) {\n caddyLines.push(`http://${name}.${baseDomain} {`);\n caddyLines.push(` bind ${tsInfo.ip}`);\n caddyLines.push(` reverse_proxy ${tunnel.host}:${tunnel.port}`);\n caddyLines.push('}');\n caddyLines.push('');\n }\n\n fs.writeFileSync(caddyfile, caddyLines.join('\\n'));\n\n // 6. Start or reload Caddy\n let caddyRunning = false;\n try {\n execFileSync('pgrep', ['-f', 'caddy run.*mesh-dev-caddy'], { stdio: 'pipe' });\n caddyRunning = true;\n } catch {\n /* not running */\n }\n\n if (caddyRunning) {\n try {\n execFileSync('caddy', ['reload', '--config', caddyfile, '--adapter', 'caddyfile'], {\n stdio: 'pipe',\n });\n } catch {\n logWarn('Caddy reload failed \u2014 check Caddyfile syntax');\n return null;\n }\n } else {\n try {\n execFileSync('which', ['caddy'], { stdio: 'pipe' });\n } catch {\n logWarn(\n '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',\n );\n return null;\n }\n const caddy = spawn('caddy', ['run', '--config', caddyfile, '--adapter', 'caddyfile'], {\n stdio: 'ignore',\n detached: true,\n cwd: caddyDir,\n });\n caddy.unref();\n }\n\n // 7. Register DNS records (after Caddy is confirmed running)\n try {\n const existing = readDnsRecords(dnsConfig);\n const allRecords = existing.filter((r) => !r.name.endsWith(baseDomain));\n allRecords.push(...records);\n registerDnsRecords(dnsConfig, allRecords);\n } catch {\n logWarn(\n 'DNS registration failed \u2014 Headscale pod may be unreachable. Dev routing may not work.',\n );\n return null;\n }\n\n return { tsHostname: tsInfo.hostname, tsIp: tsInfo.ip, baseDomain, dnsConfig };\n}\n\n// ============================================================================\n// Core actions\n// ============================================================================\n\n/**\n * Start all services in tmux windows.\n */\nasync function startServices(\n sessionName: string,\n appRoot: string,\n devOutput: DevOutput,\n headless: boolean,\n awsEnv: Record<string, string>,\n tunnelPlan: TunnelPlan = { transport: 'vpn-direct' },\n worktreeRoot?: string,\n taskQueueSuffix = '',\n): Promise<DevOutput> {\n if (!hasTmux()) {\n logError('tmux is required. Install with: brew install tmux');\n process.exit(1);\n }\n\n // Always kill any existing session and recreate for a clean start.\n // Tunnels (if any) are set up as part of this flow below.\n if (sessionExists(sessionName)) {\n logInfo(`Killing existing session: ${sessionName}`);\n killSession(sessionName);\n }\n\n const serviceNames = Object.keys(devOutput.services);\n if (serviceNames.length === 0) {\n logWarn('No services defined in dev output.');\n return devOutput;\n }\n\n // Create tmux session with a status window (always fresh).\n logInfo(`Creating tmux session: ${sessionName}`);\n execFileSync('tmux', ['new-session', '-d', '-s', sessionName, '-n', 'status', '-c', appRoot]);\n\n // Set AWS credentials on the session BEFORE starting anything that needs them\n // (SSM tunnels, services). tmux windows inherit the session environment.\n setTmuxEnv(sessionName, awsEnv);\n\n // Bring up VPC tunnels (rewriting devOutput.tunnels to localhost:PORT) via the\n // chosen backing. Tailscale (shared per-tenant userspace daemon): an explicit\n // `--transport tailscale` now hard-fails on any bring-up error (see below); only\n // an auto-resolved tailscale choice falls back to SSM. vpn-direct needs no tunnels.\n const hasTunnels = Object.keys(devOutput.tunnels).length > 0;\n let effectiveTransport = tunnelPlan.transport;\n if (hasTunnels && effectiveTransport === 'tailscale' && tunnelPlan.tailscale) {\n try {\n const rewritten = await startTailscaleTunnels(devOutput, tunnelPlan.tailscale);\n // Verify the forwarders actually ROUTE, not just bind (MESH-2268). Transport\n // selection picks tailscale on PRESENCE (a tailscaled binary exists), so an\n // auto-selected daemon can come up joined to a tailnet that doesn't route the\n // mesh VPC, or with a dead SOCKS upstream (MESH-2169) \u2014 the forwarder binds\n // its port either way and the bare-accept readiness gate below (waitForPort)\n // would pass it, letting services start and then die on a gRPC deadline.\n // Throw on a dead critical tunnel so the catch below applies the same\n // explicit-hard-fail-vs-auto-SSM-fallback policy as a bring-up failure \u2014 and\n // only commit the rewrite (reassign devOutput) once it holds, so an SSM\n // fallback runs startSsmTunnels on the ORIGINAL, un-rewritten tunnels.\n const dead = await firstUnroutableTunnel(rewritten.tunnels);\n if (dead) {\n throw new Error(\n `forwarder for '${dead}' bound but does not route the VPC ` +\n `(dead SOCKS upstream or non-routing tailnet)`,\n );\n }\n devOutput = rewritten;\n // Announce ONLY after the join + forwarders actually came up \u2014 the previous\n // pre-flight \"Using userspace-Tailscale tunnels\" printed before this even ran,\n // so a fallback still read as success (MESH-2098).\n logInfo('Connected: userspace-Tailscale tunnels for VPC resources (shared per tenant).');\n } catch (err) {\n const reason = err instanceof Error ? err.message : String(err);\n if (tunnelPlan.explicit) {\n // The user explicitly asked for --transport=tailscale. Don't silently\n // override that with SSM \u2014 surface the real reason (often a pending VPN\n // node registration whose URL was printed above) and let them act.\n logError(`Tailscale transport failed: ${reason}`);\n logInfo(\n 'If a VPN registration URL was shown above, open it to authorize this machine, then re-run.',\n );\n logInfo('Or switch backing explicitly: mesh dev --transport ssm');\n // We already killed the prior session and created a fresh (empty) one\n // above; tear it down so an explicit-tailscale failure leaves no orphaned\n // half-session behind.\n killSession(sessionName);\n process.exit(1);\n }\n logWarn(`Tailscale transport unavailable (${reason}) \u2014 falling back to SSM tunnels.`);\n effectiveTransport = 'ssm';\n }\n }\n if (hasTunnels && effectiveTransport === 'ssm') {\n devOutput = await startSsmTunnels(sessionName, devOutput);\n }\n if (hasTunnels && effectiveTransport !== 'vpn-direct') {\n\n // Gate service start on tunnel readiness: poll each tunnel's local port\n // until it accepts TCP. Without this, Temporal-connected services (api,\n // worker, vcs) raced the SSM tunnel and died connection-refused at launch.\n for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {\n if (tunnel.host !== 'localhost') continue; // not SSM-rewritten\n logInfo(`Waiting for tunnel ${tName} (localhost:${tunnel.port})...`);\n const ready = await waitForPort('localhost', tunnel.port, TUNNEL_READY_TIMEOUT_MS);\n if (!ready) {\n logError(\n `Tunnel '${tName}' did not become ready on localhost:${tunnel.port} within ${TUNNEL_READY_TIMEOUT_MS / 1000}s.`,\n );\n logInfo(` Check the tunnel window for errors: tmux attach -t ${sessionName} (window tunnel-${tName})`);\n logInfo(' Common causes: expired AWS credentials, bastion stopped, session-manager-plugin errors.');\n throw new Error(\n `SSM tunnel '${tName}' not ready on localhost:${tunnel.port} after ${TUNNEL_READY_TIMEOUT_MS / 1000}s`,\n );\n }\n logSuccess(` Tunnel ready: ${tName} (localhost:${tunnel.port})`);\n }\n }\n\n const tunnelNames = Object.keys(devOutput.tunnels);\n\n // Set up subdomain routing if Tailscale is available\n const subdomainCtx = await setupSubdomainRouting(devOutput, appRoot);\n\n // Log VPC connections\n if (tunnelNames.length > 0) {\n const isSSM = Object.values(devOutput.tunnels).some(\n (t) => t.host === 'localhost' && t.port >= SSM_TUNNEL_BASE_PORT,\n );\n logSuccess(isSSM ? 'SSM tunnel connections:' : 'VPN direct connections:');\n for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {\n if (subdomainCtx) {\n logInfo(` ${tName} \\u2192 http://${tName}.${subdomainCtx.baseDomain}`);\n } else {\n logInfo(` ${tName} \\u2192 ${tunnel.host}:${tunnel.port}`);\n }\n }\n }\n\n // Resolve secrets from Secrets Manager\n let secretEnvVars: Record<string, string> = {};\n if (devOutput.secrets && Object.keys(devOutput.secrets).length > 0) {\n secretEnvVars = await resolveSecrets(devOutput.secrets);\n }\n\n // Resolve Temporal auth credentials from SSM when a temporal endpoint is configured.\n const tenant = devOutput.platform?.tenant ?? 'mesh';\n const platformEnv = devOutput.platform?.env ?? 'dev';\n\n // Write a session-scoped kubeconfig (aws eks get-token) so the kubectl calls\n // below (encoding-key read, temporal-pod env fallback) don't depend on an\n // ambient ~/.kube/config. Best-effort: continue without it if unresolved.\n // The local platform (`mesh start`) has no cluster \u2014 skip entirely.\n const isLocalPlatform = tenant === 'local';\n if (!isLocalPlatform) {\n // Hub platform name \u2014 the EKS export lives under it, not under a hardcoded\n // \"mesh\" (MESH-2515); same axis as the bastion resolution.\n const platformName = resolveHubPlatformName(devOutput.platform);\n const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {\n onError: ({ parameter, error }) =>\n logWarn(\n `EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`,\n ),\n });\n if (kubeconfigPath) {\n process.env.KUBECONFIG = kubeconfigPath;\n setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });\n logSuccess(`Kubeconfig resolved from SSM (hub ${platformName}/${platformEnv}) \u2192 ${kubeconfigPath}`);\n } else {\n logWarn('Could not resolve EKS kubeconfig from SSM; kubectl calls will use ambient config (if any).');\n }\n }\n\n let temporalAuthVars: Record<string, string> = {};\n if (devOutput.tunnels['temporal']) {\n // Deliberately NOT the `?? 'mesh'` fallback the EKS path above uses: the\n // Temporal-auth fallback is `?? tenant` (dedicated tenants may run their\n // own Temporal). Whether that difference is correct is MESH-2551.\n temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);\n\n // Resolve the encoding key from K8s so local dev can decrypt\n // payloads created by deployed workers.\n const appName = devOutput.app ?? '';\n if (appName) {\n const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);\n if (encodingKey) {\n temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;\n }\n }\n\n // Set TEMPORAL_ADDRESS to the VPC endpoint for direct VPN access (or the\n // pinned loopback address under SSM fallback \u2014 see tunnelClientAddress).\n const temporalTunnel = devOutput.tunnels['temporal'];\n temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);\n }\n\n // Set shared env vars (secrets + temporal auth) on the tmux session.\n // AWS creds were already set earlier (before SSM tunnels started).\n setTmuxEnv(sessionName, secretEnvVars);\n setTmuxEnv(sessionName, temporalAuthVars);\n\n // Inject dev user identity from mesh login credentials so services can\n // emulate the oauth2-proxy auth headers locally.\n //\n // The credentials are stored under the platform context (e.g. 'mesh.dev'),\n // not the tenant name. Use 'mesh' as the platform name since all tenants\n // share the same Zitadel instance.\n //\n // Services (e.g., Hub UI) use these env vars as a fallback when the real\n // oauth2-proxy `X-Forwarded-*` request headers are absent:\n // DEV_USER_ID - subject from idToken, matches conversation ownership\n // DEV_USER_EMAIL - user email from idToken\n // DEV_USER_ID_TOKEN - full idToken JWT (profile claims, authorization hint)\n // DEV_USER_ACCESS_TOKEN - full accessToken JWT (has project:roles claim)\n // Local platform creds live under the `local` context (mesh login local).\n const credContext = devOutput.platform\n ? isLocalPlatform\n ? 'local'\n : loginContextFor(devOutput.platform)\n : null;\n const devUserVars = resolveDevUserVars(devOutput);\n if (Object.keys(devUserVars).length > 0) {\n setTmuxEnv(sessionName, devUserVars);\n logSuccess(`Dev user injected from mesh login: ${credContext}`);\n\n // Serve a continuously-fresh dev-user token so services survive past the\n // ~1h Zitadel horizon without a restart (MESH-2039). The static vars above\n // remain the fallback when this URL is absent. App-agnostic: gated on the\n // platform context, not the app \u2014 any `mesh dev` app inherits it.\n const tokenPort = await findFreePort();\n const tokenUrl = `http://127.0.0.1:${tokenPort}`;\n setTmuxEnv(sessionName, { DEV_USER_TOKEN_URL: tokenUrl });\n execFileSync('tmux', ['new-window', '-t', sessionName, '-n', 'token-server']);\n execFileSync(\n 'tmux',\n ['set-option', '-t', `${sessionName}:token-server`, 'remain-on-exit', 'on'],\n { stdio: 'ignore' },\n );\n execFileSync('tmux', [\n 'send-keys',\n '-t',\n `${sessionName}:token-server`,\n `npx mesh dev __token-server ${tokenPort} ${credContext}`,\n 'Enter',\n ]);\n // Best-effort readiness so the first requests land (non-fatal).\n for (let i = 0; i < 15 && !(await isPortListening(tokenPort)); i++) {\n await new Promise((r) => setTimeout(r, 200));\n }\n logSuccess(`Dev-user token-server: ${tokenUrl} (context ${credContext})`);\n } else if (credContext) {\n logWarn(`No mesh login credentials found for '${credContext}'.`);\n logInfo(` Run: mesh login ${credContext}`);\n logInfo(' Hub UI will show unauthenticated state without valid credentials.');\n }\n\n const monorepoRoot = findMonorepoRoot();\n\n for (const [name, service] of Object.entries(devOutput.services)) {\n const cmd = service.command.join(' ');\n\n // Skip services with no dev command (e.g., deployed-only like ApiDocs)\n if (!cmd) {\n logInfo(`Skipped: ${name} (no dev command, deployed to K8s)`);\n continue;\n }\n\n const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, monorepoRoot));\n\n // Wrong-worktree guard (MESH-2013): if `rebaseServiceSrc` couldn't re-root the\n // deploy-time absolute path onto THIS worktree, `serviceDir` can point into a\n // DIFFERENT worktree \u2014 silently running stale code. Refuse rather than launch it.\n if (worktreeRoot && fs.existsSync(serviceDir)) {\n const wtRootWithSep = worktreeRoot.endsWith(path.sep) ? worktreeRoot : worktreeRoot + path.sep;\n const outside = serviceDir !== worktreeRoot && !serviceDir.startsWith(wtRootWithSep);\n // A dev-LINKED package is outside the worktree by construction, and is what\n // this app runs either way \u2014 see `isLinkedDependencyDir`. Only an\n // unexplained outside path is the wrong-worktree failure.\n if (outside && isLinkedDependencyDir(serviceDir, appRoot)) {\n logInfo(`${name}: source is a dev-linked package (${serviceDir})`);\n } else if (outside) {\n logError(\n `Refusing to launch '${name}': its source resolved to ${serviceDir}, ` +\n `outside this worktree (${worktreeRoot}), and it is not a package this ` +\n `app resolves there. The stack was likely deployed from another ` +\n `worktree. Re-run \\`mesh deploy up\\` here, or pass --app.`,\n );\n process.exit(1);\n }\n }\n\n // Skip services whose source directory doesn't exist in this worktree.\n // This happens when the Pulumi stack was deployed from a different branch\n // that added a service not yet present here (e.g., hub-worker added on\n // feature/hub-credential-management but absent from feature/app-detail-redesign).\n if (!fs.existsSync(serviceDir)) {\n logWarn(\n `Skipped: ${name} (source directory not found: ${serviceDir}` +\n `${serviceDir !== service.src ? ` \u2014 rebased from ${service.src}` : ''})`,\n );\n continue;\n }\n // Per-service env vars are written to a 0600 env file and `source`d by\n // the launch command, scoping them to the process (no cross-service\n // leaks through the tmux session environment) while keeping the\n // send-keys string short \u2014 inlining the full env used to intermittently\n // exceed what tmux send-keys delivers intact, silently truncating the\n // command so the service never started.\n //\n // Overlay the RESOLVED Temporal vars (real encoding key + tunnel address +\n // M2M auth, fetched from K8s/SSM) on top of the dev-output env. The dev\n // output carries placeholder values from the Agent's Temporal link (e.g.\n // a `dev-encoding-key-*` placeholder), and the per-service env would\n // otherwise shadow the session-level resolved values \u2014 desyncing services\n // that DO carry the placeholder (worker) from those that don't (api),\n // which breaks payload encryption (mismatched encoding keys \u2192 decode null).\n const serviceVars = {\n ...getServiceEnvVars(service, devOutput.tunnels),\n ...temporalAuthVars,\n // Overlay the resolved dev-user identity for the same reason as the\n // Temporal vars: the dev output may carry a placeholder (e.g. hub ui's\n // DEV_USER_ACCESS_TOKEN=\"dev-local\"), and since the sourced env file\n // wins over the tmux session env, the placeholder would clobber the\n // real token injected above \u2014 silently 401ing tenant-scoped Hub views.\n ...devUserVars,\n // Per-worktree Temporal task-queue isolation (MESH-2013 Slice 2): agent\n // workers/apps append this to their task-queue names so concurrent\n // worktrees don't steal each other's workflow tasks. Empty on the primary\n // checkout \u2192 omitted \u2192 unchanged historical behavior.\n ...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),\n // AWS, for the same reason as the Temporal and dev-user overlays above: the\n // sourced env file WINS over the tmux session env, so a service that reads this\n // file gets whatever the file says and nothing else.\n //\n // It matters beyond the service's own process. A module dev service can hand\n // credentials to something it starts \u2014 the workspaces manager gives each\n // workspace's sidecar an AWS profile so Claude Code can reach Bedrock \u2014 and with\n // no AWS_* here it silently has none to give. `buildChildAwsEnv` has already\n // chosen the SELF-REFRESHING shape (a profile with `credential_process`, not a\n // frozen STS triple), which is exactly what a long-lived child needs.\n ...awsEnv,\n };\n const envFilePath = getServiceEnvFilePath(sessionName, name);\n writeEnvFile(envFilePath, serviceVars);\n\n // Local mode ships the service's stdout to the platform's log pipeline\n // via OTLP (assets/log-shipper.mjs): the OTEL_* vars in the env file\n // carry the hosted k8s_* attribution, so the Hub's logs view covers\n // local runs with zero file plumbing.\n const launchCmd = buildLaunchCommand(\n envFilePath,\n serviceDir,\n cmd,\n service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined,\n );\n\n execFileSync('tmux', ['new-window', '-t', sessionName, '-n', name, '-c', serviceDir]);\n\n // Keep window alive on exit so errors are visible\n execFileSync('tmux', ['set-option', '-t', `${sessionName}:${name}`, 'remain-on-exit', 'on'], {\n stdio: 'ignore',\n });\n\n execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${name}`, launchCmd, 'Enter']);\n\n // Log the ACTUAL launch dir (`serviceDir`, after the worktree rebase), not the\n // raw deploy-time `service.src` \u2014 printing the pre-rebase path made a correctly\n // re-rooted service look like it was running from a different (stale) worktree.\n logSuccess(`Started: ${name} (${serviceDir}, port ${service.port})`);\n }\n\n // Generate reverse proxy config for DevBox hostname-based routing.\n if (process.env.DEVCONTAINER === '1') {\n try {\n await updateDevboxProxy(devOutput, appRoot);\n } catch (e) {\n logWarn(`Could not update devbox proxy: ${(e as Error).message}`);\n }\n }\n\n // Run live status updates in the status window\n const statusCmd = `watch -n2 -t npx mesh dev --status --session '${sessionName}'`;\n execFileSync('tmux', ['send-keys', '-t', `${sessionName}:status`, statusCmd, 'Enter']);\n\n console.log('');\n logSuccess(`Dev session started: ${sessionName}`);\n console.log('');\n\n if (tunnelNames.length > 0) {\n console.log('Connections (VPN direct):');\n for (const tName of tunnelNames) {\n const t = devOutput.tunnels[tName]!;\n if (subdomainCtx) {\n console.log(` ${tName.padEnd(20)} http://${tName}.${subdomainCtx.baseDomain}`);\n } else {\n console.log(` ${tName.padEnd(20)} ${t.host}:${t.port}`);\n }\n }\n console.log('');\n }\n\n console.log('Services:');\n for (const name of serviceNames) {\n const s = devOutput.services[name]!;\n const hasCmd = s.command && s.command.length > 0;\n if (hasCmd) {\n if (subdomainCtx && s.port) {\n console.log(` ${name.padEnd(20)} http://${name}.${subdomainCtx.baseDomain}`);\n } else {\n const addr = s.port ? `http://localhost:${s.port}` : '(no port)';\n console.log(` ${name.padEnd(20)} ${addr}`);\n }\n } else {\n console.log(` ${name.padEnd(20)} (deployed)`);\n }\n }\n\n console.log('');\n\n if (!headless) {\n logInfo('Attaching to tmux session...');\n if (process.env.TMUX) {\n spawnSync('tmux', ['switch-client', '-t', sessionName], { stdio: 'inherit' });\n } else {\n spawnSync('tmux', ['attach', '-t', sessionName], { stdio: 'inherit' });\n }\n } else {\n console.log(`Attach with: tmux attach -t ${sessionName}`);\n console.log(`Stop with: mesh dev --kill`);\n console.log('');\n }\n\n // Return the (possibly SSM-tunnel-rewritten) devOutput so the caller can\n // persist it in session state for --status / restart.\n return devOutput;\n}\n\n/**\n * Show status of running services.\n */\nasync function showStatus(\n sessionName: string,\n devOutput: DevOutput,\n asJson: boolean,\n): Promise<void> {\n if (!sessionExists(sessionName)) {\n if (asJson) {\n console.log(JSON.stringify({ running: false, session: sessionName }));\n } else {\n logInfo(`No active session: ${sessionName}`);\n }\n return;\n }\n\n // Get tmux window list\n let windows: string[] = [];\n try {\n const raw = execFileSync(\n 'tmux',\n ['list-windows', '-t', sessionName, '-F', '#{window_name} #{pane_dead}'],\n { encoding: 'utf-8' },\n );\n windows = raw.trim().split('\\n');\n } catch {\n // Session might have disappeared\n }\n\n const windowStatus: Record<string, string> = {};\n for (const line of windows) {\n const [name, dead] = line.split(' ');\n if (name) windowStatus[name] = dead === '1' ? 'exited' : 'running';\n }\n\n const tunnelNames = Object.keys(devOutput.tunnels);\n // Live-probe each tunnel so a mid-session tunnel death shows as DOWN here\n // (this render runs every ~2s in the status window) \u2014 MESH-2016.\n const tunnelHealth = await probeTunnelHealth(devOutput.tunnels);\n const healthByName = new Map(tunnelHealth.map((h) => [h.name, h]));\n\n if (asJson) {\n const services: Record<string, object> = {};\n for (const [name, svc] of Object.entries(devOutput.services)) {\n services[name] = {\n status: windowStatus[name] ?? 'unknown',\n port: svc.port,\n src: svc.src,\n };\n }\n const tunnels: Record<string, object> = {};\n for (const [name, tun] of Object.entries(devOutput.tunnels)) {\n tunnels[name] = {\n host: tun.host,\n port: tun.port,\n reachable: healthByName.get(name)?.reachable ?? false,\n };\n }\n console.log(\n JSON.stringify({ running: true, session: sessionName, services, tunnels }, null, 2),\n );\n } else {\n console.log('\\u2500\\u2500 mesh dev \\u2500\\u2500');\n console.log('');\n console.log(`Session: ${sessionName}`);\n\n if (tunnelNames.length > 0) {\n console.log('');\n for (const line of formatTunnelHealth(tunnelHealth)) console.log(line);\n }\n\n console.log('');\n console.log('Services:');\n for (const [name, svc] of Object.entries(devOutput.services)) {\n const hasCmd = svc.command && svc.command.length > 0;\n if (!hasCmd) {\n console.log(` \u2601 ${name.padEnd(16)} deployed ${svc.src}`);\n continue;\n }\n const status = windowStatus[name] ?? 'unknown';\n const icon = status === 'running' ? '\u25CF' : status === 'exited' ? '\u2717' : '?';\n const addr = svc.port ? `:${svc.port}` : '';\n console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(10)} ${addr.padEnd(8)} ${svc.src}`);\n }\n console.log('');\n console.log('Commands:');\n console.log(' mesh dev restart <s> Restart a service');\n console.log(' mesh dev logs <s> Tail service logs');\n console.log(' mesh dev --kill Stop everything');\n console.log('');\n console.log('tmux: Ctrl+b n/p switch windows, d detach');\n }\n}\n\n/**\n * Restart a single service window.\n */\nasync function restartService(\n sessionName: string,\n serviceName: string,\n appRoot: string,\n devOutput: DevOutput,\n awsEnv: Record<string, string>,\n opts?: { refreshEnv?: boolean; stack?: string; sessionState?: SessionState | null },\n): Promise<void> {\n let service = devOutput.services[serviceName];\n if (!service) {\n logError(`Unknown service: ${serviceName}`);\n logInfo(`Available: ${Object.keys(devOutput.services).join(', ')}`);\n process.exit(1);\n }\n\n if (!sessionExists(sessionName)) {\n logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);\n process.exit(1);\n }\n\n // --refresh-env: regenerate this service's env file from CURRENT stack/SSM\n // outputs BEFORE killing the running process, so a failed read leaves the\n // good env file (and the running service) untouched (fail-closed).\n if (opts?.refreshEnv) {\n if (!opts.stack) {\n logError('--refresh-env: no stack resolved; keeping existing env and NOT restarting.');\n return;\n }\n // The session's allocated devOutput pins the ports/tunnels to reuse.\n const pinned = opts.sessionState?.devOutput ?? devOutput;\n let fresh: DevOutput;\n try {\n fresh = getDevOutput(appRoot, opts.stack, awsEnv);\n } catch (err) {\n logError(\n `--refresh-env: failed to read stack output for '${opts.stack}'. Keeping the existing env file for '${serviceName}' and NOT restarting.`,\n );\n logInfo(String((err as Error)?.message ?? err));\n return;\n }\n const merged = mergePinnedPorts(fresh, pinned);\n const mergedService = merged.services[serviceName];\n if (!mergedService) {\n logError(\n `--refresh-env: service '${serviceName}' is absent from the fresh stack output. Keeping the existing env file and NOT restarting.`,\n );\n return;\n }\n const temporalAuthVars = await resolveTemporalAuthVars(sessionName, merged);\n // Recompute the per-worktree suffix (deterministic from the worktree path,\n // so it matches what launch wrote) \u2014 a restarted service must keep polling\n // this worktree's isolated task queue (MESH-2013 Slice 2).\n const { taskQueueSuffix } = resolveWorktreeIdentity(appRoot);\n const serviceVars = {\n ...getServiceEnvVars(mergedService, merged.tunnels),\n ...temporalAuthVars,\n // Same overlay as launch: without it, --refresh-env rewrites the env\n // file from dev output and its DEV_USER_* placeholders clobber the real\n // token from `mesh login` (the \"restart silently 401s the Hub\" trap).\n ...resolveDevUserVars(merged),\n ...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),\n // Same overlay as launch \u2014 a refresh that dropped these would take a working\n // service's credentials away on restart, which is the hardest kind of change to\n // attribute afterwards.\n ...awsEnv,\n };\n writeEnvFile(getServiceEnvFilePath(sessionName, serviceName), serviceVars);\n // Relaunch from the refreshed service definition (command/src may have changed).\n service = mergedService;\n logSuccess(`Regenerated env for '${serviceName}' from current stack/SSM outputs.`);\n }\n\n const target = `${sessionName}:${serviceName}`;\n\n // Kill the current pane process and respawn\n try {\n execFileSync('tmux', ['respawn-pane', '-k', '-t', target], { stdio: 'ignore' });\n } catch {\n // Window might not exist yet\n logError(`Window '${serviceName}' not found in session.`);\n process.exit(1);\n }\n\n // Refresh shared env (AWS creds may have rotated) on the session.\n setTmuxEnv(sessionName, awsEnv);\n\n const cmd = service.command.join(' ');\n\n // Source the SAME env file written at launch \u2014 it is the session's env\n // contract for this service, guaranteeing a byte-identical environment.\n // Reconstructing env here has historically diverged from launch (e.g.\n // TEMPORAL_ENCODING_KEY drift \u2192 \"cannot decrypt payload\" / missing key \u2192\n // \"Unknown encoding: binary/encrypted\").\n const envFilePath = getServiceEnvFilePath(sessionName, serviceName);\n let restartCmd: string;\n if (fs.existsSync(envFilePath)) {\n const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, findMonorepoRoot()));\n // Keep the local log pipeline attached across restarts.\n restartCmd = buildLaunchCommand(\n envFilePath,\n serviceDir,\n cmd,\n service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined,\n );\n } else {\n logWarn(\n `No launch env file for '${serviceName}' (${envFilePath}) \u2014 session predates env-file launches.`,\n );\n logWarn('Falling back to reconstructed env; restart `mesh dev` for a faithful environment.');\n const serviceVars = getServiceEnvVars(service, devOutput.tunnels);\n restartCmd = `${envPrefix(serviceVars)}${cmd}`;\n }\n execFileSync('tmux', ['send-keys', '-t', target, restartCmd, 'Enter']);\n\n logSuccess(`Restarted: ${serviceName}`);\n}\n\n/**\n * Capture and display logs from a service window.\n */\nfunction showLogs(sessionName: string, serviceName: string, tail: number): void {\n if (!sessionExists(sessionName)) {\n logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);\n process.exit(1);\n }\n\n const target = `${sessionName}:${serviceName}`;\n\n try {\n const result = execFileSync('tmux', ['capture-pane', '-t', target, '-p', '-S', `-${tail}`], {\n encoding: 'utf-8',\n });\n process.stdout.write(result);\n } catch {\n logError(`Could not capture logs for '${serviceName}'.`);\n logInfo('Is the service name correct? Check with: mesh dev --status');\n process.exit(1);\n }\n}\n\n// ============================================================================\n// Command registration\n// ============================================================================\n\n/**\n * Assemble a {@link DoctorContext} for the current app/stack/session, resolving\n * the deployer role the same way the `restart`/launch paths do (default role +\n * Zitadel-claim admin selection). Used by `mesh dev doctor`.\n */\nfunction buildDoctorContext(\n appRoot: string,\n stack: string,\n sessionName: string,\n): DoctorContext {\n const state = loadSessionState(sessionName);\n const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');\n const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');\n const platformContext = derivePlatformContext(appRoot, stack);\n let deployerRole = defaultDeployerRole;\n if (defaultDeployerRole) {\n const meshCreds = platformContext ? readCredentials(platformContext) : null;\n const idToken =\n meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;\n deployerRole = selectRoleForCaller(idToken, {\n defaultRole: defaultDeployerRole,\n adminRole: adminDeployerRole ?? undefined,\n });\n }\n return {\n appRoot,\n stack,\n sessionName,\n deployerRole,\n platformContext,\n credMethod: null,\n sessionState: state,\n };\n}\n\nexport function registerDevCommand(program: Command): void {\n const dev = program\n .command('dev')\n .description('Start local dev environment (reads Pulumi stack outputs)')\n .option('--app <path>', 'Path to a Pulumi app (relative to monorepo root or cwd)')\n .option('--stack <stack>', 'Pulumi stack name (default: auto-detect)')\n .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())\n .option('--headless', 'Start without attaching to tmux')\n .option('--kill', 'Kill existing dev session')\n .option('--status', 'Show service status')\n .option('--json', 'Output status as JSON (with --status)')\n .option(\n '--session <name>',\n 'Override the tmux session name (auto-derived per git worktree by default \u2014 omit to keep concurrent worktrees isolated)',\n )\n .option('--dry-run', 'Print the resolved worktree/session/port plan and exit without launching')\n .option('--force', 'Relaunch even if workflow code changed since launch (may strand in-flight conversations)')\n .option('--profile <name>', 'AWS SSO profile to use (e.g., mesh-dev)')\n .addOption(\n new Option(\n '--transport <mode>',\n 'Tunnel backing (default: auto \u2014 Tailscale when available, else SSM/VPN)',\n ).choices(['auto', 'ssm', 'tailscale']),\n )\n .option(\n '--local',\n '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.',\n )\n .option(\n '--externals [names]',\n \"Also realize the app's declared external services (package.json \u2192 mesh.externals): all of them, or a comma-separated subset of name[=mode] entries. Modes: mock (emulate \u2014 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 \u2014 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.\",\n )\n .option('--mock [names]', 'Alias for --externals.')\n .option(\n '--runner <runner>',\n \"Process runner for local mode: 'tmux' (default \u2014 dev machines) or 'docker' (CI/headless: services run as a docker compose project with host networking; Linux semantics).\",\n 'tmux',\n );\n\n // Hidden: the dev-user token-server (MESH-2039). Launched into its own tmux\n // window by the dev launcher; serves a continuously-fresh dev-user token.\n dev\n .command('__token-server <port> <context>', { hidden: true })\n .action(async (port: string, context: string) => {\n const { startTokenServer } = await import('./dev-token-server.js');\n await startTokenServer(Number(port), context);\n });\n\n // Default action: start or show status\n dev.action(async (options: DevOptions) => {\n // An explicit --profile must win over stale shell-exported STS credentials.\n // The AWS SDK/CLI prefer AWS_ACCESS_KEY_ID over AWS_PROFILE, so clear any\n // static credentials before resolving the deployer role.\n if (options.profile) {\n applyAwsProfileOverride(options.profile);\n }\n\n // Fast-path: --status/--kill with explicit --session can skip app root resolution\n if (options.session && (options.status || options.kill)) {\n const sessionName = options.session;\n if (loadSessionState(sessionName)?.runner === 'docker') {\n if (options.kill) {\n dockerDevDown(sessionName);\n composeExternalsDown(loadSessionState(sessionName)?.composeExternals);\n localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);\n removeSessionState(sessionName);\n logSuccess(`Killed docker dev session: ${sessionName}`);\n } else {\n console.log(dockerDevPs(sessionName));\n }\n return;\n }\n if (options.kill) {\n composeExternalsDown(loadSessionState(sessionName)?.composeExternals);\n localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);\n // Clean up DNS records for this app\n try {\n const devOutput = loadSessionState(sessionName)?.devOutput;\n if (devOutput) {\n const tsInfo = await getTailscaleInfo();\n if (tsInfo) {\n const appName = devOutput.app ?? sessionName.replace(/-dev$/, '');\n const stack = devOutput.stack ?? 'dev';\n const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;\n const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };\n unregisterDnsRecords(headscaleDnsConfig(platform.name ?? platform.tenant, platform.env), baseDomain);\n }\n }\n } catch {\n /* best effort */\n }\n\n if (sessionExists(sessionName)) {\n killSession(sessionName);\n logSuccess(`Killed session: ${sessionName}`);\n } else {\n logInfo(`No active session: ${sessionName}`);\n }\n removeSessionState(sessionName);\n return;\n }\n // --status with --session: use state file, no Pulumi needed\n const state = loadSessionState(sessionName);\n if (state) {\n await showStatus(sessionName, state.devOutput, !!options.json);\n return;\n }\n // No state \u2014 fall through to normal flow\n }\n\n const appRoot = findAppRoot(options.app);\n const projectName = getProjectName(appRoot);\n // Port block scoped to (worktree, app): concurrent sibling-app sessions\n // must draw disjoint ports (probing races slow boots). Scoped once here so\n // local and tethered modes resolve the SAME plan \u2014 every other field stays\n // worktree-scoped, so session naming and task queues are unaffected.\n const worktree = withAppScopedPortBlock(resolveWorktreeIdentity(appRoot), appRoot);\n const sessionName = options.session ?? deriveSessionName(projectName, worktree);\n\n // --kill\n if (options.kill) {\n composeExternalsDown(loadSessionState(sessionName)?.composeExternals);\n localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);\n if (loadSessionState(sessionName)?.runner === 'docker') {\n dockerDevDown(sessionName);\n removeSessionState(sessionName);\n logSuccess(`Killed docker dev session: ${sessionName}`);\n return;\n }\n // Tailscale VPN tunnels are machine-global + shared across sessions, so\n // they persist on purpose after a kill. Capture the tenant before state\n // is removed so we can point at the explicit stop below.\n const killTsTenant = loadSessionState(sessionName)?.devOutput.platform?.name ?? 'mesh';\n // Clean up DNS records for this app\n try {\n const devOutput = loadSessionState(sessionName)?.devOutput;\n if (devOutput) {\n const tsInfo = await getTailscaleInfo();\n if (tsInfo) {\n const appName = devOutput.app ?? projectName;\n const stack = devOutput.stack ?? 'dev';\n const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;\n const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };\n unregisterDnsRecords(headscaleDnsConfig(platform.name ?? platform.tenant, platform.env), baseDomain);\n }\n }\n } catch {\n /* best effort */\n }\n\n if (sessionExists(sessionName)) {\n killSession(sessionName);\n logSuccess(`Killed session: ${sessionName}`);\n } else {\n logInfo(`No active session: ${sessionName}`);\n }\n removeSessionState(sessionName);\n if (readTailscaleState(killTsTenant)) {\n logInfo(\n `VPN tunnels persist across sessions \u2014 stop them with: mesh vpn tunnel down --tenant ${killTsTenant}`,\n );\n }\n return;\n }\n\n // \u2500\u2500 Local mode (design \u00A75.2) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Explicit --local, or auto when the app has no Pulumi program at all\n // (hasStackBacking also checks the monorepo ROOT \u2014 apps like apps/hub\n // deploy via a root program and must keep the tethered default).\n // Synthesizes DevOutput from repo conventions and wires services to the\n // `mesh start` platform: no AWS credentials, no VPN, no Pulumi state.\n const localMode = !!options.local || !hasStackBacking(appRoot);\n if (localMode) {\n // Read-only status query: report and exit BEFORE any side effect\n // (platform boot, Zitadel provisioning, registry writes) \u2014 parity\n // with the tethered path's early --status short-circuit.\n if (options.status) {\n const state = loadSessionState(sessionName);\n if (state?.runner === 'docker') {\n console.log(dockerDevPs(sessionName));\n return;\n }\n const statusOutput =\n state?.devOutput ??\n (buildLocalDevOutput(appRoot, detectLocalTenant(appRoot), { mocks: {} }) as unknown as DevOutput);\n showStatus(sessionName, statusOutput, !!options.json);\n return;\n }\n\n const dockerRunner = options.runner === 'docker';\n if (!dockerRunner && !hasTmux()) {\n logError('tmux is not installed. Fix: brew install tmux (or use --runner docker)');\n process.exit(1);\n }\n await ensureLocalPlatformRunning();\n const localTenant = detectLocalTenant(appRoot);\n logInfo(`Project: ${projectName}, Stack: local (mesh start platform)`);\n\n // --externals (alias --mock): resolve the requested subset of the\n // app's declared externals (package.json \u2192 mesh.externals). Entries\n // are name[=mode] \u2014 an explicit mode overrides the declaration's\n // default for this run (materialized onto the decl so every\n // downstream externalMode() call agrees). Mock-mode externals join\n // the same orchestration as services named mock-{name}; all\n // realizations get their credential secret seeded after ports are\n // final.\n const externalsRequest = options.externals ?? options.mock;\n let selectedMocks: Record<string, LocalMockDecl> = {};\n // The externals the developer NAMED (empty for the bare flag, which\n // selects everything declared). Failing to realize one of these is\n // fatal; failing to realize one the developer never asked for by name\n // only costs that external.\n const requestedExplicitly = new Set<string>();\n if (externalsRequest) {\n const declared = readLocalMocks(appRoot);\n let requested: string[];\n let overrides = new Map<string, ExternalMode>();\n if (externalsRequest === true) {\n requested = Object.keys(declared);\n } else {\n ({ names: requested, overrides } = parseExternalsSelection(String(externalsRequest)));\n for (const name of requested) requestedExplicitly.add(name);\n }\n const unknown = requested.filter((name) => !declared[name]);\n if (unknown.length > 0) {\n throw new MeshCliError(\n `Unknown external(s): ${unknown.join(', ')} \u2014 declared in package.json mesh.externals: ${Object.keys(declared).join(', ') || '(none)'}`,\n { remediation: { docs: 'package.json \u2192 \"mesh\": { \"externals\": { \u2026 } }' } },\n );\n }\n if (requested.length === 0) {\n logWarn('No externals declared (package.json \u2192 mesh.externals) \u2014 continuing without.');\n }\n selectedMocks = Object.fromEntries(\n requested.map((name) => [\n name,\n overrides.has(name) ? { ...declared[name]!, mode: overrides.get(name)! } : declared[name]!,\n ]),\n );\n // Validate every declaration's resolved mode up front \u2014 a bad decl\n // (or an override the decl can't satisfy) should fail here, not\n // after half the orchestration is up.\n for (const [name, decl] of Object.entries(selectedMocks)) externalMode(name, decl);\n }\n\n const rawDevOutput = buildLocalDevOutput(appRoot, localTenant, {\n mocks: selectedMocks,\n }) as unknown as DevOutput;\n\n // --dry-run (worktree-plan parity with tethered mode): print the\n // resolved session/port plan and exit before any side effect \u2014\n // no Zitadel provisioning, no registry writes, no launch.\n if (options.dryRun) {\n printDevPlan(sessionName, appRoot, worktree, await allocatePorts(rawDevOutput, worktree));\n // Non-service realizations (local/remote) don't appear in the port\n // plan \u2014 list them so the dry run shows the whole picture.\n for (const [name, decl] of Object.entries(selectedMocks)) {\n const mode = externalMode(name, decl);\n if (mode === 'local') {\n console.log(` external ${name.padEnd(18)} local \u2014 docker compose (${decl.compose}) \u2192 localhost:${decl.port}`);\n } else if (mode === 'remote') {\n console.log(` external ${name.padEnd(18)} remote \u2014 actual service credentials (no local process)`);\n }\n }\n return;\n }\n\n let signInServices: string[] = [];\n /** The app's own version \u2014 stamped on the registration like a deploy. */\n let appVersion: string | undefined;\n // Auto-provision the app tenant's auth config (approved Zitadel\n // tenant-auth pattern: org per tenant \u2192 project per app \u2192 application\n // per service + credential secret). Best-effort: the dev loop still\n // works if Zitadel isn't seeded yet.\n try {\n const tenant = localTenant;\n const app = rawDevOutput.app ?? projectName;\n logInfo(`Provisioning auth config for tenant '${tenant}', app '${app}'\u2026`);\n const services = Object.keys(rawDevOutput.services);\n // Mocks are stand-ins, not services of the app \u2014 no Zitadel\n // application for them (the registry still lists them for the Hub).\n const authServices = services.filter((name) => !name.startsWith('mock-'));\n // App-declared project roles (package.json \u2192 mesh.auth.roles): created\n // on the project and granted to the local `{app}-m2m` caller, so\n // role-scoped services (roles claim \u2192 scopes) accept its tokens.\n let authRoles: string[] = [];\n // Services carrying a BROWSER login (package.json \u2192 mesh.auth.signIn).\n // Registered after ports are allocated \u2014 the redirect URI is the port.\n signInServices = [];\n try {\n const appPkg = JSON.parse(\n fs.readFileSync(path.join(appRoot, 'package.json'), 'utf-8'),\n );\n if (Array.isArray(appPkg?.mesh?.auth?.roles)) {\n authRoles = appPkg.mesh.auth.roles.filter((r: unknown) => typeof r === 'string');\n }\n if (Array.isArray(appPkg?.mesh?.auth?.signIn)) {\n signInServices = appPkg.mesh.auth.signIn.filter((r: unknown) => typeof r === 'string');\n }\n if (typeof appPkg?.version === 'string') appVersion = appPkg.version;\n } catch {\n // no package.json / unparsable \u2014 roles stay empty\n }\n await ensureAppTenantAuth({ tenant, app, services: authServices, roles: authRoles });\n // The app's own Temporal namespace ({tenant}-{env}-{app}) \u2014 the same\n // name the cloud provisioner creates and the Hub's per-app workflow\n // view resolves.\n await ensureTemporalNamespace(localAppNamespace(tenant, app));\n } catch (err) {\n logWarn(\n `Auth auto-provisioning skipped: ${err instanceof Error ? err.message : err}` +\n ` \u2014 if the local Zitadel predates seeding, run: mesh stop --destroy && mesh start`,\n );\n }\n\n const devOutput = await allocatePorts(rawDevOutput, worktree);\n\n // Browser sign-in AFTER port allocation: the callback URL is the port a\n // service actually listens on, and a stale URI is the difference between\n // a working login and an \"invalid redirect_uri\" nobody can debug from\n // the app side.\n for (const service of signInServices) {\n const port = devOutput.services[service]?.port;\n if (!port) {\n logWarn(`mesh.auth.signIn names '${service}', which this app does not run \u2014 no sign-in app registered.`);\n continue;\n }\n try {\n await ensureSignInApp({\n tenant: localTenant,\n app: devOutput.app ?? projectName,\n service,\n baseUrl: `http://localhost:${port}`,\n });\n } catch (err) {\n logWarn(\n `Could not register the browser sign-in for '${service}' (${err instanceof Error ? err.message : err})` +\n ` \u2014 the Hub's Access \u2192 Sign-in tab will report this app has no login.`,\n );\n }\n }\n\n // Register AFTER allocatePorts: the registry must carry the ports the\n // services actually listen on, or StackPort/Hub (and anything reading\n // /mesh-platform/\u2026/stacks/local) points at dead URLs.\n try {\n await registerLocalApp({\n tenant: localTenant,\n app: devOutput.app ?? projectName,\n version: appVersion,\n services: Object.keys(devOutput.services),\n ports: Object.fromEntries(\n Object.entries(devOutput.services).map(([name, svc]) => [name, svc.port]),\n ),\n // ExternalService.link() parity: consuming services carry the\n // external names, so the Hub shows consumers + per-app uptime.\n links: Object.values(selectedMocks)\n .map((decl) => decl.external)\n .filter((n): n is string => !!n),\n });\n } catch (err) {\n logWarn(`Local registry registration skipped: ${err instanceof Error ? err.message : err}`);\n }\n\n // Wire realizations AFTER ports are final: realize each declaration\n // by mode, then seed the external's credential secret and register it\n // in the Hub \u2014 the app's resolveCredentials() path runs unchanged.\n // mock \u2192 mock-{name} service URL (Prism / mock process)\n // local \u2192 docker compose up -d --wait, endpoint from decl.port\n // remote \u2192 actual-service credentials seeded (inline, or pulled\n // from the app's tenant environment)\n const composeExternals: ComposeExternalRef[] = [];\n const externalProbeFiles: string[] = [];\n for (const [name, decl] of Object.entries(selectedMocks)) {\n const mode = externalMode(name, decl);\n try {\n let probeFile: string | undefined;\n if (mode === 'local') {\n logInfo(`Starting docker external '${name}' (${decl.compose})\u2026`);\n // undefined = adopted a realization another checkout owns \u2014\n // not recorded, so --kill leaves it running.\n const composeRef = await composeExternalUp(appRoot, sessionName, name, decl);\n if (composeRef) composeExternals.push(composeRef);\n probeFile = await seedLocalMock({\n tenant: localTenant,\n app: devOutput.app ?? projectName,\n name,\n decl,\n endpoint: { url: `http://localhost:${decl.port}`, host: 'localhost', port: decl.port! },\n });\n } else if (mode === 'remote') {\n probeFile = await seedLocalMock({\n tenant: localTenant,\n app: devOutput.app ?? projectName,\n name,\n decl,\n });\n } else {\n const mockService = devOutput.services[`mock-${name}`];\n if (!mockService) continue;\n probeFile = await seedLocalMock({\n tenant: localTenant,\n app: devOutput.app ?? projectName,\n name,\n decl,\n endpoint: {\n url: `http://localhost:${mockService.port}`,\n host: 'localhost',\n port: mockService.port,\n },\n });\n }\n if (probeFile) externalProbeFiles.push(probeFile);\n } catch (err) {\n // A remote external the developer didn't name costs only itself:\n // the bare `--externals` selects everything declared, and a\n // machine with no AWS identity (or no vendor key in the shell)\n // must still get its mock and local externals \u2014 that's the\n // zero-access local loop. Naming it keeps the failure fatal.\n if (mode === 'remote' && !requestedExplicitly.has(name) && err instanceof MeshCliError) {\n logWarn(\n `External '${name}': skipped \u2014 ${err.message}` +\n ` (re-run with \\`mesh dev --externals ${name}=remote\\` to make this fatal).`,\n );\n continue;\n }\n if (err instanceof MeshCliError) throw err; // bad decl / missing env var / failed remote pull \u2014 hard stop\n if (mode === 'local') {\n // The app's services depend on this external \u2014 starting them\n // against a dead dependency helps nobody.\n throw new MeshCliError(\n `Docker external '${name}' failed to start: ${err instanceof Error ? err.message : err}`,\n { remediation: { command: `docker compose -f ${decl.compose} up # debug it directly` } },\n );\n }\n logWarn(`External '${name}' credential seeding failed: ${err instanceof Error ? err.message : err}`);\n }\n }\n\n if (dockerRunner) {\n // C2: render the same DevOutput as a docker compose project.\n const composePath = writeDevCompose(\n sessionName,\n appRoot,\n devOutput.services as Record<string, { src: string; port: number; command: string[]; env?: Record<string, { value: string }> }>,\n );\n logInfo(`Docker runner: ${composePath}`);\n dockerDevUp(sessionName);\n registerServiceProbes(devOutput, localTenant, externalProbeFiles);\n saveSessionState(sessionName, {\n appRoot,\n stack: 'local',\n devOutput,\n startedAt: new Date().toISOString(),\n runner: 'docker',\n composeExternals,\n externalProbeFiles,\n });\n console.log('');\n logSuccess(`Dev session started (docker): ${sessionName}`);\n console.log('');\n console.log('Services:');\n for (const [name, service] of Object.entries(devOutput.services)) {\n console.log(` ${name.padEnd(20)} http://localhost:${service.port}`);\n }\n console.log('');\n console.log(`Status with: mesh dev --status --session '${sessionName}'`);\n console.log('Stop with: mesh dev --kill');\n return;\n }\n\n const finalDevOutput = await startServices(\n sessionName,\n appRoot,\n devOutput,\n !!options.headless,\n localAwsEnv(), // ministack wiring \u2014 the only \"AWS\" children need\n { transport: 'vpn-direct' }, // local mode: no tunnels\n );\n registerServiceProbes(finalDevOutput, localTenant, externalProbeFiles);\n saveSessionState(sessionName, {\n appRoot,\n stack: 'local',\n devOutput: finalDevOutput,\n startedAt: new Date().toISOString(),\n runner: 'tmux',\n composeExternals,\n externalProbeFiles,\n });\n return;\n }\n\n if (options.mock || options.externals) {\n logWarn(\n '--externals/--mock is local-mode only for now (tethered dev outputs do not carry external declarations yet \u2014 MESH-1885). Continuing without them.',\n );\n }\n\n const stack = detectStack(appRoot, resolveStackOption(options));\n logInfo(`Project: ${projectName}, Stack: ${stack}`);\n\n // Detect if the user accidentally ran `mesh dev` from the monorepo root\n // instead of an app directory. The monorepo root has a Pulumi.yaml but\n // no services/apps to run. Standalone apps (create-app scaffolds) ARE\n // their own workspace root \u2014 an api/ or worker/ package right here means\n // this is an app, not a monorepo root.\n const monorepoRoot = findMonorepoRoot();\n const looksLikeStandaloneApp = ['api', 'worker', 'web'].some((dir) =>\n fs.existsSync(path.join(appRoot, dir, 'package.json')),\n );\n if (monorepoRoot && path.resolve(appRoot) === path.resolve(monorepoRoot) && !looksLikeStandaloneApp) {\n logError(`mesh dev should be run from an app directory, not the monorepo root.\\n`);\n logInfo('Try:');\n logInfo(' cd apps/hub && mesh dev');\n logInfo('');\n logInfo('Or specify the app path:');\n logInfo(' mesh dev --app apps/hub');\n logInfo('');\n logInfo('List available apps:');\n logInfo(' mesh dev list');\n process.exit(1);\n }\n\n // \u2500\u2500 Prerequisite checks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Check all prerequisites upfront so the user gets one clear list of\n // what needs fixing, rather than failing one step at a time.\n const issues: string[] = [];\n const profileHint = options.profile ?? process.env.AWS_PROFILE ?? 'mesh-dev';\n\n // AWS role + credential resolution.\n // The stack can declare two role ARNs:\n // - `mesh:deployerRole` \u2014 the default per-tenant deployer\n // - `mesh:adminDeployerRole` \u2014 optional admin variant assumed when the\n // caller's Zitadel JWT carries an admin-tier role (e.g.\n // `mesh.platform:admin`). selectRoleForCaller picks between them.\n // tmux + credential validation/diagnosis is handled below by the shared\n // doctor preflight so `mesh dev` and `mesh dev doctor` agree.\n const platformContext = derivePlatformContext(appRoot, stack);\n const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');\n const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');\n let deployerRole: string | null = defaultDeployerRole;\n if (defaultDeployerRole) {\n const meshCreds = platformContext ? readCredentials(platformContext) : null;\n const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;\n deployerRole = selectRoleForCaller(idToken, {\n defaultRole: defaultDeployerRole,\n adminRole: adminDeployerRole ?? undefined,\n });\n if (adminDeployerRole && deployerRole === adminDeployerRole) {\n logInfo(`Caller has admin Zitadel role \u2014 assuming ${deployerRole.split('/').pop()} (admin variant)`);\n }\n }\n let awsEnv: Record<string, string>;\n let credMethod: ResolvedAwsCreds['method'] | 'ambient' = 'ambient';\n // Method fed to the doctor context: the resolved method on success, or\n // `null` on a failed resolution so the `creds` check probes for the exact\n // reason (no-session / expired / assume-denied / stale-env-override).\n let credMethodForDoctor: ResolvedAwsCreds['method'] | 'ambient' | null = 'ambient';\n if (deployerRole) {\n const resolved = await resolveAwsCredentials(deployerRole, appRoot, stack);\n if (!resolved) {\n awsEnv = {};\n credMethodForDoctor = null;\n } else {\n awsEnv = resolved.env;\n credMethod = resolved.method;\n credMethodForDoctor = resolved.method;\n }\n } else {\n awsEnv = getAwsEnvVars();\n credMethodForDoctor = 'ambient';\n }\n\n // 3. VPN or SSM fallback\n const vpnConnected = await isVpnConnected();\n // `let`: the auto-selected `vpn-direct` may be downgraded to `ssm` below by a\n // reachability probe once devOutput's Temporal endpoint is known (MESH-2016).\n let transport = resolveTransport(options.transport, {\n vpnConnected,\n hasSsmPlugin: hasSessionManagerPlugin(),\n tailscaleAvailable: tailscaleAvailable(),\n });\n if (options.transport === 'tailscale' && !tailscaleAvailable()) {\n issues.push(\n ' \\u2718 --transport=tailscale but the tailscaled binary is missing.\\n' +\n ' Fix: brew install tailscale',\n );\n }\n if (transport === 'ssm') {\n if (hasSessionManagerPlugin()) {\n if (!vpnConnected) {\n logWarn('VPN not connected \\u2014 will use SSM port-forwarding tunnels for VPC resources.');\n }\n logInfo(\n ' Tunnels use the 2XXXX port range (e.g., PostgreSQL on 25432, Temporal on 27233).',\n );\n logInfo(' Limitations: no kubectl exec into pods, no internal ingress/ALB access.');\n logInfo(' For full VPC access, connect VPN: mesh vpn connect');\n } else {\n issues.push(\n ' \\u2718 VPN is not connected and session-manager-plugin is not installed.\\n' +\n ' Option 1 (VPN): mesh vpn connect\\n' +\n ' Option 2 (SSM): brew install --cask session-manager-plugin\\n' +\n ' Option 3 (Tailscale): mesh dev --transport=tailscale',\n );\n }\n } else if (transport === 'tailscale') {\n // The daemon bring-up + join happen in startServices; announce success there,\n // not here \u2014 otherwise a fallback to SSM still reads as \"using Tailscale\"\n // (MESH-2098). A pending node registration prints its own URL during the join.\n logInfo('Bringing up userspace-Tailscale tunnels for VPC resources (shared per tenant)\u2026');\n }\n\n // Shared doctor preflight: the same checks as `mesh dev doctor`, so launch\n // and on-demand diagnosis name failures identically. Errors block launch;\n // warnings (config/worktree) print and proceed.\n const preflightCtx: DoctorContext = {\n appRoot,\n stack,\n sessionName,\n deployerRole,\n platformContext,\n credMethod: credMethodForDoctor,\n sessionState: null,\n };\n const preflight = await runChecks(preflightCtx, 'preflight', ALL_CHECKS);\n const preflightReport = renderHuman(preflight);\n if (preflightReport) console.log(preflightReport);\n const preflightStatus = aggregateStatus(preflight.map((r) => r.result));\n if (preflightStatus === 'error' || issues.length > 0) {\n logError('\\nPrerequisites not met. Fix the above before running mesh dev, then retry: mesh dev');\n for (const issue of issues) console.log(issue);\n process.exit(1);\n }\n\n // \u2500\u2500 Credentials resolved, inject into environment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Ensure AWS_REGION is set from the Pulumi stack config. Without this, AWS\n // SDK clients (SSM, Secrets Manager) would default to us-east-1.\n const awsRegion = readStackConfig(appRoot, stack, 'aws:region');\n if (awsRegion && !awsEnv.AWS_REGION) {\n awsEnv.AWS_REGION = awsRegion;\n }\n Object.assign(process.env, awsEnv);\n\n // Build the env that long-lived child services (ui, api, ai-assistant) and\n // SSM tunnels will see. When credentials came from AWS SSO, prefer passing\n // AWS_PROFILE + AWS_REGION so the AWS SDK v3 default provider chain can\n // auto-refresh from the SSO cache (~8h session) as short-lived STS tokens\n // expire. Without this, the hub API starts throwing ExpiredTokenException\n // about an hour in and tenants disappear from the UI. For non-SSO paths\n // (Zitadel JWT, CI/CD env keys) there is no profile to fall back to, so\n // pass the STS keys directly and accept that the user must restart\n // `mesh dev` when they expire.\n // For the Zitadel deployer path, hand children a self-refreshing\n // credential_process profile (context + role from the resolution above,\n // written into this session's scratch dir) instead of a frozen STS triple.\n const zitadelOpts =\n credMethod === 'zitadel' && deployerRole\n ? (() => {\n const context = derivePlatformContext(appRoot, stack);\n return context\n ? {\n context,\n roleArn: deployerRole,\n sessionScratchDir: getSessionEnvDir(sessionName),\n meshBin: resolveStableMeshBin(process.argv[1]),\n }\n : undefined;\n })()\n : undefined;\n const childAwsEnv = buildChildAwsEnv(awsEnv, credMethod, profileHint, zitadelOpts);\n\n let rawDevOutput: DevOutput;\n try {\n rawDevOutput = getDevOutput(appRoot, stack, awsEnv);\n } catch (err) {\n if (err instanceof MissingStackOutputError) {\n // Only the launch path can conclude \"nothing to run\" \u2014 print the\n // forward-chaining first-run fix and exit cleanly (MESH-2097). The\n // suggested commands carry --stack because the bare forms error right\n // after `stack init` (matching stack.ts's next-step output).\n logError(`No Pulumi stack output for '${stack}' \u2014 mesh dev has nothing to run.`);\n logInfo('If this is a new app, initialize and materialize it first:');\n logInfo(' mesh stack init # create your personal dev stack');\n logInfo(` mesh deploy up --stack ${stack} --yes # produce the app output mesh dev reads`);\n logInfo(`Then: mesh dev --stage ${stack} (the stack must export an \\`app\\` or \\`dev\\` output.)`);\n logInfo('No deployed stack yet? Run against the local platform: mesh dev --local (needs `mesh start`).');\n // Surface the underlying read error so an auth/network/JSON failure isn't\n // silently misdiagnosed as \"fresh app\" with zero forensics.\n const cause = (err.cause as Error | undefined)?.message;\n if (cause) logInfo(`(underlying stack-read error: ${cause})`);\n process.exit(1);\n }\n throw err;\n }\n\n // --status \u2014 use persisted state (has allocated ports), fall back to raw\n if (options.status) {\n const state = loadSessionState(sessionName);\n await showStatus(sessionName, state?.devOutput ?? rawDevOutput, !!options.json);\n return;\n }\n\n // Allocate dynamic ports (avoid collisions with other running apps).\n // devOutput is passed by value and may be rewritten inside startServices\n // if SSM tunnels are used (to point tunnels at localhost:2XXXX).\n const devOutput = await allocatePorts(rawDevOutput, worktree);\n\n // Reachability gate (MESH-2016): the transport chosen above used a PRESENCE\n // check (isVpnConnected \u2192 \"is any Tailscale/VPN up\"), which is tailnet-blind \u2014\n // Tailscale-for-SSH looks connected but doesn't route the mesh VPC. Now that\n // devOutput is known, probe the real Temporal VPC endpoint and downgrade an\n // auto-selected vpn-direct to SSM when it's unreachable.\n transport = await gateVpnDirectReachability(transport, devOutput);\n\n // --dry-run: show the resolved worktree/session/port plan, launch nothing.\n if (options.dryRun) {\n printDevPlan(sessionName, appRoot, worktree, devOutput);\n return;\n }\n\n // Build the tunnel plan for the chosen transport. For Tailscale, resolve the\n // shared per-tenant daemon context (tenant = hub platform name, since the\n // bastion is a hub resource); reuse the daemon's SOCKS port if it's already up.\n let tunnelPlan: TunnelPlan = { transport };\n if (transport === 'tailscale') {\n const tsTenant = devOutput.platform?.name ?? 'mesh';\n const tsEnv = devOutput.platform?.env ?? 'dev';\n const tsContext = derivePlatformContext(appRoot, stack) ?? `${tsTenant}.${tsEnv}`;\n const loginServer = deriveLoginServer(tsContext);\n if (!loginServer) {\n // Implicit selection (auto/unset) must degrade to the legacy transport,\n // not dead-end. Only an explicit --transport=tailscale may hard-error.\n const msg = `No login config for context '${tsContext}' \u2014 run: mesh login ${tsContext}`;\n if (options.transport === 'tailscale') {\n logError(msg);\n process.exit(1);\n }\n const legacy: Transport = vpnConnected\n ? 'vpn-direct'\n : hasSessionManagerPlugin()\n ? 'ssm'\n : 'vpn-direct';\n // Reachability-gate this legacy fallback too (MESH-2016): otherwise a\n // machine with tailscaled (\u2192 auto picks tailscale) but no mesh login\n // config, with any tailnet up (vpnConnected), would land on an UNPROBED\n // vpn-direct and reproduce the exact ECONNREFUSED this feature fixes.\n const gatedLegacy = await gateVpnDirectReachability(legacy, devOutput);\n logWarn(`${msg} \u2014 falling back to ${gatedLegacy} transport.`);\n tunnelPlan = { transport: gatedLegacy };\n } else {\n const prior = readTailscaleState(tsTenant);\n const daemonUp = tailscaleDaemonState(tsTenant).backendState !== 'Down';\n // Reuse the running daemon's real SOCKS port (daemon.json survives\n // `down`); never mint a fresh one the daemon isn't listening on.\n const socksPort = daemonUp\n ? (readTailscaleDaemonMeta(tsTenant)?.socksPort ?? prior?.socksPort ?? (await findFreePort()))\n : await findFreePort();\n // Zero-touch VPN join (MESH-2099): try to mint a single-use pre-auth key\n // from the platform's VpnJoinBroker so `tailscale up --authkey` joins\n // silently. Any failure leaves preAuthKey undefined \u2192 the interactive\n // browser-relay join (MESH-2098) is used instead.\n let preAuthKey: string | undefined;\n const brokerUrl = resolveVpnJoinBroker(tsContext);\n if (brokerUrl) {\n const minted = await mintPreAuthKey(tsContext, brokerUrl, { getValidToken });\n if (minted?.authKey) {\n preAuthKey = minted.authKey;\n } else {\n logInfo('zero-touch VPN join unavailable, falling back to browser registration');\n }\n }\n tunnelPlan = {\n transport,\n explicit: options.transport === 'tailscale',\n tailscale: {\n tenant: tsTenant,\n env: tsEnv,\n region: awsRegion ?? 'us-east-2',\n loginServer,\n socksPort,\n preAuthKey,\n },\n };\n }\n }\n\n // Relaunch safety (MESH-2017): a relaunch recreates the worker onto current\n // code. If the workflow source changed since this session started, warn before\n // it replays in-flight conversations against it (require --force). Checked\n // BEFORE startServices, which kills+recreates the session.\n if (workflowChangeWouldStrand(loadSessionState(sessionName), appRoot, !!options.force)) {\n process.exit(1);\n }\n\n // Start services. startServices handles tmux session creation, tunnel setup\n // (SSM or Tailscale, per tunnelPlan), and service windows. It returns the\n // final devOutput with tunnels rewritten to localhost ports.\n const finalDevOutput = await startServices(\n sessionName,\n appRoot,\n devOutput,\n !!options.headless,\n childAwsEnv,\n tunnelPlan,\n worktree.worktreeRoot,\n worktree.taskQueueSuffix,\n );\n\n // Persist session state so --status and restart see allocated ports\n // and the final tunnel endpoints (localhost for SSM, VPC for VPN).\n saveSessionState(sessionName, {\n appRoot,\n stack,\n devOutput: finalDevOutput,\n startedAt: new Date().toISOString(),\n workflowFingerprint: fingerprintWorkflowSource(appRoot, finalDevOutput.services),\n });\n });\n\n // Subcommand: logs\n dev\n .command('logs <service>')\n .description('Show logs for a service')\n .option('--tail <lines>', 'Number of lines', '100')\n .action((service: string, opts: { tail: string }) => {\n // --session is on the parent dev command\n const sessionName = dev.opts().session as string | undefined;\n const resolvedSession = sessionName ?? `${getProjectName(findAppRoot())}-dev`;\n if (loadSessionState(resolvedSession)?.runner === 'docker') {\n console.log(dockerDevLogs(resolvedSession, service, parseInt(opts.tail, 10)));\n return;\n }\n if (sessionName) {\n showLogs(sessionName, service, parseInt(opts.tail, 10));\n } else {\n const appRoot = findAppRoot();\n const projectName = getProjectName(appRoot);\n showLogs(`${projectName}-dev`, service, parseInt(opts.tail, 10));\n }\n });\n\n // Subcommand: restart\n dev\n .command('restart <service>')\n .description('Restart a service')\n .option('--stack <stack>', 'Pulumi stack name')\n .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())\n .option(\n '--refresh-env',\n \"Regenerate this service's env from current stack/SSM outputs before restarting (for config/deploy changes)\",\n )\n .option('--force', 'Restart the worker even if workflow code changed since launch (may strand in-flight conversations)')\n .action(async (service: string, opts: { stage?: string; refreshEnv?: boolean; force?: boolean }) => {\n // Hoisted so the catch can name the right login context (not a hardcoded\n // one) even when a throw happens before cred resolution. MESH-2089.\n let ctx: string | null = null;\n try {\n // --session is on the parent dev command\n const parentSession = dev.opts().session as string | undefined;\n const parentProfile = dev.opts().profile as string | undefined;\n if (parentProfile) {\n applyAwsProfileOverride(parentProfile);\n }\n const appRoot = findAppRoot();\n const projectName = getProjectName(appRoot);\n const sessionName = parentSession ?? `${projectName}-dev`;\n const state = loadSessionState(sessionName);\n if (state?.runner === 'docker') {\n dockerDevRestart(sessionName, service);\n logSuccess(`Restarted: ${service}`);\n return;\n }\n // Relaunch safety (MESH-2017): restarting the WORKER onto changed workflow\n // code strands in-flight conversations; restarting other services (ui/api)\n // doesn't. Gate the worker restart on the workflow-source fingerprint.\n if (/worker/i.test(service) && workflowChangeWouldStrand(state, appRoot, !!opts.force)) {\n process.exit(1);\n }\n const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));\n ctx = derivePlatformContext(appRoot, stack);\n // Resolve credentials BEFORE reading any Pulumi/SSM/kubeconfig output\n // (MESH-2090). With ambient AWS_* cleared (the documented restart\n // hygiene), the parent-side AWS calls the restart path makes \u2014 the\n // getDevOutput fallback below, and under --refresh-env the\n // resolveTemporalAuthVars/ensureKubeconfig calls \u2014 read the default SDK\n // chain (process.env). Resolving first lets us thread the resolved creds\n // into this process's env, and lets the precise login preflight fire\n // before the devOutput fallback (which was the MESH-2089 crash site).\n const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');\n const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');\n let deployerRole = defaultDeployerRole;\n if (defaultDeployerRole) {\n const meshCreds = ctx ? readCredentials(ctx) : null;\n const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;\n deployerRole = selectRoleForCaller(idToken, {\n defaultRole: defaultDeployerRole,\n adminRole: adminDeployerRole ?? undefined,\n });\n }\n const resolved = deployerRole ? await resolveAwsCredentials(deployerRole, appRoot, stack) : null;\n // Login preflight (MESH-2089): if a deployer role is configured but creds\n // couldn't be resolved AND there are no ambient AWS creds to fall back on,\n // the restart would proceed with no credentials and fail downstream. Name\n // the real problem (e.g. \"run mesh login <ctx> --device\") instead.\n if (deployerRole && !resolved && !process.env.AWS_ACCESS_KEY_ID) {\n const pf = ctx ? credProbeToPreflightError(await probeCredentials(ctx, deployerRole), ctx) : null;\n logError(\n pf?.message ??\n `Couldn't resolve AWS credentials to restart '${service}'. Run: mesh login ${ctx ?? 'mesh.dev'} --device`,\n );\n process.exit(1);\n }\n const baseAwsEnv = resolved?.env ?? getAwsEnvVars();\n // Ensure AWS_REGION is set from stack config so parent-side SDK clients\n // (SSM, EKS) don't default to us-east-1. Mirrors the launch path.\n const awsRegion = readStackConfig(appRoot, stack, 'aws:region');\n if (awsRegion && !baseAwsEnv.AWS_REGION) {\n baseAwsEnv.AWS_REGION = awsRegion;\n }\n // Inject the resolved creds into THIS process's env so the parent-side\n // AWS calls below run credentialed even when ambient AWS_* was cleared\n // (MESH-2090). Mirrors the launch path's inject (see startServices).\n Object.assign(process.env, baseAwsEnv);\n const profileHint = parentProfile ?? process.env.AWS_PROFILE ?? 'mesh-dev';\n const zitadelOpts =\n resolved?.method === 'zitadel' && deployerRole && ctx\n ? {\n context: ctx,\n roleArn: deployerRole,\n sessionScratchDir: getSessionEnvDir(sessionName),\n meshBin: resolveStableMeshBin(process.argv[1]),\n }\n : undefined;\n const awsEnv = buildChildAwsEnv(baseAwsEnv, resolved?.method ?? 'ambient', profileHint, zitadelOpts);\n // Use persisted state (has allocated ports), fall back to raw Pulumi\n // output \u2014 now credentialed via the resolved env (MESH-2090).\n const devOutput = state?.devOutput ?? getDevOutput(appRoot, stack, baseAwsEnv);\n await restartService(sessionName, service, appRoot, devOutput, awsEnv, {\n refreshEnv: !!opts.refreshEnv,\n stack,\n sessionState: state,\n });\n // Re-baseline the workflow fingerprint after a worker restart (MESH-2017):\n // the worker now runs the current code, so a subsequent unchanged restart\n // must NOT nag \u2014 otherwise a good-citizen `--force` earns a session of\n // false warnings, training the reflex this guard exists to prevent.\n if (/worker/i.test(service) && state) {\n saveSessionState(sessionName, {\n ...state,\n workflowFingerprint: fingerprintWorkflowSource(appRoot, state.devOutput?.services),\n });\n }\n } catch (err) {\n // Never crash uncaught (MESH-2089): a cred/kubeconfig/tmux failure should\n // name itself, not dump a bare Node stack. The underlying error is logged\n // so the exact cause is pinned if this recurs.\n logError(`Failed to restart '${service}': ${(err as Error)?.message ?? String(err)}`);\n logInfo(\n `If this is a credential/kubeconfig issue, run \\`mesh login ${ctx ?? '<your platform context>'} --device\\`, ` +\n 'or `mesh dev` to relaunch the session cleanly.',\n );\n process.exit(1);\n }\n });\n\n // Subcommand: doctor \u2014 diagnose the dev session\n dev\n .command('doctor')\n .description(\n 'Diagnose the dev session (creds, tunnels, config, ports, worktree, Temporal) \u2014 names the fix',\n )\n .option('--stack <stack>', 'Pulumi stack name')\n .addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())\n .option('--json', 'Machine-readable output')\n .action(async (opts: { stack?: string; stage?: string; json?: boolean }) => {\n const parentProfile = dev.opts().profile as string | undefined;\n if (parentProfile) {\n applyAwsProfileOverride(parentProfile);\n }\n const appRoot = findAppRoot();\n const projectName = getProjectName(appRoot);\n const sessionName = (dev.opts().session as string | undefined) ?? `${projectName}-dev`;\n const state = loadSessionState(sessionName);\n const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));\n // `--json` may land on either the subcommand or the parent `dev`\n // command (both declare it), so honor both.\n const asJson = !!opts.json || !!(dev.opts().json as boolean | undefined);\n const ctx = buildDoctorContext(appRoot, stack, sessionName);\n const status = await runDoctor(ctx, { json: asJson });\n process.exit(status === 'error' ? 1 : 0);\n });\n\n // Subcommand: list \u2014 discover apps in the monorepo\n dev\n .command('list')\n .description('List Pulumi apps in the monorepo')\n .action(() => {\n // --json is on the parent dev command\n const asJson = dev.opts().json;\n const mono = findMonorepoRoot();\n if (!mono) {\n logError('Could not find monorepo root (pnpm-workspace.yaml or .git)');\n process.exit(1);\n }\n\n const apps = discoverApps(mono);\n\n if (asJson) {\n // Include running state for each app\n const result = apps.map((app) => {\n const sessionName = `${app.name}-dev`;\n return {\n ...app,\n running: sessionExists(sessionName),\n session: sessionName,\n };\n });\n console.log(JSON.stringify(result, null, 2));\n } else {\n if (apps.length === 0) {\n logInfo('No Pulumi apps found.');\n return;\n }\n console.log('Apps in monorepo:');\n console.log('');\n for (const app of apps) {\n const sessionName = `${app.name}-dev`;\n const running = sessionExists(sessionName);\n const icon = running ? '\u25CF' : '\u25CB';\n const stacks = app.stacks.length > 0 ? ` (${app.stacks.join(', ')})` : '';\n console.log(\n ` ${icon} ${app.name.padEnd(24)} ${app.tenant.padEnd(12)} ${app.relativePath}${stacks}`,\n );\n }\n console.log('');\n }\n });\n\n // Subcommand: test-user \u2014 retrieve Temporal test user credentials\n dev\n .command('test-user [name]')\n .description('Get Temporal test user credentials (from Pulumi-managed test users)')\n .option('--tenant <tenant>', 'Tenant name', 'mesh')\n .option('--env <env>', 'Environment', 'dev')\n .option('--region <region>', 'AWS region', 'us-east-2')\n .action(\n async (name: string | undefined, opts: { tenant: string; env: string; region: string }) => {\n const { SSMClient, GetParameterCommand, GetParametersByPathCommand } =\n await import('@aws-sdk/client-ssm');\n const ssm = new SSMClient({ region: opts.region });\n const basePath = `/mesh-platform/${opts.tenant}/${opts.env}/temporal/test-users`;\n\n if (!name) {\n // List all test users\n try {\n const resp = await ssm.send(\n new GetParametersByPathCommand({\n Path: basePath,\n Recursive: true,\n WithDecryption: true,\n }),\n );\n\n if (!resp.Parameters || resp.Parameters.length === 0) {\n logWarn(`No test users found at ${basePath}`);\n logInfo(\n 'Test users are defined in Pulumi config under mesh:temporal.authorization.testUsers',\n );\n logInfo(\"They're only available on dev stacks.\");\n return;\n }\n\n console.log('Temporal test users:');\n console.log('');\n for (const param of resp.Parameters) {\n const userName = param.Name!.split('/').pop()!;\n const creds = JSON.parse(param.Value!) as {\n email: string;\n password: string;\n roles: string[];\n description?: string;\n };\n console.log(` ${userName}`);\n console.log(` Email: ${creds.email}`);\n console.log(` Password: ${creds.password}`);\n console.log(` Roles: ${creds.roles.join(', ')}`);\n if (creds.description) console.log(` Note: ${creds.description}`);\n console.log('');\n }\n logInfo('Login at: https://temporal.dev.mesh-platform.trabian.com');\n } catch (err) {\n logError(\n `Failed to list test users: ${err instanceof Error ? err.message : String(err)}`,\n );\n process.exit(1);\n }\n } else {\n // Get a specific test user\n try {\n const resp = await ssm.send(\n new GetParameterCommand({\n Name: `${basePath}/${name}`,\n WithDecryption: true,\n }),\n );\n\n if (!resp.Parameter?.Value) {\n logError(`Test user '${name}' not found at ${basePath}/${name}`);\n return;\n }\n\n const creds = JSON.parse(resp.Parameter.Value) as {\n email: string;\n password: string;\n roles: string[];\n description?: string;\n };\n // Check if --json is set on parent\n const asJson = dev.opts().json;\n if (asJson) {\n console.log(JSON.stringify(creds, null, 2));\n } else {\n console.log(`Email: ${creds.email}`);\n console.log(`Password: ${creds.password}`);\n console.log(`Roles: ${creds.roles.join(', ')}`);\n if (creds.description) console.log(`Note: ${creds.description}`);\n console.log('');\n logInfo('Login at: https://temporal.dev.mesh-platform.trabian.com');\n }\n } catch (err: any) {\n const msg = err.message ?? String(err);\n if (msg.includes('ParameterNotFound') || err.name === 'ParameterNotFound') {\n logError(`Test user '${name}' not found.`);\n logInfo(`List available users with: mesh dev test-user`);\n } else {\n logError(`Failed to get test user: ${msg}`);\n }\n process.exit(1);\n }\n }\n },\n );\n}\n", "/**\n * Resolve a tenant's VPC tunnel targets from the platform bastion's service\n * map \u2014 the same source `startSsmTunnels` reads \u2014 mapping bastion service keys\n * to the app's tunnel names, resolving each ELB/RDS hostname to its private IP,\n * and assigning collision-free localhost ports via the SSM port convention.\n * (MESH-2040 / Slice 1 of MESH-2014.)\n */\n\nimport { execFileSync } from \"node:child_process\";\nimport * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { reserveSsmLocalPortCandidate, preferredSsmLocalPort } from \"../commands/dev.js\";\nimport { CONFIG_DIR, atomicWriteFileSync } from \"../commands/login.js\";\n\n/** Local-port block width per tenant \u2014 wider than the canonical port span so\n * two tenants' port blocks never overlap. */\nexport const PORT_BLOCK = 3000;\n\n/**\n * Deterministic per-tenant index for local-port offsetting. Reads a machine-global\n * registry (`~/.config/mesh/tailscale/port-registry.json`), seeded `{ mesh: 0 }` so\n * the hub's canonical ports are grandfathered regardless of registration order.\n * Assigns the next index to an unseen tenant and persists it. `registryPath` is\n * injectable for tests.\n */\nexport function tenantPortIndex(tenant: string, registryPath?: string): number {\n const p = registryPath ?? path.join(CONFIG_DIR, \"tailscale\", \"port-registry.json\");\n let reg: Record<string, number>;\n try {\n reg = JSON.parse(fs.readFileSync(p, \"utf8\")) as Record<string, number>;\n } catch {\n reg = { mesh: 0 };\n }\n if (tenant in reg) return reg[tenant]!;\n const idx = Math.max(-1, ...Object.values(reg)) + 1;\n reg[tenant] = idx;\n fs.mkdirSync(path.dirname(p), { recursive: true });\n atomicWriteFileSync(p, JSON.stringify(reg, null, 2), 0o600);\n return idx;\n}\n\nexport interface TunnelTarget {\n /** App-facing tunnel name (e.g. \"temporal\"). */\n name: string;\n /** Local port the forwarder listens on (SSM convention: base + remotePort). */\n localPort: number;\n /** VPC hostname (internal ELB / RDS endpoint). */\n targetHost: string;\n /** Resolved private IP of `targetHost`. */\n targetIp: string;\n /** Port on the VPC service. */\n remotePort: number;\n}\n\n/**\n * Bastion service key \u2192 app tunnel name. Mirrors `tunnelToBastionKey` in dev.ts\n * (inverted): the bastion exposes `temporal-frontend`; the app calls it `temporal`.\n */\nexport const TUNNEL_NAME_BY_BASTION_KEY: Record<string, string> = {\n \"temporal-frontend\": \"temporal\",\n \"temporal-ui\": \"temporal-ui\",\n rds: \"rds\",\n};\n\n/**\n * Build the tunnel target set. `resolveIp` is injected for testability (the\n * command passes `resolveElbIp`). Services not in the name map are skipped;\n * services whose host can't be resolved are skipped (the forwarder needs an IP).\n */\nexport function resolveTunnelTargets(\n services: Record<string, { host: string; port: number }>,\n resolveIp: (host: string) => string | null,\n portOffset = 0,\n): TunnelTarget[] {\n const reserved = new Set<number>();\n const targets: TunnelTarget[] = [];\n for (const [key, svc] of Object.entries(services)) {\n const name = TUNNEL_NAME_BY_BASTION_KEY[key];\n if (!name) continue;\n const targetIp = resolveIp(svc.host);\n if (!targetIp) continue;\n // Per-tenant offset keeps concurrent platform tenants off each other's ports.\n const localPort = reserveSsmLocalPortCandidate(\n svc.port,\n reserved,\n preferredSsmLocalPort(svc.port) + portOffset,\n );\n targets.push({ name, localPort, targetHost: svc.host, targetIp, remotePort: svc.port });\n }\n return targets;\n}\n\n/**\n * Resolve an AWS-internal ELB/RDS hostname to its private IP via a public\n * resolver \u2014 these names publish their private (10.x) address to public DNS\n * but the host's default resolver may not answer for them. Returns null on\n * failure (caller skips that tunnel).\n */\nexport function resolveElbIp(host: string): string | null {\n try {\n const out = execFileSync(\"dig\", [\"+short\", host, \"@1.1.1.1\"], {\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n const ip = out\n .trim()\n .split(\"\\n\")\n .map((l) => l.trim())\n .find((l) => /^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(l));\n return ip ?? null;\n } catch {\n return null;\n }\n}\n", "/**\n * A minimal SOCKS5 CONNECT forwarder: listens on a local TCP port and pipes\n * each client connection through a SOCKS5 proxy (no-auth) to a fixed target.\n *\n * Presents the same plain `localhost:PORT` contract the SSM tunnels give today,\n * so an app (e.g. a Temporal gRPC client) connects with no proxy awareness \u2014\n * the SOCKS5 proxy is the userspace-Tailscale daemon (MESH-2040 / MESH-2036).\n */\n\nimport * as net from \"node:net\";\n\n/** `\"10.0.137.203\"` \u2192 `[10,0,137,203]`; throws on anything that isn't IPv4. */\nexport function ipv4ToBytes(ip: string): number[] {\n const parts = ip.split(\".\");\n if (parts.length !== 4) throw new Error(`not an IPv4 address: ${ip}`);\n return parts.map((p) => {\n const n = Number(p);\n if (!Number.isInteger(n) || n < 0 || n > 255 || !/^\\d+$/.test(p)) {\n throw new Error(`not an IPv4 address: ${ip}`);\n }\n return n;\n });\n}\n\n/** SOCKS5 CONNECT request for an IPv4 target (VER, CMD=CONNECT, RSV, ATYP=IPv4, addr, port). */\nexport function socks5ConnectRequest(ip: string, port: number): Buffer {\n return Buffer.from([0x05, 0x01, 0x00, 0x01, ...ipv4ToBytes(ip), (port >> 8) & 0xff, port & 0xff]);\n}\n\nconst SOCKS_REPLY_MESSAGES: Record<number, string> = {\n 0: \"succeeded\",\n 1: \"general SOCKS server failure\",\n 2: \"connection not allowed by ruleset\",\n 3: \"network unreachable\",\n 4: \"host unreachable\",\n 5: \"connection refused\",\n 6: \"TTL expired\",\n 7: \"command not supported\",\n 8: \"address type not supported\",\n};\n\n/** Interpret the SOCKS5 reply code (byte 1 of the CONNECT reply). */\nexport function interpretSocksReply(code: number): { ok: boolean; message: string } {\n return { ok: code === 0, message: SOCKS_REPLY_MESSAGES[code] ?? `unknown SOCKS reply ${code}` };\n}\n\nexport interface ForwarderOptions {\n /** Local TCP port to listen on (0 = ephemeral, useful in tests). */\n listenPort: number;\n socksHost?: string;\n socksPort: number;\n targetIp: string;\n targetPort: number;\n /**\n * Observes server errors \u2014 above all a bind failure (`EADDRINUSE`). The\n * forwarder still never crashes the process, but the error must not be\n * INVISIBLE: an unbound forwarder whose port is held by a foreign process is\n * the silent-dead-tunnel class (MESH-2407), so callers record it and the\n * ownership handshake reports the port as not-listening.\n */\n onError?: (err: NodeJS.ErrnoException) => void;\n}\n\n/**\n * Create (and start) a forwarder server. Each inbound client is spliced to\n * `targetIp:targetPort` via a SOCKS5 CONNECT through `socksHost:socksPort`.\n */\nexport function createForwarder(opts: ForwarderOptions): net.Server {\n const socksHost = opts.socksHost ?? \"127.0.0.1\";\n const server = net.createServer((client) => {\n const up = net.connect(opts.socksPort, socksHost);\n let buf = Buffer.alloc(0);\n let phase = 0; // 0=await method-select, 1=await connect reply, 2=streaming\n up.on(\"connect\", () => up.write(Buffer.from([0x05, 0x01, 0x00]))); // greet, no-auth\n up.on(\"data\", (d) => {\n if (phase === 2) return;\n buf = Buffer.concat([buf, d]);\n if (phase === 0) {\n if (buf.length < 2) return;\n buf = buf.subarray(2);\n phase = 1;\n up.write(socks5ConnectRequest(opts.targetIp, opts.targetPort));\n }\n if (phase === 1) {\n if (buf.length < 10) return;\n const reply = interpretSocksReply(buf[1]!);\n const leftover = buf.subarray(10);\n phase = 2;\n if (!reply.ok) {\n up.destroy();\n client.destroy();\n return;\n }\n if (leftover.length) client.write(leftover);\n client.pipe(up);\n up.pipe(client);\n }\n });\n client.on(\"error\", () => up.destroy());\n up.on(\"error\", () => client.destroy());\n // A cleanly-closed client (clean FIN, no \"error\") must still tear down the\n // upstream SOCKS\u2192target leg \u2014 otherwise a probe that connects then closes\n // strands a real VPC-target connection until the target times out.\n client.on(\"close\", () => up.destroy());\n up.on(\"close\", () => client.destroy());\n });\n // A bind failure (e.g. the port is already held by an SSM tunnel) must not\n // crash the process running this forwarder \u2014 but it must be OBSERVED, not\n // swallowed: the runner records it and ownership checks fail the port.\n // Attach before listen() so the error is caught.\n server.on(\"error\", (err) => opts.onError?.(err));\n server.listen(opts.listenPort, \"127.0.0.1\");\n return server;\n}\n", "/**\n * Ownership contract for the tunnel runner (MESH-2407).\n *\n * Three silent-dead-tunnel incidents in ten days (MESH-2169, MESH-2014, the\n * #2991 re-review) shared one enabler: readiness was inferred from \"something\n * accepts TCP on the port\". That conflates LIVENESS (a listener exists) with\n * OWNERSHIP (the listener is OUR runner, wired to OUR SOCKS upstream). This\n * module is the ownership half of the contract:\n *\n * - The runner that actually binds the forwarder ports writes a MANIFEST\n * (pid, process-group id, a per-run random token, a loopback control port)\n * and answers a one-shot control handshake with that token plus the ports\n * it is listening on \u2014 read from its in-process `net.Server`s, so the\n * answer can't be faked by a foreign listener and probing it opens no\n * SOCKS\u2192VPC data connection.\n * - Callers verify ownership (manifest \u2192 live pid \u2192 control handshake \u2192\n * token/socks-port/port-coverage match) instead of dialing ports. A port\n * that accepts TCP without passing the handshake is a SQUATTER and must be\n * a loud failure, never adopt-and-continue.\n * - Teardown signals process GROUPS recorded here (plus argv-discovered\n * runners), so no kill path can orphan a grandchild with ports still bound.\n */\n\nimport * as crypto from 'node:crypto';\nimport * as fs from 'node:fs';\nimport * as net from 'node:net';\nimport * as path from 'node:path';\nimport { execFileSync } from 'node:child_process';\n\n/** Written by the runner (the process that binds the ports) \u2014 never by callers. */\nexport interface RunnerManifest {\n tenant: string;\n /** Pid of the runner process itself (the port owner), NOT its launcher. */\n pid: number;\n /** Process-group id \u2014 all teardown paths signal `-pgid`, never a bare pid. */\n pgid: number;\n /** Per-run random token; the control handshake must echo it. */\n token: string;\n /** Loopback port answering the one-shot ownership handshake. */\n controlPort: number;\n /** SOCKS port the runner's forwarders dial (must match the live daemon's). */\n socksPort: number;\n /** Forwarder ports the runner is responsible for. */\n ports: number[];\n startedAt: string;\n}\n\n/** What the runner's control endpoint answers with. */\nexport interface ControlResponse {\n token: string;\n tenant: string;\n pid: number;\n socksPort: number;\n /** Ports whose in-process `net.Server` is currently listening. */\n listening: number[];\n /** port \u2192 error code for forwarders that failed to bind (e.g. EADDRINUSE). */\n bindErrors: Record<string, string>;\n}\n\nexport function mintRunnerToken(): string {\n return crypto.randomBytes(16).toString('hex');\n}\n\nexport function readRunnerManifest(file: string): RunnerManifest | null {\n if (!fs.existsSync(file)) return null;\n try {\n return JSON.parse(fs.readFileSync(file, 'utf8')) as RunnerManifest;\n } catch {\n return null;\n }\n}\n\nexport function writeRunnerManifest(file: string, manifest: RunnerManifest): void {\n fs.mkdirSync(path.dirname(file), { recursive: true });\n fs.writeFileSync(file, JSON.stringify(manifest, null, 2), { mode: 0o600 });\n}\n\n/**\n * Remove the manifest \u2014 but only if it is still OURS (`token` matches). A dying\n * runner must not clobber the manifest of a newer runner that already replaced it.\n * Omit `token` for teardown paths that own the whole tenant state.\n */\nexport function clearRunnerManifest(file: string, token?: string): void {\n if (token !== undefined && readRunnerManifest(file)?.token !== token) return;\n try {\n fs.unlinkSync(file);\n } catch {\n /* already gone */\n }\n}\n\n/** Process-group id of a pid via `ps` (macOS/BSD); null when undiscoverable. */\nexport function getProcessGroupId(pid: number): number | null {\n try {\n const out = execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], {\n encoding: 'utf8',\n stdio: ['ignore', 'pipe', 'ignore'],\n }).trim();\n const pgid = Number(out);\n return Number.isInteger(pgid) && pgid > 0 ? pgid : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Pids of tunnel-runner processes for a tenant, parsed from `ps` output\n * (`pid command` lines). Matches both `__run` and the legacy `__supervise`\n * argv, and skips `exclude` (the calling process, when it IS the runner).\n * Pure for unit tests; `discoverRunnerPids` feeds it live `ps` output.\n */\nexport function parseRunnerPidsFromPs(\n psOutput: string,\n tenant: string,\n exclude?: number,\n): number[] {\n const pids: number[] = [];\n for (const line of psOutput.split('\\n')) {\n if (!/vpn\\s+tunnel\\s+(?:__run|__supervise)\\s+/.test(line)) continue;\n const m = line.trim().match(/^(\\d+)\\s+(.*)$/);\n if (!m) continue;\n const argv = m[2]!.split(/\\s+/);\n // The tenant is the argument right after __run/__supervise \u2014 an exact\n // match, so tenant \"enc\" never matches a runner for tenant \"encore\".\n const i = argv.findIndex((a) => a === '__run' || a === '__supervise');\n if (i === -1 || argv[i + 1] !== tenant) continue;\n const pid = Number(m[1]);\n if (pid !== exclude) pids.push(pid);\n }\n return pids;\n}\n\n/** Live argv discovery of runner pids for a tenant (empty when `ps` fails). */\nexport function discoverRunnerPids(tenant: string, exclude?: number): number[] {\n try {\n const out = execFileSync('ps', ['ax', '-o', 'pid=,command='], {\n encoding: 'utf8',\n stdio: ['ignore', 'pipe', 'ignore'],\n });\n return parseRunnerPidsFromPs(out, tenant, exclude);\n } catch {\n return [];\n }\n}\n\n/**\n * Start the runner's one-shot control endpoint on a loopback ephemeral port.\n * Each connection gets one JSON line (the live payload) and is closed \u2014 no\n * protocol state, nothing routable, and probing it touches no data plane.\n */\nexport function startControlServer(\n payload: () => ControlResponse,\n): Promise<{ server: net.Server; port: number }> {\n return new Promise((resolve, reject) => {\n const server = net.createServer((socket) => {\n socket.on('error', () => {});\n try {\n socket.end(`${JSON.stringify(payload())}\\n`);\n } catch {\n socket.destroy();\n }\n });\n server.on('error', reject);\n server.listen(0, '127.0.0.1', () => {\n resolve({ server, port: (server.address() as net.AddressInfo).port });\n });\n });\n}\n\n/** Dial the control endpoint and parse its one-line JSON answer; null on any failure. */\nexport function queryControl(port: number, timeoutMs = 700): Promise<ControlResponse | null> {\n return new Promise((resolve) => {\n const s = new net.Socket();\n let buf = '';\n let done = false;\n const fin = (v: ControlResponse | null) => {\n if (done) return;\n done = true;\n s.destroy();\n resolve(v);\n };\n s.setTimeout(timeoutMs);\n s.once('timeout', () => fin(null));\n s.once('error', () => fin(null));\n s.on('data', (d) => {\n buf += d.toString();\n });\n s.once('close', () => {\n try {\n fin(JSON.parse(buf) as ControlResponse);\n } catch {\n fin(null);\n }\n });\n s.connect(port, '127.0.0.1');\n });\n}\n\nexport interface OwnershipCheck {\n ok: boolean;\n /** Machine-readable failure reason (\"\" when ok). */\n reason: string;\n /** The control answer, when the handshake got that far. */\n resp?: ControlResponse;\n}\n\nfunction defaultPidAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * The ownership handshake: manifest \u2192 live pid \u2192 control endpoint \u2192 token,\n * SOCKS port, and port coverage all match. Anything less is NOT ours \u2014 the\n * caller must treat an accepting-but-unowned port as a squatter, never reuse it.\n */\nexport async function verifyRunnerOwnership(args: {\n manifest: RunnerManifest | null;\n expectedSocksPort: number;\n expectedPorts: number[];\n isPidAlive?: (pid: number) => boolean;\n query?: (port: number) => Promise<ControlResponse | null>;\n}): Promise<OwnershipCheck> {\n const { manifest } = args;\n if (!manifest) return { ok: false, reason: 'no-manifest' };\n const alive = args.isPidAlive ?? defaultPidAlive;\n if (!alive(manifest.pid)) return { ok: false, reason: 'runner-dead' };\n const resp = await (args.query ?? queryControl)(manifest.controlPort);\n if (!resp) return { ok: false, reason: 'no-control-answer' };\n if (resp.token !== manifest.token) return { ok: false, reason: 'token-mismatch', resp };\n if (resp.socksPort !== args.expectedSocksPort) {\n return { ok: false, reason: 'socks-port-mismatch', resp };\n }\n const listening = new Set(resp.listening);\n const missing = args.expectedPorts.filter((p) => !listening.has(p));\n if (missing.length > 0) {\n return { ok: false, reason: `ports-not-listening:${missing.join(',')}`, resp };\n }\n return { ok: true, reason: '', resp };\n}\n\n/**\n * Poll an ownership check until it passes or `deadlineMs` of wall-clock elapses;\n * returns the last (failing) check otherwise. The deadline bounds TIME, not\n * rounds \u2014 each check can itself cost up to the control-query timeout, so a\n * round-counting loop would multiply the promised deadline several-fold.\n * Retrying also matters for correctness, not just latency: two failure reasons\n * are transient by design (`ports-not-listening:` while the runner's self-heal\n * loop rebinds a forwarder, `no-control-answer` under accept-backlog pressure),\n * and a single-shot sample of a healthy shared runner in one of those windows\n * would tear it down for every concurrent session on the tenant (MESH-2014).\n */\nexport async function pollOwnership(\n check: () => Promise<OwnershipCheck>,\n deadlineMs: number,\n): Promise<OwnershipCheck> {\n let last: OwnershipCheck = { ok: false, reason: 'not-checked' };\n const end = Date.now() + deadlineMs;\n do {\n last = await check();\n if (last.ok) return last;\n await new Promise((r) => setTimeout(r, 250));\n } while (Date.now() < end);\n return last;\n}\n\n/** Best-effort `lsof` description of what holds a port (\"cmd (pid N)\"); null if unknown. */\nexport function describePortOwner(port: number): string | null {\n try {\n const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fcp'], {\n encoding: 'utf8',\n stdio: ['ignore', 'pipe', 'ignore'],\n });\n // -F output: \"p<pid>\\nc<command>\\n\u2026\" per process.\n const pid = out.match(/^p(\\d+)$/m)?.[1];\n const cmd = out.match(/^c(.+)$/m)?.[1];\n if (!pid && !cmd) return null;\n return `${cmd ?? '?'} (pid ${pid ?? '?'})`;\n } catch {\n return null;\n }\n}\n\nexport interface SquattedPort {\n port: number;\n owner: string | null;\n}\n\n/**\n * Of `ports`, the ones something is accepting TCP on. Called AFTER ownership\n * failed / teardown ran, so every hit is a foreign or stale listener.\n */\nexport async function findSquattedPorts(\n ports: number[],\n accepts: (port: number) => Promise<boolean>,\n): Promise<SquattedPort[]> {\n const squatted: SquattedPort[] = [];\n for (const port of ports) {\n if (await accepts(port)) squatted.push({ port, owner: describePortOwner(port) });\n }\n return squatted;\n}\n\n/**\n * The loud-failure text for a squatted tunnel port (`mesh dev doctor` style:\n * what is wrong, why we refuse, exactly what to run). Never adopt-and-continue.\n */\nexport function buildSquatterError(\n tenant: string,\n squatted: SquattedPort[],\n detail?: string,\n): string {\n const lines = squatted.map(\n (s) => ` 127.0.0.1:${s.port} held by ${s.owner ?? 'an unidentified process'}`,\n );\n const first = squatted[0]?.port ?? '<port>';\n return [\n `Tunnel port(s) for tenant '${tenant}' are bound by a process mesh does not own${detail ? ` (${detail})` : ''}:`,\n ...lines,\n ` Refusing to reuse a foreign listener \u2014 traffic would silently flow to the wrong upstream.`,\n ` To fix:`,\n ` mesh vpn -t ${tenant} tunnel down --stop # tear down mesh-owned runners + daemon`,\n ` lsof -nP -iTCP:${first} -sTCP:LISTEN # identify what still holds the port`,\n ` then stop that process and re-run.`,\n // Port blocks are a 63-bucket hash (MESH-2375), so a DIFFERENT tenant's\n // runner can collide onto this block \u2014 in which case the `down` above is a\n // no-op and only lsof identifies the real owner.\n ` (If lsof names another mesh runner, it belongs to a different tenant \u2014 tear that one down instead.)`,\n ].join('\\n');\n}\n", "/**\n * Userspace-Tailscale daemon management + tunnel-session state for\n * `mesh vpn tunnel` (MESH-2040 / Slice 1 of MESH-2014).\n *\n * A per-tenant `tailscaled` runs in userspace-networking mode (brew formula,\n * NOT the GUI app) exposing a SOCKS5 proxy; forwarders bridge `localhost:PORT`\n * to VPC services through it. State lives under `~/.config/mesh/tailscale/<tenant>/`\n * (r+w-granted under safehouse \u2014 see the MESH-2036 spike).\n */\n\nimport * as fs from \"node:fs\";\nimport * as net from \"node:net\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { spawn, execFileSync } from \"node:child_process\";\nimport { CONFIG_DIR, atomicWriteFileSync } from \"../commands/login.js\";\nimport { resolveStableMeshBin } from \"./aws-auth.js\";\nimport { logInfo, logWarn } from \"./log.js\";\nimport { MeshCliError, renderErrorBody } from \"./errors.js\";\nimport { assertSignalablePid } from \"./pid.js\";\nimport type { PidSource } from \"./pid.js\";\nimport { getPlatformBastionInfo } from \"./bastion.js\";\nimport { resolveTunnelTargets, resolveElbIp, tenantPortIndex, PORT_BLOCK } from \"./tailscale-targets.js\";\nimport { createForwarder } from \"./socks-forward.js\";\nimport {\n mintRunnerToken,\n readRunnerManifest,\n writeRunnerManifest,\n clearRunnerManifest,\n getProcessGroupId,\n discoverRunnerPids,\n startControlServer,\n verifyRunnerOwnership,\n pollOwnership,\n findSquattedPorts,\n buildSquatterError,\n} from \"./tunnel-ownership.js\";\nimport type { ControlResponse, OwnershipCheck } from \"./tunnel-ownership.js\";\nimport type { TunnelTarget } from \"./tailscale-targets.js\";\nimport type { DevOutput } from \"../commands/dev.js\";\n\nconst sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));\n\n// ============================================================================\n// Paths\n// ============================================================================\n\nexport function tenantStateDir(tenant: string): string {\n return path.join(CONFIG_DIR, \"tailscale\", tenant);\n}\nexport function socketPath(tenant: string): string {\n return path.join(tenantStateDir(tenant), \"tailscaled.sock\");\n}\nexport function logPath(tenant: string): string {\n return path.join(tenantStateDir(tenant), \"tailscaled.log\");\n}\nexport function statePath(tenant: string): string {\n return path.join(tenantStateDir(tenant), \"tunnel-state.json\");\n}\n/**\n * Daemon-scoped metadata, written by `ensureDaemon` when it actually spawns the\n * daemon. Unlike `tunnel-state.json` (which `down` clears), this survives `down`\n * so a later `up` reuses the RUNNING daemon's SOCKS port instead of minting a\n * fresh one the daemon isn't listening on (which would leave every forwarder\n * dialing a dead port). Also records the daemon pid so `stopDaemon` can kill it.\n */\nexport function daemonMetaPath(tenant: string): string {\n return path.join(tenantStateDir(tenant), \"daemon.json\");\n}\n/**\n * Ownership manifest written by the RUNNER (the process that binds the\n * forwarder ports) \u2014 pid, process-group id, per-run token, control port. The\n * ownership handshake (MESH-2407) verifies against it; teardown signals the\n * recorded process group.\n */\nexport function runnerManifestPath(tenant: string): string {\n return path.join(tenantStateDir(tenant), \"runner.json\");\n}\n\n// ============================================================================\n// Tunnel-session state\n// ============================================================================\n\nexport interface TunnelState {\n tenant: string;\n env: string;\n socksPort: number;\n loginServer: string;\n /** PID of the detached forwarder supervisor (null until spawned). */\n supervisorPid: number | null;\n /** How the runner is supervised. Defaults to \"detached\" when absent. */\n managed?: \"launchd\" | \"detached\";\n startedAt: string;\n tunnels: TunnelTarget[];\n}\n\nexport function readTunnelState(tenant: string): TunnelState | null {\n const p = statePath(tenant);\n if (!fs.existsSync(p)) return null;\n try {\n return JSON.parse(fs.readFileSync(p, \"utf8\")) as TunnelState;\n } catch {\n return null;\n }\n}\n\nexport function writeTunnelState(tenant: string, state: TunnelState): void {\n fs.mkdirSync(tenantStateDir(tenant), { recursive: true });\n atomicWriteFileSync(statePath(tenant), JSON.stringify(state, null, 2), 0o600);\n}\n\nexport function clearTunnelState(tenant: string): void {\n try {\n fs.unlinkSync(statePath(tenant));\n } catch {\n /* already gone */\n }\n}\n\nexport interface DaemonMeta {\n /** The `--socks5-server` port the running daemon actually listens on. */\n socksPort: number;\n /** PID of the detached `tailscaled` process (for `stopDaemon`). */\n pid: number;\n}\n\nexport function readDaemonMeta(tenant: string): DaemonMeta | null {\n const p = daemonMetaPath(tenant);\n if (!fs.existsSync(p)) return null;\n try {\n return JSON.parse(fs.readFileSync(p, \"utf8\")) as DaemonMeta;\n } catch {\n return null;\n }\n}\n\nexport function writeDaemonMeta(tenant: string, meta: DaemonMeta): void {\n fs.mkdirSync(tenantStateDir(tenant), { recursive: true });\n atomicWriteFileSync(daemonMetaPath(tenant), JSON.stringify(meta, null, 2), 0o600);\n}\n\nexport function clearDaemonMeta(tenant: string): void {\n try {\n fs.unlinkSync(daemonMetaPath(tenant));\n } catch {\n /* already gone */\n }\n}\n\n// ============================================================================\n// Daemon status parsing\n// ============================================================================\n\n/** Parse `tailscale status --json` \u2192 the fields we care about. */\nexport function parseDaemonState(statusJson: string): {\n backendState: string;\n selfName?: string;\n} {\n try {\n const j = JSON.parse(statusJson) as { BackendState?: string; Self?: { HostName?: string } };\n return { backendState: j.BackendState ?? \"Unknown\", selfName: j.Self?.HostName };\n } catch {\n return { backendState: \"Unknown\" };\n }\n}\n\n// ============================================================================\n// Binary resolution (macOS / brew \u2014 Slice 1)\n// ============================================================================\n\n/** The brew `tailscaled` (NOT the GUI app, which ships no standalone daemon). */\nexport function tailscaledBinPath(): string {\n return resolveBrewBin(\"tailscaled\");\n}\n/** The brew `tailscale` CLI (prefer it over the GUI shim, which aborts under sandbox). */\nexport function tailscaleBinPath(): string {\n return resolveBrewBin(\"tailscale\");\n}\nfunction resolveBrewBin(name: string): string {\n const brew = `/opt/homebrew/bin/${name}`;\n if (fs.existsSync(brew)) return brew;\n try {\n const p = execFileSync(\"which\", [name], { encoding: \"utf8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] }).trim();\n if (p) return p;\n } catch {\n /* fall through */\n }\n throw new Error(`${name} not found \u2014 install the standalone binary: brew install tailscale`);\n}\n\n// ============================================================================\n// Daemon lifecycle\n// ============================================================================\n\n/** Backend state via the tenant's control socket; \"Down\" when no daemon answers. */\nexport function daemonState(tenant: string): { backendState: string; selfName?: string } {\n if (!fs.existsSync(socketPath(tenant))) return { backendState: \"Down\" };\n try {\n const out = execFileSync(\n tailscaleBinPath(),\n [\"--socket\", socketPath(tenant), \"status\", \"--json\"],\n { encoding: \"utf8\", stdio: [\"ignore\", \"pipe\", \"ignore\"] },\n );\n return parseDaemonState(out);\n } catch {\n return { backendState: \"Down\" };\n }\n}\n\n/**\n * Extract the `--socks5-server` port from a `tailscaled` command line. Pure so\n * it's unit-testable. Accepts both `--socks5-server=127.0.0.1:PORT` and the\n * space-separated form, and a `localhost` host literal. Returns null if absent\n * or malformed.\n */\nexport function parseSocksPortFromArgv(argv: string): number | null {\n const m = argv.match(/--socks5-server[=\\s]+(?:127\\.0\\.0\\.1|localhost):(\\d+)/);\n if (!m) return null;\n const port = Number(m[1]);\n return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;\n}\n\n/**\n * Find the running userspace `tailscaled` for a tenant by its unique\n * `--socket=<path>` argument, returning its pid and the SOCKS port it ACTUALLY\n * bound (parsed from argv). Authoritative when `daemon.json` is missing or stale\n * (e.g. a daemon left by an older build that predates the meta file). Returns\n * null when no matching process is found or `ps` is unavailable.\n */\nexport function findRunningDaemon(tenant: string): { pid: number; socksPort: number | null } | null {\n const sock = socketPath(tenant);\n let out: string;\n try {\n out = execFileSync(\"ps\", [\"ax\", \"-o\", \"pid=,command=\"], {\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n } catch {\n return null;\n }\n for (const line of out.split(\"\\n\")) {\n // Match this tenant's daemon by its unique --socket path (skips the ps line,\n // other tenants' daemons, and the GUI app).\n if (!line.includes(\"tailscaled\") || !line.includes(`--socket=${sock}`)) continue;\n const m = line.trim().match(/^(\\d+)\\s+(.*)$/);\n if (!m) continue;\n return { pid: Number(m[1]), socksPort: parseSocksPortFromArgv(m[2]!) };\n }\n return null;\n}\n\n/** True if something accepts TCP on 127.0.0.1:port within `timeoutMs`. */\nexport function portAccepts(port: number, timeoutMs = 400): Promise<boolean> {\n return new Promise((resolve) => {\n const s = new net.Socket();\n let done = false;\n const fin = (v: boolean) => {\n if (done) return;\n done = true;\n s.destroy();\n resolve(v);\n };\n s.setTimeout(timeoutMs);\n s.once(\"connect\", () => fin(true));\n s.once(\"timeout\", () => fin(false));\n s.once(\"error\", () => fin(false));\n s.connect(port, \"127.0.0.1\");\n });\n}\n\n/**\n * Ensure a userspace `tailscaled` is running for the tenant and return the SOCKS\n * port the forwarders should dial. Idempotent \u2014 reuses a live daemon, but\n * RECONCILES the port: a daemon that's up on a different port than requested\n * (older build with no `daemon.json`, or stale meta) would otherwise leave every\n * forwarder dialing a dead port (MESH-2169). Trust `opts.socksPort` only if it\n * truly accepts; else adopt the running daemon's real port; else restart fresh.\n */\nexport async function ensureDaemon(\n tenant: string,\n opts: { region: string; socksPort: number },\n): Promise<number> {\n if (daemonState(tenant).backendState !== \"Down\") {\n // The daemon's SOCKS proxy is only useful on the exact port the forwarders\n // will dial. If the requested port already accepts, record it and use it.\n if (await portAccepts(opts.socksPort)) {\n writeDaemonMeta(tenant, {\n socksPort: opts.socksPort,\n pid: readDaemonMeta(tenant)?.pid ?? findRunningDaemon(tenant)?.pid ?? 0,\n });\n return opts.socksPort;\n }\n // Requested port is dead \u2014 the daemon is on a different one. Discover its\n // real SOCKS port from argv and adopt it (no restart \u2192 no severing other\n // sessions), rewriting daemon.json so future runs read the right port.\n const running = findRunningDaemon(tenant);\n if (running?.socksPort && (await portAccepts(running.socksPort))) {\n writeDaemonMeta(tenant, { socksPort: running.socksPort, pid: running.pid });\n return running.socksPort;\n }\n // Can't discover/verify a working SOCKS port \u2014 the daemon is unusable to us.\n // Tear it down (state dir keeps the node identity \u2192 no re-login) and fall\n // through to a fresh start on opts.socksPort.\n stopDaemon(tenant);\n for (let i = 0; i < 12 && daemonState(tenant).backendState !== \"Down\"; i++) {\n await sleep(250);\n }\n }\n const dir = tenantStateDir(tenant);\n fs.mkdirSync(dir, { recursive: true });\n const log = fs.openSync(logPath(tenant), \"a\");\n try {\n const child = spawn(\n tailscaledBinPath(),\n [\n \"--tun=userspace-networking\",\n `--socket=${socketPath(tenant)}`,\n `--statedir=${dir}`,\n `--socks5-server=127.0.0.1:${opts.socksPort}`,\n \"--port=0\",\n ],\n {\n detached: true,\n stdio: [\"ignore\", log, log],\n env: { ...process.env, AWS_REGION: opts.region },\n },\n );\n child.unref();\n // Persist the socks port + pid so `down` \u2192 `up` reuses this daemon's real\n // port (not a fresh one it isn't listening on) and `stopDaemon` can kill it.\n if (child.pid) writeDaemonMeta(tenant, { socksPort: opts.socksPort, pid: child.pid });\n } finally {\n fs.closeSync(log);\n }\n for (let i = 0; i < 40; i++) {\n await sleep(250);\n if (daemonState(tenant).backendState !== \"Down\") return opts.socksPort;\n }\n throw new Error(`tailscaled did not start for tenant '${tenant}' \u2014 see ${logPath(tenant)}`);\n}\n\n/**\n * Stop the detached `tailscaled` for a tenant (SIGTERM its pid) and remove the\n * socket + daemon metadata. Leaves the Headscale node identity in `tailscaled.state`\n * so a later `up` re-spawns and rejoins without an interactive re-login \u2014 unless\n * the caller logged out first.\n *\n * Best-effort, and that includes a corrupt `daemon.json`: an unsignalable\n * recorded pid is REFUSED (never handed to `kill`) but only warns \u2014 the socket\n * and meta are still cleared. `ensureDaemon` calls this on the UP path to\n * recycle a daemon it can't verify, so throwing here would dead-end\n * `mesh vpn tunnel up` / `mesh dev` on the very file this function deletes.\n * Safe to call when nothing is running.\n */\nexport function stopDaemon(tenant: string): void {\n // Prefer the recorded pid; fall back to discovering it from argv so a daemon\n // left without a daemon.json (older build / cleared meta) can still be killed.\n const meta = readDaemonMeta(tenant) ?? (() => {\n const running = findRunningDaemon(tenant);\n return running ? { socksPort: running.socksPort ?? 0, pid: running.pid } : null;\n })();\n // `ensureDaemon` records `pid: 0` when it adopts a running daemon whose pid it\n // could not attribute, so absent-or-0 is this file's \"no pid known\" sentinel \u2014\n // skip it, don't signal and don't fail. EVERY other unsignalable value (1, a\n // negative, a fraction, a string from a truncated/hand-edited daemon.json) is\n // corruption and must be refused rather than handed to kill. See utils/pid.ts.\n if (meta?.pid) {\n try {\n assertSignalablePid(meta.pid, {\n what: `the VPN daemon pid recorded for tenant '${tenant}'`,\n file: daemonMetaPath(tenant),\n });\n process.kill(meta.pid, \"SIGTERM\");\n } catch (err) {\n // Refusing to SIGNAL is the whole point; refusing to CLEAN UP is not.\n // The unlink + clearDaemonMeta below are what make the corrupt file\n // self-healing, and `ensureDaemon` (the UP path) depends on them. Warn\n // with the `rm` remediation intact and fall through.\n if (err instanceof MeshCliError) logWarn(renderErrorBody(err));\n /* else: ESRCH/EPERM from process.kill \u2014 already gone, as before */\n }\n }\n for (const p of [socketPath(tenant)]) {\n try {\n fs.unlinkSync(p);\n } catch {\n /* already gone */\n }\n }\n clearDaemonMeta(tenant);\n}\n\n/**\n * Build the `tailscale up` argument vector. Pure so it can be unit-tested and so\n * the interactive (browser-register) and zero-touch (pre-auth key) forms differ\n * only by the trailing `--authkey`.\n */\nexport function buildTailscaleUpArgs(opts: {\n socket: string;\n loginServer: string;\n hostname: string;\n preAuthKey?: string;\n}): string[] {\n const args = [\n \"--socket\", opts.socket, \"up\",\n \"--login-server\", opts.loginServer,\n \"--accept-routes\",\n \"--hostname\", opts.hostname,\n ];\n if (opts.preAuthKey) args.push(\"--authkey\", opts.preAuthKey);\n return args;\n}\n\n/**\n * Join the tenant's Headscale tailnet via Zitadel OIDC if not already Running.\n * Prints the device-flow registration URL and polls until registered. When a\n * pre-auth key is supplied the join is non-interactive: no `/register/` URL is\n * emitted, so the scrape/relay is skipped and we just wait for Running.\n */\nexport async function joinHeadscale(\n tenant: string,\n loginServer: string,\n opts: { preAuthKey?: string } = {},\n): Promise<void> {\n if (daemonState(tenant).backendState === \"Running\") return;\n const hostname = `mesh-${tenant}-${os.hostname().split(\".\")[0]}`;\n const child = spawn(\n tailscaleBinPath(),\n buildTailscaleUpArgs({ socket: socketPath(tenant), loginServer, hostname, preAuthKey: opts.preAuthKey }),\n { stdio: [\"ignore\", \"pipe\", \"pipe\"] },\n );\n if (!opts.preAuthKey) {\n let buf = \"\";\n let urlShown = false;\n const capture = (d: Buffer) => {\n buf += d.toString();\n if (urlShown) return;\n const m = buf.match(/https?:\\/\\/\\S+\\/register\\/\\S+/);\n if (m) {\n urlShown = true;\n // Relay the registration URL for headless/SSH machines (there's no local\n // browser to pop). One-time per machine \u2014 the node registration persists.\n logInfo(\n `\\n This machine isn't registered with the platform VPN yet.\\n` +\n ` Open this URL in a browser to authorize it (one-time, sign in with Zitadel):\\n\\n` +\n ` ${m[0]}\\n\\n` +\n ` Waiting for approval (up to 5 min)\u2026`,\n );\n }\n };\n child.stdout.on(\"data\", capture);\n child.stderr.on(\"data\", capture);\n }\n // Poll up to ~5 min \u2014 the join is an interactive browser approval.\n for (let i = 0; i < 600; i++) {\n await sleep(500);\n if (daemonState(tenant).backendState === \"Running\") {\n child.kill();\n return;\n }\n }\n child.kill();\n throw new Error(\n `VPN join not completed for tenant '${tenant}' within 5 min. Re-run \\`mesh vpn tunnel up\\` to resume \u2014 the daemon is still up.`,\n );\n}\n\n/** Log the tenant daemon out of Headscale (disconnect). Daemon process persists. */\nexport function logout(tenant: string): void {\n try {\n execFileSync(tailscaleBinPath(), [\"--socket\", socketPath(tenant), \"logout\"], { stdio: \"ignore\" });\n } catch {\n /* best-effort */\n }\n}\n\n// ============================================================================\n// Forwarder supervisor (shared per tenant) + devOutput rewrite\n// ============================================================================\n\n/**\n * True iff two target sets are identical down to targetIp + remotePort. A live\n * runner pins each forwarder's target at startup, so if the ELB IP rotated (or a\n * port changed) the running forwarders point at a dead address and must be\n * respawned, not reused. Pid-agnostic (a launchd runner records no supervisor pid).\n */\nexport function tunnelTargetsEqual(a: TunnelTarget[], b: TunnelTarget[]): boolean {\n const key = (ts: TunnelTarget[]) =>\n ts.map((t) => `${t.name}:${t.localPort}:${t.targetIp}:${t.remotePort}`).sort().join(\",\");\n return key(a) === key(b);\n}\n\n/** True iff `state` has a supervisor pid and its target set equals `targets`. */\nexport function supervisorTargetsMatch(\n state: TunnelState | null,\n targets: TunnelTarget[],\n): boolean {\n if (!state || !state.supervisorPid) return false;\n return tunnelTargetsEqual(state.tunnels, targets);\n}\n\nfunction pidAlive(pid: number | null | undefined): boolean {\n if (!pid) return false;\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * SIGTERM the supervisor's whole process GROUP, not the bare pid.\n *\n * `state.supervisorPid` is the pid of the detached spawn \u2014 which, under the\n * canonical source launcher, is `bin/mesh.mjs`. That launcher blocks in\n * `execFileSync` around the *real* runner (a `node --import tsx \u2026 mesh.ts`\n * grandchild that actually binds the forwarder ports), and `execFileSync` does\n * not forward signals. So signalling the bare pid kills only the launcher and\n * ORPHANS the runner, which keeps its ports bound \u2014 the orphan then answers\n * `allForwardersListening`'s ownership-less liveness dial and silently pins\n * every tunnel to a stale target (the MESH-2169 / MESH-2014 failure class).\n *\n * The launcher is spawned `detached: true`, so it is a process-group leader\n * (`pgid === pid`); `process.kill(-pid, \u2026)` reaps the launcher and the runner\n * together. Falls back to the bare pid for a legacy state file whose pid was\n * not a group leader, and tolerates ESRCH (already gone).\n *\n * `pid` is always recovered \u2014 from `tunnel-state.json`, `runner.json`, or a\n * `ps` scan \u2014 never constructed here, so it is guarded first. The guard rejects\n * anything below 2, which is what makes the NEGATED form safe: `pid >= 2` \u21D2\n * `-pid <= -2`, a real process group, never `kill(0)` (this shell's own group)\n * or `kill(-1)` (broadcast to everything the user can signal). See utils/pid.ts.\n *\n * @throws {MeshCliError} when `pid` is unsignalable. Callers reaping SEVERAL\n * independently-recovered pids must catch per-pid (see `stopTunnelRunners`) \u2014\n * letting one refusal propagate would skip the other pids and leave a live\n * runner holding ports.\n */\nexport function killSupervisor(pid: number, source?: PidSource): void {\n // MUST be outside the try \u2014 both signals below are wrapped in catch-alls, so a\n // guard placed inside would have its refusal swallowed and fall through to the\n // bare-pid kill it was meant to prevent.\n assertSignalablePid(pid, source ?? { what: \"tunnel supervisor pid\" });\n try {\n process.kill(-pid, \"SIGTERM\"); // detached spawn \u21D2 pid is the group leader\n } catch {\n try {\n process.kill(pid, \"SIGTERM\"); // not a group leader (old state file) \u2014 best effort\n } catch {\n /* already gone (ESRCH) */\n }\n }\n}\n\n/**\n * The ownership handshake for a tenant's tunnel runner (MESH-2407): manifest \u2192\n * live pid \u2192 control endpoint \u2192 token + SOCKS port + port coverage. This is\n * the ONLY readiness/reuse signal \u2014 a bare TCP dial proves a listener exists,\n * not that it is ours, and adopting a foreign listener is exactly the\n * silent-dead-tunnel class (MESH-2169 / MESH-2014 / #2991). As a bonus the\n * handshake reads listening state from the runner's in-process servers, so it\n * opens no SOCKS\u2192VPC data connection the way port dials do.\n */\nexport async function verifyTunnelOwnership(\n tenant: string,\n expected: { socksPort: number; ports: number[] },\n): Promise<OwnershipCheck> {\n return verifyRunnerOwnership({\n manifest: readRunnerManifest(runnerManifestPath(tenant)),\n expectedSocksPort: expected.socksPort,\n expectedPorts: expected.ports,\n });\n}\n\n/**\n * Poll the ownership handshake until it passes or `deadlineMs` of WALL CLOCK\n * elapses (each check can cost up to the control-query timeout, so the bound\n * must be time, not rounds); returns the last check.\n */\nfunction awaitOwnership(\n tenant: string,\n expected: { socksPort: number; ports: number[] },\n deadlineMs: number,\n): Promise<OwnershipCheck> {\n return pollOwnership(() => verifyTunnelOwnership(tenant, expected), deadlineMs);\n}\n\n/**\n * Tear down EVERY runner we can attribute to the tenant: the recorded\n * supervisor pid (as a process group \u2014 see `killSupervisor`), the launchd\n * agent, the manifest's process group, and any `__run`/`__supervise` process\n * discovered from argv (a runner from an older build, or one whose state file\n * was lost). This is the single teardown path \u2014 the #2991 lesson generalized:\n * no kill path may leave a grandchild with ports still bound.\n */\nexport function stopTunnelRunners(tenant: string, prior: TunnelState | null): void {\n if (prior?.managed === \"launchd\") uninstallLaunchAgent(tenant);\n // Each pid below is RECOVERED (two state files and a ps scan), so each names\n // its own provenance \u2014 the refusal has to point at the exact file to delete,\n // or a corrupt state dir bricks the very command that would have cleared it.\n //\n // The three sources are INDEPENDENT, so a refusal on one must not skip the\n // others or `clearRunnerManifest`. Aborting would let one corrupt integer in\n // `tunnel-state.json` strand a live, uncorrupted runner from `runner.json`\n // with its forwarder ports still bound \u2014 the silent-squatter class this\n // teardown exists to prevent (\"no kill path may leave a grandchild with ports\n // still bound\", MESH-2407). Refuse the bad pid, reap the rest;\n // `assertTunnelPortsFree` is the backstop that still fails loudly if an\n // un-reaped runner really is holding ports.\n const killOrWarn = (pid: number, source: PidSource): void => {\n try {\n killSupervisor(pid, source);\n } catch (err) {\n if (!(err instanceof MeshCliError)) throw err;\n logWarn(renderErrorBody(err));\n }\n };\n if (prior?.supervisorPid)\n killOrWarn(prior.supervisorPid, {\n what: `the tunnel supervisor pid recorded for tenant '${tenant}'`,\n file: statePath(tenant),\n });\n const manifest = readRunnerManifest(runnerManifestPath(tenant));\n if (manifest)\n killOrWarn(manifest.pgid || manifest.pid, {\n what: `the tunnel runner process group recorded for tenant '${tenant}'`,\n file: runnerManifestPath(tenant),\n });\n for (const pid of discoverRunnerPids(tenant, process.pid))\n killOrWarn(pid, { what: `a tunnel runner pid discovered from 'ps' for tenant '${tenant}'` });\n clearRunnerManifest(runnerManifestPath(tenant));\n}\n\n/**\n * After teardown, wait for the tenant's forwarder ports to actually free. Any\n * port still accepting is held by a process we could not kill \u2014 a foreign\n * squatter \u2014 and spawning a runner over it would produce forwarders that\n * silently fail to bind (the false-green readiness gate). Fail LOUDLY instead.\n */\nasync function assertTunnelPortsFree(tenant: string, ports: number[]): Promise<void> {\n let held = ports;\n for (let i = 0; i < 8; i++) {\n const stillHeld: number[] = [];\n for (const p of held) {\n if (await portAccepts(p, 250)) stillHeld.push(p);\n }\n held = stillHeld;\n if (held.length === 0) return;\n await sleep(250);\n }\n const squatted = await findSquattedPorts(held, (p) => portAccepts(p, 250));\n throw new Error(buildSquatterError(tenant, squatted, \"still bound after teardown\"));\n}\n\n/**\n * Ensure the per-tenant forwarder supervisor is running for `state.tunnels`.\n * Idempotent: reuses a live runner ONLY when it passes the ownership handshake\n * (our manifest token, our SOCKS port, every target port listening in-process);\n * otherwise tears down every attributable runner, verifies the ports actually\n * freed (a still-held port is a foreign squatter \u2192 loud failure, never spawn\n * over it), and (re)spawns a fresh runner \u2014 whose readiness is again the\n * handshake, not a bare port dial. Persists the state and returns the pid.\n */\nexport async function ensureForwarders(state: TunnelState): Promise<number> {\n const prior = readTunnelState(state.tenant);\n const expected = { socksPort: state.socksPort, ports: state.tunnels.map((t) => t.localPort) };\n // Reuse-if-healthy must be mode-aware. A launchd runner records no supervisorPid\n // (KeepAlive owns liveness), so gating reuse on pidAlive would tear down + rebind\n // the shared tunnels on EVERY `up`/`mesh dev` \u2014 severing other concurrent sessions,\n // the exact failure MESH-2014 exists to kill. Treat the launchd agent as \"alive\"\n // when its plist is installed; keep the pid check for the detached path.\n const runnerAlive =\n prior?.managed === \"launchd\"\n ? fs.existsSync(launchAgentPath(state.tenant))\n : pidAlive(prior?.supervisorPid);\n if (\n prior &&\n runnerAlive &&\n tunnelTargetsEqual(prior.tunnels, state.tunnels) &&\n // A prior supervisor spawned for a DIFFERENT SOCKS port still binds the same\n // local ports but dials a dead upstream \u2014 reusing it silently strands every\n // tunnel (MESH-2169). The port must match, and the runner must PROVE it is\n // ours via the ownership handshake (liveness \u2260 ownership \u2014 a port answering\n // a dial can be another session's SSM tunnel or an orphaned runner, #2991).\n // Poll briefly rather than sample once: `ports-not-listening` during the\n // runner's own rebind window and `no-control-answer` under load are\n // transient, and a single miss here would tear down a healthy runner SHARED\n // by every concurrent session on the tenant (MESH-2014). This branch is\n // only reached when the runner is alive with matching targets, so the\n // retry costs nothing on cold-start or genuinely-dead paths.\n prior.socksPort === state.socksPort &&\n (await awaitOwnership(state.tenant, expected, 1000)).ok\n ) {\n state.supervisorPid = prior.supervisorPid;\n state.managed = prior.managed;\n writeTunnelState(state.tenant, state);\n return prior.supervisorPid ?? 0;\n }\n // Tear down every attributable runner (state pid group, launchd agent,\n // manifest process group, argv-discovered strays), then require the ports to\n // actually free \u2014 a still-held port is foreign and must fail loudly here,\n // not surface later as a forwarder that silently failed to bind.\n stopTunnelRunners(state.tenant, prior);\n await assertTunnelPortsFree(state.tenant, expected.ports);\n // Write state first so the runner reads the correct targets on start.\n state.supervisorPid = null;\n // Prefer a launchd-supervised runner (survives reboot/crash); fall back to a\n // detached runner where launchd isn't usable (e.g. sandboxed shells). Both run\n // the same self-healing `__run`.\n // argv[1] under the canonical source launcher is bin/mesh.ts, which plain\n // node cannot execute (same failure class as the credential_process fix in\n // aws-auth.ts) \u2014 resolve to the stable launcher, or the bare `mesh` fallback.\n const meshBin = resolveStableMeshBin(process.argv[1]);\n if (launchdAvailable()) {\n state.managed = \"launchd\";\n writeTunnelState(state.tenant, state);\n // launchd does no PATH lookup, so the bare `mesh` fallback can't back a\n // LaunchAgent (it would become `node mesh \u2026`, crash-looping under KeepAlive).\n // Require an absolute path; otherwise fall through to the detached runner,\n // where spawn *does* resolve `mesh` on PATH.\n if (path.isAbsolute(meshBin) && installLaunchAgent(state.tenant, meshBin)) {\n // Installing the agent proves nothing about the forwarders \u2014 wait for the\n // runner's ownership handshake (this path used to return success blind).\n if ((await awaitOwnership(state.tenant, expected, 8000)).ok) return 0;\n uninstallLaunchAgent(state.tenant);\n // `launchctl bootout` signals the job but doesn't wait for it \u2014 spawning\n // the detached runner immediately would race the dying launchd runner for\n // the ports (EADDRINUSE \u2192 a transient turned into a hard failure). Every\n // teardown path must be followed by the ports-actually-freed check.\n await assertTunnelPortsFree(state.tenant, expected.ports);\n logInfo(\"launchd runner did not take ownership of the tunnel ports \u2014 using a detached runner.\");\n } else {\n logInfo(\"launchd install failed \u2014 using a detached self-healing runner.\");\n }\n }\n state.managed = \"detached\";\n writeTunnelState(state.tenant, state);\n // meshBin is the stable launcher, or the bare `mesh` fallback which spawn\n // resolves on PATH here (no PATH-lookup ternary needed \u2014 that was a no-op).\n const child = spawn(meshBin, [\"vpn\", \"tunnel\", \"__run\", state.tenant], {\n detached: true,\n stdio: \"ignore\",\n });\n // ENOENT from the bare `mesh` fallback (mesh-cli not installed globally)\n // arrives asynchronously \u2014 without this handler an unhandled 'error' throws\n // an uncaught exception and takes down `mesh dev` instead of degrading.\n child.on(\"error\", (err) =>\n logWarn(`tunnel supervisor failed to start (${err.message}) \u2014 falling back to SSM.`),\n );\n child.unref();\n state.supervisorPid = child.pid ?? null;\n writeTunnelState(state.tenant, state);\n // Readiness = the ownership handshake, not a port dial: the fresh runner must\n // present its manifest token and report every forwarder listening from its\n // own in-process servers. A dial would also pass for another session's SSM\n // tunnel on the same 2XXXX port \u2014 the false-green this contract kills.\n // Throwing here lands in startServices' catch \u2192 clean SSM fallback.\n const last = await awaitOwnership(state.tenant, expected, 5000);\n if (last.ok) return child.pid ?? -1;\n // Diagnose before failing: a port that accepts TCP while ownership failed is\n // held by someone else \u2014 name it and say exactly what to do.\n const squatted = await findSquattedPorts(expected.ports, (p) => portAccepts(p, 250));\n if (squatted.length > 0) {\n throw new Error(buildSquatterError(state.tenant, squatted, `ownership check: ${last.reason}`));\n }\n const bindErrors = Object.entries(last.resp?.bindErrors ?? {})\n .map(([port, code]) => `port ${port}: ${code}`)\n .join(\", \");\n throw new Error(\n `Tailscale forwarders did not take ownership for tenant '${state.tenant}' ` +\n `(${last.reason}${bindErrors ? `; ${bindErrors}` : \"\"}) \u2014 see ${logPath(state.tenant)}.`,\n );\n}\n\n/**\n * Rewrite `devOutput.tunnels` so each resolved target points at its local\n * forwarder (`localhost:localPort`) \u2014 the same shape `startSsmTunnels`\n * produces. Unmatched tunnels are left untouched. Pure (no mutation of input).\n */\nexport function rewriteTunnelsForTargets(\n devOutput: DevOutput,\n targets: TunnelTarget[],\n): DevOutput {\n const tunnels = { ...devOutput.tunnels };\n for (const t of targets) {\n tunnels[t.name] = { host: \"localhost\", port: t.localPort };\n }\n return { ...devOutput, tunnels };\n}\n\n/** True when the brew `tailscaled` binary is resolvable (Tailscale transport is usable). */\nexport function tailscaleAvailable(): boolean {\n try {\n tailscaledBinPath();\n return true;\n } catch {\n return false;\n }\n}\n\n// ============================================================================\n// Shared engine: ensure/start Tailscale tunnels (used by the command + mesh dev)\n// ============================================================================\n\n/**\n * Ensure the tenant's Tailscale tunnels are fully up: the shared per-tenant\n * daemon (started + OIDC-joined if needed), the resolved VPC targets, and the\n * forwarder supervisor. Idempotent (shared across worktrees/invocations of the\n * same tenant). Returns the resolved targets. Used by both `mesh vpn tunnel up`\n * and `mesh dev --transport=tailscale`.\n */\nexport async function ensureTailscaleTunnels(\n tenant: string,\n env: string,\n opts: { region: string; loginServer: string; socksPort: number; preAuthKey?: string },\n): Promise<TunnelTarget[]> {\n // Reconcile the daemon's SOCKS port \u2014 an already-running daemon may be on a\n // different port than requested; the forwarders MUST dial the real one (MESH-2169).\n const socksPort = await ensureDaemon(tenant, { region: opts.region, socksPort: opts.socksPort });\n await joinHeadscale(tenant, opts.loginServer, { preAuthKey: opts.preAuthKey });\n // Pass the region explicitly rather than clobbering process.env.AWS_REGION \u2014\n // this runs mid `mesh dev` launch and must not mutate the global env.\n const bastion = await getPlatformBastionInfo(tenant, env, opts.region);\n if (!bastion) {\n throw new Error(`Could not read the platform bastion for ${tenant}/${env} (check AWS credentials).`);\n }\n const portOffset = tenantPortIndex(tenant) * PORT_BLOCK;\n const targets = resolveTunnelTargets(bastion.services, resolveElbIp, portOffset);\n if (targets.length === 0) {\n throw new Error(`No resolvable VPC tunnels found for ${tenant}/${env}.`);\n }\n const state: TunnelState = {\n tenant,\n env,\n socksPort,\n loginServer: opts.loginServer,\n supervisorPid: null,\n startedAt: new Date().toISOString(),\n tunnels: targets,\n };\n await ensureForwarders(state);\n return targets;\n}\n\n/**\n * `startSsmTunnels` drop-in for `mesh dev`: ensure the tenant's Tailscale\n * tunnels, then rewrite `devOutput.tunnels` to the local forwarder ports.\n */\nexport async function startTailscaleTunnels(\n devOutput: DevOutput,\n ctx: { tenant: string; env: string; region: string; loginServer: string; socksPort: number; preAuthKey?: string },\n): Promise<DevOutput> {\n const targets = await ensureTailscaleTunnels(ctx.tenant, ctx.env, {\n region: ctx.region,\n loginServer: ctx.loginServer,\n socksPort: ctx.socksPort,\n preAuthKey: ctx.preAuthKey,\n });\n return rewriteTunnelsForTargets(devOutput, targets);\n}\n\n// ============================================================================\n// launchd supervision + self-heal (Slice 2a)\n// ============================================================================\n\nexport function launchAgentLabel(tenant: string): string {\n return `tech.mesh.tunnel.${tenant}`;\n}\n\n/** Minimal XML-escape for plist string values (controlled inputs, but paths can vary). */\nfunction xmlEscape(s: string): string {\n return s\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\");\n}\n\n/** Render the per-tenant LaunchAgent plist (pure). */\nexport function renderLaunchAgentPlist(args: {\n tenant: string;\n meshBin: string;\n nodeBin: string;\n logPath: string;\n /** PATH for the agent \u2014 must include the dir of `tailscaled` so daemon reheal\n * works under launchd's minimal PATH (esp. Intel `/usr/local/bin`). */\n pathEnv?: string;\n}): string {\n const pathEnv = args.pathEnv ?? \"/usr/bin:/bin:/usr/sbin:/sbin\";\n return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n <key>Label</key>\n <string>${launchAgentLabel(args.tenant)}</string>\n <key>ProgramArguments</key>\n <array>\n <string>${xmlEscape(args.nodeBin)}</string>\n <string>${xmlEscape(args.meshBin)}</string>\n <string>vpn</string>\n <string>tunnel</string>\n <string>__run</string>\n <string>${xmlEscape(args.tenant)}</string>\n </array>\n <key>EnvironmentVariables</key>\n <dict>\n <key>PATH</key>\n <string>${xmlEscape(pathEnv)}</string>\n </dict>\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n </dict>\n <key>StandardOutPath</key>\n <string>${xmlEscape(args.logPath)}</string>\n <key>StandardErrorPath</key>\n <string>${xmlEscape(args.logPath)}</string>\n</dict>\n</plist>\n`;\n}\n\n/** Targets whose local forwarder port isn't currently accepting connections. */\nexport function forwardersNeedingRebind(\n targets: TunnelTarget[],\n listening: Set<number>,\n): TunnelTarget[] {\n return targets.filter((t) => !listening.has(t.localPort));\n}\n\nexport function launchAgentPath(tenant: string): string {\n return path.join(os.homedir(), \"Library\", \"LaunchAgents\", `${launchAgentLabel(tenant)}.plist`);\n}\n\nfunction guiDomain(): string {\n return `gui/${process.getuid?.() ?? 0}`;\n}\n\nlet _launchdAvailable: boolean | undefined;\n/** True when launchctl works for this user (blocked under the seatbelt sandbox \u2192 false). */\nexport function launchdAvailable(): boolean {\n if (_launchdAvailable !== undefined) return _launchdAvailable;\n try {\n execFileSync(\"which\", [\"launchctl\"], { stdio: \"ignore\" });\n execFileSync(\"launchctl\", [\"print\", guiDomain()], { stdio: \"ignore\" });\n _launchdAvailable = true;\n } catch {\n _launchdAvailable = false;\n }\n return _launchdAvailable;\n}\n\n/**\n * Write + (re)load the per-tenant LaunchAgent. Returns true iff it fully\n * succeeded \u2014 any failure (plist write blocked by the sandbox, `launchctl`\n * bootstrap denied, \u2026) returns false so the caller falls back to a detached\n * runner. Never throws.\n */\nexport function installLaunchAgent(tenant: string, meshBin: string): boolean {\n const plistPath = launchAgentPath(tenant);\n try {\n // Resolve symlinks so the plist embeds a stable path (a since-deleted\n // worktree symlink would leave a KeepAlive agent respawning a dead binary).\n let realMeshBin = meshBin;\n try {\n realMeshBin = fs.realpathSync(meshBin);\n } catch {\n /* keep the raw path */\n }\n // Bake a PATH that includes the tailscaled dir so the runner's daemon reheal\n // works under launchd's minimal PATH (Intel installs live in /usr/local/bin).\n let pathEnv = \"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin\";\n try {\n pathEnv = `${path.dirname(tailscaledBinPath())}:${pathEnv}`;\n } catch {\n /* fall back to the default PATH */\n }\n fs.mkdirSync(path.dirname(plistPath), { recursive: true });\n fs.writeFileSync(\n plistPath,\n renderLaunchAgentPlist({\n tenant,\n meshBin: realMeshBin,\n nodeBin: process.execPath,\n logPath: path.join(tenantStateDir(tenant), \"runner.log\"),\n pathEnv,\n }),\n );\n try {\n execFileSync(\"launchctl\", [\"bootout\", guiDomain(), plistPath], { stdio: \"ignore\" });\n } catch {\n /* not loaded \u2014 fine */\n }\n execFileSync(\"launchctl\", [\"bootstrap\", guiDomain(), plistPath], { stdio: \"ignore\" });\n return true;\n } catch {\n return false;\n }\n}\n\n/** Unload + remove the per-tenant LaunchAgent (best-effort). */\nexport function uninstallLaunchAgent(tenant: string): void {\n const plistPath = launchAgentPath(tenant);\n try {\n execFileSync(\"launchctl\", [\"bootout\", guiDomain(), plistPath], { stdio: \"ignore\" });\n } catch {\n /* not loaded */\n }\n try {\n fs.unlinkSync(plistPath);\n } catch {\n /* already gone */\n }\n}\n\n// ============================================================================\n// The runner (__run): forwarders + continuous self-heal\n// ============================================================================\n\n/**\n * Long-lived runner for a tenant's tunnels \u2014 used both under launchd and as the\n * detached fallback. Runs a forwarder per target and, every ~5s, self-heals:\n * respawns the daemon if its process died, and rebinds any forwarder whose port\n * stopped listening. As the process that actually binds the ports, it is the\n * sole author of the ownership manifest + control endpoint (MESH-2407) that\n * callers verify against. Needs no AWS (targets come from tunnel-state). Never\n * returns.\n */\nexport async function runTunnels(tenant: string): Promise<never> {\n const st = readTunnelState(tenant);\n if (!st) {\n // State is gone (torn down). Under launchd's KeepAlive a non-zero exit would\n // respawn every ~10s forever (surviving reboots) \u2014 so self-uninstall the\n // agent (no-op when detached), drop any manifest a previous incarnation\n // left (it describes a runner that no longer exists), and exit cleanly.\n uninstallLaunchAgent(tenant);\n clearRunnerManifest(runnerManifestPath(tenant));\n process.exit(0);\n }\n const token = mintRunnerToken();\n const servers = new Map<number, import(\"node:net\").Server>();\n // port \u2192 last bind error code. Surfaced through the control endpoint so a\n // failed bind (EADDRINUSE from a squatted port) is a visible, attributable\n // failure at the caller \u2014 never a silently-dead forwarder.\n const bindErrors = new Map<number, string>();\n const spawnFor = (t: TunnelTarget) => {\n servers.get(t.localPort)?.close();\n bindErrors.delete(t.localPort);\n const server = createForwarder({\n listenPort: t.localPort,\n socksPort: st.socksPort,\n targetIp: t.targetIp,\n targetPort: t.remotePort,\n onError: (err) => {\n bindErrors.set(t.localPort, err.code ?? err.message);\n logWarn(`forwarder ${t.name} failed on 127.0.0.1:${t.localPort}: ${err.code ?? err.message}`);\n },\n });\n server.on(\"listening\", () => bindErrors.delete(t.localPort));\n servers.set(t.localPort, server);\n };\n for (const t of st.tunnels) spawnFor(t);\n const control = await startControlServer(\n (): ControlResponse => ({\n token,\n tenant,\n pid: process.pid,\n socksPort: st.socksPort,\n listening: st.tunnels.filter((t) => servers.get(t.localPort)?.listening).map((t) => t.localPort),\n bindErrors: Object.fromEntries([...bindErrors].map(([p, c]) => [String(p), c])),\n }),\n );\n writeRunnerManifest(runnerManifestPath(tenant), {\n tenant,\n pid: process.pid,\n pgid: getProcessGroupId(process.pid) ?? process.pid,\n token,\n controlPort: control.port,\n socksPort: st.socksPort,\n ports: st.tunnels.map((t) => t.localPort),\n startedAt: new Date().toISOString(),\n });\n const shutdown = () => {\n control.server.close();\n for (const s of servers.values()) s.close();\n // Token-guarded: never clobber the manifest of a newer runner that already\n // replaced us (teardown kills groups, and signal delivery order is loose).\n clearRunnerManifest(runnerManifestPath(tenant), token);\n process.exit(0);\n };\n process.on(\"SIGTERM\", shutdown);\n process.on(\"SIGINT\", shutdown);\n for (;;) {\n await sleep(5000);\n try {\n if (daemonState(tenant).backendState === \"Down\") {\n await ensureDaemon(tenant, {\n region: process.env.AWS_REGION ?? \"us-east-2\",\n socksPort: st.socksPort,\n });\n }\n // Check the in-process net.Server directly \u2014 do NOT dial the forwarder.\n // A dial makes the forwarder open a real SOCKS\u2192VPC-target connection, and\n // the probe's clean FIN doesn't tear the upstream leg down, leaking a\n // connection to Temporal/RDS/etc. every 5s per target.\n const listening = new Set<number>();\n for (const t of st.tunnels) {\n if (servers.get(t.localPort)?.listening) listening.add(t.localPort);\n }\n for (const t of forwardersNeedingRebind(st.tunnels, listening)) spawnFor(t);\n } catch {\n /* keep the loop alive across transient errors */\n }\n }\n}\n", "/**\n * `mesh vpn tunnel` \u2014 userspace-Tailscale tunnels to a tenant's VPC services.\n *\n * Stands up the tenant's `localhost:PORT` set (drop-in for the SSM tunnels)\n * backed by a per-tenant userspace `tailscaled` + SOCKS5 forwarders. Detached\n * and managed: `up` starts everything and returns; `down`/`status` manage it.\n * (MESH-2040 / Slice 1 of MESH-2014; graduates the MESH-2036 spike.)\n */\n\nimport type { Command } from \"commander\";\nimport * as net from \"node:net\";\nimport { logInfo, logError, logSuccess } from \"../../utils/log.js\";\nimport { getContextConfig } from \"../login.js\";\nimport {\n ensureTailscaleTunnels,\n stopDaemon,\n logout,\n daemonState,\n readTunnelState,\n clearTunnelState,\n readDaemonMeta,\n runTunnels,\n stopTunnelRunners,\n verifyTunnelOwnership,\n} from \"../../utils/tailscale.js\";\n\ninterface VpnOpts {\n tenant: string;\n env: string;\n}\n\n/**\n * Derive the Headscale login-server URL from the login context's issuer\n * (`identity.<env>.<domain>` \u2192 `vpn.<env>.<domain>`). Mirrors\n * `vpnEndpointFromContext` in index.ts (kept local to avoid a circular import;\n * Slice 2 can unify).\n */\nfunction deriveLoginServer(context: string): string {\n const cfg = getContextConfig(context);\n if (!cfg?.issuer) {\n throw new Error(`No login config for context '${context}'. Run: mesh login ${context}`);\n }\n const u = new URL(cfg.issuer);\n const parts = u.hostname.split(\".\");\n parts[0] = \"vpn\";\n return `https://${parts.join(\".\")}`;\n}\n\nfunction findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const s = net.createServer();\n s.on(\"error\", reject);\n s.listen(0, \"127.0.0.1\", () => {\n const port = (s.address() as net.AddressInfo).port;\n s.close(() => resolve(port));\n });\n });\n}\n\nfunction canConnect(port: number): Promise<boolean> {\n return new Promise((resolve) => {\n const s = new net.Socket();\n let settled = false;\n const done = (ok: boolean) => {\n if (settled) return;\n settled = true;\n s.destroy();\n resolve(ok);\n };\n s.setTimeout(500);\n s.once(\"connect\", () => done(true));\n s.once(\"timeout\", () => done(false));\n s.once(\"error\", () => done(false));\n s.connect(port, \"127.0.0.1\");\n });\n}\n\n// \u2500\u2500 up \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function tunnelUp(vpn: VpnOpts, cmdOpts: { context?: string }): Promise<void> {\n const { tenant, env } = vpn;\n const context = cmdOpts.context ?? `${tenant}.${env}`;\n const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? \"us-east-2\";\n const loginServer = deriveLoginServer(context);\n\n const prior = readTunnelState(tenant);\n const daemonUp = daemonState(tenant).backendState !== \"Down\";\n // When the daemon is already up, reuse the SOCKS port IT is listening on\n // (from daemon.json, which `down` doesn't clear) \u2014 not `prior` (cleared by\n // `down`) and never a fresh port the daemon isn't bound to.\n const socksPort = daemonUp\n ? (readDaemonMeta(tenant)?.socksPort ?? prior?.socksPort ?? (await findFreePort()))\n : await findFreePort();\n\n logInfo(`Bringing up userspace VPN tunnels for tenant '${tenant}'...`);\n const targets = await ensureTailscaleTunnels(tenant, env, { region, loginServer, socksPort });\n\n logSuccess(`VPN tunnels up for '${tenant}' (${targets.length}):`);\n for (const t of targets) {\n logInfo(` ${t.name.padEnd(14)} 127.0.0.1:${t.localPort} \u2192 ${t.targetHost}:${t.remotePort}`);\n }\n}\n\n// The __run / __supervise runner lives in utils/tailscale.ts (runTunnels) \u2014\n// forwarders + a continuous self-heal loop, used under launchd or detached.\n\n// \u2500\u2500 down \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction tunnelDown(vpn: VpnOpts, cmdOpts: { logout?: boolean; stop?: boolean }): void {\n const { tenant } = vpn;\n const st = readTunnelState(tenant);\n // Nothing to tear down for the forwarders \u2014 but still honor an explicit\n // --stop/--logout so a leaked daemon (state file already gone) can be killed.\n if (!st && !cmdOpts.stop && !cmdOpts.logout) {\n logInfo(`No tunnels running for tenant '${tenant}'.`);\n return;\n }\n // One teardown path for every runner shape (state pid group, launchd agent,\n // manifest process group, argv-discovered strays) \u2014 no kill path may leave a\n // grandchild with ports still bound (MESH-2407).\n stopTunnelRunners(tenant, st);\n if (cmdOpts.logout) {\n logout(tenant);\n logInfo(\"Logged the VPN daemon out of Headscale.\");\n }\n // By default the daemon persists (join-once / reuse). --stop or --logout\n // (after which the daemon is useless) tear it down too.\n if (cmdOpts.stop || cmdOpts.logout) {\n stopDaemon(tenant);\n logInfo(\"Stopped the userspace VPN daemon.\");\n }\n clearTunnelState(tenant);\n logSuccess(`VPN tunnels down for '${tenant}'.`);\n}\n\n// \u2500\u2500 status \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function tunnelStatus(vpn: VpnOpts, cmdOpts: { json?: boolean }): Promise<void> {\n const { tenant } = vpn;\n const st = readTunnelState(tenant);\n const ds = daemonState(tenant);\n // Ownership handshake against the runner's manifest \u2014 status must distinguish\n // \"our runner owns the ports\" from \"something answers the port\" (MESH-2407).\n const owned = st\n ? await verifyTunnelOwnership(tenant, {\n socksPort: st.socksPort,\n ports: st.tunnels.map((t) => t.localPort),\n })\n : null;\n if (cmdOpts.json) {\n console.log(\n JSON.stringify(\n {\n tenant,\n daemon: ds,\n up: !!st,\n managed: st?.managed ?? null,\n owned: owned?.ok ?? false,\n ownership: owned ? (owned.ok ? \"owned\" : owned.reason) : \"no-state\",\n tunnels: st?.tunnels ?? [],\n },\n null,\n 2,\n ),\n );\n return;\n }\n logInfo(`Daemon: ${ds.backendState}${ds.selfName ? ` (${ds.selfName})` : \"\"}`);\n if (!st) {\n logInfo(`No tunnels running for tenant '${tenant}'.`);\n return;\n }\n logInfo(`Managed: ${st.managed ?? \"detached\"}`);\n logInfo(\n owned?.ok\n ? `Runner: owned (verified)`\n : `Runner: NOT OWNED (${owned?.reason}) \u2014 ports below may be foreign listeners`,\n );\n const listening = owned?.ok ? new Set(owned.resp?.listening ?? []) : null;\n for (const t of st.tunnels) {\n // When ownership holds, report from the runner's in-process listening set\n // (no data-plane dial); otherwise fall back to a dial, flagged above.\n const up = listening ? listening.has(t.localPort) : await canConnect(t.localPort);\n logInfo(` ${up ? \"\u2713\" : \"\u2717\"} ${t.name.padEnd(14)} 127.0.0.1:${t.localPort} \u2192 ${t.targetHost}:${t.remotePort}`);\n }\n}\n\n// \u2500\u2500 registration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Run an action, printing a clean error (not a stack trace) + exit 1 on failure. */\nasync function guard(fn: () => void | Promise<void>): Promise<void> {\n try {\n await fn();\n } catch (e) {\n logError(e instanceof Error ? e.message : String(e));\n process.exit(1);\n }\n}\n\nexport function registerTunnelSubcommands(vpn: Command): void {\n const tunnel = vpn\n .command(\"tunnel\")\n .description(\"Userspace-Tailscale tunnels to VPC services (localhost:PORT set)\");\n\n tunnel\n .command(\"up\", { isDefault: true })\n .description(\"Start the tenant's VPC tunnels over userspace Tailscale\")\n .option(\"--context <ctx>\", \"Login context for the VPN (default: <tenant>.<env>)\")\n .action((cmdOpts: { context?: string }) => guard(() => tunnelUp(vpn.opts() as VpnOpts, cmdOpts)));\n\n tunnel\n .command(\"down\")\n .description(\"Stop the tenant's VPC tunnels\")\n .option(\"--stop\", \"Also stop the userspace tailscaled daemon (keeps VPN login)\")\n .option(\"--logout\", \"Log the daemon out of Headscale and stop it\")\n .action((cmdOpts: { logout?: boolean; stop?: boolean }) =>\n guard(() => tunnelDown(vpn.opts() as VpnOpts, cmdOpts)),\n );\n\n tunnel\n .command(\"status\")\n .description(\"Show daemon + tunnel status\")\n .option(\"--json\", \"Machine-readable output\")\n .action((cmdOpts: { json?: boolean }) => guard(() => tunnelStatus(vpn.opts() as VpnOpts, cmdOpts)));\n\n tunnel\n .command(\"__run <tenant>\", { hidden: true })\n .action((tenant: string) => runTunnels(tenant));\n\n // Back-compat alias for any launchd/detached runner referencing the old name.\n tunnel\n .command(\"__supervise <tenant>\", { hidden: true })\n .action((tenant: string) => runTunnels(tenant));\n}\n", "/**\n * mesh vpn \u2014 Headscale VPN management commands\n *\n * Commands:\n * mesh vpn connect <context> Connect to VPN via Tailscale + OIDC\n * mesh vpn disconnect Disconnect from VPN\n * mesh vpn status Show VPN control plane status\n * mesh vpn api-key Create a Headscale API key\n * mesh vpn pre-auth-key Create a pre-authentication key for a user\n * mesh vpn users List registered VPN users\n *\n * All commands shell out to kubectl. The user must have kubeconfig\n * configured for the target cluster (e.g., via `aws eks update-kubeconfig`).\n */\n\nimport { Command } from \"commander\";\nimport { execFileSync, type ExecFileSyncOptions } from \"child_process\";\nimport * as net from \"node:net\";\nimport { logInfo, logSuccess, logError, logWarn } from \"../../utils/index.js\";\nimport { getContextConfig } from \"../login.js\";\nimport { registerTunnelSubcommands } from \"./tunnel.js\";\nimport {\n ensureDaemon,\n joinHeadscale,\n daemonState,\n stopDaemon,\n tailscaleAvailable,\n readDaemonMeta,\n findRunningDaemon,\n} from \"../../utils/tailscale.js\";\n\n// =============================================================================\n// Constants\n// =============================================================================\n\n/** Default namespace follows the convention: {tenant}-{env}-headscale */\nfunction defaultNamespace(tenant: string, env: string): string {\n return `${tenant}-${env}-headscale`;\n}\n\nconst HEADSCALE_POD = \"headscale-0\";\nconst HEADSCALE_CONTAINER = \"headscale\";\n\n// =============================================================================\n// Options\n// =============================================================================\n\ninterface VpnOptions {\n tenant: string;\n env: string;\n namespace?: string;\n}\n\ninterface PreAuthKeyOptions extends VpnOptions {\n user: string;\n expiration?: string;\n reusable?: boolean;\n ephemeral?: boolean;\n}\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction resolveNamespace(options: VpnOptions): string {\n return options.namespace ?? defaultNamespace(options.tenant, options.env);\n}\n\n/**\n * Run a headscale CLI command inside the headscale pod.\n * Returns stdout as a string. Throws on non-zero exit.\n */\nfunction headscaleExec(\n namespace: string,\n args: string[],\n opts?: { json?: boolean },\n): string {\n const cmd = [\"headscale\", ...args];\n if (opts?.json) {\n cmd.push(\"--output\", \"json\");\n }\n\n const execOpts: ExecFileSyncOptions = {\n encoding: \"utf-8\" as BufferEncoding,\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n };\n\n try {\n const result = execFileSync(\n \"kubectl\",\n [\n \"exec\",\n \"-n\", namespace,\n HEADSCALE_POD,\n \"-c\", HEADSCALE_CONTAINER,\n \"--\",\n ...cmd,\n ],\n execOpts,\n );\n return (result as string).trim();\n } catch (error: unknown) {\n const execError = error as { stderr?: Buffer | string; status?: number };\n const stderr = execError.stderr?.toString().trim() ?? \"\";\n if (stderr) {\n throw new Error(stderr);\n }\n throw error;\n }\n}\n\n/**\n * Derive the likely EKS cluster name from tenant/env convention.\n */\nfunction clusterName(tenant: string, env: string): string {\n return `${tenant}-${env}-eks`;\n}\n\n/**\n * Check that the headscale pod is running and reachable.\n */\nfunction assertPodReady(namespace: string, options: VpnOptions): void {\n try {\n const output = execFileSync(\n \"kubectl\",\n [\n \"get\", \"pod\", HEADSCALE_POD,\n \"-n\", namespace,\n \"-o\", \"jsonpath={.status.phase}\",\n ],\n { encoding: \"utf-8\", stdio: [\"pipe\", \"pipe\", \"pipe\"] },\n );\n if (output.trim() !== \"Running\") {\n logError(`Headscale pod is not running (status: ${output.trim()})`);\n process.exit(1);\n }\n } catch (err: unknown) {\n const stderr = (err as { stderr?: Buffer | string }).stderr?.toString() ?? \"\";\n logError(`Cannot reach headscale pod in namespace ${namespace}`);\n if (stderr.includes(\"expired\") || stderr.includes(\"token\")) {\n logInfo(\"Your AWS credentials appear to be expired. Refresh them and retry.\");\n } else {\n const cluster = clusterName(options.tenant, options.env);\n logInfo(\"Make sure your kubeconfig is configured for the target cluster:\");\n const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? \"us-east-2\";\n logInfo(` aws eks update-kubeconfig --name ${cluster} --region ${region}`);\n }\n process.exit(1);\n }\n}\n\n// =============================================================================\n// Commands\n// =============================================================================\n\nasync function vpnStatus(options: VpnOptions): Promise<void> {\n const ns = resolveNamespace(options);\n logInfo(`Checking Headscale in namespace ${ns}...`);\n\n assertPodReady(ns, options);\n\n // Get pod details\n const podJson = execFileSync(\n \"kubectl\",\n [\n \"get\", \"pod\", HEADSCALE_POD,\n \"-n\", ns,\n \"-o\", \"json\",\n ],\n { encoding: \"utf-8\", stdio: [\"pipe\", \"pipe\", \"pipe\"] },\n );\n const pod = JSON.parse(podJson);\n const container = pod.status?.containerStatuses?.find(\n (c: { name: string }) => c.name === HEADSCALE_CONTAINER,\n );\n const image = container?.image ?? \"unknown\";\n const ready = container?.ready ?? false;\n const restarts = container?.restartCount ?? 0;\n\n // Get the HTTPRoute hostname\n let endpoint = \"unknown\";\n try {\n endpoint = execFileSync(\n \"kubectl\",\n [\n \"get\", \"httproute\",\n \"-n\", ns,\n \"-o\", \"jsonpath={.items[0].spec.hostnames[0]}\",\n ],\n { encoding: \"utf-8\", stdio: [\"pipe\", \"pipe\", \"pipe\"] },\n ).trim();\n if (endpoint) {\n endpoint = `https://${endpoint}`;\n }\n } catch {\n // HTTPRoute may not exist\n }\n\n // List users\n let userCount = 0;\n try {\n const usersJson = headscaleExec(ns, [\"users\", \"list\"], { json: true });\n const users = JSON.parse(usersJson);\n userCount = Array.isArray(users) ? users.length : 0;\n } catch {\n // Users may not be queryable yet\n }\n\n // List nodes\n let nodeCount = 0;\n try {\n const nodesJson = headscaleExec(ns, [\"nodes\", \"list\"], { json: true });\n const nodes = JSON.parse(nodesJson);\n nodeCount = Array.isArray(nodes) ? nodes.length : 0;\n } catch {\n // Nodes may not be queryable\n }\n\n console.log(\"\");\n console.log(` Endpoint: ${endpoint}`);\n console.log(` Image: ${image}`);\n console.log(` Ready: ${ready ? \"yes\" : \"no\"}`);\n console.log(` Restarts: ${restarts}`);\n console.log(` Namespace: ${ns}`);\n console.log(` Users: ${userCount}`);\n console.log(` Nodes: ${nodeCount}`);\n console.log(\"\");\n\n if (ready) {\n logSuccess(\"Headscale is healthy\");\n } else {\n logWarn(\"Headscale pod is not ready\");\n }\n}\n\nasync function createApiKey(options: VpnOptions): Promise<void> {\n const ns = resolveNamespace(options);\n logInfo(`Creating Headscale API key in namespace ${ns}...`);\n\n assertPodReady(ns, options);\n\n try {\n const output = headscaleExec(ns, [\"apikeys\", \"create\"]);\n console.log(\"\");\n logSuccess(\"API key created:\");\n console.log(\"\");\n console.log(` ${output}`);\n console.log(\"\");\n logWarn(\"Store this key securely \u2014 it cannot be retrieved again.\");\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : String(error);\n logError(`Failed to create API key: ${message}`);\n process.exit(1);\n }\n}\n\nasync function createPreAuthKey(options: PreAuthKeyOptions): Promise<void> {\n const ns = resolveNamespace(options);\n const { user, expiration, reusable, ephemeral } = options;\n\n logInfo(`Creating pre-auth key for user \"${user}\" in namespace ${ns}...`);\n\n assertPodReady(ns, options);\n\n // Ensure the user exists\n try {\n headscaleExec(ns, [\"users\", \"create\", user]);\n logInfo(`Created user \"${user}\"`);\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : String(error);\n // \"already exists\" is fine\n if (!message.includes(\"already exists\")) {\n logError(`Failed to create user: ${message}`);\n process.exit(1);\n }\n logInfo(`User \"${user}\" already exists`);\n }\n\n const args = [\"preauthkeys\", \"create\", \"--user\", user];\n if (expiration) {\n args.push(\"--expiration\", expiration);\n }\n if (reusable) {\n args.push(\"--reusable\");\n }\n if (ephemeral) {\n args.push(\"--ephemeral\");\n }\n\n try {\n const output = headscaleExec(ns, args);\n console.log(\"\");\n logSuccess(\"Pre-authentication key created:\");\n console.log(\"\");\n console.log(` ${output}`);\n console.log(\"\");\n logInfo(\"Use this key to register a node:\");\n logInfo(` tailscale up --login-server <endpoint> --authkey ${output}`);\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : String(error);\n logError(`Failed to create pre-auth key: ${message}`);\n process.exit(1);\n }\n}\n\nasync function listUsers(options: VpnOptions): Promise<void> {\n const ns = resolveNamespace(options);\n logInfo(`Listing Headscale users in namespace ${ns}...`);\n\n assertPodReady(ns, options);\n\n try {\n const output = headscaleExec(ns, [\"users\", \"list\"]);\n console.log(\"\");\n console.log(output);\n console.log(\"\");\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : String(error);\n logError(`Failed to list users: ${message}`);\n process.exit(1);\n }\n}\n\n// =============================================================================\n// VPN Connect / Disconnect\n// =============================================================================\n\n/**\n * Derive the VPN (Headscale) endpoint from a mesh login context.\n *\n * The login config stores an issuer URL like:\n * https://identity.dev.mesh-platform.trabian.com\n * The VPN endpoint follows the same domain with \"vpn\" as the subdomain:\n * https://vpn.dev.mesh-platform.trabian.com\n */\nfunction vpnEndpointFromContext(context: string): string {\n const config = getContextConfig(context);\n if (!config) {\n throw new Error(\n `No configuration found for \"${context}\".\\n` +\n `Run: mesh login ${context} (or add the context to ~/.config/mesh/config.json)`\n );\n }\n\n const issuerUrl = new URL(config.issuer);\n // Replace the first subdomain (\"identity\") with \"vpn\"\n const hostParts = issuerUrl.hostname.split(\".\");\n if (hostParts.length < 3) {\n throw new Error(\n `Cannot derive VPN endpoint from issuer \"${config.issuer}\". ` +\n `Expected a subdomain like identity.<env>.<domain>.`\n );\n }\n hostParts[0] = \"vpn\";\n return `https://${hostParts.join(\".\")}`;\n}\n\n/** Split a platform context (\"mesh.dev\") into its tenant + env. Pure. */\nexport function parseContextTenantEnv(context: string): { tenant: string; env: string } {\n const [tenant, env] = context.split(\".\");\n return { tenant: tenant || \"mesh\", env: env || \"dev\" };\n}\n\n/** Ephemeral free TCP port on loopback for the userspace daemon's SOCKS proxy. */\nfunction findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const srv = net.createServer();\n srv.on(\"error\", reject);\n srv.listen(0, \"127.0.0.1\", () => {\n const addr = srv.address();\n const port = typeof addr === \"object\" && addr ? addr.port : 0;\n srv.close(() => resolve(port));\n });\n });\n}\n\n/**\n * Check whether the `tailscale` CLI is installed and return its path,\n * or null if not found.\n */\nfunction findTailscale(): string | null {\n try {\n return execFileSync(\"which\", [\"tailscale\"], {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n }).trim() || null;\n } catch {\n return null;\n }\n}\n\ninterface TailscaleStatus {\n BackendState?: string;\n /** Control server URL, e.g. \"https://vpn.dev.mesh-platform.trabian.com\" */\n ControlURL?: string;\n CurrentTailnet?: { Name?: string } | null;\n}\n\n/** Return Tailscale's full status, or null if tailscale is not running. */\nfunction tailscaleStatus(): TailscaleStatus | null {\n try {\n const json = execFileSync(\"tailscale\", [\"status\", \"--json\"], {\n encoding: \"utf-8\",\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n return JSON.parse(json) as TailscaleStatus;\n } catch {\n return null;\n }\n}\n\n/** Return Tailscale's BackendState, or null if tailscale is not running. */\nexport function tailscaleBackendState(): string | null {\n return tailscaleStatus()?.BackendState ?? null;\n}\n\n/**\n * Normalize a control server URL for comparison.\n * Trims trailing slashes and lowercases the origin.\n */\nfunction normalizeControlUrl(url: string): string {\n try {\n const u = new URL(url);\n return `${u.protocol}//${u.host}`.toLowerCase();\n } catch {\n return url.replace(/\\/+$/, \"\").toLowerCase();\n }\n}\n\nasync function vpnConnect(context: string, opts: { system?: boolean } = {}): Promise<void> {\n const endpoint = vpnEndpointFromContext(context);\n if (opts.system) {\n // Whole-machine VPN via the GUI Tailscale.app daemon. Opt-in only: it needs\n // the GUI app installed/running and CANNOT run headless or under the safehouse\n // sandbox (dyld can't load the app bundle's Sparkle.framework \u2192 abort trap).\n await vpnConnectSystem(endpoint);\n return;\n }\n await vpnConnectUserspace(context, endpoint);\n}\n\n/**\n * Default path: bring up the per-tenant userspace `tailscaled` (brew formula, NOT\n * the GUI app) and register this machine with the platform Headscale. This is the\n * same daemon `mesh dev --transport tailscale` / `mesh vpn tunnel` already use, so\n * it runs headless and under the sandbox. It exposes a local SOCKS5 proxy \u2014 the\n * userspace analog of system-wide routing \u2014 instead of a system TUN; for\n * auto-forwarded VPC services (Temporal, RDS, \u2026) use `mesh vpn tunnel` / `mesh dev`.\n */\nasync function vpnConnectUserspace(context: string, loginServer: string): Promise<void> {\n if (!tailscaleAvailable()) {\n logError(\"Standalone tailscale/tailscaled not found.\");\n logInfo(\"Install it: brew install tailscale\");\n logInfo(\"(The GUI Tailscale.app is not used here \u2014 for whole-machine VPN see `mesh vpn connect --system`.)\");\n process.exit(1);\n }\n const { tenant } = parseContextTenantEnv(context);\n const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? \"us-east-2\";\n\n if (daemonState(tenant).backendState === \"Running\") {\n // Prefer daemon.json; recover the real bound port from argv if meta is missing\n // (older build) so a \"connected\" session always shows how to reach it.\n const port = readDaemonMeta(tenant)?.socksPort ?? findRunningDaemon(tenant)?.socksPort;\n logSuccess(`Already connected to the platform VPN for '${tenant}' (userspace).`);\n if (port) logInfo(` SOCKS5 proxy: 127.0.0.1:${port} (route a tool via ALL_PROXY=socks5://127.0.0.1:${port})`);\n return;\n }\n\n logInfo(`Connecting to the platform VPN for '${tenant}' (userspace tailscaled)\u2026`);\n const socksPort = readDaemonMeta(tenant)?.socksPort ?? (await findFreePort());\n const realPort = await ensureDaemon(tenant, { region, socksPort });\n // Interactive join \u2014 joinHeadscale relays the one-time registration URL for\n // headless/SSH machines (no local browser to pop) and polls up to ~5 min.\n await joinHeadscale(tenant, loginServer);\n logSuccess(`VPN connected (userspace) for '${tenant}'.`);\n logInfo(` SOCKS5 proxy: 127.0.0.1:${realPort} \u2014 route a tool via ALL_PROXY=socks5://127.0.0.1:${realPort}`);\n logInfo(\" For auto-forwarded VPC services (Temporal, RDS, \u2026): mesh vpn tunnel up (or mesh dev)\");\n}\n\n/** Opt-in whole-machine VPN via the GUI Tailscale.app daemon (`mesh vpn connect --system`). */\nasync function vpnConnectSystem(endpoint: string): Promise<void> {\n if (!findTailscale()) {\n logError(\"Tailscale CLI not found.\");\n logInfo(\"Install it: brew install tailscale (or see https://tailscale.com/download)\");\n process.exit(1);\n }\n\n // If already connected to *this* endpoint, short-circuit. But if connected\n // to a different control server, fall through and reconnect \u2014 otherwise the\n // user would silently stay on the wrong tailnet.\n const status = tailscaleStatus();\n if (status?.BackendState === \"Running\") {\n const current = status.ControlURL ? normalizeControlUrl(status.ControlURL) : null;\n const target = normalizeControlUrl(endpoint);\n if (current === target) {\n logSuccess(`Already connected to VPN at ${endpoint}.`);\n logInfo(\"Run: tailscale status to see connected nodes.\");\n return;\n }\n if (current) {\n logWarn(`Currently connected to ${current}. Reconnecting to ${target}...`);\n }\n }\n\n logInfo(`Connecting to VPN at ${endpoint}...`);\n logInfo(\"A browser window will open for Zitadel authentication.\");\n console.log(\"\");\n\n // tailscale up is interactive (opens browser), so we inherit stdio.\n // On macOS, Tailscale.app owns the daemon \u2014 `tailscale up` talks to it.\n // On Linux, tailscaled runs as root and `tailscale up` may need sudo.\n //\n // --reset: wipe prior prefs so this command is idempotent. Without it,\n // tailscale refuses to apply prefs if a previous session set non-default\n // flags that aren't re-specified here.\n // --accept-routes: accept subnet routes advertised by the Headscale\n // SubnetRouter so clients can reach in-VPC services (EKS, RDS, internal LBs).\n const upArgs = [\n \"up\",\n \"--reset\",\n \"--login-server\", endpoint,\n \"--accept-routes\",\n ];\n try {\n execFileSync(\"tailscale\", upArgs, { stdio: \"inherit\" });\n console.log(\"\");\n logSuccess(\"VPN connected.\");\n } catch (error: unknown) {\n const code = (error as { status?: number }).status;\n const isPermissionError = process.platform === \"linux\" && (code === 1 || code === 2);\n console.log(\"\");\n logError(`tailscale up failed (exit ${code}).`);\n if (isPermissionError) {\n logInfo(\"On Linux, tailscaled runs as root \u2014 you may need sudo:\");\n logInfo(` sudo tailscale ${upArgs.join(\" \")}`);\n }\n process.exit(1);\n }\n}\n\nasync function vpnDisconnect(opts: { tenant: string; system?: boolean }): Promise<void> {\n if (opts.system) {\n // Whole-machine GUI Tailscale down (opt-in, mirrors `connect --system`).\n if (!findTailscale()) {\n logError(\"Tailscale CLI not found.\");\n process.exit(1);\n }\n if (tailscaleBackendState() !== \"Running\") {\n logInfo(\"System VPN is not connected.\");\n return;\n }\n logInfo(\"Disconnecting system VPN\u2026\");\n try {\n execFileSync(\"tailscale\", [\"down\"], { stdio: \"inherit\" });\n logSuccess(\"VPN disconnected.\");\n } catch (error: unknown) {\n const code = (error as { status?: number }).status;\n logError(`tailscale down failed (exit ${code}). Try: sudo tailscale down`);\n process.exit(1);\n }\n return;\n }\n\n // Default: stop the tenant's userspace daemon (leaves the Headscale node\n // identity in the statedir so a later `connect` rejoins without re-login).\n const { tenant } = opts;\n if (daemonState(tenant).backendState === \"Down\") {\n logInfo(`No userspace VPN daemon running for '${tenant}'.`);\n return;\n }\n logInfo(`Disconnecting userspace VPN for '${tenant}'\u2026`);\n stopDaemon(tenant);\n logSuccess(`VPN disconnected for '${tenant}'.`);\n}\n\n// =============================================================================\n// Registration\n// =============================================================================\n\nexport function registerVpnCommands(program: Command): void {\n const vpn = program\n .command(\"vpn\")\n .description(\"Headscale VPN management\")\n .option(\"-t, --tenant <tenant>\", \"Platform tenant\", \"mesh\")\n .option(\"-e, --env <env>\", \"Platform environment\", \"dev\")\n .option(\"-n, --namespace <namespace>\", \"Override K8s namespace (default: {tenant}-{env}-headscale)\");\n\n vpn\n .command(\"status\")\n .description(\"Show VPN control plane status\")\n .action(async () => {\n const opts = vpn.opts() as VpnOptions;\n await vpnStatus(opts);\n });\n\n vpn\n .command(\"connect\")\n .description(\"Connect to the platform VPN (userspace tailscaled; opens browser for Zitadel auth)\")\n .argument(\"<context>\", 'Platform context (e.g., \"mesh.dev\")')\n .option(\n \"--system\",\n \"Use the whole-machine GUI Tailscale.app instead (system TUN; cannot run headless/sandboxed)\",\n )\n .action(async (context: string, cmdOpts: { system?: boolean }) => {\n await vpnConnect(context, cmdOpts);\n });\n\n vpn\n .command(\"disconnect\")\n .description(\n \"Disconnect from the platform VPN \u2014 stops the tenant's shared userspace daemon \" +\n \"(also ends any active `mesh vpn tunnel` / `mesh dev` forwards for it); --system for GUI Tailscale\",\n )\n .option(\"--system\", \"Disconnect the whole-machine GUI Tailscale instead of the userspace daemon\")\n .action(async (cmdOpts: { system?: boolean }) => {\n const { tenant } = vpn.opts() as VpnOptions;\n await vpnDisconnect({ tenant, system: cmdOpts.system });\n });\n\n vpn\n .command(\"api-key\")\n .description(\"Create a Headscale API key\")\n .action(async () => {\n const opts = vpn.opts() as VpnOptions;\n await createApiKey(opts);\n });\n\n vpn\n .command(\"pre-auth-key\")\n .description(\"Create a pre-authentication key for node registration\")\n .requiredOption(\"-u, --user <user>\", \"User/namespace to create the key for (e.g., matt@trabian.com)\")\n .option(\"--expiration <duration>\", \"Key expiration (e.g., 24h, 7d)\", \"24h\")\n .option(\"--reusable\", \"Allow key to be used multiple times\")\n .option(\"--ephemeral\", \"Nodes registered with this key are ephemeral\")\n .action(async (cmdOpts: { user: string; expiration?: string; reusable?: boolean; ephemeral?: boolean }) => {\n const parentOpts = vpn.opts() as VpnOptions;\n await createPreAuthKey({ ...parentOpts, ...cmdOpts });\n });\n\n vpn\n .command(\"users\")\n .description(\"List registered VPN users\")\n .action(async () => {\n const opts = vpn.opts() as VpnOptions;\n await listUsers(opts);\n });\n\n registerTunnelSubcommands(vpn);\n}\n", "/**\n * mesh login \u2014 Authenticate with Zitadel via OIDC PKCE or Device Code flow.\n *\n * Opens browser to Zitadel (PKCE) or prints a code to enter in the browser\n * (Device Code). Exchanges for tokens and caches at ~/.config/mesh/credentials.json.\n *\n * Config is read from ~/.config/mesh/config.json, keyed by context\n * (e.g., \"mesh.dev\"). After deploying the platform, copy the CLI client ID\n * from the Pulumi stack output into the config file.\n *\n * In containers/SSH sessions, device code flow is used automatically.\n *\n * `mesh login` is for a DEPLOYED platform only \u2014 the tenant platform you\n * deploy to, reach the Hub of, or tunnel into. Package-registry access is a\n * separate, global identity owned by `mesh registry login`; a platform login\n * never writes ~/.npmrc, and the reserved `registry` credential key is refused\n * here so the two can never be confused.\n *\n * Usage:\n * mesh login acme.dev \u2014 authenticate to the acme dev platform\n * mesh login acme.dev --device \u2014 force device code flow\n * mesh login acme.dev --status \u2014 show current auth state (Zitadel session if\n * there is one, otherwise whatever the AWS\n * credential chain resolves \u2014 SSO or key pair)\n * mesh logout acme.dev \u2014 clear cached credentials\n */\n\nimport { Command } from \"commander\";\nimport * as http from \"http\";\nimport * as crypto from \"crypto\";\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport { execFileSync } from \"child_process\";\nimport { logError, logInfo, logSuccess, logWarn } from \"../utils/log.js\";\nimport { MeshCliError } from \"../utils/errors.js\";\nimport {\n assumeRoleCredentials,\n assumeRoleWithWebIdentity,\n describeAssumeFailure,\n probeAwsIdentity,\n renderCredentialProcessProfile,\n tokenIssuer,\n resolveStableMeshBin,\n selectRoleForCaller,\n toCredentialProcessJson,\n upsertManagedAwsConfigSection,\n type AwsIdentity,\n} from \"../utils/aws-auth.js\";\n\nimport { firstPartyDomainFor } from \"../utils/first-party-contexts.js\";\nimport { tailscaleBackendState } from \"./vpn/index.js\";\n// =============================================================================\n// Paths (XDG Base Directory Specification)\n// =============================================================================\n\nconst CONFIG_DIR = path.join(\n process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? \"~\", \".config\"),\n \"mesh\"\n);\nconst CONFIG_FILE = path.join(CONFIG_DIR, \"config.json\");\nconst CREDENTIALS_FILE = path.join(CONFIG_DIR, \"credentials.json\");\n\nconst REDIRECT_PORT = 9876;\nconst REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;\nconst SCOPES =\n \"openid email profile offline_access urn:zitadel:iam:org:project:id:zitadel:aud\";\n\n// =============================================================================\n// Config & Credentials\n// =============================================================================\n\ninterface ContextConfig {\n issuer: string;\n clientId: string;\n /**\n * Default IAM role ARN to assume for this context when no `--role` flag\n * and no `MESH_AWS_ROLE` env var are provided. Set automatically the first\n * time `mesh login ... --export --role <arn>` runs against this context;\n * subsequent `--role` values overwrite it.\n */\n defaultRole?: string;\n /**\n * Optional admin-tier IAM role for this context. When set,\n * `selectRoleForCaller` upgrades to this ARN automatically when the\n * caller's Zitadel JWT carries the admin role (default\n * `mesh.platform:admin`; override via adminClaimRoles).\n */\n adminRole?: string;\n /**\n * Zitadel role names that map a caller to `adminRole`. Defaults to\n * `[\"mesh.platform:admin\"]` when omitted.\n */\n adminClaimRoles?: string[];\n /** Headscale login server (published by the platform). */\n vpn?: string;\n /** Zero-touch pre-auth broker URL (published by the platform). */\n vpnJoinBroker?: string;\n /**\n * Zitadel-gated registry-auth broker URL (published by the platform).\n * With this set, `mesh registry login` needs no AWS credentials at all.\n */\n registryBroker?: string;\n}\n\ninterface CachedCredentials {\n idToken: string;\n accessToken: string;\n refreshToken?: string;\n expiresAt: string; // ISO date\n email?: string;\n sub?: string;\n}\n\ntype ConfigFile = Record<string, ContextConfig>;\ntype CredentialsFile = Record<string, CachedCredentials>;\n\nfunction readConfig(): ConfigFile {\n if (!fs.existsSync(CONFIG_FILE)) return {};\n try {\n return JSON.parse(fs.readFileSync(CONFIG_FILE, \"utf-8\"));\n } catch {\n return {};\n }\n}\n\nexport function writeContextConfig(context: string, config: ContextConfig): void {\n const existing = readConfig();\n existing[context] = config;\n fs.mkdirSync(CONFIG_DIR, { recursive: true });\n fs.writeFileSync(CONFIG_FILE, JSON.stringify(existing, null, 2));\n}\n\n/**\n * Forget a cached context config so the next use runs discovery again.\n *\n * @example\n * clearContextConfig(\"registry\"); // `mesh registry logout` \u2014 re-discovers the registry next time\n */\nexport function clearContextConfig(context: string): void {\n const existing = readConfig();\n if (!(context in existing)) return;\n delete existing[context];\n fs.mkdirSync(CONFIG_DIR, { recursive: true });\n fs.writeFileSync(CONFIG_FILE, JSON.stringify(existing, null, 2));\n}\n\nfunction getContextConfig(context: string): ContextConfig | null {\n const config = readConfig();\n return config[context] ?? null;\n}\n\n/**\n * Parse a mesh context like \"mesh.dev\" or \"acme.uat\" into tenant + env.\n * Returns null if the context doesn't match the expected {tenant}.{env} pattern.\n */\nfunction parseTenantEnv(context: string): { tenant: string; env: string } | null {\n const parts = context.split(\".\");\n if (parts.length !== 2 || !parts[0] || !parts[1]) return null;\n return { tenant: parts[0], env: parts[1] };\n}\n\n/**\n * Attempt auto-discovery of platform config from SSM Parameter Store.\n *\n * Uses ambient AWS credentials to read the zitadel config export at\n * /mesh-platform/{tenant}/{env}/platform/zitadel. This is the preferred\n * discovery path for users who already have AWS SSO access to the platform.\n *\n * Returns null if AWS credentials are unavailable, SSM read fails, or the\n * parameter doesn't contain the required fields.\n */\nasync function discoverConfigFromSsm(context: string): Promise<ContextConfig | null> {\n const parsed = parseTenantEnv(context);\n if (!parsed) return null;\n\n const { tenant, env } = parsed;\n const ssmPath = `/mesh-platform/${tenant}/${env}/platform/zitadel`;\n logInfo(`Attempting SSM discovery from ${ssmPath}...`);\n\n try {\n const { SSMClient, GetParameterCommand } = await import(\"@aws-sdk/client-ssm\");\n const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? \"us-east-2\";\n const ssm = new SSMClient({ region });\n\n const resp = await ssm.send(new GetParameterCommand({ Name: ssmPath }));\n const raw = resp.Parameter?.Value;\n if (!raw) {\n logWarn(`SSM parameter ${ssmPath} has no value`);\n return null;\n }\n\n const data = JSON.parse(raw) as Record<string, unknown>;\n const issuer = data.endpoint;\n const clientId = data.cliClientId;\n\n if (typeof issuer !== \"string\" || !issuer) {\n logWarn(\"SSM zitadel entry missing 'endpoint' field\");\n return null;\n }\n if (typeof clientId !== \"string\" || !clientId) {\n logWarn(\n \"SSM zitadel entry missing 'cliClientId' field.\\n\" +\n \" The platform needs to be deployed with an updated ZitadelPlatformIdentity\\n\" +\n \" that exports cliClientId. Until then, contact your platform admin.\"\n );\n return null;\n }\n\n const config: ContextConfig = { issuer, clientId };\n if (typeof data.vpn === \"string\") config.vpn = data.vpn;\n if (typeof data.vpnJoinBroker === \"string\") config.vpnJoinBroker = data.vpnJoinBroker;\n if (typeof data.registryBroker === \"string\") config.registryBroker = data.registryBroker;\n writeContextConfig(context, config);\n logSuccess(`Discovered platform configuration for ${context} via SSM`);\n return config;\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n if (message.includes(\"ExpiredToken\") || message.includes(\"credentials\")) {\n logWarn(`SSM discovery failed: AWS credentials expired or unavailable.`);\n // Do NOT send someone to `aws sso login` when an anonymous path is about\n // to be tried \u2014 on the install journey that is the exact misdirection\n // this feature removes. Only mention AWS when nothing else can resolve.\n if (firstPartyDomainFor(context) || context.split(\".\").length - 1 >= 2) {\n logInfo(` Falling back to anonymous HTTPS discovery \u2014 no AWS needed.`);\n } else {\n logInfo(` If you have an AWS account: aws sso login --profile <profile>`);\n logInfo(` Otherwise use the platform's full domain, e.g. mesh login dev.<tenant>.meshtech.io`);\n }\n } else if (message.includes(\"ParameterNotFound\")) {\n logWarn(`SSM parameter not found: ${ssmPath}`);\n logInfo(\" This platform context may not be deployed.\");\n } else {\n logWarn(`SSM discovery failed: ${message}`);\n }\n return null;\n }\n}\n\n/**\n * Attempt auto-discovery of platform config from a well-known endpoint.\n * Returns null if discovery fails for any reason (network, timeout, bad data).\n *\n * `contextKey` is the name the resulting config is CACHED under, which is not\n * always the domain probed: a pinned first-party alias (`mesh.dev`) probes\n * `dev.platform.meshtech.io` but must be cached under `mesh.dev`, or the very\n * next command re-runs discovery and the alias buys nothing.\n */\nasync function discoverConfig(\n domain: string,\n contextKey: string = domain,\n opts: { quiet?: boolean } = {},\n): Promise<ContextConfig | null> {\n const url = `https://cli.${domain}/.well-known/mesh.json`;\n if (!opts.quiet) logInfo(`Attempting discovery from ${url}...`);\n\n try {\n const controller = new AbortController();\n const timeout = setTimeout(() => controller.abort(), 5000);\n const resp = await fetch(url, { signal: controller.signal });\n clearTimeout(timeout);\n\n if (!resp.ok) {\n logWarn(`Discovery endpoint returned ${resp.status}`);\n return null;\n }\n\n const data = (await resp.json()) as Record<string, unknown>;\n const issuer = data.issuer;\n const clientId = data.clientId;\n\n if (typeof issuer !== \"string\" || !issuer || typeof clientId !== \"string\" || !clientId) {\n logWarn(\"Discovery endpoint returned invalid config (missing issuer or clientId)\");\n return null;\n }\n\n const config: ContextConfig = { issuer, clientId };\n if (typeof data.vpn === \"string\") config.vpn = data.vpn;\n if (typeof data.vpnJoinBroker === \"string\") config.vpnJoinBroker = data.vpnJoinBroker;\n if (typeof data.registryBroker === \"string\") config.registryBroker = data.registryBroker;\n if (!opts.quiet) {\n writeContextConfig(contextKey, config);\n logSuccess(`Discovered platform configuration for ${contextKey}`);\n }\n return config;\n } catch (err: unknown) {\n const message = err instanceof Error ? err.message : String(err);\n if (message.includes(\"abort\")) {\n logWarn(\"Discovery timed out\");\n } else {\n logWarn(`Discovery failed: ${message}`);\n }\n return null;\n }\n}\n\n/**\n * Anonymous discovery against `https://cli.<domain>/.well-known/mesh.json`.\n *\n * `quiet: true` returns the payload WITHOUT caching it under `contextKey` and\n * without the progress lines \u2014 for a caller that stores the record itself\n * under a name that is not a platform context (the package registry's\n * reserved key). Warnings still print, because a failed discovery is the\n * caller's problem either way.\n *\n * @example\n * const cfg = await discoverConfigFromWellKnown(\"dev.platform.meshtech.io\", \"registry\", { quiet: true });\n */\nexport function discoverConfigFromWellKnown(\n domain: string,\n contextKey: string = domain,\n opts: { quiet?: boolean } = {},\n): Promise<ContextConfig | null> {\n return discoverConfig(domain, contextKey, opts);\n}\n\nfunction readAllCredentials(): CredentialsFile {\n if (!fs.existsSync(CREDENTIALS_FILE)) return {};\n try {\n return JSON.parse(fs.readFileSync(CREDENTIALS_FILE, \"utf-8\"));\n } catch {\n return {};\n }\n}\n\nfunction readCredentials(context: string): CachedCredentials | null {\n return readAllCredentials()[context] ?? null;\n}\n\n/** Monotonic counter mixed into temp-file names alongside the pid, so two\n * writes from the same process in the same tick (e.g. rapid retries) never\n * collide on a temp path. */\nlet atomicWriteCounter = 0;\n\n/**\n * Write `data` to `path` atomically: write to a temp file in the SAME\n * directory as `path` (so the subsequent rename is same-filesystem, hence\n * atomic), then `fs.renameSync` it into place. This guarantees concurrent\n * readers (e.g. other `credential_process` invocations racing a refresh)\n * only ever observe a fully-written file \u2014 never a truncated one from an\n * interleaved `fs.writeFileSync`.\n *\n * On any error, best-effort removes the temp file before rethrowing.\n *\n * Exported for unit testing.\n */\nexport function atomicWriteFileSync(path: string, data: string, mode: number): void {\n const tmpPath = `${path}.${process.pid}.${atomicWriteCounter++}.tmp`;\n try {\n fs.writeFileSync(tmpPath, data, { mode });\n fs.renameSync(tmpPath, path);\n } catch (err) {\n try {\n fs.unlinkSync(tmpPath);\n } catch {\n // best-effort cleanup \u2014 ignore (e.g. write itself never created the file)\n }\n throw err;\n }\n}\n\nfunction writeCredentials(context: string, creds: CachedCredentials): void {\n fs.mkdirSync(CONFIG_DIR, { recursive: true });\n const all = readAllCredentials();\n all[context] = creds;\n atomicWriteFileSync(CREDENTIALS_FILE, JSON.stringify(all, null, 2), 0o600);\n}\n\nfunction clearCredentials(context: string): void {\n const all = readAllCredentials();\n delete all[context];\n fs.mkdirSync(CONFIG_DIR, { recursive: true });\n fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(all, null, 2), {\n mode: 0o600,\n });\n}\n\n// =============================================================================\n// OIDC Helpers\n// =============================================================================\n\ninterface TokenResponse {\n access_token: string;\n id_token: string;\n refresh_token?: string;\n token_type: string;\n expires_in: number;\n}\n\nfunction base64url(buffer: Buffer): string {\n return buffer.toString(\"base64url\");\n}\n\nfunction generateCodeVerifier(): string {\n return base64url(crypto.randomBytes(32));\n}\n\nfunction generateCodeChallenge(verifier: string): string {\n return base64url(crypto.createHash(\"sha256\").update(verifier).digest());\n}\n\nfunction decodeJwtPayload(token: string): Record<string, unknown> {\n const parts = token.split(\".\");\n if (parts.length !== 3) throw new Error(\"Invalid JWT\");\n return JSON.parse(Buffer.from(parts[1]!, \"base64url\").toString());\n}\n\nasync function exchangeCode(\n issuer: string,\n clientId: string,\n code: string,\n codeVerifier: string\n): Promise<TokenResponse> {\n const body = new URLSearchParams({\n grant_type: \"authorization_code\",\n code,\n redirect_uri: REDIRECT_URI,\n client_id: clientId,\n code_verifier: codeVerifier,\n });\n\n const resp = await fetch(`${issuer}/oauth/v2/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: body.toString(),\n });\n\n if (!resp.ok) {\n const text = await resp.text();\n throw new Error(`Token exchange failed (${resp.status}): ${text}`);\n }\n\n return resp.json() as Promise<TokenResponse>;\n}\n\nasync function refreshTokens(\n issuer: string,\n clientId: string,\n refreshToken: string\n): Promise<TokenResponse> {\n const body = new URLSearchParams({\n grant_type: \"refresh_token\",\n refresh_token: refreshToken,\n client_id: clientId,\n });\n\n const resp = await fetch(`${issuer}/oauth/v2/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: body.toString(),\n });\n\n if (!resp.ok) {\n throw new Error(`Token refresh failed (${resp.status})`);\n }\n\n return resp.json() as Promise<TokenResponse>;\n}\n\n// =============================================================================\n// Login Flow\n// =============================================================================\n\nfunction login(context: string, config: ContextConfig): Promise<void> {\n return new Promise((resolve, reject) => {\n const codeVerifier = generateCodeVerifier();\n const codeChallenge = generateCodeChallenge(codeVerifier);\n const state = base64url(crypto.randomBytes(16));\n\n const authUrl = new URL(`${config.issuer}/oauth/v2/authorize`);\n authUrl.searchParams.set(\"client_id\", config.clientId);\n authUrl.searchParams.set(\"redirect_uri\", REDIRECT_URI);\n authUrl.searchParams.set(\"response_type\", \"code\");\n authUrl.searchParams.set(\"scope\", SCOPES);\n authUrl.searchParams.set(\"code_challenge\", codeChallenge);\n authUrl.searchParams.set(\"code_challenge_method\", \"S256\");\n authUrl.searchParams.set(\"state\", state);\n\n // Tracked teardown \u2014 without this `mesh login` hangs after success\n // because (a) the 2-minute timeout below kept the event loop alive,\n // and (b) the browser's keep-alive socket survived `server.close()`\n // (which only stops new connections, not active ones).\n let timeoutId: NodeJS.Timeout | undefined;\n\n const server = http.createServer(async (req, res) => {\n try {\n const url = new URL(req.url ?? \"/\", `http://localhost:${REDIRECT_PORT}`);\n if (url.pathname !== \"/callback\") {\n res.writeHead(404);\n res.end(\"Not found\");\n return;\n }\n\n const error = url.searchParams.get(\"error\");\n if (error) {\n const desc =\n url.searchParams.get(\"error_description\") ?? error;\n const safeDesc = desc.replace(/&/g, \"&amp;\").replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\").replace(/\"/g, \"&quot;\");\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n `<html><body><h2>Login failed</h2><p>${safeDesc}</p></body></html>`\n );\n teardown();\n reject(new Error(desc));\n return;\n }\n\n const returnedState = url.searchParams.get(\"state\");\n if (returnedState !== state) {\n res.writeHead(400);\n res.end(\"State mismatch\");\n teardown();\n reject(new Error(\"State mismatch\"));\n return;\n }\n\n const code = url.searchParams.get(\"code\");\n if (!code) {\n res.writeHead(400);\n res.end(\"No code\");\n teardown();\n reject(new Error(\"No authorization code received\"));\n return;\n }\n\n // Exchange code for tokens\n const tokens = await exchangeCode(\n config.issuer,\n config.clientId,\n code,\n codeVerifier\n );\n const idPayload = decodeJwtPayload(tokens.id_token);\n const email =\n (idPayload.email as string) ??\n (idPayload.preferred_username as string) ??\n \"unknown\";\n const sub = idPayload.sub as string;\n\n // Parse tenants from access token\n // (the Zitadel Action enriches the access token with urn:mesh:tenants)\n let tenants: string[] = [];\n try {\n const accessPayload = decodeJwtPayload(tokens.access_token);\n tenants =\n (accessPayload[\"urn:mesh:tenants\"] as string[]) ?? [];\n } catch {\n // Access token may be opaque (non-JWT) in some configs\n }\n\n // Cache credentials\n writeCredentials(context, {\n idToken: tokens.id_token,\n accessToken: tokens.access_token,\n refreshToken: tokens.refresh_token,\n expiresAt: new Date(\n Date.now() + tokens.expires_in * 1000\n ).toISOString(),\n email,\n sub,\n });\n\n res.writeHead(200, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n `<html><body><h2>&#x2705; Logged in to ${context}!</h2><p>You can close this tab.</p></body></html>`\n );\n\n logSuccess(`Logged in as ${email} (${context})`);\n if (tenants.length > 0) {\n logInfo(`Deployable tenants: ${tenants.join(\", \")}`);\n }\n hintVpnIfDisconnected(context);\n\n teardown();\n resolve();\n } catch (err) {\n teardown();\n reject(err);\n }\n });\n\n // Defined after `server` so the closure captures it; covers\n // success, error, and timeout paths.\n const teardown = () => {\n if (timeoutId) clearTimeout(timeoutId);\n server.closeAllConnections?.();\n server.close();\n };\n\n server.listen(REDIRECT_PORT, () => {\n logInfo(`Opening browser for authentication (${context})...`);\n\n const url = authUrl.toString();\n try {\n if (process.platform === \"darwin\") {\n execFileSync(\"open\", [url], { stdio: \"ignore\" });\n } else if (process.platform === \"linux\") {\n execFileSync(\"xdg-open\", [url], { stdio: \"ignore\" });\n } else {\n logInfo(`Open this URL in your browser:\\n${url}`);\n }\n } catch {\n logInfo(`Open this URL in your browser:\\n${url}`);\n }\n });\n\n // Timeout after 2 minutes. We keep a handle so success / error\n // paths can clear it via `teardown()` \u2014 otherwise the timer keeps\n // the Node event loop alive for 2 minutes after a successful\n // login and the CLI appears to hang.\n timeoutId = setTimeout(() => {\n teardown();\n reject(new Error(\"Login timed out (2 minutes)\"));\n }, 120_000);\n });\n}\n\n\n// =============================================================================\n// Device Code Flow (RFC 8628) \u2014 works in containers, SSH, headless envs\n// =============================================================================\n\ninterface DeviceAuthResponse {\n device_code: string;\n user_code: string;\n verification_uri: string;\n verification_uri_complete?: string;\n expires_in: number;\n interval: number;\n}\n\n/** Number of fresh device codes to issue before giving up. A code's TTL is\n * short (a couple of minutes), so if the user is slow we quietly issue another\n * rather than failing the whole login (we hit this repeatedly in practice). */\nconst MAX_DEVICE_CODES = 3;\n\n/**\n * Run device-code login, re-issuing a fresh code (up to `maxCodes`) if the user\n * doesn't finish before a code expires. `attempt` performs ONE code's\n * request+poll and resolves `\"success\"` or `\"expired\"`; it rejects only on a\n * hard error (denied / network). Extracted + injectable so the reissue loop is\n * unit-testable without real network calls.\n */\nexport async function deviceLoginWithReissue(\n attempt: (codeNumber: number) => Promise<\"success\" | \"expired\">,\n maxCodes: number = MAX_DEVICE_CODES,\n): Promise<void> {\n for (let n = 1; n <= maxCodes; n++) {\n if (n > 1) logWarn(`That code expired \u2014 issuing a fresh one (${n}/${maxCodes})\u2026`);\n if ((await attempt(n)) === \"success\") return;\n }\n throw new Error(\n `Device login not completed after ${maxCodes} codes. Re-run the command when you're ready to authorize.`,\n );\n}\n\nasync function deviceCodeLogin(context: string, config: ContextConfig): Promise<void> {\n await deviceLoginWithReissue(() => attemptDeviceCode(context, config));\n}\n\nasync function attemptDeviceCode(\n context: string,\n config: ContextConfig,\n): Promise<\"success\" | \"expired\"> {\n // Step 1: Request device authorization\n const resp = await fetch(`${config.issuer}/oauth/v2/device_authorization`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n client_id: config.clientId,\n scope: SCOPES,\n }).toString(),\n });\n\n if (!resp.ok) {\n const text = await resp.text();\n throw new Error(`Device authorization request failed (${resp.status}): ${text}`);\n }\n\n const deviceAuth = await resp.json() as DeviceAuthResponse;\n const openUrl = deviceAuth.verification_uri_complete ?? deviceAuth.verification_uri;\n\n // Step 2: Display instructions\n console.log();\n logInfo(`Open this URL in your browser:\\n`);\n logInfo(` ${openUrl}\\n`);\n logInfo(`Code: ${deviceAuth.user_code}`);\n console.log();\n logInfo(\"Waiting for authorization...\");\n\n // Try to open browser (works on host, harmless failure in container)\n try {\n if (process.platform === \"darwin\") {\n execFileSync(\"open\", [openUrl], { stdio: \"ignore\" });\n } else if (process.platform === \"linux\") {\n execFileSync(\"xdg-open\", [openUrl], { stdio: \"ignore\" });\n }\n } catch { /* ignore \u2014 user can open manually */ }\n\n // Step 3: Poll for token\n const deadline = Date.now() + deviceAuth.expires_in * 1000;\n let interval = deviceAuth.interval * 1000;\n\n while (Date.now() < deadline) {\n await new Promise((r) => setTimeout(r, interval));\n\n const tokenResp = await fetch(`${config.issuer}/oauth/v2/token`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n client_id: config.clientId,\n grant_type: \"urn:ietf:params:oauth:grant-type:device_code\",\n device_code: deviceAuth.device_code,\n }).toString(),\n });\n\n if (tokenResp.ok) {\n const tokens = await tokenResp.json() as TokenResponse;\n const idPayload = decodeJwtPayload(tokens.id_token);\n const email = (idPayload.email as string) ?? (idPayload.preferred_username as string) ?? \"unknown\";\n const sub = idPayload.sub as string;\n\n let tenants: string[] = [];\n try {\n const accessPayload = decodeJwtPayload(tokens.access_token);\n tenants = (accessPayload[\"urn:mesh:tenants\"] as string[]) ?? [];\n } catch { /* opaque access token */ }\n\n writeCredentials(context, {\n idToken: tokens.id_token,\n accessToken: tokens.access_token,\n refreshToken: tokens.refresh_token,\n expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),\n email,\n sub,\n });\n\n logSuccess(`Logged in as ${email} (${context})`);\n if (tenants.length > 0) {\n logInfo(`Deployable tenants: ${tenants.join(\", \")}`);\n }\n hintVpnIfDisconnected(context);\n return \"success\";\n }\n\n const error = await tokenResp.json() as { error: string; error_description?: string };\n switch (error.error) {\n case \"authorization_pending\":\n continue;\n case \"slow_down\":\n interval += 1000;\n continue;\n case \"expired_token\":\n return \"expired\"; // caller re-issues a fresh code\n case \"access_denied\":\n throw new Error(\"Authorization denied by user.\");\n default:\n throw new Error(`Token exchange failed: ${error.error} \u2014 ${error.error_description ?? \"\"}`);\n }\n }\n\n return \"expired\"; // deadline reached without authorization \u2014 re-issue\n}\n\n/**\n * Detect an environment where the PKCE browser-callback flow won't work \u2014 a\n * container, a remote/SSH session, or any non-interactive shell (piped output,\n * headless, agent/CI-run). In all of these the callback flow has no browser to\n * open and no interactive terminal, so it can only hang until the 2-minute\n * timeout. Device-code flow (print a URL + code to complete elsewhere) is the\n * correct fallback, so callers use it whenever this returns true.\n *\n * Exported for tests.\n */\nexport function isRemoteEnvironment(): boolean {\n // Docker / devcontainer\n if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) return true;\n if (fs.existsSync(\"/.dockerenv\")) return true;\n // SSH session\n if (process.env.SSH_CLIENT || process.env.SSH_TTY) return true;\n // No interactive terminal on stdin/stdout \u2014 `isTTY` is `undefined` (falsy)\n // when the stream is not a TTY. This is the case that bit us: an agent- or\n // script-driven shell over Tailscale has none of the env vars above set, so\n // the old check fell through to the browser flow and timed out. Device flow\n // still works when a browser IS available (just copy the URL+code), so\n // preferring it here is strictly safer than a guaranteed timeout.\n if (!process.stdout.isTTY || !process.stdin.isTTY) return true;\n return false;\n}\n\n/**\n * After a successful login, hint about VPN if Tailscale is not connected.\n * Non-blocking \u2014 never fails or delays the login flow.\n */\nfunction hintVpnIfDisconnected(context: string): void {\n // The package registry is not a platform: no VPN sits behind it.\n if (context === RESERVED_REGISTRY_CONTEXT) return;\n try {\n const state = tailscaleBackendState();\n if (state !== \"Running\") {\n console.log(\"\");\n logInfo(`VPN not connected. To access dev services, run:`);\n logInfo(` mesh vpn connect ${context}`);\n }\n } catch {\n // Tailscale not installed or not queryable \u2014 skip hint\n }\n}\n\n// =============================================================================\n// Status\n// =============================================================================\n\n/**\n * What `--status` reports when there is no usable Zitadel session.\n *\n * Zitadel is one way to reach a platform's AWS account, not the only one. A\n * profile authenticated with SSO \u2014 or with a plain access-key/secret pair \u2014\n * is just as good, and everything downstream already accepts it: aws-auth's\n * path 1 (ambient env creds) and path 3 (AssumeRole via the current session)\n * never touch the OIDC cache. Reporting \"not logged in\" purely because that\n * cache is empty sent people off to run a device flow they did not need, and\n * failed the check for anyone deliberately using static credentials.\n *\n * So: fall through to the AWS credential chain and report what it finds.\n */\n/** One `--status` output line plus which logger it goes through. */\nexport interface StatusLine {\n level: \"success\" | \"info\" | \"warn\";\n text: string;\n}\n\n/**\n * The account id embedded in an IAM role ARN\n * (`arn:aws:iam::123456789012:role/...`), or undefined when there is no ARN\n * to read one from.\n */\nexport function accountIdFromRoleArn(\n roleArn: string | undefined\n): string | undefined {\n return roleArn?.match(/^arn:aws:iam::(\\d{12}):/)?.[1];\n}\n\n/**\n * Decide everything `--status` says (and its exit code) when there is no\n * usable Zitadel session \u2014 pure so the wording and the exit decision are\n * pinned by tests.\n *\n * The probe only proves \"some AWS identity works on this machine\"; it says\n * nothing about whether that identity belongs to `context`. So the claim is\n * scoped to the credentials \u2014 unless the context config carries a role ARN,\n * whose account id makes the context check free: verify when we can, say so\n * when we can't.\n */\nexport function renderAwsFallbackStatus(\n context: string,\n reason: string,\n identity: AwsIdentity | null,\n expectedAccountId?: string\n): { lines: StatusLine[]; exitCode: 0 | 1 } {\n if (!identity) {\n return {\n lines: [\n {\n level: \"warn\",\n text: `${reason}, and no working AWS credentials were found either.`,\n },\n { level: \"info\", text: \"Authenticate with whichever you use:\" },\n { level: \"info\", text: ` mesh login ${context} (Zitadel SSO)` },\n {\n level: \"info\",\n text: \" aws sso login --profile \u2026 (AWS SSO profile)\",\n },\n {\n level: \"info\",\n text: \" export AWS_PROFILE=\u2026 (profile with a key/secret pair)\",\n },\n ],\n exitCode: 1,\n };\n }\n\n const lines: StatusLine[] = [\n {\n level: \"success\",\n text: `AWS credentials are working (${identity.source})`,\n },\n { level: \"info\", text: `Identity: ${identity.arn}` },\n { level: \"info\", text: `Account: ${identity.accountId}` },\n ];\n\n if (expectedAccountId && identity.accountId !== expectedAccountId) {\n lines.push({\n level: \"warn\",\n text:\n `This account does not match ${context}'s configured role account ` +\n `(${expectedAccountId}) \u2014 commands against ${context} will likely ` +\n `fail with AccessDenied. Check AWS_PROFILE / your exported credentials.`,\n });\n } else if (expectedAccountId) {\n lines.push({\n level: \"info\",\n text: `Account matches ${context}'s configured role account.`,\n });\n } else {\n lines.push({\n level: \"info\",\n text:\n `This is a working AWS identity, not a verified connection to ` +\n `${context} \u2014 nothing here checked that this account is ${context}'s.`,\n });\n }\n\n lines.push({\n level: \"info\",\n text:\n `${reason} \u2014 that only matters for the commands that need a *user* ` +\n `identity (Hub SSO, \\`mesh dev\\` test users, zero-touch VPN join). ` +\n `Deploys, registry auth and secrets work off these credentials.`,\n });\n\n return { lines, exitCode: 0 };\n}\n\nasync function showAwsCredentialStatus(\n context: string,\n reason: string,\n config: ContextConfig\n): Promise<void> {\n const identity = await probeAwsIdentity();\n const { lines, exitCode } = renderAwsFallbackStatus(\n context,\n reason,\n identity,\n accountIdFromRoleArn(config.defaultRole ?? config.adminRole)\n );\n\n for (const line of lines) {\n if (line.level === \"success\") logSuccess(line.text);\n else if (line.level === \"warn\") logWarn(line.text);\n else logInfo(line.text);\n }\n if (exitCode !== 0) process.exit(exitCode);\n}\n\nasync function showStatus(\n context: string,\n config: ContextConfig\n): Promise<void> {\n const creds = readCredentials(context);\n if (!creds) {\n await showAwsCredentialStatus(context, \"No cached Zitadel session\", config);\n return;\n }\n\n const expired = new Date(creds.expiresAt) < new Date();\n\n if (expired && creds.refreshToken) {\n logInfo(\"Token expired, attempting refresh...\");\n try {\n const tokens = await refreshTokens(\n config.issuer,\n config.clientId,\n creds.refreshToken\n );\n const idPayload = decodeJwtPayload(tokens.id_token);\n writeCredentials(context, {\n ...creds,\n idToken: tokens.id_token,\n accessToken: tokens.access_token,\n refreshToken: tokens.refresh_token ?? creds.refreshToken,\n expiresAt: new Date(\n Date.now() + tokens.expires_in * 1000\n ).toISOString(),\n email: (idPayload.email as string) ?? creds.email,\n });\n logSuccess(`Token refreshed for ${creds.email ?? \"unknown\"}`);\n return;\n } catch {\n await showAwsCredentialStatus(\n context,\n \"Zitadel token expired and refresh failed\",\n config\n );\n return;\n }\n }\n\n if (expired) {\n await showAwsCredentialStatus(\n context,\n `Zitadel token expired at ${creds.expiresAt}`,\n config\n );\n return;\n }\n\n // Parse tenants from access token\n let tenants: string[] = [];\n try {\n const accessPayload = decodeJwtPayload(creds.accessToken);\n tenants = (accessPayload[\"urn:mesh:tenants\"] as string[]) ?? [];\n } catch {\n // Access token may be opaque\n }\n\n logSuccess(`Logged in as ${creds.email ?? \"unknown\"} (${context})`);\n logInfo(`Subject: ${creds.sub}`);\n logInfo(`Expires: ${creds.expiresAt}`);\n if (tenants.length > 0) {\n logInfo(`Deployable tenants: ${tenants.join(\", \")}`);\n }\n}\n// =============================================================================\n// Commands\n// =============================================================================\n\nexport function registerLoginCommand(program: Command): void {\n program\n .command(\"login\")\n .description(\"Authenticate with Zitadel (OIDC PKCE or Device Code)\")\n .argument(\"<context>\", 'Platform context (e.g., \"mesh.dev\")')\n .option(\n \"--status\",\n \"Show current authentication status \u2014 the Zitadel session if one is cached, otherwise the AWS identity the credential chain resolves (SSO profile or key pair). Exits non-zero only when neither works.\"\n )\n .option(\"--device\", \"Force device code flow (no callback server needed)\")\n .option(\n \"--export\",\n \"After login, print a self-refreshing AWS credential_process profile as shell `export` statements (use with `eval`). The resulting shell auto-refreshes credentials via `mesh login`. Requires --role or MESH_AWS_ROLE.\"\n )\n .option(\n \"--static\",\n \"With --export: print raw temporary AWS credentials (a frozen ~1h triple) instead of the default self-refreshing credential_process profile\"\n )\n .option(\n \"--credential-process\",\n \"Print AWS credential_process JSON (used by mesh dev's temp profile); refreshes the Zitadel token as needed. Requires --role or a cached defaultRole.\"\n )\n .option(\n \"--role <arn>\",\n \"IAM role ARN to assume via web identity (used with --export). Saved as the default for this context so future --export runs can omit it. Resolution order: --role > MESH_AWS_ROLE > cached defaultRole.\"\n )\n .option(\n \"--region <region>\",\n \"AWS region to include in the exported AWS_REGION. Defaults to AWS_REGION env or us-east-2.\"\n )\n .option(\n \"--no-registry\",\n \"Deprecated and ignored \u2014 mesh login no longer touches the package registry (that is `mesh registry login`)\"\n )\n .action(async (context: string, opts: {\n status?: boolean;\n device?: boolean;\n export?: boolean;\n static?: boolean;\n credentialProcess?: boolean;\n role?: string;\n region?: string;\n registry?: boolean;\n }) => {\n refuseReservedRegistryContext(context, \"login\");\n\n let config = getContextConfig(context);\n if (!config) {\n // One chain, shared with `ensureLogin` \u2014 see discoverConfigGuarded.\n // Deliberately NOT re-implemented here: this call site and that one\n // drifted apart once already, and the difference was whether a clean\n // laptop could resolve a short context at all.\n config = await discoverConfigGuarded(context);\n\n if (!config) {\n logError(renderNoConfigHelp(context));\n process.exit(1);\n }\n }\n\n if (opts.status) {\n await showStatus(context, config);\n return;\n }\n\n if (opts.export) {\n await exportAwsCredentials(context, config, opts);\n return;\n }\n\n if (opts.credentialProcess) {\n await credentialProcessAwsCredentials(context, config, opts);\n return;\n }\n\n if (opts.registry === false) {\n logWarn(NO_REGISTRY_DEPRECATED);\n }\n\n try {\n await runLoginFlow(context, config, opts);\n } catch (err) {\n logError(`Login failed: ${(err as Error).message}`);\n process.exit(1);\n }\n\n // Platform sign-in and package-registry access are two identities. A\n // platform login never writes ~/.npmrc; it only points at the command\n // that does, and only when this machine has no registry access yet.\n await hintRegistryIfMissing();\n });\n\n program\n .command(\"logout\")\n .description(\"Clear cached Zitadel credentials\")\n .argument(\"<context>\", 'Platform context (e.g., \"mesh.dev\")')\n .action((context: string) => {\n refuseReservedRegistryContext(context, \"logout\");\n clearCredentials(context);\n logSuccess(`Logged out of ${context}. Credentials cleared.`);\n });\n}\n\n/**\n * The credential key the package registry's own session lives under. Not a\n * platform: `mesh login` / `mesh logout` refuse it, and the post-login hints\n * (VPN, tenants) never apply to it. `utils/registry-identity.ts` owns the\n * registry side of this contract and re-exports the key.\n */\nexport const RESERVED_REGISTRY_CONTEXT = \"registry\";\n\n/** Printed once when `--no-registry` is passed: the flag no longer changes anything. */\nexport const NO_REGISTRY_DEPRECATED =\n \"--no-registry is deprecated and ignored: mesh login no longer touches the registry.\";\n\n/** Printed after a platform login on a machine with no package-registry access. */\nexport const REGISTRY_SEPARATE_HINT =\n \"Package registry access is separate from platform sign-in \u2014 run: mesh registry login\";\n\n/**\n * The reserved `registry` credential key is not a platform. `mesh login\n * registry` would try to discover a platform named \"registry\" and cache a\n * platform session on top of the package registry's; refuse it and name the\n * command that owns that key.\n */\nfunction refuseReservedRegistryContext(context: string, verb: \"login\" | \"logout\"): void {\n if (context !== RESERVED_REGISTRY_CONTEXT) return;\n throw new MeshCliError(\n `\"registry\" is the package registry's own session, not a platform context.`,\n { remediation: { command: `mesh registry ${verb}` } },\n );\n}\n\n/**\n * After a successful platform login: one line pointing at `mesh registry\n * login` when the machine has neither a registry session nor a working\n * `~/.npmrc` token. Silent when either exists, and silent when the registry\n * cannot be reached (a network fault is not a missing grant).\n */\nasync function hintRegistryIfMissing(): Promise<void> {\n try {\n const { readRegistrySession } = await import(\"../utils/registry-identity.js\");\n if (readRegistrySession()) return;\n const { probeRegistryToken } = await import(\"../utils/auth-preflight.js\");\n const probe = await probeRegistryToken();\n if (probe.state === \"missing\" || probe.state === \"expired\") {\n logInfo(REGISTRY_SEPARATE_HINT);\n }\n } catch {\n // A hint must never fail a login that already succeeded.\n }\n}\n\n/**\n * The \"no configuration found\" explanation for a context that neither SSM,\n * the pinned first-party table, nor the well-known endpoint could resolve \u2014\n * every path tried, then the `config.json` snippet to add by hand.\n *\n * Shared by the `login` command and the `mesh init` wizard's platform step so\n * a discovery failure reads the same wherever it happens.\n *\n * @example\n * logError(renderNoConfigHelp(\"acme.dev\"));\n */\nexport function renderNoConfigHelp(context: string): string {\n const pinned = firstPartyDomainFor(context);\n return (\n `No configuration found for \"${context}\".\\n\\n` +\n `Tried:\\n` +\n ` 1. SSM: /mesh-platform/${context.split(\".\")[0]}/${context.split(\".\")[1]}/platform/zitadel\\n` +\n ` (requires AWS SSO login + read access)\\n` +\n (pinned\n ? ` 2. HTTPS: https://cli.${pinned}/.well-known/mesh.json (pinned first-party domain)\\n\\n`\n : ` 2. HTTPS: https://cli.${context}/.well-known/mesh.json\\n` +\n ` (only attempted for a full domain, or a known first-party context)\\n\\n`) +\n `To fix:\\n` +\n ` - Use the platform's full domain: mesh login <env>.<tenant>.meshtech.io\\n` +\n ` - Or, if you have AWS access, ensure SSO is active: aws sso login --profile <profile>\\n` +\n ` - Or add config manually to ${CONFIG_FILE}:\\n\\n` +\n ` {\\n` +\n ` \"${context}\": {\\n` +\n ` \"issuer\": \"https://identity.<env>.<your-platform-domain>\",\\n` +\n ` \"clientId\": \"<cli-oidc-client-id>\"\\n` +\n ` }\\n` +\n ` }\\n`\n );\n}\n\n// =============================================================================\n// Exports (for use by other mesh commands)\n// =============================================================================\n\n/**\n * Get a valid access token for the given context, refreshing if needed.\n *\n * Returns the id token (a JWT with `sub` claim) suitable for\n * Authorization: Bearer headers. Returns null if no credentials are\n * cached or refresh fails.\n */\n/**\n * True while a cached token is still valid, treating anything within `marginMs`\n * of expiry as already stale. The margin lets pollers (e.g. the dev-user\n * token-server) re-mint BEFORE expiry instead of handing out a token that dies\n * before the caller's next poll.\n */\nexport function tokenStillValid(expiresAt: string, marginMs = 0, now = Date.now()): boolean {\n return new Date(expiresAt).getTime() - marginMs > now;\n}\n\nasync function getValidToken(\n context: string,\n opts: { marginMs?: number } = {},\n): Promise<string | null> {\n const config = getContextConfig(context);\n if (!config) return null;\n\n const creds = readCredentials(context);\n if (!creds) return null;\n\n if (tokenStillValid(creds.expiresAt, opts.marginMs ?? 0)) {\n return creds.idToken;\n }\n\n // Attempt refresh\n if (!creds.refreshToken) return null;\n try {\n const tokens = await refreshTokens(config.issuer, config.clientId, creds.refreshToken);\n const idPayload = decodeJwtPayload(tokens.id_token);\n writeCredentials(context, {\n ...creds,\n idToken: tokens.id_token,\n accessToken: tokens.access_token,\n refreshToken: tokens.refresh_token ?? creds.refreshToken,\n expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),\n email: (idPayload.email as string) ?? creds.email,\n });\n return tokens.id_token;\n } catch {\n return null;\n }\n}\n\n// =============================================================================\n// Credential freshness probe (shared seam: mesh dev doctor + preflight)\n// =============================================================================\n\n/**\n * Structured diagnosis of the deployer-credential path for a context/role,\n * without mutating anything. The shared seam behind `mesh dev doctor`'s creds\n * check and the launch preflight (MESH-2015), reusing the same\n * `getValidToken` + `assumeRoleCredentials` path as `--credential-process`\n * (MESH-1965).\n */\nexport type CredProbe =\n | { state: \"ok\"; ttlSeconds: number; expiresAt: string; email?: string }\n | { state: \"no-session\" }\n | { state: \"expired-session\" }\n | { state: \"assume-denied\"; detail: string }\n | { state: \"stale-env-override\" };\n\n/**\n * Probe deployer creds for `context`/`roleArn`. Order matters:\n * 1. No cached Zitadel session \u2192 `no-session`.\n * 2. Cached session AND ambient `AWS_*` env keys present \u2192 `stale-env-override`\n * (the frozen triple would override the self-refreshing profile at\n * runtime \u2014 the exact `mesh dev` trap). Guarded on a cached session so\n * legitimate CI env-key credentials, which have no `mesh login` session,\n * are not misreported.\n * 3. Session can't be refreshed \u2192 `expired-session`.\n * 4. AssumeRoleWithWebIdentity fails \u2192 `assume-denied` (valid identity, wrong\n * IAM/role/policy).\n * 5. Otherwise \u2192 `ok` with the STS TTL.\n */\nexport async function probeCredentials(\n context: string,\n roleArn: string,\n): Promise<CredProbe> {\n const cached = readCredentials(context);\n if (!cached) return { state: \"no-session\" };\n\n if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SESSION_TOKEN) {\n return { state: \"stale-env-override\" };\n }\n\n const token = await getValidToken(context);\n if (!token) return { state: \"expired-session\" };\n\n const sessionName = (cached.email ?? \"mesh-cli-doctor\")\n .replace(/[^a-zA-Z0-9=,.@-]/g, \"_\")\n .slice(0, 64);\n const sts = await assumeRoleCredentials(roleArn, token, sessionName);\n if (!sts) {\n return {\n state: \"assume-denied\",\n detail: describeAssumeFailure({ roleArn, context, issuer: tokenIssuer(token) }, \"inline\"),\n };\n }\n const ttlSeconds = Math.max(\n 0,\n Math.round((Date.parse(sts.Expiration) - Date.now()) / 1000),\n );\n return { state: \"ok\", ttlSeconds, expiresAt: sts.Expiration, email: cached.email };\n}\n\n// =============================================================================\n// AWS credential export (--export flag)\n// =============================================================================\n\n/**\n * Single-quote a value for shell-safe inclusion in an `export` statement.\n * Single-quoted strings in POSIX shells don't expand `$` or other metachars;\n * embedded single-quotes are escaped via the `'\\''` trick.\n */\nfunction shellSingleQuote(value: string): string {\n return `'${value.replace(/'/g, \"'\\\\''\")}'`;\n}\n\n/**\n * Render the `--static` output: raw temporary AWS credentials as shell\n * `export` statements (the old frozen ~1h triple). Pure \u2014 no I/O. Includes a\n * trailing newline. Exported for unit testing.\n */\nexport function renderExportStaticLines(args: {\n accessKey: string;\n secretKey: string;\n sessionToken: string;\n region: string;\n}): string {\n return (\n `export AWS_ACCESS_KEY_ID=${shellSingleQuote(args.accessKey)}\\n` +\n `export AWS_SECRET_ACCESS_KEY=${shellSingleQuote(args.secretKey)}\\n` +\n `export AWS_SESSION_TOKEN=${shellSingleQuote(args.sessionToken)}\\n` +\n `export AWS_REGION=${shellSingleQuote(args.region)}\\n`\n );\n}\n\n/**\n * Render the default `--export` output: point the shell at a self-refreshing\n * `credential_process` profile. The leading `unset` clears any stale STS\n * triple already in the shell so it can't shadow the profile. Pure \u2014 no I/O.\n * Includes a trailing newline. Exported for unit testing.\n *\n * Deliberately does NOT export `AWS_CONFIG_FILE`: the profile lives in the\n * user's REAL AWS config file (managed marker section \u2014 see\n * `upsertManagedAwsConfigSection`), so their other profiles (e.g.\n * `AWS_PROFILE=mesh-dev` InfraAdmin) keep working in the same shell.\n * Redirecting AWS_CONFIG_FILE to a mesh-private file used to clobber\n * ~/.aws/config for every subsequent AWS command.\n */\nexport function renderExportProfileLines(args: {\n profileName: string;\n region: string;\n}): string {\n return (\n `unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN\\n` +\n `export AWS_PROFILE=${shellSingleQuote(args.profileName)}\\n` +\n `export AWS_REGION=${shellSingleQuote(args.region)}\\n`\n );\n}\n\n/**\n * The AWS config file the managed profile section is written into: the\n * user's `$AWS_CONFIG_FILE` when THEY have set one, else `~/.aws/config` \u2014\n * the same file `aws configure sso` manages. Exported for unit testing.\n */\nexport function resolveUserAwsConfigPath(\n env: Record<string, string | undefined> = process.env,\n): string {\n return env.AWS_CONFIG_FILE ?? path.join(env.HOME ?? \"~\", \".aws\", \"config\");\n}\n\n/** True if `dir` can be created (if needed) and written. Used to detect an\n * unwritable `~/.aws` (e.g. a sandbox with no `~/.aws` grant) without throwing. */\nfunction canWriteDir(dir: string): boolean {\n try {\n fs.mkdirSync(dir, { recursive: true });\n fs.accessSync(dir, fs.constants.W_OK);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Where to write the managed AWS profile, with a safehouse fallback. Normally\n * the user's real `~/.aws/config` (augmented in place, so their other tools see\n * the profile \u2014 hence `redirected: false`, we don't touch their `AWS_CONFIG_FILE`).\n * But when `~/.aws` is not writable (a sandbox without a `~/.aws` grant), fall\n * back to a mesh-managed file under `~/.config/mesh` we CAN write, and set\n * `redirected: true` so the caller also exports `AWS_CONFIG_FILE` (otherwise the\n * AWS SDK, looking only at `~/.aws/config`, would never find the profile). An\n * explicit `AWS_CONFIG_FILE` always wins and is never redirected.\n */\nexport function resolveAwsConfigTarget(\n env: Record<string, string | undefined> = process.env,\n): { configPath: string; redirected: boolean } {\n if (env.AWS_CONFIG_FILE) return { configPath: env.AWS_CONFIG_FILE, redirected: false };\n const real = path.join(env.HOME ?? \"~\", \".aws\", \"config\");\n if (canWriteDir(path.dirname(real))) return { configPath: real, redirected: false };\n return { configPath: path.join(CONFIG_DIR, \"aws-config\"), redirected: true };\n}\n\n/**\n * Resolve which IAM role ARN to use, honoring the same precedence in both\n * `--export` and `--credential-process`: explicit `--role` wins, then\n * `MESH_AWS_ROLE`, then the context's cached `defaultRole`. Pure aside from\n * reading `process.env.MESH_AWS_ROLE` \u2014 returns an explanatory error instead\n * of throwing so callers can route it to stderr and exit non-zero.\n *\n * Exported for unit testing.\n */\nexport function resolveRoleOrExplain(\n opts: { role?: string },\n config: { defaultRole?: string },\n): { role: string } | { error: string } {\n const role = opts.role ?? process.env.MESH_AWS_ROLE ?? config.defaultRole;\n if (!role) {\n return {\n error:\n \"No IAM role available: pass --role <arn>, set MESH_AWS_ROLE, or run \" +\n \"`mesh login <context> --export --role <arn>` once to cache a default role for this context.\",\n };\n }\n return { role };\n}\n\n/**\n * Ensure a valid Zitadel ID token is cached for the given context \u2014 refreshing\n * or running the full login flow as needed. Returns null on failure.\n */\n/**\n * Run the sign-in itself \u2014 browser PKCE, or the device-code flow when asked\n * for or when there is no browser to open (`isRemoteEnvironment`) \u2014 and cache\n * the resulting session under `context`. Rejects when the user denies, the\n * flow times out, or the issuer refuses.\n *\n * The one sign-in routine every caller shares: the `login` command, on-demand\n * login from other commands, and the package registry's own session.\n *\n * @example\n * await runLoginFlow(\"acme.dev\", config, { device: false });\n */\nexport async function runLoginFlow(\n context: string,\n config: ContextConfig,\n opts: { device?: boolean } = {},\n): Promise<void> {\n const useDevice = opts.device || isRemoteEnvironment();\n if (useDevice) {\n logInfo(\"Using device code flow\");\n await deviceCodeLogin(context, config);\n } else {\n await login(context, config);\n }\n}\n\nasync function ensureValidToken(\n context: string,\n config: ContextConfig,\n opts: { device?: boolean },\n): Promise<string | null> {\n let token = await getValidToken(context);\n if (token) return token;\n\n logInfo(`No valid Zitadel session for ${context} \u2014 running login flow`);\n try {\n await runLoginFlow(context, config, opts);\n } catch (err) {\n logError(`Login failed: ${(err as Error).message}`);\n return null;\n }\n return await getValidToken(context);\n}\n\n/**\n * Ensure a valid login session for `context`, running the login flow (device\n * code in remote/non-TTY, browser callback otherwise) if none is cached. Returns\n * the cached credentials, or null if no config is available to log in with.\n *\n * For use by other commands (e.g. `secrets set`, the AWS-cred resolver) so they\n * can trigger login on demand instead of just failing with guidance.\n */\nexport async function ensureLogin(\n context: string,\n opts: { device?: boolean; interactive?: boolean } = {},\n): Promise<CachedCredentials | null> {\n const existing = readCredentials(context);\n if (existing && new Date(existing.expiresAt) > new Date()) return existing;\n\n // interactive: false \u2192 cached-session-or-nothing. Callers with their own\n // fallback chain (e.g. the AWS-cred resolver's SSO path) use this so an\n // expired cache doesn't stall headless runs in a device-code poll.\n if (opts.interactive === false) return null;\n\n // Same guarded discovery chain as the `login` command: SSM first, and the\n // well-known HTTPS endpoint only for domain-looking contexts (\u22652 dots).\n // Hitting `https://cli.<short-name>/.well-known/mesh.json` for an uncached\n // context like \"mesh.dev\" would let whoever registers that domain seed our\n // cached issuer/clientId (login phishing).\n const config = getContextConfig(context) ?? (await discoverConfigGuarded(context));\n if (!config) {\n logWarn(`No login config for \"${context}\". Run: mesh login ${context}`);\n return null;\n }\n const token = await ensureValidToken(context, config, opts);\n if (!token) return null;\n return readCredentials(context);\n}\n\n/**\n * The one discovery chain, used by both the `login` command and `ensureLogin`:\n *\n * 1. SSM, when the context parses as `{tenant}.{env}` \u2014 the primary path for\n * internal users who already have AWS SSO access.\n * 2. The anonymous HTTPS well-known endpoint at the PINNED domain for a known\n * first-party short context (`mesh.dev` \u2192 `dev.platform.meshtech.io`). This\n * is what makes `mesh login mesh.dev` work on a laptop with no AWS at all\n * \u2014 the P0 case. The domain comes from a compiled-in table, never from the\n * context string, so it is not a phishing surface.\n * 3. The anonymous HTTPS endpoint at the context itself, only when it looks\n * like a real domain (\u22652 dots).\n *\n * Never hits `https://cli.<short-name>/\u2026` for an UNKNOWN short context \u2014 that\n * guard is the anti-phishing property and step 2 is deliberately a pinned\n * allow-list rather than a relaxation of it.\n */\nasync function discoverConfigGuarded(context: string): Promise<ContextConfig | null> {\n let config: ContextConfig | null = null;\n if (parseTenantEnv(context)) {\n config = await discoverConfigFromSsm(context);\n }\n if (!config) {\n const pinned = firstPartyDomainFor(context);\n if (pinned) {\n logInfo(`\"${context}\" is a known Mesh platform \u2014 resolving via ${pinned} (no AWS needed)`);\n config = await discoverConfig(pinned, context);\n }\n }\n if (!config && context.split(\".\").length - 1 >= 2) {\n config = await discoverConfig(context);\n }\n return config;\n}\n\n/**\n * Resolve AWS credentials via Zitadel JWT \u2192 AssumeRoleWithWebIdentity, then\n * print them as `export` statements on stdout. All status/error output goes\n * to stderr so the caller can safely `eval \"$(mesh login ... --export ...)\"`.\n */\nasync function exportAwsCredentials(\n context: string,\n config: ContextConfig,\n opts: { role?: string; region?: string; device?: boolean; static?: boolean },\n): Promise<void> {\n const token = await ensureValidToken(context, config, opts);\n if (!token) {\n process.exit(1);\n }\n\n // Pick a role for the caller. `resolveRoleOrExplain` handles the\n // --role / MESH_AWS_ROLE / cached-defaultRole precedence (shared with\n // --credential-process); on top of that, when we fell back to the cached\n // default we ask `selectRoleForCaller` to upgrade to the context's\n // `adminRole` when the Zitadel JWT carries an admin claim.\n const resolvedRole = resolveRoleOrExplain(opts, config);\n if (\"error\" in resolvedRole) {\n logError(\n \"--export requires --role <arn> (or MESH_AWS_ROLE env var, or a cached defaultRole).\\n\" +\n \" Example: mesh login mesh.dev --export --role arn:aws:iam::123456789012:role/mesh-developer\\n\" +\n \" After the first run, the role is saved to ~/.config/mesh/config.json and --role can be omitted.\"\n );\n process.exit(1);\n }\n let roleArn = resolvedRole.role;\n if (!opts.role && !process.env.MESH_AWS_ROLE && config.defaultRole) {\n roleArn = selectRoleForCaller(token, {\n defaultRole: config.defaultRole,\n adminRole: config.adminRole,\n adminClaimRoles: config.adminClaimRoles,\n });\n }\n if (config.adminRole && roleArn === config.adminRole && !opts.role) {\n logInfo(\n `Caller has admin Zitadel role \u2014 assuming ${roleArn.split(\"/\").pop()} (admin variant)`,\n );\n }\n\n // Persist --role so subsequent runs can omit it.\n if (opts.role && opts.role !== config.defaultRole) {\n writeContextConfig(context, { ...config, defaultRole: opts.role });\n logInfo(`Saved default role for ${context}`);\n }\n\n const region =\n opts.region ??\n process.env.AWS_REGION ??\n process.env.AWS_DEFAULT_REGION ??\n \"us-east-2\";\n\n const roleName = roleArn.split(\"/\").pop() ?? roleArn;\n\n // --static: resolve a frozen STS triple now (the old behavior). Consumers\n // that need raw credentials (no `mesh` binary on refresh) opt into this.\n if (opts.static) {\n const creds = readCredentials(context);\n const sessionName = (creds?.email ?? \"mesh-cli-export\")\n .replace(/[^a-zA-Z0-9=,.@-]/g, \"_\")\n .slice(0, 64);\n\n const env = await assumeRoleWithWebIdentity(roleArn, token, sessionName);\n if (!env) {\n logError(\n describeAssumeFailure({ roleArn, context, issuer: tokenIssuer(token) }),\n );\n process.exit(1);\n }\n\n const accessKey = env.AWS_ACCESS_KEY_ID;\n const secretKey = env.AWS_SECRET_ACCESS_KEY;\n const sessionToken = env.AWS_SESSION_TOKEN;\n if (!accessKey || !secretKey || !sessionToken) {\n logError(\"AWS STS returned incomplete credentials\");\n process.exit(1);\n }\n\n process.stdout.write(\n renderExportStaticLines({ accessKey, secretKey, sessionToken, region }),\n );\n logSuccess(`Exported static AWS credentials for ${roleName} in ${region}`);\n return;\n }\n\n // Default: manage a `[profile mesh-<context>]` block inside the user's REAL\n // AWS config file ($AWS_CONFIG_FILE if they set one, else ~/.aws/config) as\n // an idempotent marker-delimited section \u2014 the same file `aws configure sso`\n // manages \u2014 and export only AWS_PROFILE. The profile's `credential_process`\n // re-invokes `mesh login \u2026 --credential-process`, so `eval`-ed shells\n // auto-refresh instead of dying ~1h in. We must NOT export AWS_CONFIG_FILE\n // pointing at a mesh-private file: that hides ~/.aws/config from the whole\n // shell and breaks the user's other profiles (e.g. AWS_PROFILE=mesh-dev).\n const meshBin = resolveStableMeshBin(process.argv[1]);\n const sanitized = context.replace(/[^A-Za-z0-9_-]/g, \"-\");\n const profileName = `mesh-${sanitized}`;\n // Safehouse-friendly: falls back to a mesh-managed config when ~/.aws is not\n // writable (and then `redirected` tells us to export AWS_CONFIG_FILE too).\n const { configPath, redirected } = resolveAwsConfigTarget();\n fs.mkdirSync(path.dirname(configPath), { recursive: true });\n // Read \u2192 build \u2192 atomic-write back-to-back (all sync) so the lost-update\n // window against concurrent writers (another `mesh login --export`, the\n // user's own editor, `aws configure`) is as small as possible; the temp+\n // rename write means concurrent READERS never observe a truncated file.\n const existedBefore = fs.existsSync(configPath);\n const existing = existedBefore ? fs.readFileSync(configPath, \"utf-8\") : \"\";\n // Preserve the user's existing mode (rename replaces the inode, so the\n // temp file's mode wins) \u2014 never chmod their config out from under them.\n // Restrict to 0600 only when WE create the file.\n const mode = existedBefore ? fs.statSync(configPath).mode & 0o777 : 0o600;\n atomicWriteFileSync(\n configPath,\n upsertManagedAwsConfigSection(\n existing,\n context,\n renderCredentialProcessProfile({\n profileName,\n context,\n roleArn,\n region,\n meshBin,\n }),\n ),\n mode,\n );\n\n process.stdout.write(renderExportProfileLines({ profileName, region }));\n // When we redirected away from an unwritable ~/.aws, the profile lives in a\n // mesh-managed file \u2014 export AWS_CONFIG_FILE so the AWS SDK finds it.\n if (redirected) {\n process.stdout.write(`export AWS_CONFIG_FILE=${shellSingleQuote(configPath)}\\n`);\n }\n logSuccess(\n `Exported self-refreshing AWS profile ${profileName} for ${roleName} in ${region} ` +\n `(managed section in ${configPath})`,\n );\n}\n\n// =============================================================================\n// AWS credential_process (--credential-process flag)\n// =============================================================================\n\n/**\n * Resolve AWS credentials via Zitadel JWT \u2192 AssumeRoleWithWebIdentity, then\n * print them as AWS `credential_process` JSON on stdout \u2014 nothing else may\n * reach stdout in this mode, since the AWS SDK parses it directly. All\n * status/error output goes to stderr.\n *\n * Unlike `--export`, this does NOT run an interactive login flow when no\n * session is cached (there's no terminal/browser to drive it from a child\n * process) \u2014 it only refreshes an existing session via its refresh token\n * (`getValidToken`). Callers must `mesh login <context>` at least once.\n */\nasync function credentialProcessAwsCredentials(\n context: string,\n config: ContextConfig,\n opts: { role?: string },\n): Promise<void> {\n const token = await getValidToken(context);\n if (!token) {\n logError(\n `No valid Zitadel session for ${context} (missing, expired, or refresh failed). Run: mesh login ${context}`\n );\n process.exit(1);\n }\n\n const resolvedRole = resolveRoleOrExplain(opts, config);\n if (\"error\" in resolvedRole) {\n logError(resolvedRole.error);\n process.exit(1);\n }\n\n const creds = readCredentials(context);\n const sessionName = (creds?.email ?? \"mesh-cli-credential-process\")\n .replace(/[^a-zA-Z0-9=,.@-]/g, \"_\")\n .slice(0, 64);\n\n const stsCreds = await assumeRoleCredentials(resolvedRole.role, token, sessionName);\n if (!stsCreds) {\n logError(\n describeAssumeFailure({\n roleArn: resolvedRole.role,\n context,\n issuer: tokenIssuer(token),\n }),\n );\n process.exit(1);\n }\n\n process.stdout.write(toCredentialProcessJson(stsCreds) + \"\\n\");\n}\n\nexport { CONFIG_DIR, CONFIG_FILE, CREDENTIALS_FILE };\nexport type { ContextConfig, CachedCredentials, CredentialsFile };\n/** Every cached context config, keyed by context name. */\nexport function readAllContextConfigs(): ConfigFile {\n return readConfig();\n}\n\n/**\n * The discovery chain on its own, without the browser flow that follows it.\n *\n * Exported so the clean-laptop bootstrap \u2014 SSM \u2192 pinned first-party alias \u2192\n * full-domain, and the anti-phishing refusal for an unknown short context \u2014 can\n * be exercised end-to-end against the live well-known endpoint. The `login`\n * command's own path is otherwise only reachable by completing an interactive\n * sign-in, which is why this leg had no coverage above the unit tier.\n */\nexport async function discoverConfigForContext(context: string): Promise<ContextConfig | null> {\n return getContextConfig(context) ?? (await discoverConfigGuarded(context));\n}\n\nexport { readCredentials, readAllCredentials, getContextConfig, decodeJwtPayload, getValidToken, clearCredentials };", "/**\n * Shared AWS credential resolution for mesh CLI commands.\n *\n * Three-tier resolution:\n * 1. Existing AWS credentials in environment (CI/CD) \u2192 use directly\n * 2. Zitadel JWT via `mesh login` \u2192 AssumeRoleWithWebIdentity\n * 3. Fallback \u2192 AssumeRole via current AWS session (SSO)\n *\n * Uses @aws-sdk/client-sts directly \u2014 does NOT shell out to the `aws` CLI\n * binary, so the binary is not a runtime requirement.\n */\n\nimport * as fs from \"fs\";\nimport * as path from \"path\";\nimport {\n AssumeRoleCommand,\n AssumeRoleWithWebIdentityCommand,\n GetCallerIdentityCommand,\n STSClient,\n} from \"@aws-sdk/client-sts\";\nimport { logInfo, logSuccess, logWarn } from \"./log.js\";\nimport { isExpiredAwsTokenMessage, SSO_LOGIN_FIX } from \"./auth-preflight.js\";\nimport { ensureLogin } from \"../commands/login.js\";\n\nconst DEFAULT_REGION =\n process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? \"us-east-2\";\n\n/**\n * Derive the platform context key (e.g., \"mesh.dev\") from a Pulumi stack config.\n *\n * Reads `mesh:platform` from Pulumi.<stack>.yaml. Supports both formats:\n * mesh:platform: '{\"env\":\"dev\",\"name\":\"mesh\"}' \u2192 \"mesh.dev\"\n * mesh:platform:\n * name: mesh\n * env: dev \u2192 \"mesh.dev\"\n */\nexport function derivePlatformContext(appRoot: string, stack: string): string | null {\n const configFile = path.join(appRoot, `Pulumi.${stack}.yaml`);\n if (!fs.existsSync(configFile)) return null;\n\n const content = fs.readFileSync(configFile, \"utf-8\");\n\n // Try JSON-encoded string first\n const jsonMatch = content.match(/mesh:platform:\\s*'(\\{[^']+\\})'/);\n if (jsonMatch) {\n try {\n const parsed = JSON.parse(jsonMatch[1]!);\n if (parsed.name && parsed.env) return `${parsed.name}.${parsed.env}`;\n } catch {\n // fall through\n }\n }\n\n // Try YAML block format\n const nameMatch = content.match(/mesh:platform:[\\s\\S]*?name:\\s*(\\S+)/);\n const envMatch = content.match(/mesh:platform:[\\s\\S]*?env:\\s*(\\S+)/);\n if (nameMatch && envMatch) {\n return `${nameMatch[1]}.${envMatch[1]}`;\n }\n\n return null;\n}\n\n/**\n * Quote a `credential_process` command token for AWS config-file syntax: the\n * AWS SDK/CLI split the value on spaces, honoring double quotes \u2014 so any\n * token containing whitespace (e.g. a mesh binary under \"Application\n * Support\") must be wrapped in double quotes or the command is mis-split.\n * Tokens without whitespace are left bare (byte-identical to the historic\n * output).\n */\nfunction quoteCredProcessToken(token: string): string {\n return /\\s/.test(token) ? `\"${token}\"` : token;\n}\n\n/**\n * Resolve a STABLE absolute path to the running `mesh` entrypoint for\n * embedding in a `credential_process` line \u2014 or fall back to plain `mesh`\n * (PATH lookup) when `argv1` is missing, doesn't exist on disk, or lives in\n * a transient location (`npx` cache / OS temp dir): a profile pointing at a\n * path that evaporates breaks every later AWS call with a cryptic\n * credential_process error. The fallback is announced via `logWarn` so the\n * user knows to install mesh-cli globally.\n */\nexport function resolveStableMeshBin(\n argv1: string | undefined,\n deps: { exists?: (p: string) => boolean; warn?: (msg: string) => void } = {},\n): string {\n const exists = deps.exists ?? fs.existsSync;\n const warn = deps.warn ?? logWarn;\n let resolved = argv1 ? path.resolve(argv1) : undefined;\n // Under the source launcher the CLI process's argv[1] is a TypeScript entry\n // (bin/mesh.ts), which plain node cannot execute (type-stripped .js-suffixed\n // imports fail with ERR_MODULE_NOT_FOUND). Point the profile at the sibling\n // .mjs launcher instead \u2014 it bootstraps tsx itself, from any cwd/env. Keyed on\n // the `.ts` extension + a derived sibling (not the literal `mesh.ts`), so a\n // rename of the source entry doesn't silently revert to a broken value. Assign\n // unconditionally: if the sibling is absent the existing !exists guard below\n // reports it by name (rather than collapsing into the \"argv1 missing\" branch).\n if (resolved && /\\.ts$/.test(resolved)) {\n resolved = path.join(path.dirname(resolved), path.basename(resolved, \".ts\") + \".mjs\");\n }\n const transient =\n resolved !== undefined &&\n (/[\\\\/]_npx[\\\\/]/.test(resolved) || /^\\/(?:private\\/)?(?:tmp|var\\/folders)\\//.test(resolved));\n if (!resolved || !exists(resolved) || transient) {\n warn(\n `credential_process cannot reference a stable mesh binary` +\n (resolved ? ` (${resolved} is transient or missing)` : \"\") +\n ` \u2014 falling back to \\`mesh\\` on PATH. Install mesh-cli globally so the profile keeps refreshing.`,\n );\n return \"mesh\";\n }\n return resolved;\n}\n\n/**\n * Render an AWS config-file `[profile \u2026]` block whose `credential_process`\n * re-invokes `mesh login \u2026 --credential-process`, so the AWS SDK's default\n * provider chain auto-refreshes the underlying STS triple (the `mesh login`\n * process re-issues the Zitadel token as needed).\n *\n * Shared by `mesh dev` (child services) and `mesh login --export` so both\n * produce byte-identical profiles. Pure \u2014 no I/O. The `region` line is\n * emitted only when `region` is a non-empty string. Includes a trailing\n * newline. The binary path and role ARN are double-quoted when they contain\n * whitespace (AWS config command-splitting rules).\n */\nexport function renderCredentialProcessProfile(args: {\n profileName: string;\n context: string;\n roleArn: string;\n region?: string;\n meshBin: string;\n}): string {\n const lines = [\n `[profile ${args.profileName}]`,\n `credential_process = ${quoteCredProcessToken(args.meshBin)} login ${quoteCredProcessToken(args.context)} --credential-process --role ${quoteCredProcessToken(args.roleArn)}`,\n ];\n if (args.region) lines.push(`region = ${args.region}`);\n return lines.join(\"\\n\") + \"\\n\";\n}\n\n// Marker lines delimiting the mesh-managed section of the user's AWS config\n// file. Follows the `conda init` / `nvm` managed-block convention so the\n// section is discoverable and safely re-writable. The START marker carries\n// the context (one managed section per context); the END marker is shared.\nconst MANAGED_START_PREFIX = `# >>> mesh-cli managed profile `;\nconst MANAGED_START = (context: string) => `${MANAGED_START_PREFIX}${context} >>>`;\nconst MANAGED_END = `# <<< mesh-cli managed <<<`;\n\n/**\n * Given a managed START marker for OUR context whose matching END is missing\n * (orphaned \u2014 e.g. the user deleted the END line), find the index of the first\n * character we should NOT reclaim. Conservative by construction: because\n * `MANAGED_END` is a shared literal across contexts, an END found further down\n * may belong to ANOTHER context's healthy section \u2014 so we never scan to a\n * distant END or to EOF. We only reclaim the lines the mesh CLI provably\n * wrote (see `renderCredentialProcessProfile`): one `[profile \u2026]` header\n * followed by contiguous `key = value` lines, stopping at the first blank\n * line, comment/marker, second profile header, or `boundIdx` (the next\n * managed START of any context), whichever comes first.\n *\n * @param afterStart index just past OUR START marker text (before its newline)\n * @param boundIdx index of the next managed START marker after ours, or -1\n */\nfunction orphanedSectionTail(existing: string, afterStart: number, boundIdx: number): number {\n const limit = boundIdx === -1 ? existing.length : boundIdx;\n let idx = afterStart;\n if (idx >= limit || existing[idx] !== \"\\n\") return Math.min(idx, limit);\n idx++; // consume the newline terminating our START marker line\n let claimedHeader = false;\n while (idx < limit) {\n const nl = existing.indexOf(\"\\n\", idx);\n const lineEnd = nl === -1 || nl > limit ? limit : nl;\n const line = existing.slice(idx, lineEnd);\n const ours = claimedHeader\n ? /^[A-Za-z_][A-Za-z0-9_]*\\s*=/.test(line) // key = value continuation\n : /^\\[profile [^\\]]+\\]\\s*$/.test(line); // exactly one profile header\n if (!ours) return idx;\n claimedHeader = true;\n idx = lineEnd >= limit ? limit : lineEnd + 1;\n }\n return limit;\n}\n\n/**\n * Insert or replace the mesh-managed `[profile \u2026]` section for `context` in\n * the user's AWS config content, preserving ALL other content byte-for-byte\n * (this is the user's real ~/.aws/config \u2014 clobbering it breaks their other\n * profiles). Pure \u2014 no I/O; exported for unit testing.\n *\n * - Existing managed section for this context \u2192 replaced in place.\n * - Orphaned START (matching END lost) \u2192 only the contiguous profile block\n * that is provably ours is reclaimed; other contexts' sections and user\n * content below are preserved (see `orphanedSectionTail`).\n * - No section \u2192 appended, separated by a single blank line (and a trailing\n * newline is added to the existing content if it lacks one).\n */\nexport function upsertManagedAwsConfigSection(\n existing: string,\n context: string,\n profileBlock: string,\n): string {\n const body = profileBlock.endsWith(\"\\n\") ? profileBlock : profileBlock + \"\\n\";\n const section = `${MANAGED_START(context)}\\n${body}${MANAGED_END}\\n`;\n\n const startIdx = existing.indexOf(MANAGED_START(context));\n if (startIdx !== -1) {\n const afterStart = startIdx + MANAGED_START(context).length;\n const endIdx = existing.indexOf(MANAGED_END, afterStart);\n // MANAGED_END is a shared literal across contexts, so the first END below\n // our START is only OURS if no other context's START sits in between \u2014\n // otherwise that END closes the OTHER section and scanning to it would\n // swallow that section whole.\n const foreignIdx = existing.indexOf(MANAGED_START_PREFIX, afterStart);\n let tail: number;\n if (endIdx !== -1 && (foreignIdx === -1 || endIdx < foreignIdx)) {\n // Healthy section: replace START..END. Swallow a single trailing\n // newline of the old section so replacement is idempotent (the new\n // section brings its own).\n const tailIdx = endIdx + MANAGED_END.length;\n tail = existing[tailIdx] === \"\\n\" ? tailIdx + 1 : tailIdx;\n } else {\n // Orphaned START (our END missing). Reclaim only the contiguous\n // profile block that is provably ours \u2014 never foreign sections, user\n // profiles below, or everything to EOF.\n tail = orphanedSectionTail(existing, afterStart, foreignIdx);\n }\n return existing.slice(0, startIdx) + section + existing.slice(tail);\n }\n\n if (existing.length === 0) return section;\n const sep = existing.endsWith(\"\\n\\n\") ? \"\" : existing.endsWith(\"\\n\") ? \"\\n\" : \"\\n\\n\";\n return existing + sep + section;\n}\n\n/**\n * Remove a BARE `[profile name]` block \u2014 one outside any managed section \u2014 from\n * AWS config content. A file written before the managed-section format carries\n * exactly one such block, and migrating it must not leave that block behind as a\n * duplicate profile. Pure; exported for unit testing.\n *\n * @example\n * stripBareProfile(\"[profile acme]\\nregion = us-east-2\\n\", \"acme\"); // \u2192 \"\"\n */\nexport function stripBareProfile(existing: string, profileName: string): string {\n const header = `[profile ${profileName}]`;\n const lines = existing.split(\"\\n\");\n const out: string[] = [];\n let managed = false;\n let dropping = false;\n for (const line of lines) {\n if (line.startsWith(MANAGED_START_PREFIX)) managed = true;\n if (managed) {\n out.push(line);\n if (line === MANAGED_END) managed = false;\n continue;\n }\n if (line.trim() === header) {\n dropping = true;\n continue;\n }\n if (dropping && line.startsWith(\"[\")) dropping = false;\n if (!dropping) out.push(line);\n }\n return out.join(\"\\n\");\n}\n\nfunction newStsClient(): STSClient {\n return new STSClient({ region: DEFAULT_REGION });\n}\n\n/** Pull AWS env vars out of an STS Credentials struct; null if any field is missing. */\nfunction toEnv(credentials: {\n AccessKeyId?: string;\n SecretAccessKey?: string;\n SessionToken?: string;\n}): Record<string, string> | null {\n const { AccessKeyId, SecretAccessKey, SessionToken } = credentials;\n if (!AccessKeyId || !SecretAccessKey || !SessionToken) return null;\n return {\n AWS_ACCESS_KEY_ID: AccessKeyId,\n AWS_SECRET_ACCESS_KEY: SecretAccessKey,\n AWS_SESSION_TOKEN: SessionToken,\n };\n}\n\n/**\n * IAM rejects a DurationSeconds higher than the role's MaxSessionDuration with\n * a ValidationError. We detect that and fall back to a one-hour session.\n */\nfunction isMaxSessionDurationError(err: unknown): boolean {\n if (!(err instanceof Error)) return false;\n const message = err.message ?? \"\";\n return (\n message.includes(\"MaxSessionDuration\") ||\n message.includes(\"DurationSeconds\")\n );\n}\n\n/** Assume an IAM role via current AWS session and return credentials as env vars. */\nexport async function assumeRole(\n roleArn: string,\n sessionName: string = \"mesh-cli\",\n durationSeconds: number = 43200,\n): Promise<Record<string, string> | null> {\n const sts = newStsClient();\n for (const duration of [durationSeconds, 3600]) {\n try {\n const resp = await sts.send(\n new AssumeRoleCommand({\n RoleArn: roleArn,\n RoleSessionName: sessionName,\n DurationSeconds: duration,\n }),\n );\n if (!resp.Credentials) return null;\n return toEnv(resp.Credentials);\n } catch (err) {\n if (!isMaxSessionDurationError(err) || duration === 3600) {\n const message = err instanceof Error ? err.message : String(err);\n logWarn(`AssumeRole failed: ${message}`);\n // Name the SSO fix when the ambient session is the problem \u2014 the raw\n // STS error never mentions `aws sso login` (MESH-2479).\n if (isExpiredAwsTokenMessage(message)) {\n logWarn(` Your AWS SSO session looks expired or missing \u2014 refresh with: ${SSO_LOGIN_FIX}`);\n }\n return null;\n }\n // Retry with shorter duration\n }\n }\n return null;\n}\n\n/** STS credentials for a Zitadel-JWT-assumed role, including expiry. */\nexport interface StsCreds {\n AccessKeyId: string;\n SecretAccessKey: string;\n SessionToken: string;\n /** ISO-8601 expiration timestamp. */\n Expiration: string;\n}\n\n/**\n * Assume role using a Zitadel JWT via AssumeRoleWithWebIdentity, returning\n * the full STS credential struct (including `Expiration`) rather than the\n * env-var shape. Used by callers that need to track/refresh expiry (e.g.\n * `credential_process`).\n */\nexport async function assumeRoleCredentials(\n roleArn: string,\n idToken: string,\n sessionName: string,\n durationSeconds: number = 43200,\n): Promise<StsCreds | null> {\n // STS WebIdentity does not require ambient AWS credentials \u2014 the JWT proves identity.\n const sts = newStsClient();\n for (const duration of [durationSeconds, 3600]) {\n try {\n const resp = await sts.send(\n new AssumeRoleWithWebIdentityCommand({\n RoleArn: roleArn,\n RoleSessionName: sessionName,\n WebIdentityToken: idToken,\n DurationSeconds: duration,\n }),\n );\n if (!resp.Credentials) return null;\n const env = toEnv(resp.Credentials);\n if (!env) return null;\n return {\n // `env` is a `Record<string, string>` (index-signature typed), so\n // `noUncheckedIndexedAccess` widens these to `string | undefined`\n // even though `toEnv` guarantees they're set \u2014 assert accordingly.\n AccessKeyId: env.AWS_ACCESS_KEY_ID!,\n SecretAccessKey: env.AWS_SECRET_ACCESS_KEY!,\n SessionToken: env.AWS_SESSION_TOKEN!,\n Expiration: resp.Credentials.Expiration!.toISOString(),\n };\n } catch (err) {\n if (!isMaxSessionDurationError(err) || duration === 3600) {\n logWarn(\n `AssumeRoleWithWebIdentity failed: ${err instanceof Error ? err.message : String(err)}`,\n );\n return null;\n }\n // Retry with shorter duration\n }\n }\n return null;\n}\n\n/** Assume role using a Zitadel JWT via AssumeRoleWithWebIdentity. */\nexport async function assumeRoleWithWebIdentity(\n roleArn: string,\n idToken: string,\n sessionName: string,\n durationSeconds: number = 43200,\n): Promise<Record<string, string> | null> {\n const creds = await assumeRoleCredentials(roleArn, idToken, sessionName, durationSeconds);\n if (!creds) return null;\n return toEnv(creds);\n}\n\n/**\n * Extract the `iss` claim from a JWT without ever throwing \u2014 malformed or\n * corrupt tokens (e.g. a damaged `~/.config/mesh/credentials.json`) yield\n * `undefined`. The failure-diagnostic paths use this; a decode that throws\n * there would crash before the diagnostic prints, which is exactly the\n * failure class MESH-2515 removes.\n */\nexport function tokenIssuer(token: string): string | undefined {\n const iss = decodeJwtPayload(token)?.iss;\n return typeof iss === \"string\" && iss ? iss : undefined;\n}\n\n/**\n * Explain an AssumeRoleWithWebIdentity failure in platform terms: which login\n * session (context + token issuer) tried to assume which role, in which AWS\n * account. STS's \"web identity token could not be validated\" reads like an\n * IAM/expiry problem, but the usual cause is a context whose issuer the role's\n * account simply doesn't trust \u2014 e.g. a `mesh.dev` session against a role in a\n * `trabian`-platform account (MESH-2515). stderr only \u2014 callers in\n * `--credential-process` mode must keep stdout JSON-clean.\n *\n * `format: \"inline\"` renders the same facts as one line, for detail fields\n * that get interpolated mid-sentence (the doctor/preflight cred probe). Both\n * forms keep the `AssumeRoleWithWebIdentity failed` prefix the doctor plan\n * documents as the detail capture.\n */\nexport function describeAssumeFailure(\n opts: {\n roleArn: string;\n context?: string;\n issuer?: string;\n },\n format: \"block\" | \"inline\" = \"block\",\n): string {\n const account = /^arn:aws:iam::(\\d+):/.exec(opts.roleArn)?.[1];\n const head =\n `AssumeRoleWithWebIdentity failed for ${opts.roleArn}` +\n (account ? ` (account ${account})` : \"\");\n const session = opts.context\n ? `mesh login context '${opts.context}'` + (opts.issuer ? ` (issuer ${opts.issuer})` : \"\")\n : opts.issuer\n ? `issuer ${opts.issuer}`\n : undefined;\n if (format === \"inline\") {\n return (\n head +\n (session ? ` via ${session}` : \"\") +\n \" \u2014 if the role's account doesn't trust this issuer, this is a context mismatch\" +\n \" (wrong `mesh:platform`), not an IAM permission problem\"\n );\n }\n const lines = [head];\n if (session) lines.push(` Session: ${session}`);\n lines.push(\n \" If the role's account does not trust this issuer, you are logged into the\",\n \" wrong context for this stack \u2014 check `mesh:platform` in the app's Pulumi\",\n \" config and `mesh login <platform>.<env>` accordingly. (This is a context\",\n \" mismatch, not an IAM permission problem.)\",\n );\n return lines.join(\"\\n\");\n}\n\n/** Format STS credentials as AWS `credential_process` JSON (Version 1). */\nexport function toCredentialProcessJson(creds: StsCreds): string {\n return JSON.stringify({\n Version: 1,\n AccessKeyId: creds.AccessKeyId,\n SecretAccessKey: creds.SecretAccessKey,\n SessionToken: creds.SessionToken,\n Expiration: creds.Expiration,\n });\n}\n\nexport interface ResolvedAwsCreds {\n /** AWS credential env vars (ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN) */\n env: Record<string, string>;\n /** How the credentials were obtained */\n method: \"environment\" | \"zitadel\" | \"sso\";\n}\n\nexport interface AwsIdentity {\n /** Full caller ARN, e.g. arn:aws:iam::159923586610:user/CI-CD_User */\n arn: string;\n accountId: string;\n /** Where the SDK's default chain found the credentials, for the human reading it. */\n source: \"environment\" | \"profile\";\n}\n\n/**\n * Ask \"is there a working AWS connection right now?\" \u2014 nothing more.\n *\n * Deliberately NOT `resolveAwsCredentials`: that one answers \"get me credentials\n * for role X\" and has an opinion about how they were obtained. This one runs\n * GetCallerIdentity through the SDK's default provider chain and reports whatever\n * it finds, so a static key/secret pair in `~/.aws/credentials` counts exactly as\n * much as an SSO session or a Zitadel-minted role. Returns null when the chain\n * yields nothing usable.\n */\nexport async function probeAwsIdentity(): Promise<AwsIdentity | null> {\n try {\n const out = await newStsClient().send(new GetCallerIdentityCommand({}));\n if (!out.Arn || !out.Account) return null;\n return {\n arn: out.Arn,\n accountId: out.Account,\n source: process.env.AWS_ACCESS_KEY_ID ? \"environment\" : \"profile\",\n };\n } catch {\n return null;\n }\n}\n\n/**\n * Decode a Zitadel-issued JWT payload (no signature check \u2014 we only\n * use it for role-selection hints in mesh-cli; the actual STS calls\n * still verify against the OIDC provider). Returns null on malformed\n * input.\n */\nfunction decodeJwtPayload(token: string): Record<string, unknown> | null {\n const parts = token.split(\".\");\n if (parts.length !== 3 || !parts[1]) return null;\n try {\n return JSON.parse(Buffer.from(parts[1], \"base64url\").toString());\n } catch {\n return null;\n }\n}\n\n/**\n * Pick the AWS role ARN to assume based on Zitadel claims.\n *\n * Zitadel emits project roles under\n * `urn:zitadel:iam:org:project:<projectId>:roles` as a nested object\n * keyed by role name. We treat the presence of `mesh.platform:admin`\n * (or any role listed in `meshcli.adminRoles` in config) as the\n * signal to assume the broader admin role when the caller has\n * configured one.\n *\n * Returns `defaultRole` when no admin role is configured, no admin\n * claim is present, or the JWT can't be parsed. This keeps existing\n * deployments working without a config change.\n *\n * The admin AWS role's IAM trust policy is expected to gate\n * assumption on the same Zitadel claim in production (so the CLI\n * preference can't be spoofed). That setup is its own change \u2014 see\n * TODO(mesh-zitadel-aws-role-mapping).\n */\nexport function selectRoleForCaller(\n idToken: string | null,\n options: {\n defaultRole: string;\n adminRole?: string;\n /**\n * Zitadel role names that should map to `adminRole`. Defaults to\n * a single `mesh.platform:admin` role; callers can override via\n * config to introduce role tiers later.\n */\n adminClaimRoles?: string[];\n },\n): string {\n if (!options.adminRole || !idToken) return options.defaultRole;\n const adminRoles = options.adminClaimRoles ?? [\"mesh.platform:admin\"];\n const claims = decodeJwtPayload(idToken);\n if (!claims) return options.defaultRole;\n\n // Find the project-roles claim \u2014 key shape:\n // urn:zitadel:iam:org:project:<projectId>:roles\n // Multiple projects can emit roles; we walk all matching keys.\n for (const [key, value] of Object.entries(claims)) {\n if (!key.startsWith(\"urn:zitadel:iam:org:project:\")) continue;\n if (!key.endsWith(\":roles\")) continue;\n if (!value || typeof value !== \"object\") continue;\n const claimedRoles = Object.keys(value as Record<string, unknown>);\n for (const target of adminRoles) {\n if (claimedRoles.includes(target)) return options.adminRole;\n }\n }\n return options.defaultRole;\n}\n\n/**\n * Resolve AWS credentials using the three-tier strategy.\n *\n * @param roleArn The IAM role to assume (from mesh:deployerRole)\n * @param appRoot Pulumi app root directory\n * @param stack Pulumi stack name\n * @returns Resolved credentials, or null if all methods fail\n *\n * To inherit a broader role based on Zitadel claims, call\n * `selectRoleForCaller` first and pass the result as `roleArn`.\n */\nexport async function resolveAwsCredentials(\n roleArn: string,\n appRoot: string,\n stack: string,\n): Promise<ResolvedAwsCreds | null> {\n const roleName = roleArn.split(\"/\").pop() ?? roleArn;\n\n // Path 1: Existing AWS credentials in environment (CI/CD)\n if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SESSION_TOKEN) {\n logInfo(\"Using existing AWS credentials from environment\");\n return {\n env: {\n AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,\n AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY ?? \"\",\n AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN,\n },\n method: \"environment\",\n };\n }\n\n // Path 2: Zitadel JWT via mesh login \u2192 AssumeRoleWithWebIdentity.\n // Non-interactive here: only a valid CACHED session is used. An interactive\n // device-flow before the SSO fallback would stall headless runs (CI,\n // `mesh dev` with a working AWS_PROFILE) for minutes polling device codes\n // nobody sees \u2014 SSO gets its chance first; interactive login is Path 4.\n const platformContext = derivePlatformContext(appRoot, stack);\n if (platformContext) {\n const meshCreds = await ensureLogin(platformContext, { interactive: false });\n if (meshCreds) {\n const sessionName = (meshCreds.email ?? \"mesh-cli\")\n .replace(/[^a-zA-Z0-9=,.@-]/g, \"_\")\n .slice(0, 64);\n logInfo(`Assuming ${roleName} via Zitadel (${meshCreds.email ?? platformContext})`);\n\n const creds = await assumeRoleWithWebIdentity(roleArn, meshCreds.idToken, sessionName);\n if (creds) {\n logSuccess(`Assumed ${roleName} via Zitadel`);\n return { env: creds, method: \"zitadel\" };\n }\n\n logWarn(\n \"Zitadel JWT auth failed \u2014 falling back to AWS SSO.\\n\" +\n ` (Try: mesh login ${platformContext})`,\n );\n }\n }\n\n // Path 3: Fallback \u2014 AssumeRole via current AWS session (SSO)\n logInfo(`Assuming ${roleName} via AWS SSO`);\n const creds = await assumeRole(roleArn, \"mesh-cli\");\n if (creds) {\n logSuccess(`Assumed ${roleName}`);\n return { env: creds, method: \"sso\" };\n }\n\n // Path 4: Last resort \u2014 interactive Zitadel login (device/browser). Only\n // reached when there's no env cred, no cached session, and no working SSO,\n // i.e. a human at a terminal who genuinely needs to log in.\n if (platformContext && process.stdout.isTTY) {\n const meshCreds = await ensureLogin(platformContext);\n if (meshCreds) {\n const sessionName = (meshCreds.email ?? \"mesh-cli\")\n .replace(/[^a-zA-Z0-9=,.@-]/g, \"_\")\n .slice(0, 64);\n logInfo(`Assuming ${roleName} via Zitadel (${meshCreds.email ?? platformContext})`);\n const zCreds = await assumeRoleWithWebIdentity(roleArn, meshCreds.idToken, sessionName);\n if (zCreds) {\n logSuccess(`Assumed ${roleName} via Zitadel`);\n return { env: zCreds, method: \"zitadel\" };\n }\n }\n }\n\n return null;\n}\n", "/**\n * Serialize a fetched Temporal workflow history into the replay-fixture JSON\n * shape \u2014 the same canonical proto3-JSON `{ events: [...] }` that\n * `@mesh-tech/agent-sdk`'s `replay/history-io.ts` `historyToFixture` produces,\n * so a captured file replays via `replayFixture` (which rehydrates it with\n * `historyFromJSON`) for LOCAL debugging. Captured PRODUCTION histories are\n * never committed (they contain real, decrypted customer/conversation data \u2014\n * see the CLI's do-not-commit warning); only synthetic goldens live in\n * `packages/agent-sdk/src/replay/fixtures/`.\n *\n * We reuse `@temporalio/common`'s `historyToJSON` directly (the exact helper\n * agent-sdk's history-io uses) rather than importing `@mesh-tech/agent-sdk`:\n * the CLI has no reason to pull in the whole worker/replay stack, and keeping\n * the dependency pointed at the shared Temporal library guarantees the output\n * round-trips through `historyFromJSON`.\n *\n * The one thing a *real* history needs that a synthetic golden fixture doesn't:\n * its payloads are ENCRYPTED (the worker's AES-GCM codec). `historyToJSON`\n * would just base64 the ciphertext, which `replayFixture`'s plaintext converter\n * can't decode. So before serializing we walk every Payload in the history and\n * replace each encrypted one with its decrypted inner Payload \u2014 yielding a\n * plaintext fixture identical in shape to a codec-free golden capture.\n */\n\n// Explicit `.js` \u2014 this package publishes native ESM, so the compiled dist\n// must use a fully-specified specifier or Node throws ERR_MODULE_NOT_FOUND at\n// runtime (MESH-2262). tsx tolerates the extensionless form; node does not.\nimport { historyToJSON } from \"@temporalio/common/lib/proto-utils.js\";\n\nimport type { RawPayload, RawPayloadDecrypter } from \"./temporal-codec.js\";\n\n/**\n * A Temporal Payload as it appears on a raw history event (proto-decoded):\n * `metadata` is a map of header name \u2192 bytes, `data` is the (possibly\n * encrypted) body bytes. Matches `temporal.api.common.v1.Payload`.\n */\ninterface HistoryPayload {\n metadata?: Record<string, Uint8Array | null | undefined> | null;\n data?: Uint8Array | null;\n}\n\nfunction isPayload(value: unknown): value is HistoryPayload {\n return (\n value != null &&\n typeof value === \"object\" &&\n \"metadata\" in value &&\n \"data\" in value\n );\n}\n\n/**\n * Walk an arbitrary history-event tree and decrypt every encrypted Payload in\n * place, using `decrypt` to turn an encrypted Payload into its plaintext inner\n * Payload. Payloads the decrypter reports as non-encrypted (`null`) are left\n * untouched. Returns the number of payloads that were decrypted.\n *\n * The `{ metadata, data }` heuristic mirrors Temporal's own `fixPayloads` in\n * `@temporalio/common/lib/proto-utils`, so it identifies exactly the nodes\n * proto3-JSON treats as payloads. Once a node is a Payload we do NOT recurse\n * into it (its metadata values are bytes, not nested payloads).\n */\nexport async function decryptHistoryPayloads(\n node: unknown,\n decrypt: RawPayloadDecrypter,\n): Promise<number> {\n if (node == null || typeof node !== \"object\") return 0;\n\n if (isPayload(node)) {\n const inner = await decrypt(node as RawPayload);\n if (inner === null) return 0; // not encrypted \u2014 leave as-is\n // Replace the encrypted envelope with the decrypted inner payload's bytes.\n node.metadata = inner.metadata ?? {};\n node.data = inner.data ?? new Uint8Array(0);\n return 1;\n }\n\n let count = 0;\n if (Array.isArray(node)) {\n for (const item of node) count += await decryptHistoryPayloads(item, decrypt);\n } else {\n for (const value of Object.values(node as Record<string, unknown>)) {\n count += await decryptHistoryPayloads(value, decrypt);\n }\n }\n return count;\n}\n\n/** Result of {@link serializeHistoryToFixture}. */\nexport interface CaptureResult {\n /** The parsed proto3-JSON fixture object (drop-in for `replayFixture`). */\n fixture: unknown;\n /** Total events serialized. */\n eventCount: number;\n /** How many encrypted payloads were decrypted (0 when no decrypter given). */\n decryptedPayloads: number;\n}\n\n/**\n * Turn an ordered list of raw history events into a replay fixture object.\n *\n * @param events - Raw proto-decoded history events, in order (as yielded by the\n * CLI's `iterHistoryEvents` over `getWorkflowExecutionHistory`).\n * @param decrypter - Optional raw payload decrypter. When provided, every\n * encrypted payload in the history is decrypted in place before serialization\n * so the fixture is replayable with a plaintext converter. When omitted, the\n * payloads are serialized as-is (ciphertext, base64-encoded).\n *\n * `historyToJSON` runs its input through protobuf `History.fromObject`, so a\n * plain `{ events }` object of raw events is accepted directly; it returns a\n * proto3-JSON *string* which we parse back to a structured object (the same\n * `JSON.parse(historyToJSON(...))` round-trip `historyToFixture` performs).\n */\nexport async function serializeHistoryToFixture(\n events: unknown[],\n decrypter?: RawPayloadDecrypter,\n): Promise<CaptureResult> {\n let decryptedPayloads = 0;\n if (decrypter) {\n for (const event of events) {\n decryptedPayloads += await decryptHistoryPayloads(event, decrypter);\n }\n }\n\n const fixture = JSON.parse(historyToJSON({ events } as never)) as unknown;\n return { fixture, eventCount: events.length, decryptedPayloads };\n}\n", "/**\n * mesh temporal \u2014 inspect Temporal workflows for the current app/stack.\n *\n * Commands:\n * mesh temporal describe <workflowId> [runId] Show workflow status and metadata\n * mesh temporal history <workflowId> [runId] Show workflow event history\n *\n * Connection info is resolved from the app's Pulumi stack output (same as mesh dev).\n * Uses the raw gRPC API so we can render events without a codec proxy; when\n * `--show-payloads` is set we resolve the app's `TEMPORAL_ENCODING_KEY` from\n * its Kubernetes secret and decrypt inline via the shared codec helper.\n */\n\nimport { spawnSync } from \"node:child_process\";\nimport { writeFileSync, mkdirSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { Command } from \"commander\";\nimport { logError, logInfo, logSuccess, logWarn } from \"../utils/log.js\";\nimport {\n classifyHistoryEvents,\n extractSnapshotMessages,\n reconstructTranscript,\n renderTranscriptMarkdown,\n} from \"../utils/recover-conversation.js\";\nimport {\n findAppRoot,\n findStackConfigs,\n getCurrentStack,\n pulumiStackOutput,\n readStackConfig,\n} from \"../utils/pulumi.js\";\nimport { resolveAwsCredentials } from \"../utils/aws-auth.js\";\nimport { resolveTemporalAuth, acquireTemporalBearer } from \"../utils/temporal-auth.js\";\nimport {\n buildDecrypterForNamespace,\n buildRawDecrypterForNamespace,\n type PayloadDecrypter,\n} from \"../utils/temporal-codec.js\";\n\n// \u2500\u2500 Connection resolution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\ninterface TemporalConnectionInfo {\n address: string;\n namespace: string;\n /**\n * Context for minting a Zitadel Bearer for auth-enforcing servers. Absent when\n * connecting via explicit --address/env (then auth falls back to ambient env).\n */\n auth?: { tenant: string; env: string; platformName: string };\n}\n\n/**\n * Resolve Temporal address and namespace from the current app's stack output.\n * Falls back to env vars if stack output isn't available.\n */\nasync function resolveConnection(\n options: { stack?: string; address?: string; namespace?: string },\n): Promise<TemporalConnectionInfo> {\n // Explicit overrides take precedence\n if (options.address && options.namespace) {\n return { address: options.address, namespace: options.namespace };\n }\n\n // Try from environment (set by mesh dev)\n if (!options.address && process.env.TEMPORAL_ADDRESS) {\n const address = process.env.TEMPORAL_ADDRESS;\n const namespace = options.namespace ?? process.env.TEMPORAL_NAMESPACE;\n if (namespace) return { address, namespace };\n }\n\n // Resolve from Pulumi stack output\n const appRoot = findAppRoot(process.cwd());\n if (!appRoot) {\n throw new Error(\n \"Not in a Mesh app directory (no Pulumi.yaml found).\\n\" +\n \"Run from an app directory, or provide --address and --namespace.\",\n );\n }\n\n const stack = options.stack ?? getCurrentStack(appRoot);\n if (!stack) {\n const stacks = findStackConfigs(appRoot);\n throw new Error(\n `No Pulumi stack selected.\\n` +\n (stacks.length > 0\n ? `Available: ${stacks.join(\", \")}\\nUse: mesh temporal --stack <name> ...`\n : \"No stack configs found in this directory.\"),\n );\n }\n\n const sa = stack ? [\"--stack\", stack] : [];\n\n // Resolve AWS credentials the same way mesh dev does\n const deployerRole = readStackConfig(appRoot, stack, \"mesh:deployerRole\");\n const awsCreds = deployerRole\n ? (await resolveAwsCredentials(deployerRole, appRoot, stack))?.env\n : undefined;\n\n // Pulumi's `app` output from env.register() is a struct containing a\n // secret-tainted `dev` field (secret because service env vars include values\n // like TEMPORAL_ENCODING_KEY). pulumiStackOutput transparently retries with\n // --show-secrets when it sees a `\"[secret]\"` placeholder, so we can reach\n // the non-sensitive tunnel/namespace fields nested inside.\n let devOutput: any;\n let appOutput: any;\n try {\n const result = pulumiStackOutput(appRoot, \"app\", sa, awsCreds);\n appOutput = JSON.parse(result);\n devOutput = appOutput.dev ?? appOutput;\n } catch {\n // Legacy stacks that exported `dev` directly (pre-env.register() format).\n try {\n const result = pulumiStackOutput(appRoot, \"dev\", sa, awsCreds);\n devOutput = JSON.parse(result);\n appOutput = devOutput;\n } catch {\n throw new Error(\n `Could not read stack output for '${stack}'.\\n` +\n \"Ensure you've run: mesh deploy up\\n\" +\n \"Or provide --address and --namespace explicitly.\",\n );\n }\n }\n\n const tunnel = devOutput?.tunnels?.temporal;\n // namespace lives at the app output level, not inside dev\n const namespace = options.namespace ?? appOutput?.namespace ?? devOutput?.namespace;\n\n if (!tunnel) {\n throw new Error(\"No temporal tunnel found in stack output.\");\n }\n if (!namespace) {\n throw new Error(\"No temporal namespace found in stack output.\");\n }\n\n // Auth context for minting a Bearer, mirroring resolveTemporalAuthVars in dev.ts:\n // tenant/env/name come from the PLATFORM block (platformName is the platform's\n // name, not the app tenant \u2014 it keys the Zitadel issuer lookup).\n const platform = (devOutput?.platform ?? appOutput?.platform) as\n | { tenant?: string; env?: string; name?: string }\n | undefined;\n const auth = platform\n ? {\n tenant: platform.tenant ?? \"mesh\",\n env: platform.env ?? \"dev\",\n platformName: platform.name ?? platform.tenant ?? \"mesh\",\n }\n : undefined;\n\n return {\n address: options.address ?? `${tunnel.host}:${tunnel.port}`,\n namespace,\n auth,\n };\n}\n\n/**\n * Mint a Zitadel Bearer for the connection, or return undefined to connect\n * unauthenticated (correct for local/non-enforcing Temporal). Prefers ambient\n * env creds (e.g. exported by `mesh dev`); otherwise resolves the app's M2M\n * credentials from SSM via the auth context.\n */\nasync function resolveBearerToken(\n auth?: { tenant: string; env: string; platformName: string },\n): Promise<string | undefined> {\n try {\n const haveEnvCreds =\n !!process.env.TEMPORAL_AUTH_CLIENT_ID && !!process.env.ZITADEL_ISSUER;\n const vars =\n !haveEnvCreds && auth\n ? await resolveTemporalAuth(auth.tenant, auth.env, auth.platformName)\n : {};\n return await acquireTemporalBearer(vars);\n } catch (err) {\n logWarn(\n `Temporal auth unavailable (${err instanceof Error ? err.message : String(err)}); ` +\n `connecting unauthenticated`,\n );\n return undefined;\n }\n}\n\n// \u2500\u2500 Event type names \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n// Temporal event type enum \u2192 human-readable name\nconst EVENT_TYPE_NAMES: Record<number, string> = {\n 1: \"WorkflowExecutionStarted\",\n 2: \"WorkflowExecutionCompleted\",\n 3: \"WorkflowExecutionFailed\",\n 4: \"WorkflowExecutionTimedOut\",\n 5: \"WorkflowTaskScheduled\",\n 6: \"WorkflowTaskStarted\",\n 7: \"WorkflowTaskCompleted\",\n 8: \"WorkflowTaskTimedOut\",\n 9: \"WorkflowTaskFailed\",\n 10: \"ActivityTaskScheduled\",\n 11: \"ActivityTaskStarted\",\n 12: \"ActivityTaskCompleted\",\n 13: \"ActivityTaskFailed\",\n 14: \"ActivityTaskTimedOut\",\n 15: \"ActivityTaskCancelRequested\",\n 16: \"ActivityTaskCanceled\",\n 17: \"TimerStarted\",\n 18: \"TimerFired\",\n 19: \"TimerCanceled\",\n 20: \"WorkflowExecutionCancelRequested\",\n 21: \"WorkflowExecutionCanceled\",\n 24: \"WorkflowExecutionSignaled\", // not 22/23 which were removed\n 25: \"WorkflowExecutionTerminated\",\n 26: \"WorkflowExecutionSignaled\",\n 29: \"WorkflowPropertiesModified\",\n 40: \"ChildWorkflowExecutionStarted\",\n 41: \"ChildWorkflowExecutionCompleted\",\n 42: \"ChildWorkflowExecutionFailed\",\n 43: \"ChildWorkflowExecutionCanceled\",\n 44: \"ChildWorkflowExecutionTimedOut\",\n 45: \"ChildWorkflowExecutionTerminated\",\n 46: \"WorkflowPropertiesModified\",\n 48: \"NexusOperationScheduled\",\n 49: \"NexusOperationStarted\",\n 50: \"NexusOperationCompleted\",\n 51: \"NexusOperationFailed\",\n 52: \"NexusOperationCanceled\",\n 53: \"NexusOperationTimedOut\",\n};\n\n// Workflow status enum \u2192 name\nconst STATUS_NAMES: Record<number, string> = {\n 0: \"UNSPECIFIED\",\n 1: \"RUNNING\",\n 2: \"COMPLETED\",\n 3: \"FAILED\",\n 4: \"CANCELED\",\n 5: \"TERMINATED\",\n 6: \"CONTINUED_AS_NEW\",\n 7: \"TIMED_OUT\",\n};\n\n// \u2500\u2500 Commands \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function describeWorkflow(\n workflowId: string,\n runId: string | undefined,\n options: { stack?: string; address?: string; namespace?: string },\n): Promise<void> {\n const conn = await connect(options);\n try {\n const desc = await conn.connection.workflowService.describeWorkflowExecution({\n namespace: conn.namespace,\n execution: { workflowId, runId },\n });\n\n const info = desc.workflowExecutionInfo;\n if (!info) {\n logError(\"No workflow execution info returned.\");\n return;\n }\n\n console.log(`Workflow: ${workflowId}`);\n console.log(`Run ID: ${info.execution?.runId ?? runId ?? \"?\"}`);\n console.log(`Type: ${info.type?.name ?? \"?\"}`);\n console.log(`Status: ${STATUS_NAMES[info.status ?? 0] ?? info.status}`);\n console.log(`Task Queue: ${info.taskQueue ?? \"?\"}`);\n console.log(`Namespace: ${conn.namespace}`);\n\n // Pending activities\n const pending = desc.pendingActivities ?? [];\n if (pending.length > 0) {\n console.log(`\\nPending Activities (${pending.length}):`);\n for (const pa of pending) {\n console.log(` - ${pa.activityType?.name ?? \"?\"} (attempt ${pa.attempt}, state: ${pa.state})`);\n }\n }\n\n // Pending nexus operations\n const pendingNexus = desc.pendingNexusOperations ?? [];\n if (pendingNexus.length > 0) {\n console.log(`\\nPending Nexus Operations (${pendingNexus.length}):`);\n for (const pn of pendingNexus) {\n console.log(` - ${pn.operation ?? \"?\"} \u2192 ${pn.endpoint ?? \"?\"} (state: ${pn.state})`);\n }\n }\n } finally {\n conn.connection.close();\n }\n}\n\n/**\n * Page through a workflow's execution history, yielding each event in order.\n * Shared by `history` (existing-history phase) and `recover-conversation` so the\n * pagination logic lives in one place. Follow-mode long-polling is separate\n * (it uses `waitNewEvent` + terminal detection) and stays in `showHistory`.\n */\nasync function* iterHistoryEvents(\n workflowService: any,\n namespace: string,\n workflowId: string,\n runId: string | undefined,\n maximumPageSize = 100,\n): AsyncGenerator<any> {\n let nextPageToken: Uint8Array | undefined;\n do {\n const resp = await workflowService.getWorkflowExecutionHistory({\n namespace,\n execution: { workflowId, runId },\n maximumPageSize,\n nextPageToken,\n });\n\n for (const event of resp.history?.events ?? []) {\n yield event;\n }\n\n nextPageToken = resp.nextPageToken?.length ? resp.nextPageToken : undefined;\n } while (nextPageToken);\n}\n\nasync function showHistory(\n workflowId: string,\n runId: string | undefined,\n options: {\n stack?: string;\n address?: string;\n namespace?: string;\n limit?: string;\n compact?: boolean;\n follow?: boolean;\n showPayloads?: boolean;\n },\n): Promise<void> {\n const conn = await connect(options);\n const maxEvents = options.follow ? Infinity : parseInt(options.limit ?? \"200\", 10);\n\n // Compact mode skips WorkflowTask* events (scheduling noise)\n const skipTypes = new Set([5, 6, 7, 8, 9]); // WorkflowTask*\n\n // Resolve the per-app encoding key lazily \u2014 only when the caller asks for\n // payload bodies. Failure to resolve is non-fatal: we print the history\n // without payloads and the warning from buildDecrypterForNamespace tells\n // the user why.\n const decrypter: PayloadDecrypter | undefined = options.showPayloads\n ? await buildDecrypterForNamespace(conn.namespace)\n : undefined;\n\n try {\n let totalShown = 0;\n let lastEventId = 0;\n\n console.log(`History for ${workflowId} (namespace: ${conn.namespace})${options.follow ? \" [following]\" : \"\"}\\n`);\n\n // Fetch existing history first\n for await (const event of iterHistoryEvents(\n conn.connection.workflowService,\n conn.namespace,\n workflowId,\n runId,\n )) {\n lastEventId = Number(event.eventId);\n const eventType = event.eventType ?? 0;\n if (options.compact !== false && skipTypes.has(eventType)) continue;\n await printEvent(event, decrypter);\n totalShown++;\n if (totalShown >= maxEvents) break;\n }\n\n if (!options.follow) {\n console.log(`\\n${totalShown} events shown.`);\n return;\n }\n\n // Follow mode: long-poll for new events until workflow completes\n const terminalTypes = new Set([2, 3, 4, 21, 27, 28]); // Completed, Failed, TimedOut, Canceled, Terminated, ContinuedAsNew\n let done = false;\n\n while (!done) {\n try {\n const resp = await conn.connection.workflowService.getWorkflowExecutionHistory({\n namespace: conn.namespace,\n execution: { workflowId, runId },\n maximumPageSize: 100,\n waitNewEvent: true,\n // Start after the last event we've seen\n ...(lastEventId > 0 ? { skipArchival: true } : {}),\n });\n\n for (const event of resp.history?.events ?? []) {\n const eid = Number(event.eventId);\n if (eid <= lastEventId) continue; // already shown\n lastEventId = eid;\n\n const eventType = event.eventType ?? 0;\n if (options.compact !== false && skipTypes.has(eventType)) continue;\n await printEvent(event, decrypter);\n totalShown++;\n\n // Check if workflow reached a terminal state\n if (terminalTypes.has(eventType)) {\n done = true;\n }\n }\n } catch (err: any) {\n // Connection reset or workflow completed between polls\n if (err?.code === 5 /* NOT_FOUND */ || err?.message?.includes(\"not found\")) {\n console.log(\"\\nWorkflow completed or not found.\");\n done = true;\n } else {\n throw err;\n }\n }\n }\n\n console.log(`\\n${totalShown} events shown (workflow ended).`);\n } finally {\n conn.connection.close();\n }\n}\n\nasync function printEvent(\n event: any,\n decrypter: PayloadDecrypter | undefined,\n): Promise<void> {\n const eventType = event.eventType ?? 0;\n const typeName = EVENT_TYPE_NAMES[eventType] ?? `Unknown(${eventType})`;\n const id = String(event.eventId).padStart(4);\n const detail = extractEventDetail(event);\n const detailStr = detail ? ` \u2014 ${detail}` : \"\";\n console.log(`${id} ${typeName}${detailStr}`);\n\n if (!decrypter) return;\n\n // Print any named payload groups attached to this event \u2014 activity input/\n // output, signal input, workflow start args, etc. \u2014 decrypted and pretty-\n // printed under the header line with a 6-space indent so they align under\n // the event ID column.\n for (const group of extractEventPayloads(event)) {\n const rendered = await renderPayloads(group.payloads, decrypter);\n if (rendered === null) continue;\n console.log(` ${group.label}:`);\n for (const line of rendered.split(\"\\n\")) {\n console.log(` ${line}`);\n }\n }\n}\n\ninterface PayloadGroup {\n label: string;\n payloads: unknown[];\n}\n\n/**\n * Pick out the payload-bearing fields on a history event. Returns an empty\n * array for events that carry no interesting data (workflow task lifecycle,\n * timers, property modifications, etc.).\n */\nfunction extractEventPayloads(event: any): PayloadGroup[] {\n const groups: PayloadGroup[] = [];\n const pushIfAny = (label: string, payloads: unknown[] | undefined | null) => {\n if (payloads && payloads.length > 0) groups.push({ label, payloads });\n };\n\n if (event.workflowExecutionStartedEventAttributes) {\n pushIfAny(\"input\", event.workflowExecutionStartedEventAttributes.input?.payloads);\n }\n if (event.workflowExecutionCompletedEventAttributes) {\n pushIfAny(\"result\", event.workflowExecutionCompletedEventAttributes.result?.payloads);\n }\n if (event.workflowExecutionFailedEventAttributes) {\n const failure = event.workflowExecutionFailedEventAttributes.failure;\n if (failure?.message) groups.push({ label: \"failure\", payloads: [{ __failure: failure }] });\n }\n if (event.workflowExecutionSignaledEventAttributes) {\n pushIfAny(\"signal input\", event.workflowExecutionSignaledEventAttributes.input?.payloads);\n }\n if (event.activityTaskScheduledEventAttributes) {\n pushIfAny(\"input\", event.activityTaskScheduledEventAttributes.input?.payloads);\n }\n if (event.activityTaskCompletedEventAttributes) {\n pushIfAny(\"result\", event.activityTaskCompletedEventAttributes.result?.payloads);\n }\n if (event.activityTaskFailedEventAttributes) {\n const failure = event.activityTaskFailedEventAttributes.failure;\n if (failure?.message) groups.push({ label: \"failure\", payloads: [{ __failure: failure }] });\n }\n if (event.nexusOperationScheduledEventAttributes) {\n const input = event.nexusOperationScheduledEventAttributes.input;\n if (input) pushIfAny(\"input\", [input]);\n }\n if (event.nexusOperationCompletedEventAttributes) {\n const result = event.nexusOperationCompletedEventAttributes.result;\n if (result) pushIfAny(\"result\", [result]);\n }\n if (event.nexusOperationFailedEventAttributes) {\n const failure = event.nexusOperationFailedEventAttributes.failure;\n if (failure?.message) groups.push({ label: \"failure\", payloads: [{ __failure: failure }] });\n }\n return groups;\n}\n\n/**\n * Render one or more payloads into a multi-line string, or return `null`\n * when nothing rendered (e.g. all payloads were non-encrypted and empty).\n *\n * Failures (`{ __failure: ... }`) are rendered specially since they're a\n * structured error, not an encrypted payload.\n */\nasync function renderPayloads(\n payloads: unknown[],\n decrypter: PayloadDecrypter,\n): Promise<string | null> {\n const parts: string[] = [];\n for (const payload of payloads) {\n if (payload && typeof payload === \"object\" && \"__failure\" in payload) {\n const failure = (payload as { __failure: { message?: string; stackTrace?: string; cause?: unknown } }).__failure;\n parts.push(renderFailure(failure));\n continue;\n }\n const decoded = await decrypter(payload as Parameters<PayloadDecrypter>[0]);\n if (decoded === null) {\n parts.push(\"[non-encrypted payload]\");\n } else {\n parts.push(decoded);\n }\n }\n return parts.length > 0 ? parts.join(\"\\n---\\n\") : null;\n}\n\nfunction renderFailure(failure: { message?: string; stackTrace?: string; cause?: unknown }): string {\n const lines = [failure.message ?? \"(no message)\"];\n if (failure.stackTrace) lines.push(failure.stackTrace);\n return lines.join(\"\\n\");\n}\n\n/** Extract a human-readable detail string from event attributes. */\nfunction extractEventDetail(event: any): string | null {\n // Activity scheduled\n if (event.activityTaskScheduledEventAttributes) {\n const a = event.activityTaskScheduledEventAttributes;\n return `activity: ${a.activityType?.name ?? \"?\"}, queue: ${a.taskQueue?.name ?? \"?\"}`;\n }\n // Activity completed\n if (event.activityTaskCompletedEventAttributes) {\n return `scheduledId: ${event.activityTaskCompletedEventAttributes.scheduledEventId}`;\n }\n // Activity failed\n if (event.activityTaskFailedEventAttributes) {\n const a = event.activityTaskFailedEventAttributes;\n const msg = a.failure?.message ?? \"\";\n return `scheduledId: ${a.scheduledEventId}${msg ? `, error: ${msg.slice(0, 120)}` : \"\"}`;\n }\n // Activity timed out\n if (event.activityTaskTimedOutEventAttributes) {\n return `scheduledId: ${event.activityTaskTimedOutEventAttributes.scheduledEventId}`;\n }\n // Nexus scheduled\n if (event.nexusOperationScheduledEventAttributes) {\n const a = event.nexusOperationScheduledEventAttributes;\n return `endpoint: ${a.endpoint ?? \"?\"}, operation: ${a.operation ?? \"?\"}, service: ${a.service ?? \"?\"}`;\n }\n // Nexus completed/failed/timed out\n if (event.nexusOperationCompletedEventAttributes) {\n return `scheduledId: ${event.nexusOperationCompletedEventAttributes.scheduledEventId}`;\n }\n if (event.nexusOperationFailedEventAttributes) {\n const a = event.nexusOperationFailedEventAttributes;\n return `scheduledId: ${a.scheduledEventId}, error: ${a.failure?.message?.slice(0, 120) ?? \"?\"}`;\n }\n if (event.nexusOperationTimedOutEventAttributes) {\n return `scheduledId: ${event.nexusOperationTimedOutEventAttributes.scheduledEventId}`;\n }\n // Signal\n if (event.workflowExecutionSignaledEventAttributes) {\n return `signal: ${event.workflowExecutionSignaledEventAttributes.signalName ?? \"?\"}`;\n }\n // Workflow started\n if (event.workflowExecutionStartedEventAttributes) {\n const a = event.workflowExecutionStartedEventAttributes;\n return `type: ${a.workflowType?.name ?? \"?\"}, queue: ${a.taskQueue?.name ?? \"?\"}`;\n }\n // Workflow completed/failed\n if (event.workflowExecutionCompletedEventAttributes) return null;\n if (event.workflowExecutionFailedEventAttributes) {\n return `error: ${event.workflowExecutionFailedEventAttributes.failure?.message?.slice(0, 120) ?? \"?\"}`;\n }\n // Timer\n if (event.timerStartedEventAttributes) {\n const dur = event.timerStartedEventAttributes.startToFireTimeout;\n return `timerId: ${event.timerStartedEventAttributes.timerId}, duration: ${dur?.seconds ?? \"?\"}s`;\n }\n if (event.timerFiredEventAttributes) {\n return `timerId: ${event.timerFiredEventAttributes.timerId}`;\n }\n return null;\n}\n\n/**\n * Reconstruct an agent conversation's transcript from its durable Temporal\n * history \u2014 for when the worker can no longer replay it (code drift) but the\n * history is intact. Fetches the full history (same client/decrypt path as\n * `history`), classifies the `submit_user_message` signals (human turns) and\n * `callLLM` activity completions (assistant turns), decrypts their payloads,\n * and renders an ordered transcript.\n */\nasync function recoverConversation(\n workflowId: string,\n runId: string | undefined,\n options: {\n stack?: string;\n address?: string;\n namespace?: string;\n out?: string;\n json?: boolean;\n snapshot?: boolean;\n },\n): Promise<void> {\n const conn = await connect(options);\n\n // The history is encrypted \u2014 without the key there's nothing to reconstruct,\n // so (unlike `history`) a missing key is fatal here.\n const decrypter = await buildDecrypterForNamespace(conn.namespace);\n if (!decrypter) {\n conn.connection.close();\n throw new Error(\n \"Cannot decrypt conversation payloads: no encoding key available.\\n\" +\n \"Set TEMPORAL_ENCODING_KEY (e.g. from a `mesh dev` worker env) or configure \" +\n \"kubectl namespace read access, then retry.\",\n );\n }\n\n try {\n // Decode one payload group (signal input / activity result) into a plain\n // object. The decrypter yields pretty-printed JSON; parse it back. Any\n // failure returns undefined so the pure reconstructor emits a placeholder.\n const decodePayload = async (\n payloads: unknown[] | undefined | null,\n ): Promise<unknown> => {\n const first = payloads?.[0];\n if (first === undefined) return undefined;\n const decoded = await decrypter(first as Parameters<typeof decrypter>[0]);\n if (decoded === null) return undefined;\n try {\n return JSON.parse(decoded);\n } catch {\n return undefined;\n }\n };\n\n // Collect the full history in order, then classify it (which activity\n // completions were `callLLM`, which signals were `submit_user_message`)\n // via the shared pure helper.\n const rawEvents = [];\n for await (const event of iterHistoryEvents(\n conn.connection.workflowService,\n conn.namespace,\n workflowId,\n runId,\n )) {\n rawEvents.push(event);\n }\n\n // --snapshot: emit the store-ready conversation blob with STRUCTURED\n // ModelMessage[] (tool-call parts intact) for a full-fidelity backfill,\n // rather than the flattened text turns. artifacts/focus are left empty \u2014\n // the artifact index isn't reconstructable from history here, and the store\n // defaults them.\n if (options.snapshot) {\n const messages = await extractSnapshotMessages(rawEvents, decodePayload);\n const blob = {\n conversationId: workflowId,\n messages,\n artifacts: [] as unknown[],\n focus: null,\n };\n const output = JSON.stringify(blob, null, 2) + \"\\n\";\n if (options.out) {\n writeFileSync(options.out, output);\n logSuccess(\n `Wrote snapshot (${messages.length} messages) to ${options.out}`,\n );\n } else {\n process.stdout.write(output);\n }\n return;\n }\n\n const events = await classifyHistoryEvents(rawEvents, decodePayload);\n const turns = reconstructTranscript(events);\n\n const output = options.json\n ? JSON.stringify(\n {\n turns: turns.map((t) => ({\n role: t.role,\n text: t.text,\n ...(t.toolCalls ? { toolCalls: t.toolCalls } : {}),\n })),\n },\n null,\n 2,\n ) + \"\\n\"\n : renderTranscriptMarkdown(turns);\n\n if (options.out) {\n writeFileSync(options.out, output);\n logSuccess(`Wrote ${turns.length} turns to ${options.out}`);\n } else {\n process.stdout.write(output);\n }\n } finally {\n conn.connection.close();\n }\n}\n\n/**\n * Capture a workflow's FULL durable Temporal history to a replay fixture JSON\n * file \u2014 for LOCAL replay debugging of a specific production break. This is the\n * real-history counterpart of the CI gate's synthetic golden fixtures: the\n * output is the same proto3-JSON `{ events }` shape `@mesh-tech/agent-sdk`'s\n * `historyToFixture` produces, so it runs through `replayFixture` locally. It\n * is never committed to the replay fixtures dir \u2014 those are synthetic goldens;\n * this file holds real decrypted production data (see warning below).\n *\n * Payloads are DECRYPTED (via the same codec `history --show-payloads` uses) so\n * the fixture replays under agent-sdk's plaintext converter. Because that means\n * the file contains real customer/conversation data, the default output path is\n * `~/.mesh/replay-histories/` \u2014 outside any git repo \u2014 and a loud warning is\n * printed. An explicit `--out` inside a git repo is checked with\n * `git check-ignore`; a non-ignored path gets a loud warning (but is not\n * blocked). Like `recover-conversation`, a missing encoding key is fatal \u2014 an\n * encrypted fixture can't be replayed.\n */\nasync function captureHistory(\n workflowId: string,\n runId: string | undefined,\n options: {\n stack?: string;\n address?: string;\n namespace?: string;\n out?: string;\n },\n): Promise<void> {\n const conn = await connect(options);\n\n // A fixture whose payloads stay encrypted can't be replayed (the replayer\n // uses a codec-free converter), so \u2014 like recover-conversation \u2014 treat a\n // missing key as fatal rather than emitting a useless file.\n const decrypter = await buildRawDecrypterForNamespace(conn.namespace);\n if (!decrypter) {\n conn.connection.close();\n throw new Error(\n \"Cannot decrypt history payloads: no encoding key available.\\n\" +\n \"A replay fixture needs decrypted payloads. Set TEMPORAL_ENCODING_KEY \" +\n \"(e.g. from a `mesh dev` worker env) or configure kubectl namespace read \" +\n \"access, then retry.\",\n );\n }\n\n try {\n const events: unknown[] = [];\n for await (const event of iterHistoryEvents(\n conn.connection.workflowService,\n conn.namespace,\n workflowId,\n runId,\n )) {\n events.push(event);\n }\n\n if (events.length === 0) {\n throw new Error(\n `No history events found for workflow '${workflowId}'` +\n (runId ? ` (run ${runId})` : \"\") +\n `. Check the workflow id and namespace (${conn.namespace}).`,\n );\n }\n\n // Lazy import \u2014 pulls in @temporalio/common only when actually capturing,\n // so the CLI still loads without the Temporal SDK installed.\n const { serializeHistoryToFixture } = await import(\n \"../utils/capture-history.js\"\n );\n const { fixture, eventCount, decryptedPayloads } =\n await serializeHistoryToFixture(events, decrypter);\n\n const outPath =\n options.out ??\n join(homedir(), \".mesh\", \"replay-histories\", `${sanitizeFileId(workflowId)}.json`);\n mkdirSync(dirname(outPath), { recursive: true });\n if (options.out) warnIfNotGitIgnored(options.out);\n writeFileSync(outPath, JSON.stringify(fixture, null, 2) + \"\\n\");\n\n logSuccess(\n `Wrote replay history (${eventCount} events, ${decryptedPayloads} payloads decrypted) to ${outPath}`,\n );\n logWarn(\n \"\u26A0 This file contains DECRYPTED payloads \u2014 real customer/conversation \" +\n \"data.\\n\" +\n \" For LOCAL replay debugging ONLY. Do NOT commit it. (CI golden \" +\n \"fixtures are synthetic; this is the debugging counterpart.)\",\n );\n } finally {\n conn.connection.close();\n }\n}\n\n/** Make a workflow id safe to use as a filename (slashes, spaces, etc.). */\nfunction sanitizeFileId(id: string): string {\n return id.replace(/[^A-Za-z0-9._-]/g, \"_\");\n}\n\n/**\n * A replay fixture holds real DECRYPTED production data, so when the user\n * points `--out` at a path inside a git repo we check whether that path is\n * git-ignored (`git check-ignore`) and print a LOUD warning when it is not \u2014\n * we warn, we don't block. Best-effort: silent when git is unavailable or the\n * path is outside any repo (exit code 128), since there is nothing to commit\n * it into.\n */\nfunction warnIfNotGitIgnored(outPath: string): void {\n const abs = resolve(outPath);\n try {\n // Exit 0 = ignored, 1 = in a repo but NOT ignored, 128 = not a repo.\n const res = spawnSync(\"git\", [\"-C\", dirname(abs), \"check-ignore\", \"-q\", abs], {\n stdio: \"ignore\",\n });\n if (res.status !== 1) return;\n } catch {\n return; // git missing/unusable \u2014 can't check, stay quiet\n }\n logWarn(\n \"\u26A0\u26A0\u26A0 OUTPUT PATH IS INSIDE A GIT REPO AND IS *NOT* GITIGNORED \u26A0\u26A0\u26A0\\n\" +\n ` ${abs}\\n` +\n \" This fixture contains DECRYPTED production payloads. Add it to \" +\n \".gitignore or move it outside the repo \u2014 do NOT commit it.\",\n );\n}\n\nasync function terminateWorkflow(\n workflowId: string,\n runId: string | undefined,\n options: { stack?: string; address?: string; namespace?: string; reason?: string },\n): Promise<void> {\n const conn = await connect(options);\n try {\n await conn.connection.workflowService.terminateWorkflowExecution({\n namespace: conn.namespace,\n workflowExecution: { workflowId, runId },\n reason: options.reason ?? \"terminated via mesh temporal\",\n identity: \"mesh-cli\",\n });\n logSuccess(\n `Terminated ${workflowId}${runId ? ` (run ${runId})` : \"\"} in namespace ${conn.namespace}`,\n );\n } finally {\n conn.connection.close();\n }\n}\n\n/**\n * Open a raw Temporal client connection for this app's stack, with the Zitadel\n * Bearer attached when the server enforces auth.\n *\n * Exported (`@mesh-tech/mesh-cli/temporal`) for CLIs built beside this one, such as mesh-studio's\n * `studio workspace`, which reads and signals the workspace environment\n * entity over the same connection and the same auth resolution \u2014 one spelling of\n * \"how the CLI reaches this app's Temporal\", not two.\n *\n * @example\n * const { connection, namespace } = await connect({ stack: \"dev-trabianmatt\" });\n * try {\n * // \u2026use `connection` with @temporalio/client\u2026\n * } finally {\n * connection.close();\n * }\n */\nexport async function connect(\n options: { stack?: string; address?: string; namespace?: string },\n) {\n const info = await resolveConnection(options);\n logInfo(`Connecting to ${info.address} (namespace: ${info.namespace})`);\n\n // Dynamic import \u2014 @temporalio/client is only available in workspaces\n // that have it installed. The mesh-cli uses it from the monorepo.\n const { Connection } = await import(\"@temporalio/client\");\n\n // Attach a Zitadel Bearer when the server enforces auth. Without it the\n // MeshAuthorizer denies calls with \"Request unauthorized\" (e.g. terminate).\n const token = await resolveBearerToken(info.auth);\n const metadata = token ? { authorization: `Bearer ${token}` } : undefined;\n // Raise the gRPC receive cap well above the 4 MiB default. Every command here\n // is a whole-history read, and the histories worth reading are the big ones:\n // `capture-history` and `recover-conversation` exist precisely for stuck or\n // dead conversations, which are stuck or dead BECAUSE their history grew. At\n // the default cap a conversation Temporal itself terminated for \"history size\n // exceeds limit\" cannot be recovered by the tool built to recover it \u2014\n // `8 RESOURCE_EXHAUSTED: Received message larger than max (5014664 vs 4194304)`.\n // 256 MiB comfortably exceeds Temporal's own per-workflow history size limit,\n // so the cap is not the thing that stops a recovery.\n const connection = await Connection.connect({\n address: info.address,\n metadata,\n channelArgs: { \"grpc.max_receive_message_length\": 256 * 1024 * 1024 },\n });\n\n return { connection, namespace: info.namespace };\n}\n\n// \u2500\u2500 Registration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport function registerTemporalCommands(program: Command): void {\n const temporal = program\n .command(\"temporal\")\n .description(\"Inspect Temporal workflows for the current app/stack\");\n\n const sharedOpts = (cmd: Command) =>\n cmd\n .option(\"--stack <stack>\", \"Pulumi stack name (auto-detected from current directory)\")\n .option(\"--address <addr>\", \"Temporal server address (default: from stack output)\")\n .option(\"--namespace <ns>\", \"Temporal namespace (default: from stack output)\");\n\n sharedOpts(\n temporal\n .command(\"describe <workflowId> [runId]\")\n .description(\"Show workflow status and metadata\"),\n ).action(async (workflowId: string, runId: string | undefined, opts) => {\n try {\n await describeWorkflow(workflowId, runId, opts);\n } catch (err) {\n logError(err instanceof Error ? err.message : String(err));\n process.exitCode = 1;\n }\n });\n\n sharedOpts(\n temporal\n .command(\"terminate <workflowId> [runId]\")\n .description(\"Terminate a running workflow (e.g. a wedged/looping conversation)\"),\n )\n .option(\"--reason <reason>\", \"Termination reason (recorded in workflow history)\")\n .action(async (workflowId: string, runId: string | undefined, opts) => {\n try {\n await terminateWorkflow(workflowId, runId, opts);\n } catch (err) {\n logError(err instanceof Error ? err.message : String(err));\n process.exitCode = 1;\n }\n });\n\n sharedOpts(\n temporal\n .command(\"history <workflowId> [runId]\")\n .description(\"Show workflow event history\")\n .option(\"-n, --limit <n>\", \"Maximum events to show (default: 200)\")\n .option(\"-f, --follow\", \"Tail the history, waiting for new events until the workflow completes\")\n .option(\"--no-compact\", \"Show all events including WorkflowTask scheduling noise\")\n .option(\"-p, --show-payloads\", \"Decrypt and print activity inputs/outputs (reads TEMPORAL_ENCODING_KEY from the app's K8s secret via kubectl)\"),\n ).action(async (workflowId: string, runId: string | undefined, opts) => {\n try {\n await showHistory(workflowId, runId, opts);\n } catch (err) {\n logError(err instanceof Error ? err.message : String(err));\n process.exitCode = 1;\n }\n });\n\n sharedOpts(\n temporal\n .command(\"recover-conversation <workflowId> [runId]\")\n .description(\n \"Reconstruct an agent conversation transcript from durable Temporal history \" +\n \"(for when the worker can no longer replay it). Requires the encoding key \u2014 \" +\n \"reads TEMPORAL_ENCODING_KEY (or the app's K8s secret).\",\n )\n .option(\"--out <path>\", \"Write the transcript to a file instead of stdout\")\n .option(\"--json\", \"Emit a structured { turns: [...] } JSON array instead of markdown\")\n .option(\n \"--snapshot\",\n \"Emit a store-ready conversation blob { conversationId, messages, artifacts, focus } \" +\n \"with STRUCTURED ModelMessage[] (tool-call parts intact) for a full-fidelity backfill\",\n ),\n ).action(async (workflowId: string, runId: string | undefined, opts) => {\n try {\n await recoverConversation(workflowId, runId, opts);\n } catch (err) {\n logError(err instanceof Error ? err.message : String(err));\n process.exitCode = 1;\n }\n });\n\n sharedOpts(\n temporal\n .command(\"capture-history <workflowId> [runId]\")\n .description(\n \"Capture a workflow's full history to a replay fixture JSON (proto3-JSON, \" +\n \"DECRYPTED payloads) for LOCAL replay debugging. Requires the encoding key. \" +\n \"Defaults to ~/.mesh/replay-histories/<workflowId>.json (outside any repo) \u2014 \" +\n \"do NOT commit the output.\",\n )\n .option(\n \"--out <path>\",\n \"Output path (default: ~/.mesh/replay-histories/<workflowId>.json; a non-gitignored \" +\n \"path inside a git repo triggers a loud warning)\",\n ),\n ).action(async (workflowId: string, runId: string | undefined, opts) => {\n try {\n await captureHistory(workflowId, runId, opts);\n } catch (err) {\n logError(err instanceof Error ? err.message : String(err));\n process.exitCode = 1;\n }\n });\n}\n", "/**\n * Reconstruct an agent conversation's transcript from decoded Temporal history.\n *\n * An agent conversation is a Temporal `agentWorkflow`. Its durable history holds\n * everything needed to replay the human/agent exchange even when the worker can\n * no longer run the workflow (code drift):\n *\n * - `WorkflowExecutionSignaled` with signal name `submit_user_message` \u2192\n * one HUMAN turn. Its input payload is `SubmitUserMessageParams`\n * (agent-sdk `agent-core/workflows/signals.ts`): `{ model, messages,\n * streamId, ... }` where `messages` is a Vercel-AI `ModelMessage[]`; the\n * user text lives in the `user`-role message(s).\n * - `callLLM` `ActivityTaskCompleted` \u2192 one ASSISTANT turn. Its result is\n * `LLMResult` (agent-sdk `agent-core/types.ts`): `{ messages, usage?,\n * telemetry? }` where `messages` is the model's output `ModelMessage[]` \u2014\n * `assistant`-role text parts plus any `tool-call` parts.\n *\n * This module is the PURE reconstruction core: it takes events whose payloads\n * have ALREADY been decrypted into plain objects and returns ordered turns. The\n * Temporal fetch + decrypt wiring lives in `commands/temporal.ts`.\n *\n * Everything here is best-effort and defensive: payload shapes vary across\n * agent-sdk versions, so every field access is guarded and a turn that can't be\n * parsed becomes a placeholder rather than throwing \u2014 the whole point is to\n * recover *something* from an otherwise-unreplayable history.\n */\n\n/** Classification of a single decoded history event. */\nexport interface DecodedEvent {\n /** Temporal event id (stringified). Used in placeholders. */\n eventId: string;\n /** Which kind of conversation event this is. */\n kind: \"userSignal\" | \"callLLMResult\" | \"other\";\n /**\n * The decoded payload object for this event:\n * - `userSignal` \u2192 the `submit_user_message` signal input (SubmitUserMessageParams)\n * - `callLLMResult` \u2192 the `callLLM` activity result (LLMResult)\n * - `other` \u2192 unused\n */\n payload?: unknown;\n}\n\n/**\n * Minimal shape of a raw Temporal history event as consumed by\n * `classifyHistoryEvents`. Only the attribute groups we classify are declared;\n * everything else is ignored. Field access is defensive so partial/variant\n * shapes (across Temporal SDK versions) don't throw.\n */\nexport interface RawHistoryEvent {\n eventId?: unknown;\n activityTaskScheduledEventAttributes?: {\n activityType?: { name?: string | null } | null;\n /**\n * The activity's INPUT payload group. For `callLLM` this is `callLLMArgs`\n * (`{ model, messages }`) whose `messages` is the FULL accumulated\n * conversation \u2014 the highest-fidelity snapshot source.\n */\n input?: { payloads?: unknown[] | null } | null;\n } | null;\n workflowExecutionSignaledEventAttributes?: {\n signalName?: string | null;\n input?: { payloads?: unknown[] | null } | null;\n } | null;\n activityTaskCompletedEventAttributes?: {\n scheduledEventId?: unknown;\n result?: { payloads?: unknown[] | null } | null;\n } | null;\n // Other attribute groups exist on real events but are not classified here.\n [k: string]: unknown;\n}\n\n/**\n * Decrypt+parse a payload group (the `payloads` array from a signal input or\n * activity result) into a plain object. Returns `undefined` when nothing could\n * be decoded, so the pure reconstructor emits a placeholder. Injected so the\n * classification stays pure and unit-testable without a live Temporal codec.\n */\nexport type DecodePayload = (\n payloads: unknown[] | undefined | null,\n) => Promise<unknown>;\n\n/**\n * Classify a raw Temporal history into the ordered `DecodedEvent[]` that\n * `reconstructTranscript` consumes.\n *\n * `ActivityTaskCompleted` events carry only a `scheduledEventId`, not the\n * activity's type name \u2014 so to tell which completions came from `callLLM` we\n * build a map of `ActivityTaskScheduled.eventId \u2192 activityType.name` as we walk\n * the history in event order, then look each completion's `scheduledEventId` up\n * in it. Only `callLLM` completions become `callLLMResult` (assistant) events;\n * completions of other activities (e.g. `routeTools`) are dropped.\n * `submit_user_message` signals become `userSignal` (human) events.\n *\n * Pure aside from the injected `decodePayload` (which performs the decrypt):\n * never throws, and preserves history order.\n */\nexport async function classifyHistoryEvents(\n rawEvents: Iterable<RawHistoryEvent>,\n decodePayload: DecodePayload,\n): Promise<DecodedEvent[]> {\n // scheduledEventId \u2192 activity type name, so we can tell which\n // ActivityTaskCompleted events came from `callLLM`.\n const activityTypeByScheduledId = new Map<string, string>();\n const events: DecodedEvent[] = [];\n\n for (const event of rawEvents) {\n const eventId = String(event.eventId);\n\n const scheduled = event.activityTaskScheduledEventAttributes;\n if (scheduled) {\n const name = scheduled.activityType?.name;\n if (name) activityTypeByScheduledId.set(eventId, name);\n continue;\n }\n\n const signal = event.workflowExecutionSignaledEventAttributes;\n if (signal?.signalName === \"submit_user_message\") {\n events.push({\n eventId,\n kind: \"userSignal\",\n payload: await decodePayload(signal.input?.payloads),\n });\n continue;\n }\n\n const completed = event.activityTaskCompletedEventAttributes;\n if (completed) {\n const scheduledId = String(completed.scheduledEventId);\n if (activityTypeByScheduledId.get(scheduledId) === \"callLLM\") {\n events.push({\n eventId,\n kind: \"callLLMResult\",\n payload: await decodePayload(completed.result?.payloads),\n });\n }\n }\n }\n\n return events;\n}\n\n/**\n * A Vercel-AI `ModelMessage` as it appears in decoded history \u2014 kept structural\n * (not flattened to text) so tool-call / tool-result content parts survive into\n * the snapshot. Typed loosely on purpose: shapes vary across agent-sdk versions\n * and the snapshot is a best-effort passthrough of whatever was persisted.\n */\nexport type ModelMessage = { role?: string; content?: unknown } & Record<\n string,\n unknown\n>;\n\n/**\n * Extract the highest-fidelity STRUCTURED transcript for a store-ready snapshot\n * backfill \u2014 the complete `ModelMessage[]` with tool-call/tool-result parts\n * intact (as opposed to `reconstructTranscript`'s flattened text turns).\n *\n * The source is the `callLLM` activity's SCHEDULED input, `callLLMArgs`\n * (`{ model, messages }`), whose `messages` is the FULL accumulated conversation\n * (user + assistant-with-tool-calls + tool-result messages) up to \u2014 but not\n * including \u2014 that turn's assistant output. We take the LAST `callLLM`\n * scheduled input (the most-accumulated one) and append the LAST `callLLM`\n * completion's output messages (`LLMResult.messages`, the final assistant turn):\n *\n * [...lastScheduledInput.messages, ...lastCompletion.messages]\n *\n * Completions are matched to `callLLM` via `scheduledEventId` (same mapping as\n * `classifyHistoryEvents`).\n *\n * Best-effort and never throws:\n * - no `callLLM` scheduled input (0 assistant turns) \u2192 `[]`;\n * - a scheduled input but no completion (last turn interrupted) \u2192 just the\n * input messages.\n *\n * Pure aside from the injected `decodePayload` (which performs the decrypt).\n */\nexport async function extractSnapshotMessages(\n rawEvents: Iterable<RawHistoryEvent>,\n decodePayload: DecodePayload,\n): Promise<ModelMessage[]> {\n // Track the last `callLLM` scheduled input's payload group and its eventId, so\n // we can (a) decode the most-accumulated messages and (b) match its\n // completion by scheduledEventId.\n let lastCallLLMScheduledId: string | undefined;\n let lastCallLLMInputPayloads: unknown[] | null | undefined;\n // Map every scheduled activity's eventId \u2192 type name so completions resolve.\n const activityTypeByScheduledId = new Map<string, string>();\n // Track the last `callLLM` completion result payloads, keyed by matching the\n // scheduledEventId back to a `callLLM`.\n let lastCallLLMResultPayloads: unknown[] | null | undefined;\n\n for (const event of rawEvents) {\n const eventId = String(event.eventId);\n\n const scheduled = event.activityTaskScheduledEventAttributes;\n if (scheduled) {\n const name = scheduled.activityType?.name;\n if (name) activityTypeByScheduledId.set(eventId, name);\n if (name === \"callLLM\") {\n lastCallLLMScheduledId = eventId;\n lastCallLLMInputPayloads = scheduled.input?.payloads;\n // Reset the result: a completion only \"belongs\" to this newly-scheduled\n // callLLM if it arrives AFTER this point. Without the reset, a mid-turn\n // interrupted last turn (scheduled, never completed) would keep a STALE\n // earlier completion and duplicate that assistant message (it's already\n // inside this turn's accumulated input). Matches the documented contract:\n // scheduled-input-but-no-completion \u2192 just the input messages.\n lastCallLLMResultPayloads = undefined;\n }\n continue;\n }\n\n const completed = event.activityTaskCompletedEventAttributes;\n if (completed) {\n const scheduledId = String(completed.scheduledEventId);\n if (activityTypeByScheduledId.get(scheduledId) === \"callLLM\") {\n lastCallLLMResultPayloads = completed.result?.payloads;\n }\n }\n }\n\n if (lastCallLLMScheduledId === undefined) return [];\n\n const inputArgs = await decodePayload(lastCallLLMInputPayloads);\n const inputMessages = messagesFrom(inputArgs);\n\n const result = await decodePayload(lastCallLLMResultPayloads);\n const resultMessages = messagesFrom(result);\n\n return [...inputMessages, ...resultMessages];\n}\n\n/** Pull a `.messages` array off a decoded `{ messages }` payload, else `[]`. */\nfunction messagesFrom(payload: unknown): ModelMessage[] {\n if (isRecord(payload) && Array.isArray(payload.messages)) {\n return payload.messages as ModelMessage[];\n }\n return [];\n}\n\n/** One reconstructed conversation turn. */\nexport interface Turn {\n role: \"user\" | \"assistant\";\n /** Human-readable text. Empty string is valid (e.g. a tool-only assistant turn). */\n text: string;\n /** Formatted tool-call summaries (`name(compactArgs)`), assistant turns only. */\n toolCalls?: string[];\n /** Originating Temporal event id. */\n eventId: string;\n}\n\n// \u2500\u2500 Extraction helpers (all defensive) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Pull display text out of a ModelMessage `content` (string, or parts array). */\nfunction extractText(content: unknown): string {\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n return content\n .filter(\n (p): p is { type: string; text?: unknown } =>\n !!p && typeof p === \"object\" && (p as { type?: unknown }).type === \"text\",\n )\n .map((p) => (typeof p.text === \"string\" ? p.text : \"\"))\n .join(\"\");\n }\n return \"\";\n}\n\n/** Compact a tool-call's args to a single truncated JSON line. */\nfunction summarizeArgs(args: unknown): string {\n try {\n const str = JSON.stringify(args ?? {});\n return str.length > 100 ? str.substring(0, 97) + \"...\" : str;\n } catch {\n return \"...\";\n }\n}\n\n/** Format a `tool-call` content part as `name(compactArgs)`. */\nfunction formatToolCall(part: Record<string, unknown>): string {\n const name = typeof part.toolName === \"string\" ? part.toolName : \"?\";\n // AI SDK v5 uses `input`; older payloads use `args`. Accept either.\n const args = \"input\" in part ? part.input : part.args;\n return `${name}(${summarizeArgs(args)})`;\n}\n\nfunction isRecord(v: unknown): v is Record<string, unknown> {\n return !!v && typeof v === \"object\";\n}\n\n/**\n * Extract the human text from a `submit_user_message` signal payload. Returns\n * `null` when nothing recognizable is found (caller emits a placeholder).\n */\nfunction extractUserText(payload: unknown): string | null {\n if (!isRecord(payload)) return null;\n\n const messages = payload.messages;\n if (Array.isArray(messages)) {\n const parts: string[] = [];\n for (const m of messages) {\n if (isRecord(m) && m.role === \"user\") {\n const t = extractText(m.content);\n if (t) parts.push(t);\n }\n }\n if (parts.length > 0) return parts.join(\"\\n\");\n }\n\n // Defensive fallbacks for shape drift \u2014 try common single-field carriers.\n for (const field of [\"content\", \"text\", \"message\"] as const) {\n const v = payload[field];\n if (typeof v === \"string\" && v) return v;\n const t = extractText(v);\n if (t) return t;\n }\n return null;\n}\n\n/**\n * Extract the assistant text + tool calls from a `callLLM` result payload.\n * Returns `null` only when nothing at all could be parsed (no text AND no tool\n * calls) \u2014 a tool-only turn (empty text, non-empty toolCalls) is valid.\n */\nfunction extractAssistantTurn(\n payload: unknown,\n): { text: string; toolCalls: string[] } | null {\n if (!isRecord(payload)) return null;\n\n const messages = payload.messages;\n if (!Array.isArray(messages)) {\n // Defensive fallback: some shapes may carry text directly.\n const t = extractText(payload.content) || extractText(payload.text);\n return t ? { text: t, toolCalls: [] } : null;\n }\n\n let text = \"\";\n const toolCalls: string[] = [];\n for (const m of messages) {\n if (!isRecord(m) || m.role !== \"assistant\") continue;\n text += extractText(m.content);\n if (Array.isArray(m.content)) {\n for (const p of m.content) {\n if (isRecord(p) && p.type === \"tool-call\") {\n toolCalls.push(formatToolCall(p));\n }\n }\n }\n }\n\n if (!text && toolCalls.length === 0) return null;\n return { text, toolCalls };\n}\n\n// \u2500\u2500 Reconstruction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Reconstruct ordered conversation turns from decoded history events.\n *\n * Pure: no I/O, never throws. `other` events are skipped; unparseable\n * user/assistant events become placeholder turns so the recovery is complete\n * end-to-end even when individual payloads are corrupt.\n */\nexport function reconstructTranscript(events: DecodedEvent[]): Turn[] {\n const turns: Turn[] = [];\n for (const event of events) {\n if (event.kind === \"userSignal\") {\n const text = extractUserText(event.payload);\n turns.push(\n text !== null\n ? { role: \"user\", text, eventId: event.eventId }\n : { role: \"user\", text: placeholder(event.eventId), eventId: event.eventId },\n );\n } else if (event.kind === \"callLLMResult\") {\n const turn = extractAssistantTurn(event.payload);\n if (turn) {\n turns.push({\n role: \"assistant\",\n text: turn.text,\n eventId: event.eventId,\n ...(turn.toolCalls.length > 0 ? { toolCalls: turn.toolCalls } : {}),\n });\n } else {\n turns.push({\n role: \"assistant\",\n text: placeholder(event.eventId),\n eventId: event.eventId,\n });\n }\n }\n // `other` \u2192 skipped\n }\n return turns;\n}\n\nfunction placeholder(eventId: string): string {\n return `_[unparseable turn: ${eventId}]_`;\n}\n\n// \u2500\u2500 Rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** Render turns as a readable markdown transcript. */\nexport function renderTranscriptMarkdown(turns: Turn[]): string {\n const blocks: string[] = [];\n for (const turn of turns) {\n const heading = turn.role === \"user\" ? \"## User\" : \"## Assistant\";\n const lines: string[] = [heading, \"\"];\n if (turn.text) lines.push(turn.text);\n if (turn.toolCalls && turn.toolCalls.length > 0) {\n if (turn.text) lines.push(\"\");\n for (const tc of turn.toolCalls) lines.push(`\u2192 tool: ${tc}`);\n }\n blocks.push(lines.join(\"\\n\"));\n }\n return blocks.join(\"\\n\\n\") + \"\\n\";\n}\n", "/**\n * Temporal payload decryption helpers for mesh CLI.\n *\n * Mirrors the AES-256-GCM codec in `@mesh-tech/app-kit/temporal-codec`. The CLI\n * can't depend on app-kit directly because app-kit depends on mesh-cli \u2014 so\n * the codec logic is intentionally duplicated here, kept small, and bounded\n * to read-only decryption (the CLI never encrypts).\n *\n * Wire format (matches app-kit EncryptionCodec):\n * metadata[\"encoding\"] = \"binary/encrypted\"\n * metadata[\"encryption-key-id\"] = first 16 hex chars of SHA-256(encodingKey)\n * data = IV (12 bytes) || ciphertext || GCM auth tag (16 bytes)\n *\n * Decryption of `data` yields a serialized Temporal Payload protobuf, whose\n * inner `encoding` metadata tells you how to interpret the inner bytes\n * (`json/plain`, `binary/plain`, etc.). We hand-decode the Payload message\n * (a trivial 2-field protobuf) instead of depending on `@temporalio/proto`,\n * so the CLI stays dependency-clean and doesn't need a Temporal SDK peer.\n */\n\nimport { execFileSync } from \"child_process\";\nimport { webcrypto as crypto } from \"node:crypto\";\nimport { logSuccess, logWarn } from \"./log.js\";\n\n// \u2500\u2500 Encoding key resolution \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Read the Temporal encoding key from a Kubernetes secret in the given\n * namespace. Returns `undefined` when kubectl is unavailable, the secret\n * doesn't exist, or the caller isn't authenticated to the cluster.\n *\n * The K8s secret is preferred over Pulumi stack output because personal dev\n * stacks ship a placeholder encoding key that cannot decrypt deployed\n * payloads; the K8s secret is always authoritative for a given namespace.\n *\n * @param namespace - Kubernetes namespace matching the Temporal namespace\n * (mesh apps use the same name for both).\n */\nexport function resolveTemporalEncodingKeyFromK8s(\n namespace: string,\n): string | undefined {\n const secretName = `${namespace}-temporal-encoding-key`;\n try {\n const b64 = execFileSync(\n \"kubectl\",\n [\n \"get\",\n \"secret\",\n secretName,\n \"-n\",\n namespace,\n \"-o\",\n \"jsonpath={.data.TEMPORAL_ENCODING_KEY}\",\n ],\n { encoding: \"utf-8\", timeout: 10_000, stdio: [\"pipe\", \"pipe\", \"pipe\"] },\n ).trim();\n\n if (!b64) return undefined;\n return Buffer.from(b64, \"base64\").toString(\"utf-8\");\n } catch {\n // kubectl missing, secret absent, or no cluster access \u2014 caller decides.\n return undefined;\n }\n}\n\n// \u2500\u2500 AES-256-GCM decryption \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst ENCRYPTED_ENCODING = \"binary/encrypted\";\nconst IV_LENGTH = 12;\n\nasync function deriveKey(encodingKey: string): Promise<crypto.CryptoKey> {\n const keyData = await crypto.subtle.digest(\n \"SHA-256\",\n new TextEncoder().encode(encodingKey),\n );\n return crypto.subtle.importKey(\n \"raw\",\n keyData,\n { name: \"AES-GCM\" },\n false,\n [\"decrypt\"],\n );\n}\n\nasync function aesGcmDecrypt(\n data: Uint8Array,\n key: crypto.CryptoKey,\n): Promise<Uint8Array> {\n const iv = data.slice(0, IV_LENGTH);\n const ciphertextWithTag = data.slice(IV_LENGTH);\n const decrypted = await crypto.subtle.decrypt(\n { name: \"AES-GCM\", iv },\n key,\n ciphertextWithTag,\n );\n return new Uint8Array(decrypted);\n}\n\n// \u2500\u2500 Payload decoder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Minimal shape of a Temporal Payload we care about.\n * Matches `temporal.api.common.v1.Payload` from @temporalio/proto but avoids\n * importing the type at module load time (so the CLI still loads without the\n * Temporal SDK installed).\n */\nexport interface RawPayload {\n metadata?: Record<string, Uint8Array | null | undefined> | null;\n data?: Uint8Array | null;\n}\n\n/**\n * A decrypter closure bound to a specific encoding key. Returns a decoded\n * human-readable string for a single encrypted Payload, or `null` when the\n * payload wasn't encrypted and the caller should fall back to raw decoding.\n *\n * Error paths (`[undecryptable]`, `[decode error: ...]`) are returned as\n * strings rather than thrown \u2014 debugging a history is a best-effort read-only\n * operation and one bad payload shouldn't abort the whole dump.\n */\nexport type PayloadDecrypter = (\n payload: RawPayload | null | undefined,\n) => Promise<string | null>;\n\n/**\n * Build a decrypter closure for the given encoding key.\n */\nexport async function createPayloadDecrypter(\n encodingKey: string,\n): Promise<PayloadDecrypter> {\n const key = await deriveKey(encodingKey);\n\n return async (payload) => {\n if (!payload?.metadata) return null;\n\n const encodingBytes = payload.metadata[\"encoding\"];\n if (!encodingBytes) return null;\n const encoding = new TextDecoder().decode(encodingBytes);\n if (encoding !== ENCRYPTED_ENCODING) return null;\n\n if (!payload.data) return \"[encrypted payload missing data]\";\n\n let inner: RawPayload;\n try {\n const decrypted = await aesGcmDecrypt(payload.data, key);\n inner = decodePayloadProtobuf(decrypted);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n return `[undecryptable: ${msg}]`;\n }\n\n return decodeInnerPayload(inner);\n };\n}\n\n/**\n * A closure that decrypts a single encrypted Temporal Payload to its INNER\n * (plaintext) Payload \u2014 the `{ metadata, data }` the worker's codec wrapped \u2014\n * rather than the human-readable string {@link PayloadDecrypter} produces.\n *\n * Returns `null` when the payload isn't encrypted (the caller leaves it as-is).\n * Unlike the display decrypter, decryption errors THROW here: a fixture with a\n * silently-mangled payload would replay wrong, so the caller must surface it.\n */\nexport type RawPayloadDecrypter = (\n payload: RawPayload | null | undefined,\n) => Promise<RawPayload | null>;\n\n/**\n * Build a raw (Payload-in, Payload-out) decrypter for the given encoding key.\n *\n * This is the fixture-capture counterpart of {@link createPayloadDecrypter}:\n * where that one renders a payload for human display (lossy \u2014 binary payloads\n * become placeholders), this one returns the exact decrypted inner Payload so\n * the bytes can be re-serialized into a replayable proto3-JSON history.\n */\nexport async function createRawPayloadDecrypter(\n encodingKey: string,\n): Promise<RawPayloadDecrypter> {\n const key = await deriveKey(encodingKey);\n\n return async (payload) => {\n if (!payload?.metadata) return null;\n\n const encodingBytes = payload.metadata[\"encoding\"];\n if (!encodingBytes) return null;\n const encoding = new TextDecoder().decode(encodingBytes);\n if (encoding !== ENCRYPTED_ENCODING) return null;\n\n if (!payload.data) {\n throw new Error(\"encrypted payload has no data to decrypt\");\n }\n\n const decrypted = await aesGcmDecrypt(payload.data, key);\n return decodePayloadProtobuf(decrypted);\n };\n}\n\n// \u2500\u2500 Hand-rolled Payload protobuf decoder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// temporal.api.common.v1.Payload has exactly two fields:\n// message Payload {\n// map<string, bytes> metadata = 1; // field 1, length-delimited\n// bytes data = 2; // field 2, length-delimited\n// }\n// A protobuf `map<K, V>` is syntactic sugar for:\n// repeated MapEntry { K key = 1; V value = 2; }\n// so each metadata entry is itself a length-delimited submessage with two\n// inner length-delimited fields.\n//\n// Decoding this by hand avoids pulling in `@temporalio/proto` as a peer dep.\n\nfunction readVarint(buf: Uint8Array, pos: number): { value: number; next: number } {\n let value = 0;\n let shift = 0;\n let next = pos;\n while (next < buf.length) {\n const byte = buf[next]!;\n next += 1;\n value |= (byte & 0x7f) << shift;\n if ((byte & 0x80) === 0) return { value, next };\n shift += 7;\n if (shift >= 32) throw new Error(\"varint too large\");\n }\n throw new Error(\"truncated varint\");\n}\n\nfunction readLengthDelimited(\n buf: Uint8Array,\n pos: number,\n): { bytes: Uint8Array; next: number } {\n const { value: length, next: afterLen } = readVarint(buf, pos);\n const end = afterLen + length;\n if (end > buf.length) throw new Error(\"truncated length-delimited field\");\n return { bytes: buf.slice(afterLen, end), next: end };\n}\n\n/**\n * Decode a serialized `temporal.api.common.v1.Payload`.\n *\n * Unknown fields and wire types are skipped rather than throwing; we only\n * need the two documented fields and Temporal's wire format is stable.\n */\nfunction decodePayloadProtobuf(buf: Uint8Array): RawPayload {\n const metadata: Record<string, Uint8Array> = {};\n let data: Uint8Array | undefined;\n let pos = 0;\n\n while (pos < buf.length) {\n const { value: tag, next: afterTag } = readVarint(buf, pos);\n const fieldNumber = tag >>> 3;\n const wireType = tag & 0x7;\n pos = afterTag;\n\n if (wireType === 2) {\n const { bytes, next } = readLengthDelimited(buf, pos);\n pos = next;\n if (fieldNumber === 1) {\n const entry = decodeMetadataEntry(bytes);\n if (entry) metadata[entry.key] = entry.value;\n } else if (fieldNumber === 2) {\n data = bytes;\n }\n // Other fields: ignore.\n } else if (wireType === 0) {\n // varint \u2014 skip\n pos = readVarint(buf, pos).next;\n } else if (wireType === 1) {\n pos += 8; // fixed64\n } else if (wireType === 5) {\n pos += 4; // fixed32\n } else {\n throw new Error(`unsupported wire type ${wireType} at pos ${pos}`);\n }\n }\n\n return { metadata, data: data ?? new Uint8Array(0) };\n}\n\nfunction decodeMetadataEntry(\n buf: Uint8Array,\n): { key: string; value: Uint8Array } | null {\n let key: string | undefined;\n let value: Uint8Array | undefined;\n let pos = 0;\n\n while (pos < buf.length) {\n const { value: tag, next: afterTag } = readVarint(buf, pos);\n const fieldNumber = tag >>> 3;\n const wireType = tag & 0x7;\n pos = afterTag;\n if (wireType !== 2) {\n // Skip unsupported fields defensively.\n if (wireType === 0) pos = readVarint(buf, pos).next;\n else if (wireType === 1) pos += 8;\n else if (wireType === 5) pos += 4;\n else throw new Error(`unsupported metadata wire type ${wireType}`);\n continue;\n }\n const { bytes, next } = readLengthDelimited(buf, pos);\n pos = next;\n if (fieldNumber === 1) key = new TextDecoder().decode(bytes);\n else if (fieldNumber === 2) value = bytes;\n }\n\n if (key === undefined || value === undefined) return null;\n return { key, value };\n}\n\n/**\n * Render a decrypted inner Payload as a human-readable string.\n *\n * Handles the common encodings Mesh workers emit:\n * - `json/plain` \u2192 pretty-printed JSON\n * - `binary/null` \u2192 literal `null`\n * - `binary/plain` \u2192 `[binary N bytes]`\n * - anything else \u2192 `[encoding=X, N bytes]`\n */\nfunction decodeInnerPayload(payload: RawPayload): string {\n const meta = payload.metadata ?? {};\n const encodingBytes = meta[\"encoding\"];\n const encoding = encodingBytes\n ? new TextDecoder().decode(encodingBytes)\n : \"unknown\";\n const data = payload.data ?? new Uint8Array(0);\n\n if (encoding === \"binary/null\") return \"null\";\n if (encoding === \"json/plain\") {\n const text = new TextDecoder().decode(data);\n try {\n return JSON.stringify(JSON.parse(text), null, 2);\n } catch {\n // Not valid JSON despite the encoding claim \u2014 surface the raw text so\n // the debugger sees whatever garbage the worker actually wrote.\n return text;\n }\n }\n if (encoding === \"binary/plain\") return `[binary, ${data.length} bytes]`;\n return `[encoding=${encoding}, ${data.length} bytes]`;\n}\n\n// \u2500\u2500 High-level helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Resolve the Temporal encoding key for a namespace, logging where it came\n * from (or why it couldn't be found). Prefer the K8s secret (authoritative for\n * the namespace); fall back to an explicit TEMPORAL_ENCODING_KEY env var. The\n * env fallback lets you decrypt a workflow's history OFFLINE \u2014 e.g. to\n * reconstruct a conversation, or capture a replay fixture, whose worker can no\n * longer replay it \u2014 using the key from a running `mesh dev` worker env when\n * kubectl/cluster access isn't set up.\n *\n * Returns `undefined` when neither source yields a key.\n */\nexport function resolveEncodingKey(namespace: string): string | undefined {\n const fromK8s = resolveTemporalEncodingKeyFromK8s(namespace);\n const key = fromK8s ?? process.env.TEMPORAL_ENCODING_KEY;\n if (!key) {\n logWarn(\n `Could not read TEMPORAL_ENCODING_KEY from secret ` +\n `${namespace}/${namespace}-temporal-encoding-key, and TEMPORAL_ENCODING_KEY ` +\n \"is not set. Configure kubectl (namespace read \" +\n \"access), or export TEMPORAL_ENCODING_KEY (e.g. from a `mesh dev` worker env).\",\n );\n return undefined;\n }\n logSuccess(\n fromK8s\n ? `Resolved encoding key from ${namespace}-temporal-encoding-key`\n : \"Resolved encoding key from TEMPORAL_ENCODING_KEY env\",\n );\n return key;\n}\n\n/**\n * Resolve the encoding key and build a decrypter for the given namespace.\n * Logs a warning and returns `undefined` when the key can't be read; callers\n * should fall back to printing events without payloads.\n */\nexport async function buildDecrypterForNamespace(\n namespace: string,\n): Promise<PayloadDecrypter | undefined> {\n const key = resolveEncodingKey(namespace);\n if (!key) return undefined;\n return createPayloadDecrypter(key);\n}\n\n/**\n * Resolve the encoding key and build a RAW (Payload-in/Payload-out) decrypter\n * for the given namespace \u2014 used by `capture-history` to decrypt payloads back\n * into replayable proto bytes. Returns `undefined` when the key can't be read;\n * callers decide whether that's fatal.\n */\nexport async function buildRawDecrypterForNamespace(\n namespace: string,\n): Promise<RawPayloadDecrypter | undefined> {\n const key = resolveEncodingKey(namespace);\n if (!key) return undefined;\n return createRawPayloadDecrypter(key);\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["chalk", "execFileSync", "SSMClient", "GetParameterCommand", "SecretsManagerClient", "GetSecretValueCommand", "execFileSync", "init_vpn", "fs", "path", "execFileSync", "SSMClient", "GetParameterCommand", "resolve", "init_vpn", "fs", "path", "creds", "writeFileSync", "mkdirSync", "dirname", "join", "resolve", "execFileSync", "crypto", "namespace", "output", "writeFileSync", "serializeHistoryToFixture", "join", "mkdirSync", "dirname", "resolve"]
7
7
  }