@mesh-tech/mesh-cli 0.1.0 → 0.12.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.
- package/assets/intent/agents-fence.md +10 -0
- package/assets/intent/intent-claude-gate.mjs +255 -0
- package/assets/log-shipper.mjs +94 -0
- package/bin/mesh.mjs +80 -0
- package/dist/bin/mesh.d.ts +7 -9
- package/dist/bin/mesh.d.ts.map +1 -1
- package/dist/bin/mesh.js +20826 -37
- package/dist/bin/mesh.js.map +7 -1
- package/dist/build-info.json +4 -0
- package/dist/src/commands/agent-api-client.d.ts +79 -0
- package/dist/src/commands/agent-api-client.d.ts.map +1 -0
- package/dist/src/commands/agent-api-client.js +170 -0
- package/dist/src/commands/agent-api-client.js.map +1 -0
- package/dist/src/commands/artifacts.d.ts +13 -0
- package/dist/src/commands/artifacts.d.ts.map +1 -0
- package/dist/src/commands/artifacts.js +140 -0
- package/dist/src/commands/artifacts.js.map +1 -0
- package/dist/src/commands/cluster.d.ts +45 -0
- package/dist/src/commands/cluster.d.ts.map +1 -0
- package/dist/src/commands/cluster.js +259 -0
- package/dist/src/commands/cluster.js.map +1 -0
- package/dist/src/commands/conversations.d.ts +61 -0
- package/dist/src/commands/conversations.d.ts.map +1 -0
- package/dist/src/commands/conversations.js +239 -0
- package/dist/src/commands/conversations.js.map +1 -0
- package/dist/src/commands/create-app.d.ts +26 -3
- package/dist/src/commands/create-app.d.ts.map +1 -1
- package/dist/src/commands/create-app.js +465 -107
- package/dist/src/commands/create-app.js.map +1 -1
- package/dist/src/commands/deploy.d.ts +19 -0
- package/dist/src/commands/deploy.d.ts.map +1 -1
- package/dist/src/commands/deploy.js +83 -142
- package/dist/src/commands/deploy.js.map +1 -1
- package/dist/src/commands/dev-doctor.d.ts +137 -0
- package/dist/src/commands/dev-doctor.d.ts.map +1 -0
- package/dist/src/commands/dev-doctor.js +671 -0
- package/dist/src/commands/dev-doctor.js.map +1 -0
- package/dist/src/commands/dev-launch.d.ts +59 -0
- package/dist/src/commands/dev-launch.d.ts.map +1 -0
- package/dist/src/commands/dev-launch.js +118 -0
- package/dist/src/commands/dev-launch.js.map +1 -0
- package/dist/src/commands/dev-token-server.d.ts +32 -0
- package/dist/src/commands/dev-token-server.d.ts.map +1 -0
- package/dist/src/commands/dev-token-server.js +68 -0
- package/dist/src/commands/dev-token-server.js.map +1 -0
- package/dist/src/commands/dev.d.ts +289 -2
- package/dist/src/commands/dev.d.ts.map +1 -1
- package/dist/src/commands/dev.js +2501 -574
- package/dist/src/commands/dev.js.map +1 -1
- package/dist/src/commands/docs.d.ts +3 -0
- package/dist/src/commands/docs.d.ts.map +1 -0
- package/dist/src/commands/docs.js +262 -0
- package/dist/src/commands/docs.js.map +1 -0
- package/dist/src/commands/hub/index.d.ts +99 -0
- package/dist/src/commands/hub/index.d.ts.map +1 -0
- package/dist/src/commands/hub/index.js +452 -0
- package/dist/src/commands/hub/index.js.map +1 -0
- package/dist/src/commands/init.d.ts +65 -0
- package/dist/src/commands/init.d.ts.map +1 -0
- package/dist/src/commands/init.js +370 -0
- package/dist/src/commands/init.js.map +1 -0
- package/dist/src/commands/install-shim.d.ts +21 -0
- package/dist/src/commands/install-shim.d.ts.map +1 -0
- package/dist/src/commands/install-shim.js +132 -0
- package/dist/src/commands/install-shim.js.map +1 -0
- package/dist/src/commands/local/auth-provision.d.ts +71 -0
- package/dist/src/commands/local/auth-provision.d.ts.map +1 -0
- package/dist/src/commands/local/auth-provision.js +286 -0
- package/dist/src/commands/local/auth-provision.js.map +1 -0
- package/dist/src/commands/local/dev-local.d.ts +97 -0
- package/dist/src/commands/local/dev-local.d.ts.map +1 -0
- package/dist/src/commands/local/dev-local.js +306 -0
- package/dist/src/commands/local/dev-local.js.map +1 -0
- package/dist/src/commands/local/docker-runner.d.ts +37 -0
- package/dist/src/commands/local/docker-runner.d.ts.map +1 -0
- package/dist/src/commands/local/docker-runner.js +104 -0
- package/dist/src/commands/local/docker-runner.js.map +1 -0
- package/dist/src/commands/local/helpers.d.ts +22 -0
- package/dist/src/commands/local/helpers.d.ts.map +1 -0
- package/dist/src/commands/local/helpers.js +46 -0
- package/dist/src/commands/local/helpers.js.map +1 -0
- package/dist/src/commands/local/hub-local.d.ts +51 -0
- package/dist/src/commands/local/hub-local.d.ts.map +1 -0
- package/dist/src/commands/local/hub-local.js +197 -0
- package/dist/src/commands/local/hub-local.js.map +1 -0
- package/dist/src/commands/local/index.d.ts +23 -0
- package/dist/src/commands/local/index.d.ts.map +1 -0
- package/dist/src/commands/local/index.js +386 -0
- package/dist/src/commands/local/index.js.map +1 -0
- package/dist/src/commands/local/mocks.d.ts +361 -0
- package/dist/src/commands/local/mocks.d.ts.map +1 -0
- package/dist/src/commands/local/mocks.js +756 -0
- package/dist/src/commands/local/mocks.js.map +1 -0
- package/dist/src/commands/local/seed-zitadel.d.ts +151 -0
- package/dist/src/commands/local/seed-zitadel.d.ts.map +1 -0
- package/dist/src/commands/local/seed-zitadel.js +408 -0
- package/dist/src/commands/local/seed-zitadel.js.map +1 -0
- package/dist/src/commands/local/seed.d.ts +106 -0
- package/dist/src/commands/local/seed.d.ts.map +1 -0
- package/dist/src/commands/local/seed.js +252 -0
- package/dist/src/commands/local/seed.js.map +1 -0
- package/dist/src/commands/local/stack.d.ts +173 -0
- package/dist/src/commands/local/stack.d.ts.map +1 -0
- package/dist/src/commands/local/stack.js +499 -0
- package/dist/src/commands/local/stack.js.map +1 -0
- package/dist/src/commands/login.d.ts +256 -12
- package/dist/src/commands/login.d.ts.map +1 -1
- package/dist/src/commands/login.js +978 -50
- package/dist/src/commands/login.js.map +1 -1
- package/dist/src/commands/peer-addressing.d.ts +51 -0
- package/dist/src/commands/peer-addressing.d.ts.map +1 -0
- package/dist/src/commands/peer-addressing.js +64 -0
- package/dist/src/commands/peer-addressing.js.map +1 -0
- package/dist/src/commands/port-ownership.d.ts +69 -0
- package/dist/src/commands/port-ownership.d.ts.map +1 -0
- package/dist/src/commands/port-ownership.js +161 -0
- package/dist/src/commands/port-ownership.js.map +1 -0
- package/dist/src/commands/registry-publish.d.ts +3 -0
- package/dist/src/commands/registry-publish.d.ts.map +1 -0
- package/dist/src/commands/registry-publish.js +161 -0
- package/dist/src/commands/registry-publish.js.map +1 -0
- package/dist/src/commands/registry.d.ts +146 -2
- package/dist/src/commands/registry.d.ts.map +1 -1
- package/dist/src/commands/registry.js +541 -85
- package/dist/src/commands/registry.js.map +1 -1
- package/dist/src/commands/secrets/index.d.ts.map +1 -1
- package/dist/src/commands/secrets/index.js +15 -1
- package/dist/src/commands/secrets/index.js.map +1 -1
- package/dist/src/commands/secrets/migrate-config.d.ts +24 -0
- package/dist/src/commands/secrets/migrate-config.d.ts.map +1 -0
- package/dist/src/commands/secrets/migrate-config.js +277 -0
- package/dist/src/commands/secrets/migrate-config.js.map +1 -0
- package/dist/src/commands/secrets/set.d.ts +4 -0
- package/dist/src/commands/secrets/set.d.ts.map +1 -1
- package/dist/src/commands/secrets/set.js +243 -64
- package/dist/src/commands/secrets/set.js.map +1 -1
- package/dist/src/commands/skills.d.ts +34 -0
- package/dist/src/commands/skills.d.ts.map +1 -0
- package/dist/src/commands/skills.js +207 -0
- package/dist/src/commands/skills.js.map +1 -0
- package/dist/src/commands/stack.d.ts.map +1 -1
- package/dist/src/commands/stack.js +361 -23
- package/dist/src/commands/stack.js.map +1 -1
- package/dist/src/commands/temporal.d.ts +15 -0
- package/dist/src/commands/temporal.d.ts.map +1 -0
- package/dist/src/commands/temporal.js +781 -0
- package/dist/src/commands/temporal.js.map +1 -0
- package/dist/src/commands/tenant.d.ts +56 -0
- package/dist/src/commands/tenant.d.ts.map +1 -0
- package/dist/src/commands/tenant.js +233 -0
- package/dist/src/commands/tenant.js.map +1 -0
- package/dist/src/commands/tunnel/index.d.ts +49 -3
- package/dist/src/commands/tunnel/index.d.ts.map +1 -1
- package/dist/src/commands/tunnel/index.js +331 -8
- package/dist/src/commands/tunnel/index.js.map +1 -1
- package/dist/src/commands/vcs/clone.d.ts +3 -0
- package/dist/src/commands/vcs/clone.d.ts.map +1 -0
- package/dist/src/commands/vcs/clone.js +20 -0
- package/dist/src/commands/vcs/clone.js.map +1 -0
- package/dist/src/commands/vcs/common.d.ts +21 -0
- package/dist/src/commands/vcs/common.d.ts.map +1 -0
- package/dist/src/commands/vcs/common.js +62 -0
- package/dist/src/commands/vcs/common.js.map +1 -0
- package/dist/src/commands/vcs/drafts.d.ts +13 -0
- package/dist/src/commands/vcs/drafts.d.ts.map +1 -0
- package/dist/src/commands/vcs/drafts.js +45 -0
- package/dist/src/commands/vcs/drafts.js.map +1 -0
- package/dist/src/commands/vcs/get.d.ts +8 -0
- package/dist/src/commands/vcs/get.d.ts.map +1 -0
- package/dist/src/commands/vcs/get.js +61 -0
- package/dist/src/commands/vcs/get.js.map +1 -0
- package/dist/src/commands/vcs/index.d.ts +3 -0
- package/dist/src/commands/vcs/index.d.ts.map +1 -0
- package/dist/src/commands/vcs/index.js +58 -0
- package/dist/src/commands/vcs/index.js.map +1 -0
- package/dist/src/commands/vcs/propose.d.ts +23 -0
- package/dist/src/commands/vcs/propose.d.ts.map +1 -0
- package/dist/src/commands/vcs/propose.js +94 -0
- package/dist/src/commands/vcs/propose.js.map +1 -0
- package/dist/src/commands/vcs/review.d.ts +15 -0
- package/dist/src/commands/vcs/review.d.ts.map +1 -0
- package/dist/src/commands/vcs/review.js +35 -0
- package/dist/src/commands/vcs/review.js.map +1 -0
- package/dist/src/commands/vcs-enrich.d.ts +15 -0
- package/dist/src/commands/vcs-enrich.d.ts.map +1 -0
- package/dist/src/commands/vcs-enrich.js +28 -0
- package/dist/src/commands/vcs-enrich.js.map +1 -0
- package/dist/src/commands/vpn/index.d.ts +24 -0
- package/dist/src/commands/vpn/index.d.ts.map +1 -0
- package/dist/src/commands/vpn/index.js +543 -0
- package/dist/src/commands/vpn/index.js.map +1 -0
- package/dist/src/commands/vpn/tunnel.d.ts +11 -0
- package/dist/src/commands/vpn/tunnel.d.ts.map +1 -0
- package/dist/src/commands/vpn/tunnel.js +188 -0
- package/dist/src/commands/vpn/tunnel.js.map +1 -0
- package/dist/src/commands/workflow.d.ts +24 -0
- package/dist/src/commands/workflow.d.ts.map +1 -0
- package/dist/src/commands/workflow.js +577 -0
- package/dist/src/commands/workflow.js.map +1 -0
- package/dist/src/docs/assemble.d.ts +217 -0
- package/dist/src/docs/assemble.d.ts.map +1 -0
- package/dist/src/docs/assemble.js +739 -0
- package/dist/src/docs/assemble.js.map +1 -0
- package/dist/src/docs/cli-reference.d.ts +93 -0
- package/dist/src/docs/cli-reference.d.ts.map +1 -0
- package/dist/src/docs/cli-reference.js +256 -0
- package/dist/src/docs/cli-reference.js.map +1 -0
- package/dist/src/docs/discover.d.ts +60 -0
- package/dist/src/docs/discover.d.ts.map +1 -0
- package/dist/src/docs/discover.js +233 -0
- package/dist/src/docs/discover.js.map +1 -0
- package/dist/src/docs/portal.d.ts +79 -0
- package/dist/src/docs/portal.d.ts.map +1 -0
- package/dist/src/docs/portal.js +242 -0
- package/dist/src/docs/portal.js.map +1 -0
- package/dist/src/docs/registry-docs.d.ts +43 -0
- package/dist/src/docs/registry-docs.d.ts.map +1 -0
- package/dist/src/docs/registry-docs.js +138 -0
- package/dist/src/docs/registry-docs.js.map +1 -0
- package/dist/src/docs/schema.d.ts +48 -0
- package/dist/src/docs/schema.d.ts.map +1 -0
- package/dist/src/docs/schema.js +96 -0
- package/dist/src/docs/schema.js.map +1 -0
- package/dist/src/docs/serve.d.ts +27 -0
- package/dist/src/docs/serve.d.ts.map +1 -0
- package/dist/src/docs/serve.js +102 -0
- package/dist/src/docs/serve.js.map +1 -0
- package/dist/src/docs/start.d.ts +80 -0
- package/dist/src/docs/start.d.ts.map +1 -0
- package/dist/src/docs/start.js +313 -0
- package/dist/src/docs/start.js.map +1 -0
- package/dist/src/eval/agent-eval.d.mts +14 -0
- package/dist/src/eval/agent-eval.d.mts.map +1 -0
- package/dist/src/eval/agent-eval.mjs +273 -0
- package/dist/src/eval/agent-eval.mjs.map +1 -0
- package/dist/src/index.js +624 -7
- package/dist/src/index.js.map +7 -1
- package/dist/src/program.d.ts +26 -0
- package/dist/src/program.d.ts.map +1 -0
- package/dist/src/program.js +76 -0
- package/dist/src/program.js.map +1 -0
- package/dist/src/utils/auth-preflight.d.ts +159 -0
- package/dist/src/utils/auth-preflight.d.ts.map +1 -0
- package/dist/src/utils/auth-preflight.js +298 -0
- package/dist/src/utils/auth-preflight.js.map +1 -0
- package/dist/src/utils/aws-auth.d.ts +181 -0
- package/dist/src/utils/aws-auth.d.ts.map +1 -0
- package/dist/src/utils/aws-auth.js +527 -0
- package/dist/src/utils/aws-auth.js.map +1 -0
- package/dist/src/utils/bastion.d.ts +1 -1
- package/dist/src/utils/bastion.d.ts.map +1 -1
- package/dist/src/utils/bastion.js +29 -8
- package/dist/src/utils/bastion.js.map +1 -1
- package/dist/src/utils/build-info.d.ts +33 -0
- package/dist/src/utils/build-info.d.ts.map +1 -0
- package/dist/src/utils/build-info.js +102 -0
- package/dist/src/utils/build-info.js.map +1 -0
- package/dist/src/utils/capture-history.d.ts +62 -0
- package/dist/src/utils/capture-history.d.ts.map +1 -0
- package/dist/src/utils/capture-history.js +94 -0
- package/dist/src/utils/capture-history.js.map +1 -0
- package/dist/src/utils/deploy-preflight.d.ts +52 -0
- package/dist/src/utils/deploy-preflight.d.ts.map +1 -0
- package/dist/src/utils/deploy-preflight.js +183 -0
- package/dist/src/utils/deploy-preflight.js.map +1 -0
- package/dist/src/utils/errors.d.ts +75 -0
- package/dist/src/utils/errors.d.ts.map +1 -0
- package/dist/src/utils/errors.js +106 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/dist/src/utils/first-party-contexts.d.ts +40 -0
- package/dist/src/utils/first-party-contexts.d.ts.map +1 -0
- package/dist/src/utils/first-party-contexts.js +53 -0
- package/dist/src/utils/first-party-contexts.js.map +1 -0
- package/dist/src/utils/index.d.ts +2 -0
- package/dist/src/utils/index.d.ts.map +1 -1
- package/dist/src/utils/index.js +2 -0
- package/dist/src/utils/index.js.map +1 -1
- package/dist/src/utils/kubeconfig.d.ts +62 -0
- package/dist/src/utils/kubeconfig.d.ts.map +1 -0
- package/dist/src/utils/kubeconfig.js +118 -0
- package/dist/src/utils/kubeconfig.js.map +1 -0
- package/dist/src/utils/log.d.ts +27 -1
- package/dist/src/utils/log.d.ts.map +1 -1
- package/dist/src/utils/log.js +59 -5
- package/dist/src/utils/log.js.map +1 -1
- package/dist/src/utils/pid.d.ts +47 -0
- package/dist/src/utils/pid.d.ts.map +1 -0
- package/dist/src/utils/pid.js +83 -0
- package/dist/src/utils/pid.js.map +1 -0
- package/dist/src/utils/pulumi-run.d.ts +23 -0
- package/dist/src/utils/pulumi-run.d.ts.map +1 -0
- package/dist/src/utils/pulumi-run.js +112 -0
- package/dist/src/utils/pulumi-run.js.map +1 -0
- package/dist/src/utils/pulumi.d.ts +15 -0
- package/dist/src/utils/pulumi.d.ts.map +1 -1
- package/dist/src/utils/pulumi.js +35 -0
- package/dist/src/utils/pulumi.js.map +1 -1
- package/dist/src/utils/reachability.d.ts +29 -0
- package/dist/src/utils/reachability.d.ts.map +1 -0
- package/dist/src/utils/reachability.js +81 -0
- package/dist/src/utils/reachability.js.map +1 -0
- package/dist/src/utils/recover-conversation.d.ts +154 -0
- package/dist/src/utils/recover-conversation.d.ts.map +1 -0
- package/dist/src/utils/recover-conversation.js +314 -0
- package/dist/src/utils/recover-conversation.js.map +1 -0
- package/dist/src/utils/registry-broker.d.ts +148 -0
- package/dist/src/utils/registry-broker.d.ts.map +1 -0
- package/dist/src/utils/registry-broker.js +180 -0
- package/dist/src/utils/registry-broker.js.map +1 -0
- package/dist/src/utils/socks-forward.d.ts +40 -0
- package/dist/src/utils/socks-forward.d.ts.map +1 -0
- package/dist/src/utils/socks-forward.js +97 -0
- package/dist/src/utils/socks-forward.js.map +1 -0
- package/dist/src/utils/stack-flag.d.ts +23 -0
- package/dist/src/utils/stack-flag.d.ts.map +1 -0
- package/dist/src/utils/stack-flag.js +41 -0
- package/dist/src/utils/stack-flag.js.map +1 -0
- package/dist/src/utils/tailscale-targets.d.ts +52 -0
- package/dist/src/utils/tailscale-targets.d.ts.map +1 -0
- package/dist/src/utils/tailscale-targets.js +93 -0
- package/dist/src/utils/tailscale-targets.js.map +1 -0
- package/dist/src/utils/tailscale.d.ts +277 -0
- package/dist/src/utils/tailscale.d.ts.map +1 -0
- package/dist/src/utils/tailscale.js +1007 -0
- package/dist/src/utils/tailscale.js.map +1 -0
- package/dist/src/utils/temporal-auth.d.ts +43 -0
- package/dist/src/utils/temporal-auth.d.ts.map +1 -0
- package/dist/src/utils/temporal-auth.js +176 -0
- package/dist/src/utils/temporal-auth.js.map +1 -0
- package/dist/src/utils/temporal-codec.d.ts +101 -0
- package/dist/src/utils/temporal-codec.d.ts.map +1 -0
- package/dist/src/utils/temporal-codec.js +319 -0
- package/dist/src/utils/temporal-codec.js.map +1 -0
- package/dist/src/utils/tunnel-ownership.d.ts +130 -0
- package/dist/src/utils/tunnel-ownership.d.ts.map +1 -0
- package/dist/src/utils/tunnel-ownership.js +274 -0
- package/dist/src/utils/tunnel-ownership.js.map +1 -0
- package/dist/src/utils/vpn-join.d.ts +32 -0
- package/dist/src/utils/vpn-join.d.ts.map +1 -0
- package/dist/src/utils/vpn-join.js +62 -0
- package/dist/src/utils/vpn-join.js.map +1 -0
- package/dist/src/utils/vpn.d.ts +62 -0
- package/dist/src/utils/vpn.d.ts.map +1 -0
- package/dist/src/utils/vpn.js +183 -0
- package/dist/src/utils/vpn.js.map +1 -0
- package/dist/src/utils/workflow-fingerprint.d.ts +49 -0
- package/dist/src/utils/workflow-fingerprint.d.ts.map +1 -0
- package/dist/src/utils/workflow-fingerprint.js +135 -0
- package/dist/src/utils/workflow-fingerprint.js.map +1 -0
- package/dist/src/utils/worktree-identity.d.ts +86 -0
- package/dist/src/utils/worktree-identity.d.ts.map +1 -0
- package/dist/src/utils/worktree-identity.js +164 -0
- package/dist/src/utils/worktree-identity.js.map +1 -0
- package/fragments/agents/AGENTS.md.hbs +92 -0
- package/fragments/agents/CLAUDE.md.hbs +8 -0
- package/fragments/base/Pulumi.dev.yaml.hbs +10 -0
- package/fragments/base/Pulumi.yaml.hbs +9 -0
- package/fragments/base/index.ts.hbs +107 -0
- package/fragments/base/package.json.hbs +22 -0
- package/{templates/api-web-db/api → fragments/base}/tsconfig.json +3 -4
- package/fragments/bucket/api/src/storage.ts.hbs +81 -0
- package/fragments/database/api/src/db.ts.hbs +23 -0
- package/fragments/database/prisma/schema.prisma.hbs +18 -0
- package/{templates/api-web-db → fragments/service}/api/.dockerignore +2 -0
- package/fragments/service/api/package.json.hbs +40 -0
- package/fragments/service/api/src/index.ts.hbs +58 -0
- package/{templates/api-web-db → fragments/service/api}/tsconfig.json +5 -3
- package/fragments/temporal/worker/.dockerignore +4 -0
- package/fragments/temporal/worker/Dockerfile +15 -0
- package/{templates/temporal-api-worker → fragments/temporal}/worker/package.json.hbs +3 -2
- package/fragments/temporal/worker/src/activities.ts.hbs +14 -0
- package/fragments/temporal/worker/src/worker.ts.hbs +27 -0
- package/fragments/temporal/worker/src/workflows.ts.hbs +24 -0
- package/fragments/temporal/worker/tsconfig.json +13 -0
- package/package.json +35 -21
- package/skills/core/SKILL.md +365 -0
- package/skills/pull-remote-agent-context/SKILL.md +194 -0
- package/stack/docker-compose.hub.yml +134 -0
- package/stack/docker-compose.yml +447 -0
- package/stack/hub/Dockerfile.api +18 -0
- package/stack/hub/Dockerfile.auth +10 -0
- package/stack/hub/Dockerfile.ui +10 -0
- package/stack/init/01-databases.sql +12 -0
- package/stack/observability/blackbox.yml +20 -0
- package/stack/observability/otel-collector.yaml +73 -0
- package/stack/observability/prometheus.yml +36 -0
- package/stack/observability/tempo.yaml +25 -0
- package/stack/temporal/local.yaml +44 -0
- package/templates/api-auth/AGENTS.md.hbs +16 -0
- package/templates/api-auth/Pulumi.dev.yaml.hbs +8 -0
- package/templates/api-auth/Pulumi.yaml.hbs +8 -0
- package/templates/api-auth/README.md.hbs +67 -0
- package/templates/api-auth/api/package.json.hbs +28 -0
- package/templates/api-auth/api/src/index.ts.hbs +96 -0
- package/templates/api-auth/api/src/schema.ts.hbs +35 -0
- package/templates/api-auth/api/src/seed-authz.ts.hbs +48 -0
- package/templates/api-auth/api/src/spicedb-http-provider.ts.hbs +89 -0
- package/templates/api-auth/index.ts.hbs +65 -0
- package/templates/api-auth/package.json.hbs +20 -0
- package/templates/api-auth/skills/develop/SKILL.md.hbs +32 -0
- package/templates/api-role-gating/AGENTS.md.hbs +20 -0
- package/templates/api-role-gating/Pulumi.dev.yaml.hbs +8 -0
- package/templates/api-role-gating/Pulumi.yaml.hbs +8 -0
- package/templates/api-role-gating/README.md.hbs +64 -0
- package/templates/api-role-gating/api/Dockerfile +17 -0
- package/templates/api-role-gating/api/package.json.hbs +28 -0
- package/templates/api-role-gating/api/src/authn.ts.hbs +29 -0
- package/templates/api-role-gating/api/src/authz.ts.hbs +12 -0
- package/templates/api-role-gating/api/src/main.ts.hbs +58 -0
- package/templates/api-role-gating/api/src/schema.ts.hbs +22 -0
- package/templates/api-role-gating/api/tsconfig.json +13 -0
- package/templates/api-role-gating/index.ts.hbs +107 -0
- package/templates/api-role-gating/package.json.hbs +21 -0
- package/templates/api-role-gating/skills/develop/SKILL.md.hbs +60 -0
- package/templates/api-role-gating/tsconfig.json +10 -0
- package/templates/apps-repo/README.md.hbs +24 -0
- package/templates/apps-repo/gitignore +41 -0
- package/templates/apps-repo/npmrc.hbs +7 -0
- package/templates/apps-repo/package.json.hbs +15 -0
- package/templates/apps-repo/pnpm-workspace.yaml +3 -0
- package/templates/apps-repo/tsconfig.json +18 -0
- package/templates/external-service/AGENTS.md.hbs +20 -0
- package/templates/external-service/Pulumi.dev.yaml.hbs +8 -0
- package/templates/external-service/Pulumi.yaml.hbs +8 -0
- package/templates/external-service/README.md.hbs +56 -0
- package/templates/external-service/api/Dockerfile +15 -0
- package/templates/external-service/api/package.json.hbs +25 -0
- package/templates/external-service/api/src/index.ts.hbs +81 -0
- package/templates/external-service/index.ts.hbs +82 -0
- package/templates/external-service/mocks/vendorpay/package.json.hbs +9 -0
- package/templates/external-service/mocks/vendorpay/server.mjs.hbs +61 -0
- package/templates/external-service/package.json.hbs +34 -0
- package/templates/external-service/skills/develop/SKILL.md.hbs +55 -0
- package/templates/external-service/tsconfig.json +10 -0
- package/templates/platform-repo/AGENTS.md.hbs +18 -0
- package/templates/platform-repo/README.md.hbs +38 -0
- package/templates/platform-repo/core/Pulumi.yaml.hbs +12 -0
- package/templates/platform-repo/core/Pulumi.{{tenant}}-{{env}}.yaml.hbs +57 -0
- package/templates/platform-repo/core/index.ts +62 -0
- package/templates/platform-repo/core/package.json.hbs +22 -0
- package/templates/platform-repo/core/tsconfig.json +8 -0
- package/templates/platform-repo/package.json.hbs +15 -0
- package/templates/platform-repo/platform/Pulumi.yaml.hbs +12 -0
- package/templates/platform-repo/platform/Pulumi.{{tenant}}-{{env}}.yaml.hbs +59 -0
- package/templates/platform-repo/platform/index.ts +95 -0
- package/templates/platform-repo/platform/package.json.hbs +26 -0
- package/templates/platform-repo/platform/tsconfig.json +8 -0
- package/templates/platform-repo/pnpm-workspace.yaml +3 -0
- package/templates/workflow/AGENTS.md.hbs +15 -0
- package/templates/workflow/Pulumi.dev.yaml.hbs +8 -0
- package/templates/workflow/Pulumi.yaml.hbs +8 -0
- package/templates/workflow/README.md.hbs +45 -0
- package/templates/workflow/api/.dockerignore +4 -0
- package/templates/workflow/api/Dockerfile +15 -0
- package/templates/{temporal-api-worker → workflow}/api/package.json.hbs +8 -6
- package/templates/workflow/api/src/index.ts.hbs +130 -0
- package/templates/workflow/api/tsconfig.json +14 -0
- package/templates/workflow/index.ts.hbs +67 -0
- package/templates/workflow/package.json.hbs +20 -0
- package/templates/workflow/skills/develop/SKILL.md.hbs +32 -0
- package/templates/workflow/tsconfig.json +10 -0
- package/templates/workflow/worker/.dockerignore +4 -0
- package/templates/workflow/worker/Dockerfile +13 -0
- package/templates/workflow/worker/package.json.hbs +25 -0
- package/templates/workflow/worker/src/worker.ts.hbs +24 -0
- package/templates/workflow/worker/tsconfig.json +14 -0
- package/dist/src/commands/devbox.d.ts +0 -22
- package/dist/src/commands/devbox.d.ts.map +0 -1
- package/dist/src/commands/devbox.js +0 -463
- package/dist/src/commands/devbox.js.map +0 -1
- package/templates/api-web-db/api/Dockerfile +0 -16
- package/templates/api-web-db/api/package.json.hbs +0 -21
- package/templates/api-web-db/api/src/index.ts.hbs +0 -123
- package/templates/api-web-db/package.json.hbs +0 -27
- package/templates/api-web-db/sst.config.ts.hbs +0 -151
- package/templates/api-web-db/web/.dockerignore +0 -3
- package/templates/api-web-db/web/Dockerfile +0 -17
- package/templates/api-web-db/web/app/lib/db.server.ts +0 -36
- package/templates/api-web-db/web/app/root.tsx +0 -29
- package/templates/api-web-db/web/app/routes/home.tsx.hbs +0 -114
- package/templates/api-web-db/web/app/routes.ts +0 -3
- package/templates/api-web-db/web/package.json.hbs +0 -30
- package/templates/api-web-db/web/prisma/schema.prisma +0 -25
- package/templates/api-web-db/web/prisma.config.ts +0 -18
- package/templates/api-web-db/web/react-router.config.ts +0 -5
- package/templates/api-web-db/web/tsconfig.json +0 -19
- package/templates/api-web-db/web/vite.config.ts +0 -9
- package/templates/temporal-api-worker/README.md.hbs +0 -133
- package/templates/temporal-api-worker/api/src/index.ts.hbs +0 -145
- package/templates/temporal-api-worker/package.json.hbs +0 -29
- package/templates/temporal-api-worker/sst.config.ts.hbs +0 -161
- package/templates/temporal-api-worker/worker/Dockerfile +0 -11
- package/templates/temporal-api-worker/worker/src/bundle-workflows.ts +0 -38
- package/templates/temporal-api-worker/worker/src/worker.ts.hbs +0 -93
- /package/templates/{temporal-api-worker → api-auth}/api/.dockerignore +0 -0
- /package/templates/{temporal-api-worker → api-auth}/api/Dockerfile +0 -0
- /package/templates/{temporal-api-worker → api-auth}/api/tsconfig.json +0 -0
- /package/templates/{temporal-api-worker → api-auth}/tsconfig.json +0 -0
- /package/templates/{temporal-api-worker/worker → api-role-gating/api}/.dockerignore +0 -0
- /package/templates/{temporal-api-worker/worker → external-service/api}/tsconfig.json +0 -0
- /package/templates/{temporal-api-worker → workflow}/api/src/encryption-codec.ts +0 -0
- /package/templates/{temporal-api-worker → workflow}/worker/src/activities.ts.hbs +0 -0
- /package/templates/{temporal-api-worker → workflow}/worker/src/encryption-codec.ts +0 -0
- /package/templates/{temporal-api-worker → workflow}/worker/src/workflows.ts.hbs +0 -0
package/dist/src/commands/dev.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mesh dev — Pulumi-output-driven local development orchestrator
|
|
3
3
|
*
|
|
4
|
-
* Reads `pulumi stack output dev --json` to discover services and
|
|
4
|
+
* Reads `pulumi stack output dev --json` to discover services and VPC endpoints,
|
|
5
5
|
* then launches each service's dev command in a tmux window.
|
|
6
|
+
* Requires VPN connection for direct access to VPC resources.
|
|
6
7
|
*
|
|
7
8
|
* Usage:
|
|
8
9
|
* mesh dev Start all services, attach tmux
|
|
@@ -12,15 +13,82 @@
|
|
|
12
13
|
* mesh dev logs <service> Tail a service's output
|
|
13
14
|
* mesh dev restart <svc> Restart a single service
|
|
14
15
|
*/
|
|
15
|
-
import { execFileSync, spawnSync } from
|
|
16
|
-
import * as fs from
|
|
17
|
-
import * as net from
|
|
18
|
-
import * as os from
|
|
19
|
-
import * as path from
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
16
|
+
import { execFileSync, spawn, spawnSync } from 'child_process';
|
|
17
|
+
import * as fs from 'fs';
|
|
18
|
+
import * as net from 'net';
|
|
19
|
+
import * as os from 'os';
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
import { Option } from 'commander';
|
|
22
|
+
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
|
|
23
|
+
import { logInfo, logSuccess, logError, logWarn, isVpnConnected, getTailscaleInfo, headscaleDnsConfig, registerDnsRecords, unregisterDnsRecords, readDnsRecords, getPlatformBastionInfo, } from '../utils/index.js';
|
|
24
|
+
import { derivePlatformContext, renderCredentialProcessProfile, resolveAwsCredentials, resolveStableMeshBin, selectRoleForCaller, } from '../utils/aws-auth.js';
|
|
25
|
+
import { readCredentials, getContextConfig, probeCredentials, getValidToken } from './login.js';
|
|
26
|
+
import { resolveVpnJoinBroker, mintPreAuthKey } from '../utils/vpn-join.js';
|
|
27
|
+
import { credProbeToPreflightError } from '../utils/pulumi-run.js';
|
|
28
|
+
import { startTailscaleTunnels, tailscaleAvailable, daemonState as tailscaleDaemonState, readTunnelState as readTailscaleState, readDaemonMeta as readTailscaleDaemonMeta, } from '../utils/tailscale.js';
|
|
29
|
+
import { pulumiStackOutput, readStackConfig } from '../utils/pulumi.js';
|
|
30
|
+
import { ensureKubeconfig, resolveHubPlatformName, sessionKubeconfigPath, } from '../utils/kubeconfig.js';
|
|
31
|
+
import { resolveTemporalAuth } from '../utils/temporal-auth.js';
|
|
32
|
+
import { probeTcpReachable, probeConnectionHolds } from '../utils/reachability.js';
|
|
33
|
+
import { fingerprintWorkflowSource } from '../utils/workflow-fingerprint.js';
|
|
34
|
+
import { buildLaunchCommand, envFileName, waitForPort, writeEnvFile } from './dev-launch.js';
|
|
35
|
+
import { buildLocalDevOutput, detectLocalTenant, ensureLocalPlatformRunning, hasStackBacking, localAppNamespace } from './local/dev-local.js';
|
|
36
|
+
import { ensureAppTenantAuth, registerLocalApp } from './local/auth-provision.js';
|
|
37
|
+
import { localAwsEnv, ensureTemporalNamespace } from './local/seed.js';
|
|
38
|
+
import { composeExternalUp, composeExternalsDown, externalMode, localProbesRemove, parseExternalsSelection, readLocalMocks, seedLocalMock, } from './local/mocks.js';
|
|
39
|
+
import { logShipperPath } from './local/dev-local.js';
|
|
40
|
+
import { writeDevCompose, dockerDevUp, dockerDevDown, dockerDevPs, dockerDevLogs, dockerDevRestart } from './local/docker-runner.js';
|
|
41
|
+
import { MeshCliError } from '../utils/errors.js';
|
|
42
|
+
import { resolveStackOption } from '../utils/stack-flag.js';
|
|
43
|
+
import { rewriteClusterHostsToLocal } from './peer-addressing.js';
|
|
44
|
+
import { ALL_CHECKS, aggregateStatus, renderHuman, runChecks, runDoctor, } from './dev-doctor.js';
|
|
45
|
+
import { resolveWorktreeIdentity, withAppScopedPortBlock, blockBasePort, PORT_BLOCK_SERVICE_SUBRANGE, } from '../utils/worktree-identity.js';
|
|
46
|
+
/**
|
|
47
|
+
* tmux session name for a dev launch. The primary checkout keeps the historical
|
|
48
|
+
* `${projectName}-dev`; linked worktrees get a `${projectName}-${slug}` so two
|
|
49
|
+
* worktrees of the same app don't share (and kill) each other's session, state,
|
|
50
|
+
* and env-file dir (MESH-2013).
|
|
51
|
+
*/
|
|
52
|
+
function deriveSessionName(projectName, wt) {
|
|
53
|
+
return wt.isPrimary ? `${projectName}-dev` : `${projectName}-${wt.slug}`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Render the resolved per-worktree plan (`mesh dev --dry-run`): identity,
|
|
57
|
+
* session/state/env paths, and the port assignment — everything that must be
|
|
58
|
+
* collision-free across concurrent worktrees, without launching anything.
|
|
59
|
+
*/
|
|
60
|
+
function printDevPlan(sessionName, appRoot, wt, devOutput) {
|
|
61
|
+
const kind = wt.isPrimary ? 'primary checkout' : `linked worktree (block ${wt.portBlock})`;
|
|
62
|
+
console.log(`\nmesh dev plan — ${kind}\n`);
|
|
63
|
+
console.log(` worktree root : ${wt.worktreeRoot}`);
|
|
64
|
+
console.log(` app root : ${appRoot}`);
|
|
65
|
+
console.log(` token : ${wt.token || '(none — primary)'}`);
|
|
66
|
+
console.log(` tmux session : ${sessionName}`);
|
|
67
|
+
console.log(` session state : ${getSessionStatePath(sessionName)}`);
|
|
68
|
+
console.log(` env dir : ${getSessionEnvDir(sessionName)}`);
|
|
69
|
+
const tq = wt.taskQueueSuffix
|
|
70
|
+
? `<app-task-queue>${wt.taskQueueSuffix}`
|
|
71
|
+
: '<app-task-queue> (primary — unchanged)';
|
|
72
|
+
console.log(` task queue : ${tq}`);
|
|
73
|
+
console.log(`\n services (port · resolved source dir):`);
|
|
74
|
+
const locals = Object.entries(devOutput.services).filter(([, s]) => (s.port ?? 0) >= 0 && s.command && s.command.length > 0);
|
|
75
|
+
if (locals.length === 0) {
|
|
76
|
+
console.log(` (none — all services are deployed/K8s)`);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const monorepoRoot = findMonorepoRoot();
|
|
80
|
+
const wtWithSep = wt.worktreeRoot.endsWith(path.sep) ? wt.worktreeRoot : wt.worktreeRoot + path.sep;
|
|
81
|
+
for (const [name, s] of locals) {
|
|
82
|
+
const dir = path.resolve(appRoot, rebaseServiceSrc(s.src, monorepoRoot));
|
|
83
|
+
const outside = dir !== wt.worktreeRoot && !dir.startsWith(wtWithSep);
|
|
84
|
+
const port = s.port > 0 ? `:${s.port}` : '(no port)';
|
|
85
|
+
console.log(` ${name.padEnd(18)} ${port.padEnd(7)} ${dir}${outside ? ' ⚠ OUTSIDE worktree' : ''}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
console.log('');
|
|
89
|
+
}
|
|
22
90
|
function getSessionStatePath(sessionName) {
|
|
23
|
-
const dir = path.join(os.tmpdir(),
|
|
91
|
+
const dir = path.join(os.tmpdir(), 'mesh-dev-sessions');
|
|
24
92
|
if (!fs.existsSync(dir))
|
|
25
93
|
fs.mkdirSync(dir, { recursive: true });
|
|
26
94
|
return path.join(dir, `${sessionName}.json`);
|
|
@@ -33,44 +101,101 @@ function loadSessionState(sessionName) {
|
|
|
33
101
|
if (!fs.existsSync(filePath))
|
|
34
102
|
return null;
|
|
35
103
|
try {
|
|
36
|
-
return JSON.parse(fs.readFileSync(filePath,
|
|
104
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
37
105
|
}
|
|
38
106
|
catch {
|
|
39
107
|
return null;
|
|
40
108
|
}
|
|
41
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Relaunch safety (MESH-2017): if the workflow source changed since this session
|
|
112
|
+
* launched, restarting the worker replays any in-flight conversations against the
|
|
113
|
+
* NEW code — a replay-incompatible change strands them. Warn and require `--force`.
|
|
114
|
+
*
|
|
115
|
+
* Silent no-op when there's no prior session, no stored fingerprint (pre-feature
|
|
116
|
+
* session), or nothing changed — so a restart of unchanged code never nags.
|
|
117
|
+
* Returns `true` if the caller should ABORT (changed && !force).
|
|
118
|
+
*/
|
|
119
|
+
/** Pure: did the workflow source change since launch (→ risk of stranding)? */
|
|
120
|
+
export function workflowChanged(prior, current) {
|
|
121
|
+
return Boolean(prior) && Boolean(current) && current !== prior;
|
|
122
|
+
}
|
|
123
|
+
function workflowChangeWouldStrand(priorState, appRoot, force) {
|
|
124
|
+
const prior = priorState?.workflowFingerprint;
|
|
125
|
+
if (!prior)
|
|
126
|
+
return false;
|
|
127
|
+
const current = fingerprintWorkflowSource(appRoot, priorState?.devOutput?.services);
|
|
128
|
+
if (!workflowChanged(prior, current))
|
|
129
|
+
return false;
|
|
130
|
+
logWarn('Workflow code changed since this session started.\n' +
|
|
131
|
+
' Restarting the worker replays any in-flight conversations against the NEW code —\n' +
|
|
132
|
+
' a replay-incompatible change strands them (they get stuck and become unviewable).\n' +
|
|
133
|
+
' Keep it replay-compatible: gate the change with wf.patched() and regenerate the replay\n' +
|
|
134
|
+
' goldens (see the temporal-workflow-safety skill), or accept the risk.');
|
|
135
|
+
if (force) {
|
|
136
|
+
logWarn(' Proceeding anyway (--force).');
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
logError(' Refusing to restart the worker. Re-run with --force once the change is replay-safe.');
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Per-session directory holding one sourceable env file per service
|
|
144
|
+
* (`<service>.env.sh`, mode 0600). Written at launch; `mesh dev restart`
|
|
145
|
+
* sources the same file so a restarted service gets a byte-identical env.
|
|
146
|
+
*/
|
|
147
|
+
function getSessionEnvDir(sessionName) {
|
|
148
|
+
return path.join(os.tmpdir(), 'mesh-dev-sessions', sessionName);
|
|
149
|
+
}
|
|
150
|
+
function getServiceEnvFilePath(sessionName, serviceName) {
|
|
151
|
+
return path.join(getSessionEnvDir(sessionName), envFileName(serviceName));
|
|
152
|
+
}
|
|
42
153
|
function removeSessionState(sessionName) {
|
|
43
154
|
try {
|
|
44
155
|
fs.unlinkSync(getSessionStatePath(sessionName));
|
|
45
156
|
}
|
|
46
157
|
catch { }
|
|
158
|
+
try {
|
|
159
|
+
fs.rmSync(getSessionEnvDir(sessionName), { recursive: true, force: true });
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
47
162
|
}
|
|
48
163
|
/**
|
|
49
|
-
* Check whether a TCP port is available
|
|
164
|
+
* Check whether a TCP port is available — probing the way services actually bind.
|
|
165
|
+
*
|
|
166
|
+
* This used to bind `127.0.0.1` only, and that made it blind to exactly the
|
|
167
|
+
* processes it exists to find. Node's `listen(port)` (and `@hono/node-server`,
|
|
168
|
+
* which every agent-api uses) binds the WILDCARD address `::` in dual-stack mode,
|
|
169
|
+
* holding the port on every interface — yet a probe bound to `127.0.0.1` still
|
|
170
|
+
* succeeds against such a holder. So the check returned `true` for a port already
|
|
171
|
+
* held by a prior `mesh dev` service, `allocatePorts` kept the preferred port, and
|
|
172
|
+
* the service then died on `EADDRINUSE` at its real bind.
|
|
173
|
+
*
|
|
174
|
+
* Observed 2026-07-31: the `studio-api` pane died on `EADDRINUSE :::40161` while a
|
|
175
|
+
* stale agent-api from an earlier boot kept serving on the same port, so the Studio
|
|
176
|
+
* UI drove a process from a previous session's configuration and the conversation
|
|
177
|
+
* list came back empty. Reproduced in isolation — holder on `::`, loopback probe
|
|
178
|
+
* reports free, real bind fails (MESH-2590).
|
|
179
|
+
*
|
|
180
|
+
* Both addresses are probed: the wildcard catches all-interface holders, and the
|
|
181
|
+
* loopback probe still catches a loopback-only squatter. A false "busy" is cheap —
|
|
182
|
+
* `allocatePorts` simply picks another port — while a false "free" crashes the
|
|
183
|
+
* service, so this deliberately errs toward busy.
|
|
50
184
|
*/
|
|
51
|
-
async function isPortFree(port) {
|
|
52
|
-
|
|
185
|
+
export async function isPortFree(port) {
|
|
186
|
+
const bindSucceeds = (host) => new Promise((resolve) => {
|
|
53
187
|
const server = net.createServer();
|
|
54
|
-
server.once(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
188
|
+
server.once('error', () => resolve(false));
|
|
189
|
+
const onListening = () => server.close(() => resolve(true));
|
|
190
|
+
if (host === undefined)
|
|
191
|
+
server.listen(port, onListening);
|
|
192
|
+
else
|
|
193
|
+
server.listen(port, host, onListening);
|
|
58
194
|
});
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* Check if a port is being listened on (synchronous, for status display).
|
|
62
|
-
*/
|
|
63
|
-
function isPortListeningSync(port) {
|
|
64
|
-
try {
|
|
65
|
-
execFileSync("bash", ["-c", `echo >/dev/tcp/127.0.0.1/${port}`], {
|
|
66
|
-
timeout: 500,
|
|
67
|
-
stdio: "ignore",
|
|
68
|
-
});
|
|
69
|
-
return true;
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
195
|
+
// Wildcard first: it is how the services bind, so it is the probe that matters.
|
|
196
|
+
if (!(await bindSucceeds()))
|
|
72
197
|
return false;
|
|
73
|
-
|
|
198
|
+
return bindSucceeds('127.0.0.1');
|
|
74
199
|
}
|
|
75
200
|
/**
|
|
76
201
|
* Ask the OS for an ephemeral free port.
|
|
@@ -78,79 +203,203 @@ function isPortListeningSync(port) {
|
|
|
78
203
|
async function findFreePort() {
|
|
79
204
|
return new Promise((resolve, reject) => {
|
|
80
205
|
const server = net.createServer();
|
|
81
|
-
server.once(
|
|
82
|
-
server.listen(0,
|
|
206
|
+
server.once('error', reject);
|
|
207
|
+
server.listen(0, '127.0.0.1', () => {
|
|
83
208
|
const { port } = server.address();
|
|
84
209
|
server.close(() => resolve(port));
|
|
85
210
|
});
|
|
86
211
|
});
|
|
87
212
|
}
|
|
213
|
+
/** Resolve true if something is accepting connections on 127.0.0.1:port. */
|
|
214
|
+
function isPortListening(port) {
|
|
215
|
+
return new Promise((resolve) => {
|
|
216
|
+
const s = new net.Socket();
|
|
217
|
+
let done = false;
|
|
218
|
+
const fin = (ok) => {
|
|
219
|
+
if (done)
|
|
220
|
+
return;
|
|
221
|
+
done = true;
|
|
222
|
+
s.destroy();
|
|
223
|
+
resolve(ok);
|
|
224
|
+
};
|
|
225
|
+
s.setTimeout(300);
|
|
226
|
+
s.once('connect', () => fin(true));
|
|
227
|
+
s.once('timeout', () => fin(false));
|
|
228
|
+
s.once('error', () => fin(false));
|
|
229
|
+
s.connect(port, '127.0.0.1');
|
|
230
|
+
});
|
|
231
|
+
}
|
|
88
232
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
233
|
+
* Rewrite a service's OWN listen-port env var(s) from `oldPort` to `newPort`
|
|
234
|
+
* when `allocatePorts` reassigns its port (e.g. a worktree block). Matches any
|
|
235
|
+
* port-ish var (`PORT` or `*_PORT`, e.g. `VCS_PORT`/`AGENT_API_PORT`) whose value
|
|
236
|
+
* is exactly the old port — so a service that reads its own env var still binds
|
|
237
|
+
* the reassigned port instead of its deploy-time default (which would collide
|
|
238
|
+
* with another worktree/session on that default). The name + exact-value match
|
|
239
|
+
* keeps this from touching cross-service references or unrelated vars.
|
|
240
|
+
* Mutates `service.env`. Pure otherwise — exported for unit testing. (MESH-2088)
|
|
91
241
|
*/
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
for (const [name, tunnel] of Object.entries(allocated.tunnels)) {
|
|
97
|
-
const preferred = tunnel.localPort;
|
|
98
|
-
if (preferred > 0 &&
|
|
99
|
-
!usedPorts.has(preferred) &&
|
|
100
|
-
(await isPortFree(preferred))) {
|
|
101
|
-
usedPorts.add(preferred);
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
const newPort = await findFreePort();
|
|
105
|
-
if (preferred > 0)
|
|
106
|
-
logWarn(`Port ${preferred} busy for tunnel '${name}', using ${newPort}`);
|
|
107
|
-
tunnel.localPort = newPort;
|
|
108
|
-
usedPorts.add(newPort);
|
|
242
|
+
export function rewriteOwnServicePort(service, oldPort, newPort) {
|
|
243
|
+
for (const [key, spec] of Object.entries(service.env ?? {})) {
|
|
244
|
+
if ((key === 'PORT' || key.endsWith('_PORT')) && spec.value === String(oldPort)) {
|
|
245
|
+
spec.value = String(newPort);
|
|
109
246
|
}
|
|
110
247
|
}
|
|
111
|
-
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Clone devOutput and assign each service a local port.
|
|
251
|
+
*
|
|
252
|
+
* The PRIMARY checkout keeps the app's preferred ports (falling back to an
|
|
253
|
+
* OS-ephemeral port only if one is busy). LINKED worktrees draw from a reserved
|
|
254
|
+
* port block keyed on (worktree, app) — MESH-2013 for the worktree axis, #2889
|
|
255
|
+
* for the app axis — so concurrent sessions don't collide, where the
|
|
256
|
+
* `EADDRINUSE on 8080/8081/9000` class only happened because every worktree
|
|
257
|
+
* reused the same preferred ports.
|
|
258
|
+
*
|
|
259
|
+
* Disjoint by DEFAULT, not by construction: the block is a hash bucket, so two
|
|
260
|
+
* (worktree, app) pairs can share one. The in-block scan below plus the
|
|
261
|
+
* ephemeral fallback are the backstop; MESH-2375 tracks making it deterministic.
|
|
262
|
+
*/
|
|
263
|
+
async function allocatePorts(devOutput, worktree) {
|
|
264
|
+
const allocated = JSON.parse(JSON.stringify(devOutput));
|
|
265
|
+
const usedPorts = new Set();
|
|
112
266
|
const portRemaps = new Map();
|
|
267
|
+
const blockBase = worktree.isPrimary ? 0 : blockBasePort(worktree.portBlock);
|
|
268
|
+
let blockCursor = 0; // next unused index within the service sub-range
|
|
113
269
|
for (const [name, service] of Object.entries(allocated.services)) {
|
|
114
270
|
if (service.port <= 0)
|
|
115
271
|
continue;
|
|
116
|
-
// Skip
|
|
117
|
-
// They run in K8s and are accessed via tunnels, not local ports.
|
|
272
|
+
// Skip deployed services (no dev command) — they run in K8s, no local port.
|
|
118
273
|
if (!service.command || service.command.length === 0)
|
|
119
274
|
continue;
|
|
120
275
|
const preferred = service.port;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
usedPorts.
|
|
276
|
+
let target = -1;
|
|
277
|
+
if (worktree.isPrimary) {
|
|
278
|
+
if (!usedPorts.has(preferred) && (await isPortFree(preferred))) {
|
|
279
|
+
target = preferred;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
target = await findFreePort();
|
|
283
|
+
logWarn(`Port ${preferred} busy for service '${name}', using ${target}`);
|
|
284
|
+
}
|
|
124
285
|
}
|
|
125
286
|
else {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
287
|
+
// Reserved block: next free port in [blockBase, blockBase+SERVICE_SUBRANGE).
|
|
288
|
+
for (let i = blockCursor; i < PORT_BLOCK_SERVICE_SUBRANGE; i++) {
|
|
289
|
+
const candidate = blockBase + i;
|
|
290
|
+
if (!usedPorts.has(candidate) && (await isPortFree(candidate))) {
|
|
291
|
+
target = candidate;
|
|
292
|
+
blockCursor = i + 1;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
131
295
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
296
|
+
if (target < 0) {
|
|
297
|
+
// Block exhausted or contended — degrade to an OS-ephemeral port.
|
|
298
|
+
target = await findFreePort();
|
|
299
|
+
logWarn(`Worktree port block exhausted for '${name}', using ${target}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (target !== preferred) {
|
|
303
|
+
// Rewrite the service's OWN port env var(s) — not just a var literally
|
|
304
|
+
// named `PORT`. Without this, a service reading `VCS_PORT`/`AGENT_API_PORT`
|
|
305
|
+
// keeps its deploy-time default and collides across worktrees (MESH-2088).
|
|
306
|
+
rewriteOwnServicePort(service, preferred, target);
|
|
307
|
+
portRemaps.set(preferred, target);
|
|
308
|
+
service.port = target;
|
|
135
309
|
}
|
|
310
|
+
usedPorts.add(target);
|
|
136
311
|
}
|
|
137
312
|
// Cross-service URL rewriting: when a service port is reassigned,
|
|
138
313
|
// update any env var in other services that references the old port
|
|
139
314
|
// (e.g., API_URL from a link() pointing to the API's original port).
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
315
|
+
rewriteCrossServicePorts(allocated, portRemaps);
|
|
316
|
+
rewriteClusterHostsToLocal(allocated);
|
|
317
|
+
return allocated;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Rewrite `localhost:${oldPort}` → `localhost:${newPort}` in EVERY service's env
|
|
321
|
+
* values, for each `oldPort → newPort` entry in `portRemaps`. Mutates the given
|
|
322
|
+
* `devOutput` in place — callers pass a clone they own, so this stays safe.
|
|
323
|
+
*
|
|
324
|
+
* Shared by `allocatePorts` (launch-time reallocation) and `mergePinnedPorts`
|
|
325
|
+
* (refresh-time re-pinning) so both apply identical cross-service rewrites.
|
|
326
|
+
*/
|
|
327
|
+
function rewriteCrossServicePorts(devOutput, portRemaps) {
|
|
328
|
+
if (portRemaps.size === 0)
|
|
329
|
+
return;
|
|
330
|
+
for (const service of Object.values(devOutput.services)) {
|
|
331
|
+
if (!service.env)
|
|
332
|
+
continue;
|
|
333
|
+
for (const envVar of Object.values(service.env)) {
|
|
334
|
+
for (const [oldPort, newPort] of portRemaps) {
|
|
335
|
+
if (envVar.value.includes(`localhost:${oldPort}`)) {
|
|
336
|
+
envVar.value = envVar.value.replace(`localhost:${oldPort}`, `localhost:${newPort}`);
|
|
149
337
|
}
|
|
150
338
|
}
|
|
151
339
|
}
|
|
152
340
|
}
|
|
153
|
-
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Merge a session's PINNED ports/tunnels into a freshly-read DevOutput.
|
|
344
|
+
*
|
|
345
|
+
* `mesh dev restart <svc> --refresh-env` re-reads `pulumi stack output` to pick
|
|
346
|
+
* up config/deploy changes, but the running session already allocated local
|
|
347
|
+
* ports (`allocatePorts`) and opened SSM tunnels. Re-reading the raw stack
|
|
348
|
+
* output would carry the DEPLOYED (pre-allocation) ports, so writing those into
|
|
349
|
+
* the env file would point the service — and its peers — at the wrong ports and
|
|
350
|
+
* break the live tunnels.
|
|
351
|
+
*
|
|
352
|
+
* This returns `fresh` (so new/changed non-port env values win) but overrides:
|
|
353
|
+
* - EVERY service present in both `fresh` and `pinned` keeps its pinned/
|
|
354
|
+
* allocated PORT (both `service.port` and `service.env.PORT.value`) — not
|
|
355
|
+
* just the refreshed one, so a regenerated service's peer references land on
|
|
356
|
+
* the session-allocated ports, and
|
|
357
|
+
* - every tunnel present in `pinned` with the pinned host:port (the live SSM
|
|
358
|
+
* endpoints); tunnels that exist only in `fresh` survive untouched.
|
|
359
|
+
*
|
|
360
|
+
* It then re-applies BOTH of the peer-addressing rewrites `allocatePorts` does —
|
|
361
|
+
* there are two now, and naming only one here is how they drift apart:
|
|
362
|
+
* - PORT: for each service whose port differs between `fresh` (preferred) and
|
|
363
|
+
* `pinned` (allocated), `localhost:${freshPort}` → `localhost:${pinnedPort}`
|
|
364
|
+
* across EVERY service's env (e.g. a `ui`'s `API_URL` pointing at `api`).
|
|
365
|
+
* - HOST: an in-cluster address for a service running LOCALLY becomes its local
|
|
366
|
+
* one. `pulumi stack output` carries cluster hosts verbatim, so a refresh
|
|
367
|
+
* without this restores exactly the value MESH-2597 exists to remove.
|
|
368
|
+
*
|
|
369
|
+
* Pure: deep-clones and never mutates its arguments.
|
|
370
|
+
*/
|
|
371
|
+
export function mergePinnedPorts(fresh, pinned) {
|
|
372
|
+
const merged = JSON.parse(JSON.stringify(fresh));
|
|
373
|
+
// Pinned tunnels win (live SSM/VPC endpoints); fresh-only tunnels survive.
|
|
374
|
+
const pinnedTunnels = JSON.parse(JSON.stringify(pinned.tunnels ?? {}));
|
|
375
|
+
merged.tunnels = { ...(merged.tunnels ?? {}), ...pinnedTunnels };
|
|
376
|
+
// Pin EVERY service's allocated port (never re-allocate). Track fresh→pinned
|
|
377
|
+
// port remaps so peer references (localhost:<freshPort>) can be rewritten.
|
|
378
|
+
const portRemaps = new Map();
|
|
379
|
+
for (const [name, mergedSvc] of Object.entries(merged.services ?? {})) {
|
|
380
|
+
const pinnedSvc = pinned.services?.[name];
|
|
381
|
+
if (!pinnedSvc || typeof pinnedSvc.port !== 'number')
|
|
382
|
+
continue;
|
|
383
|
+
const freshPort = mergedSvc.port;
|
|
384
|
+
mergedSvc.port = pinnedSvc.port;
|
|
385
|
+
if (mergedSvc.env?.PORT) {
|
|
386
|
+
mergedSvc.env.PORT.value = String(pinnedSvc.port);
|
|
387
|
+
}
|
|
388
|
+
if (typeof freshPort === 'number' && freshPort > 0 && freshPort !== pinnedSvc.port) {
|
|
389
|
+
portRemaps.set(freshPort, pinnedSvc.port);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// Re-apply cross-service URL rewrite (mirrors allocatePorts): peer references
|
|
393
|
+
// to a preferred port become references to the session-allocated port.
|
|
394
|
+
rewriteCrossServicePorts(merged, portRemaps);
|
|
395
|
+
// BOTH rewrites, or `--refresh-env` undoes the other one. This path re-reads
|
|
396
|
+
// `pulumi stack output`, which carries in-cluster hosts verbatim, so without
|
|
397
|
+
// this a refresh restores `AGENT_API_INTERNAL_URL` to
|
|
398
|
+
// `http://studio-api.<ns>.svc.cluster.local:8080` for a service running locally
|
|
399
|
+
// — reintroducing MESH-2597 on the command whose entire purpose is refreshing
|
|
400
|
+
// env.
|
|
401
|
+
rewriteClusterHostsToLocal(merged);
|
|
402
|
+
return merged;
|
|
154
403
|
}
|
|
155
404
|
// ============================================================================
|
|
156
405
|
// Pulumi helpers
|
|
@@ -164,12 +413,12 @@ function findMonorepoRoot() {
|
|
|
164
413
|
const root = path.parse(dir).root;
|
|
165
414
|
while (dir !== root) {
|
|
166
415
|
// Look for pnpm-workspace.yaml (pnpm monorepo) or .git (repo root)
|
|
167
|
-
if (fs.existsSync(path.join(dir,
|
|
168
|
-
fs.existsSync(path.join(dir,
|
|
416
|
+
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) ||
|
|
417
|
+
fs.existsSync(path.join(dir, 'turbo.json'))) {
|
|
169
418
|
return dir;
|
|
170
419
|
}
|
|
171
420
|
// .git as fallback — but only if it's a directory (not a worktree file)
|
|
172
|
-
const gitPath = path.join(dir,
|
|
421
|
+
const gitPath = path.join(dir, '.git');
|
|
173
422
|
if (fs.existsSync(gitPath)) {
|
|
174
423
|
return dir;
|
|
175
424
|
}
|
|
@@ -190,10 +439,8 @@ function discoverApps(monorepoRoot) {
|
|
|
190
439
|
const projectName = getProjectName(appDir);
|
|
191
440
|
const stacks = fs
|
|
192
441
|
.readdirSync(appDir)
|
|
193
|
-
.filter((f) => f.startsWith(
|
|
194
|
-
f.
|
|
195
|
-
f !== "Pulumi.yaml")
|
|
196
|
-
.map((f) => f.replace(/^Pulumi\./, "").replace(/\.yaml$/, ""));
|
|
442
|
+
.filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml')
|
|
443
|
+
.map((f) => f.replace(/^Pulumi\./, '').replace(/\.yaml$/, ''));
|
|
197
444
|
apps.push({
|
|
198
445
|
name: projectName,
|
|
199
446
|
tenant,
|
|
@@ -203,42 +450,40 @@ function discoverApps(monorepoRoot) {
|
|
|
203
450
|
});
|
|
204
451
|
}
|
|
205
452
|
// 1. Tenant-scoped: {tenants,tests/tenants}/<tenant>/apps/<app>/
|
|
206
|
-
const searchDirs = [
|
|
453
|
+
const searchDirs = ['tenants', 'tests/tenants'];
|
|
207
454
|
for (const searchDir of searchDirs) {
|
|
208
455
|
const base = path.join(monorepoRoot, searchDir);
|
|
209
456
|
if (!fs.existsSync(base))
|
|
210
457
|
continue;
|
|
211
458
|
for (const tenant of fs.readdirSync(base)) {
|
|
212
|
-
const appsDir = path.join(base, tenant,
|
|
459
|
+
const appsDir = path.join(base, tenant, 'apps');
|
|
213
460
|
if (!fs.existsSync(appsDir) || !fs.statSync(appsDir).isDirectory())
|
|
214
461
|
continue;
|
|
215
462
|
for (const app of fs.readdirSync(appsDir)) {
|
|
216
463
|
const appDir = path.join(appsDir, app);
|
|
217
|
-
if (!fs.existsSync(path.join(appDir,
|
|
464
|
+
if (!fs.existsSync(path.join(appDir, 'Pulumi.yaml')))
|
|
218
465
|
continue;
|
|
219
466
|
addApp(appDir, tenant);
|
|
220
467
|
}
|
|
221
468
|
}
|
|
222
469
|
}
|
|
223
470
|
// 2. Flat layout: apps/<app>/ (tenant read from first stack config)
|
|
224
|
-
const flatAppsDir = path.join(monorepoRoot,
|
|
471
|
+
const flatAppsDir = path.join(monorepoRoot, 'apps');
|
|
225
472
|
if (fs.existsSync(flatAppsDir) && fs.statSync(flatAppsDir).isDirectory()) {
|
|
226
473
|
for (const app of fs.readdirSync(flatAppsDir)) {
|
|
227
474
|
const appDir = path.join(flatAppsDir, app);
|
|
228
475
|
if (!fs.statSync(appDir).isDirectory())
|
|
229
476
|
continue;
|
|
230
|
-
if (!fs.existsSync(path.join(appDir,
|
|
477
|
+
if (!fs.existsSync(path.join(appDir, 'Pulumi.yaml')))
|
|
231
478
|
continue;
|
|
232
479
|
// Read tenant from first stack config's mesh:tenant
|
|
233
|
-
let tenant =
|
|
480
|
+
let tenant = 'unknown';
|
|
234
481
|
const stackConfigs = fs
|
|
235
482
|
.readdirSync(appDir)
|
|
236
|
-
.filter((f) => f.startsWith(
|
|
237
|
-
f.endsWith(".yaml") &&
|
|
238
|
-
f !== "Pulumi.yaml");
|
|
483
|
+
.filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml');
|
|
239
484
|
if (stackConfigs.length > 0) {
|
|
240
485
|
try {
|
|
241
|
-
const content = fs.readFileSync(path.join(appDir, stackConfigs[0]),
|
|
486
|
+
const content = fs.readFileSync(path.join(appDir, stackConfigs[0]), 'utf-8');
|
|
242
487
|
const tenantMatch = content.match(/mesh:tenant:\s*(\S+)/);
|
|
243
488
|
if (tenantMatch)
|
|
244
489
|
tenant = tenantMatch[1];
|
|
@@ -257,13 +502,13 @@ function findAppRoot(appPath) {
|
|
|
257
502
|
if (appPath) {
|
|
258
503
|
// Resolve relative to cwd or monorepo root
|
|
259
504
|
const resolved = path.resolve(appPath);
|
|
260
|
-
if (fs.existsSync(path.join(resolved,
|
|
505
|
+
if (fs.existsSync(path.join(resolved, 'Pulumi.yaml')))
|
|
261
506
|
return resolved;
|
|
262
507
|
// Try relative to monorepo root
|
|
263
508
|
const mono = findMonorepoRoot();
|
|
264
509
|
if (mono) {
|
|
265
510
|
const fromMono = path.resolve(mono, appPath);
|
|
266
|
-
if (fs.existsSync(path.join(fromMono,
|
|
511
|
+
if (fs.existsSync(path.join(fromMono, 'Pulumi.yaml')))
|
|
267
512
|
return fromMono;
|
|
268
513
|
}
|
|
269
514
|
logError(`No Pulumi.yaml found at: ${appPath}`);
|
|
@@ -272,7 +517,7 @@ function findAppRoot(appPath) {
|
|
|
272
517
|
let dir = process.cwd();
|
|
273
518
|
const root = path.parse(dir).root;
|
|
274
519
|
while (dir !== root) {
|
|
275
|
-
if (fs.existsSync(path.join(dir,
|
|
520
|
+
if (fs.existsSync(path.join(dir, 'Pulumi.yaml'))) {
|
|
276
521
|
return dir;
|
|
277
522
|
}
|
|
278
523
|
dir = path.dirname(dir);
|
|
@@ -283,10 +528,10 @@ function findAppRoot(appPath) {
|
|
|
283
528
|
* Read the Pulumi project name from Pulumi.yaml.
|
|
284
529
|
*/
|
|
285
530
|
function getProjectName(appRoot) {
|
|
286
|
-
const yamlPath = path.join(appRoot,
|
|
531
|
+
const yamlPath = path.join(appRoot, 'Pulumi.yaml');
|
|
287
532
|
if (!fs.existsSync(yamlPath))
|
|
288
533
|
return path.basename(appRoot);
|
|
289
|
-
const content = fs.readFileSync(yamlPath,
|
|
534
|
+
const content = fs.readFileSync(yamlPath, 'utf-8');
|
|
290
535
|
const match = content.match(/^name:\s*(.+)$/m);
|
|
291
536
|
return match?.[1]?.trim() ?? path.basename(appRoot);
|
|
292
537
|
}
|
|
@@ -303,10 +548,10 @@ function detectStack(appRoot, stageArg) {
|
|
|
303
548
|
if (process.env.MESH_STAGE)
|
|
304
549
|
return process.env.MESH_STAGE;
|
|
305
550
|
try {
|
|
306
|
-
const result = execFileSync(
|
|
551
|
+
const result = execFileSync('pulumi', ['stack', '--show-name'], {
|
|
307
552
|
cwd: appRoot,
|
|
308
|
-
encoding:
|
|
309
|
-
stdio: [
|
|
553
|
+
encoding: 'utf-8',
|
|
554
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
310
555
|
}).trim();
|
|
311
556
|
if (result)
|
|
312
557
|
return result;
|
|
@@ -316,7 +561,9 @@ function detectStack(appRoot, stageArg) {
|
|
|
316
561
|
}
|
|
317
562
|
// Infer from Pulumi.<stack>.yaml config files
|
|
318
563
|
try {
|
|
319
|
-
const files = fs
|
|
564
|
+
const files = fs
|
|
565
|
+
.readdirSync(appRoot)
|
|
566
|
+
.filter((f) => f.startsWith('Pulumi.') && f.endsWith('.yaml') && f !== 'Pulumi.yaml');
|
|
320
567
|
if (files.length === 1) {
|
|
321
568
|
const match = files[0].match(/^Pulumi\.(.+)\.yaml$/);
|
|
322
569
|
if (match?.[1]) {
|
|
@@ -328,20 +575,66 @@ function detectStack(appRoot, stageArg) {
|
|
|
328
575
|
catch {
|
|
329
576
|
// Fall through
|
|
330
577
|
}
|
|
331
|
-
return
|
|
578
|
+
return 'dev';
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Build the `--stack <name>` args for Pulumi commands.
|
|
582
|
+
* Newer Pulumi versions require fully-qualified names (organization/project/stack)
|
|
583
|
+
* with `--stack`. If the stack is already selected, we can omit the flag entirely.
|
|
584
|
+
*/
|
|
585
|
+
function stackArgs(appRoot, stack) {
|
|
586
|
+
// Check if the requested stack is already the selected one — if so, skip --stack
|
|
587
|
+
try {
|
|
588
|
+
const selected = execFileSync('pulumi', ['stack', '--show-name'], {
|
|
589
|
+
cwd: appRoot,
|
|
590
|
+
encoding: 'utf-8',
|
|
591
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
592
|
+
}).trim();
|
|
593
|
+
if (selected === stack)
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
// ignore
|
|
598
|
+
}
|
|
599
|
+
// Try selecting the stack first (works with short names), then omit --stack
|
|
600
|
+
try {
|
|
601
|
+
execFileSync('pulumi', ['stack', 'select', stack], {
|
|
602
|
+
cwd: appRoot,
|
|
603
|
+
encoding: 'utf-8',
|
|
604
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
605
|
+
});
|
|
606
|
+
return [];
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
// Fall through — pass --stack and hope the name is fully qualified
|
|
610
|
+
}
|
|
611
|
+
return ['--stack', stack];
|
|
332
612
|
}
|
|
333
613
|
/**
|
|
334
614
|
* Read the dev output from Pulumi stack.
|
|
335
615
|
* Tries `app` output first (new env.register() format), falls back to `dev`.
|
|
336
616
|
*/
|
|
337
|
-
|
|
617
|
+
/**
|
|
618
|
+
* Thrown by `getDevOutput` when no readable Pulumi stack output exists (usually a
|
|
619
|
+
* fresh app that hasn't been `stack init` + `deploy`ed yet). Typed so each caller
|
|
620
|
+
* presents it in its OWN context: only the launch path can conclude "nothing to
|
|
621
|
+
* run" and print first-run guidance; the `--refresh-env`/restart handlers must
|
|
622
|
+
* keep the running session's env untouched (MESH-2097). Carries the underlying
|
|
623
|
+
* read error so an auth/network/JSON failure isn't misdiagnosed as "fresh app".
|
|
624
|
+
*/
|
|
625
|
+
class MissingStackOutputError extends Error {
|
|
626
|
+
stackName;
|
|
627
|
+
constructor(stackName, cause) {
|
|
628
|
+
super(`No Pulumi stack output for '${stackName}'`, { cause });
|
|
629
|
+
this.stackName = stackName;
|
|
630
|
+
this.name = 'MissingStackOutputError';
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
function getDevOutput(appRoot, stack, awsEnv) {
|
|
634
|
+
const sa = stackArgs(appRoot, stack);
|
|
338
635
|
// Try new format: export const app = env.register() → { dev: { ... } }
|
|
339
636
|
try {
|
|
340
|
-
const result =
|
|
341
|
-
cwd: appRoot,
|
|
342
|
-
encoding: "utf-8",
|
|
343
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
344
|
-
});
|
|
637
|
+
const result = pulumiStackOutput(appRoot, 'app', sa, awsEnv);
|
|
345
638
|
const appOutput = JSON.parse(result);
|
|
346
639
|
if (appOutput.dev) {
|
|
347
640
|
return appOutput.dev;
|
|
@@ -352,18 +645,16 @@ function getDevOutput(appRoot, stack) {
|
|
|
352
645
|
}
|
|
353
646
|
// Legacy format: export const dev = ...
|
|
354
647
|
try {
|
|
355
|
-
const result =
|
|
356
|
-
cwd: appRoot,
|
|
357
|
-
encoding: "utf-8",
|
|
358
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
359
|
-
});
|
|
648
|
+
const result = pulumiStackOutput(appRoot, 'dev', sa, awsEnv);
|
|
360
649
|
return JSON.parse(result);
|
|
361
650
|
}
|
|
362
651
|
catch (err) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
652
|
+
// No readable stack output. Throw a TYPED error so each caller decides how to
|
|
653
|
+
// present it (MESH-2097): the launch path prints first-run guidance + exits;
|
|
654
|
+
// the --refresh-env/restart handlers keep the running session untouched.
|
|
655
|
+
// (process.exit() here previously hijacked control flow past those fail-closed
|
|
656
|
+
// catches, and a bare `throw` escaped as an uncaught node stacktrace.)
|
|
657
|
+
throw new MissingStackOutputError(stack, err);
|
|
367
658
|
}
|
|
368
659
|
}
|
|
369
660
|
// ============================================================================
|
|
@@ -371,7 +662,7 @@ function getDevOutput(appRoot, stack) {
|
|
|
371
662
|
// ============================================================================
|
|
372
663
|
function hasTmux() {
|
|
373
664
|
try {
|
|
374
|
-
execFileSync(
|
|
665
|
+
execFileSync('which', ['tmux'], { stdio: 'ignore' });
|
|
375
666
|
return true;
|
|
376
667
|
}
|
|
377
668
|
catch {
|
|
@@ -380,7 +671,7 @@ function hasTmux() {
|
|
|
380
671
|
}
|
|
381
672
|
function sessionExists(name) {
|
|
382
673
|
try {
|
|
383
|
-
execFileSync(
|
|
674
|
+
execFileSync('tmux', ['has-session', '-t', name], { stdio: 'ignore' });
|
|
384
675
|
return true;
|
|
385
676
|
}
|
|
386
677
|
catch {
|
|
@@ -389,25 +680,38 @@ function sessionExists(name) {
|
|
|
389
680
|
}
|
|
390
681
|
function killSession(name) {
|
|
391
682
|
try {
|
|
392
|
-
execFileSync(
|
|
683
|
+
execFileSync('tmux', ['kill-session', '-t', name], { stdio: 'ignore' });
|
|
393
684
|
return true;
|
|
394
685
|
}
|
|
395
686
|
catch {
|
|
396
687
|
return false;
|
|
397
688
|
}
|
|
398
689
|
}
|
|
690
|
+
const AWS_STATIC_CREDENTIAL_ENV_KEYS = [
|
|
691
|
+
'AWS_ACCESS_KEY_ID',
|
|
692
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
693
|
+
'AWS_SESSION_TOKEN',
|
|
694
|
+
'AWS_WEB_IDENTITY_TOKEN_FILE',
|
|
695
|
+
'AWS_ROLE_ARN',
|
|
696
|
+
];
|
|
697
|
+
function applyAwsProfileOverride(profile) {
|
|
698
|
+
process.env.AWS_PROFILE = profile;
|
|
699
|
+
for (const key of AWS_STATIC_CREDENTIAL_ENV_KEYS) {
|
|
700
|
+
delete process.env[key];
|
|
701
|
+
}
|
|
702
|
+
}
|
|
399
703
|
/**
|
|
400
704
|
* Collect AWS credentials from the current environment.
|
|
401
705
|
*/
|
|
402
706
|
function getAwsEnvVars() {
|
|
403
707
|
const result = {};
|
|
404
708
|
const vars = [
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
709
|
+
'AWS_PROFILE',
|
|
710
|
+
'AWS_ACCESS_KEY_ID',
|
|
711
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
712
|
+
'AWS_SESSION_TOKEN',
|
|
713
|
+
'AWS_REGION',
|
|
714
|
+
'AWS_DEFAULT_REGION',
|
|
411
715
|
];
|
|
412
716
|
for (const name of vars) {
|
|
413
717
|
const value = process.env[name];
|
|
@@ -417,7 +721,114 @@ function getAwsEnvVars() {
|
|
|
417
721
|
return result;
|
|
418
722
|
}
|
|
419
723
|
/**
|
|
420
|
-
*
|
|
724
|
+
* Build the AWS env vars to hand to long-lived child services (ui, api,
|
|
725
|
+
* ai-assistant) and SSM tunnels.
|
|
726
|
+
*
|
|
727
|
+
* For the AWS SSO credential path, returns AWS_PROFILE + AWS_REGION only so
|
|
728
|
+
* the AWS SDK's default provider chain can auto-refresh credentials from the
|
|
729
|
+
* SSO cache (~8h session) as short-lived STS tokens expire. Without this,
|
|
730
|
+
* child processes inherit a frozen STS triple and start throwing
|
|
731
|
+
* ExpiredTokenException ~1h into a dev session.
|
|
732
|
+
*
|
|
733
|
+
* For the Zitadel web-identity path, when `opts` is supplied we write an AWS
|
|
734
|
+
* config file whose `credential_process` re-invokes `mesh login … --credential-process`,
|
|
735
|
+
* so the AWS SDK's default provider chain auto-refreshes the underlying STS
|
|
736
|
+
* triple (the `mesh login` process re-issues the Zitadel token as needed).
|
|
737
|
+
* This removes the ~1h forced `mesh dev` restart. When `opts` is absent we
|
|
738
|
+
* fall back to the frozen STS triple (used by CI/`environment` and any caller
|
|
739
|
+
* that doesn't thread the context/role through).
|
|
740
|
+
*
|
|
741
|
+
* For the remaining non-SSO paths (CI/CD env keys, or no deployer role),
|
|
742
|
+
* returns the explicit STS triple — there is no profile to refresh from. The
|
|
743
|
+
* user must restart `mesh dev` when those tokens expire.
|
|
744
|
+
*/
|
|
745
|
+
export function buildChildAwsEnv(awsEnv, method, profile, opts) {
|
|
746
|
+
const result = {};
|
|
747
|
+
if (awsEnv.AWS_REGION)
|
|
748
|
+
result.AWS_REGION = awsEnv.AWS_REGION;
|
|
749
|
+
if (awsEnv.AWS_DEFAULT_REGION)
|
|
750
|
+
result.AWS_DEFAULT_REGION = awsEnv.AWS_DEFAULT_REGION;
|
|
751
|
+
if (method === 'sso' && profile) {
|
|
752
|
+
result.AWS_PROFILE = profile;
|
|
753
|
+
return result;
|
|
754
|
+
}
|
|
755
|
+
// Zitadel path with a credential_process profile: hand children a profile
|
|
756
|
+
// that self-refreshes via `mesh login`, never a frozen triple.
|
|
757
|
+
if (method === 'zitadel' && opts) {
|
|
758
|
+
const sanitizedContext = opts.context.replace(/[^A-Za-z0-9_-]/g, '-');
|
|
759
|
+
const profileName = `mesh-dev-${sanitizedContext}`;
|
|
760
|
+
const configPath = path.join(opts.sessionScratchDir, 'aws-config');
|
|
761
|
+
const region = awsEnv.AWS_REGION ?? awsEnv.AWS_DEFAULT_REGION ?? '';
|
|
762
|
+
fs.mkdirSync(opts.sessionScratchDir, { recursive: true });
|
|
763
|
+
fs.writeFileSync(configPath, renderCredentialProcessProfile({
|
|
764
|
+
profileName,
|
|
765
|
+
context: opts.context,
|
|
766
|
+
roleArn: opts.roleArn,
|
|
767
|
+
region,
|
|
768
|
+
meshBin: opts.meshBin,
|
|
769
|
+
}), { mode: 0o600 });
|
|
770
|
+
const out = {
|
|
771
|
+
AWS_PROFILE: profileName,
|
|
772
|
+
AWS_CONFIG_FILE: configPath,
|
|
773
|
+
};
|
|
774
|
+
if (region)
|
|
775
|
+
out.AWS_REGION = region;
|
|
776
|
+
return out;
|
|
777
|
+
}
|
|
778
|
+
for (const key of ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN']) {
|
|
779
|
+
if (awsEnv[key])
|
|
780
|
+
result[key] = awsEnv[key];
|
|
781
|
+
}
|
|
782
|
+
if (profile && !result.AWS_ACCESS_KEY_ID) {
|
|
783
|
+
result.AWS_PROFILE = profile;
|
|
784
|
+
}
|
|
785
|
+
return result;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Resolve a service's source dir onto the CURRENT worktree (MESH-2013).
|
|
789
|
+
*
|
|
790
|
+
* `mesh dev` reads the service `src` from `pulumi stack output`. Two shapes exist:
|
|
791
|
+
* - New (post relative-src fix in infra-components): a monorepo-RELATIVE dir
|
|
792
|
+
* like `packages/agent-sdk` → just resolve it against this worktree's root.
|
|
793
|
+
* - Legacy: an ABSOLUTE path baked at the deploying `pulumi up`, e.g.
|
|
794
|
+
* `/…/mesh-platform/.worktrees/streaming-ui-builder/packages/agent-sdk`. The
|
|
795
|
+
* old logic stripped only up to `/mesh-platform/`, keeping the
|
|
796
|
+
* `.worktrees/<name>/` segment — so it re-rooted to a *sibling worktree* that
|
|
797
|
+
* also exists, silently running the wrong worktree's code. We now reduce to
|
|
798
|
+
* the monorepo-relative portion (dropping any `.worktrees/<name>/`) and
|
|
799
|
+
* re-root here, so a stack deployed from any worktree runs from THIS one.
|
|
800
|
+
*/
|
|
801
|
+
function rebaseServiceSrc(src, monorepoRoot) {
|
|
802
|
+
if (!monorepoRoot)
|
|
803
|
+
return src;
|
|
804
|
+
const rel = monorepoRelativeSrc(src);
|
|
805
|
+
if (rel === null)
|
|
806
|
+
return src;
|
|
807
|
+
const rebased = path.join(monorepoRoot, rel);
|
|
808
|
+
return fs.existsSync(rebased) ? rebased : src;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Reduce a service `src` to its monorepo-relative form (pure; no filesystem).
|
|
812
|
+
* - relative (`./packages/x` or `packages/x`) → normalized `packages/x`
|
|
813
|
+
* - legacy absolute (`/…/mesh-platform[/.worktrees/<name>]/packages/x`) →
|
|
814
|
+
* `packages/x` (drops the repo root AND any deploying-worktree segment)
|
|
815
|
+
* Returns null when an absolute path has no recognizable monorepo marker.
|
|
816
|
+
*/
|
|
817
|
+
export function monorepoRelativeSrc(src) {
|
|
818
|
+
if (!path.isAbsolute(src))
|
|
819
|
+
return src.replace(/^\.\//, '') || '.';
|
|
820
|
+
// Match the LAST `/mesh-platform` at a segment boundary — covers the repo root
|
|
821
|
+
// itself (nothing follows → '.'), a nested `.worktrees/<name>/`, and normal
|
|
822
|
+
// subpaths. A path ending at the repo root (e.g. a service whose src IS the
|
|
823
|
+
// monorepo root) previously slipped through and stayed absolute (MESH-2069).
|
|
824
|
+
const m = src.match(/^.*\/mesh-platform(?:\/(.*))?$/);
|
|
825
|
+
if (!m)
|
|
826
|
+
return null;
|
|
827
|
+
const rest = (m[1] ?? '').replace(/^\.worktrees\/[^/]+(?:\/|$)/, '');
|
|
828
|
+
return rest === '' ? '.' : rest;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Resolve env vars for a service, rewriting tunnel references to VPC endpoints.
|
|
421
832
|
*/
|
|
422
833
|
function getServiceEnvVars(service, tunnels) {
|
|
423
834
|
const result = {};
|
|
@@ -427,22 +838,20 @@ function getServiceEnvVars(service, tunnels) {
|
|
|
427
838
|
if (spec.tunnel) {
|
|
428
839
|
const tunnel = tunnels[spec.tunnel];
|
|
429
840
|
if (tunnel) {
|
|
841
|
+
// Rewrite to VPC endpoint (VPN provides direct access)
|
|
430
842
|
try {
|
|
431
|
-
// Try as a full URL first (e.g. postgres://host:5432/db)
|
|
432
843
|
const url = new URL(value);
|
|
433
844
|
if (url.hostname) {
|
|
434
|
-
url.hostname =
|
|
435
|
-
url.port = String(tunnel.
|
|
845
|
+
url.hostname = tunnel.host;
|
|
846
|
+
url.port = String(tunnel.port);
|
|
436
847
|
value = url.toString();
|
|
437
848
|
}
|
|
438
849
|
else {
|
|
439
|
-
|
|
440
|
-
value = `localhost:${tunnel.localPort}`;
|
|
850
|
+
value = `${tunnel.host}:${tunnel.port}`;
|
|
441
851
|
}
|
|
442
852
|
}
|
|
443
853
|
catch {
|
|
444
|
-
|
|
445
|
-
value = `localhost:${tunnel.localPort}`;
|
|
854
|
+
value = `${tunnel.host}:${tunnel.port}`;
|
|
446
855
|
}
|
|
447
856
|
}
|
|
448
857
|
}
|
|
@@ -459,9 +868,7 @@ function getServiceEnvVars(service, tunnels) {
|
|
|
459
868
|
*/
|
|
460
869
|
function setTmuxEnv(sessionName, vars) {
|
|
461
870
|
for (const [key, value] of Object.entries(vars)) {
|
|
462
|
-
execFileSync(
|
|
463
|
-
"set-environment", "-t", sessionName, key, value,
|
|
464
|
-
], { stdio: "ignore" });
|
|
871
|
+
execFileSync('tmux', ['set-environment', '-t', sessionName, key, value], { stdio: 'ignore' });
|
|
465
872
|
}
|
|
466
873
|
}
|
|
467
874
|
/**
|
|
@@ -474,9 +881,9 @@ function setTmuxEnv(sessionName, vars) {
|
|
|
474
881
|
function envPrefix(vars) {
|
|
475
882
|
const entries = Object.entries(vars);
|
|
476
883
|
if (entries.length === 0)
|
|
477
|
-
return
|
|
884
|
+
return '';
|
|
478
885
|
const parts = entries.map(([k, v]) => `${k}=${shellEscape(v)}`);
|
|
479
|
-
return `env ${parts.join(
|
|
886
|
+
return `env ${parts.join(' ')} `;
|
|
480
887
|
}
|
|
481
888
|
/** Escape a value for shell interpolation. */
|
|
482
889
|
function shellEscape(s) {
|
|
@@ -491,9 +898,9 @@ function shellEscape(s) {
|
|
|
491
898
|
// ============================================================================
|
|
492
899
|
/**
|
|
493
900
|
* Resolve secrets from Secrets Manager and return env var key→value pairs.
|
|
494
|
-
*
|
|
901
|
+
* VPN provides direct access — no URL rewriting needed.
|
|
495
902
|
*/
|
|
496
|
-
async function resolveSecrets(secrets
|
|
903
|
+
async function resolveSecrets(secrets) {
|
|
497
904
|
const client = new SecretsManagerClient({});
|
|
498
905
|
const resolved = {};
|
|
499
906
|
for (const [name, secret] of Object.entries(secrets)) {
|
|
@@ -503,25 +910,9 @@ async function resolveSecrets(secrets, tunnels) {
|
|
|
503
910
|
continue;
|
|
504
911
|
const values = JSON.parse(response.SecretString);
|
|
505
912
|
for (const [secretKey, envVar] of Object.entries(secret.envMapping)) {
|
|
506
|
-
const
|
|
507
|
-
if (
|
|
913
|
+
const value = values[secretKey];
|
|
914
|
+
if (value === undefined)
|
|
508
915
|
continue;
|
|
509
|
-
let value = raw;
|
|
510
|
-
// Rewrite DATABASE_URL host:port if there's a tunnel
|
|
511
|
-
if (envVar === "DATABASE_URL" || secretKey === "DATABASE_URL") {
|
|
512
|
-
for (const tunnel of Object.values(tunnels)) {
|
|
513
|
-
try {
|
|
514
|
-
const parsed = new URL(value);
|
|
515
|
-
parsed.hostname = "localhost";
|
|
516
|
-
parsed.port = String(tunnel.localPort);
|
|
517
|
-
value = parsed.toString();
|
|
518
|
-
break;
|
|
519
|
-
}
|
|
520
|
-
catch {
|
|
521
|
-
// Not a URL
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
916
|
resolved[envVar] = value;
|
|
526
917
|
}
|
|
527
918
|
logSuccess(`Secret resolved: ${name} (${secret.secretName})`);
|
|
@@ -532,6 +923,491 @@ async function resolveSecrets(secrets, tunnels) {
|
|
|
532
923
|
}
|
|
533
924
|
return resolved;
|
|
534
925
|
}
|
|
926
|
+
/**
|
|
927
|
+
* Resolve the Temporal encoding key from the K8s secret in the app namespace.
|
|
928
|
+
*
|
|
929
|
+
* The Pulumi-generated devEncodingKey is a placeholder that differs from the
|
|
930
|
+
* deployed key, causing decryption failures when the local worker encounters
|
|
931
|
+
* existing Temporal payloads. Reading the real key from K8s ensures local dev
|
|
932
|
+
* can decrypt data created by deployed workers.
|
|
933
|
+
*
|
|
934
|
+
* Returns the encoding key value, or undefined if not available.
|
|
935
|
+
*/
|
|
936
|
+
function resolveTemporalEncodingKey(tenant, env, appName) {
|
|
937
|
+
const namespace = `${tenant}-${env}-${appName}`;
|
|
938
|
+
const secretName = `${namespace}-temporal-encoding-key`;
|
|
939
|
+
try {
|
|
940
|
+
const b64 = execFileSync('kubectl', [
|
|
941
|
+
'get',
|
|
942
|
+
'secret',
|
|
943
|
+
secretName,
|
|
944
|
+
'-n',
|
|
945
|
+
namespace,
|
|
946
|
+
'-o',
|
|
947
|
+
'jsonpath={.data.TEMPORAL_ENCODING_KEY}',
|
|
948
|
+
], { encoding: 'utf-8', timeout: 10_000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
949
|
+
if (b64) {
|
|
950
|
+
const key = Buffer.from(b64, 'base64').toString('utf-8');
|
|
951
|
+
logSuccess(`Temporal encoding key resolved from K8s secret (${secretName})`);
|
|
952
|
+
return key;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
// Non-fatal — secret may not exist yet or kubectl may not have access
|
|
957
|
+
}
|
|
958
|
+
return undefined;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Re-resolve the session-level Temporal auth vars the SAME way launch does
|
|
962
|
+
* (see startServices): kubeconfig from SSM, M2M auth, encoding key from the
|
|
963
|
+
* K8s secret, and TEMPORAL_ADDRESS from the (pinned) tunnel. Used by
|
|
964
|
+
* `mesh dev restart --refresh-env` so a regenerated env file carries the live
|
|
965
|
+
* resolved values, not the dev-output placeholders (which would desync payload
|
|
966
|
+
* encryption between services).
|
|
967
|
+
*/
|
|
968
|
+
async function resolveTemporalAuthVars(sessionName, devOutput) {
|
|
969
|
+
const tenant = devOutput.platform?.tenant ?? 'mesh';
|
|
970
|
+
const platformEnv = devOutput.platform?.env ?? 'dev';
|
|
971
|
+
// Hub platform name — the EKS export lives under it, not under a hardcoded
|
|
972
|
+
// "mesh" (MESH-2515); same axis as the bastion resolution.
|
|
973
|
+
const platformName = resolveHubPlatformName(devOutput.platform);
|
|
974
|
+
const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {
|
|
975
|
+
onError: ({ parameter, error }) => logWarn(`EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`),
|
|
976
|
+
});
|
|
977
|
+
if (kubeconfigPath) {
|
|
978
|
+
process.env.KUBECONFIG = kubeconfigPath;
|
|
979
|
+
setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });
|
|
980
|
+
}
|
|
981
|
+
else if (!process.env.KUBECONFIG) {
|
|
982
|
+
// ensureKubeconfig couldn't re-resolve the cluster from SSM (transient /
|
|
983
|
+
// stale creds). Fall back to the session kubeconfig written at launch so the
|
|
984
|
+
// kubectl calls below don't default to ~/.kube/config — which macOS TCC
|
|
985
|
+
// denies for shells without Full Disk Access ("operation not permitted").
|
|
986
|
+
const existing = sessionKubeconfigPath(sessionName);
|
|
987
|
+
if (fs.existsSync(existing)) {
|
|
988
|
+
// Set only this process's env — deliberately NOT setTmuxEnv: this is a
|
|
989
|
+
// best-effort fallback to a possibly-stale last-good kubeconfig, so we use
|
|
990
|
+
// it for the kubectl calls below without persisting it into the session
|
|
991
|
+
// (a later successful re-resolve is the one that should win the session).
|
|
992
|
+
process.env.KUBECONFIG = existing;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
let temporalAuthVars = {};
|
|
996
|
+
if (devOutput.tunnels['temporal']) {
|
|
997
|
+
// Deliberately NOT `platformName` from above: the Temporal-auth fallback is
|
|
998
|
+
// `?? tenant` (dedicated tenants may run their own Temporal), while the EKS
|
|
999
|
+
// fallback is `?? 'mesh'`. Whether that difference is correct is MESH-2551;
|
|
1000
|
+
// don't "fix" either default to match the other without reading it.
|
|
1001
|
+
temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
|
|
1002
|
+
const appName = devOutput.app ?? '';
|
|
1003
|
+
if (appName) {
|
|
1004
|
+
const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);
|
|
1005
|
+
if (encodingKey) {
|
|
1006
|
+
temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const temporalTunnel = devOutput.tunnels['temporal'];
|
|
1010
|
+
temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);
|
|
1011
|
+
}
|
|
1012
|
+
return temporalAuthVars;
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Resolve the dev-user identity vars (DEV_USER_*) from cached `mesh login`
|
|
1016
|
+
* credentials for the platform context (e.g. 'mesh.dev'). Credentials are
|
|
1017
|
+
* stored under the platform context, not the tenant name — all tenants share
|
|
1018
|
+
* the same Zitadel instance.
|
|
1019
|
+
*
|
|
1020
|
+
* Services (e.g., Hub UI) use these as a fallback when the real oauth2-proxy
|
|
1021
|
+
* `X-Forwarded-*` request headers are absent:
|
|
1022
|
+
* DEV_USER_ID - subject from idToken, matches conversation ownership
|
|
1023
|
+
* DEV_USER_EMAIL - user email from idToken
|
|
1024
|
+
* DEV_USER_ID_TOKEN - full idToken JWT (profile claims, authorization hint)
|
|
1025
|
+
* DEV_USER_ACCESS_TOKEN - full accessToken JWT (has project:roles claim)
|
|
1026
|
+
*
|
|
1027
|
+
* Returned vars must be overlaid onto each service's env FILE (not just the
|
|
1028
|
+
* tmux session env): the dev output may carry a placeholder value (e.g. hub
|
|
1029
|
+
* ui's DEV_USER_ACCESS_TOKEN="dev-local"), and the sourced per-service env
|
|
1030
|
+
* file wins over the session env, so a session-env-only injection gets
|
|
1031
|
+
* clobbered and every tenant-scoped Hub view silently 401s.
|
|
1032
|
+
*/
|
|
1033
|
+
function resolveDevUserVars(devOutput) {
|
|
1034
|
+
const credContext = devOutput.platform
|
|
1035
|
+
? devOutput.platform.tenant === 'local'
|
|
1036
|
+
? 'local'
|
|
1037
|
+
: `mesh.${devOutput.platform.env}`
|
|
1038
|
+
: null;
|
|
1039
|
+
if (!credContext)
|
|
1040
|
+
return {};
|
|
1041
|
+
const meshCreds = readCredentials(credContext);
|
|
1042
|
+
if (!meshCreds?.idToken)
|
|
1043
|
+
return {};
|
|
1044
|
+
const vars = { DEV_USER_ID_TOKEN: meshCreds.idToken };
|
|
1045
|
+
try {
|
|
1046
|
+
const payload = JSON.parse(Buffer.from(meshCreds.idToken.split('.')[1], 'base64url').toString());
|
|
1047
|
+
if (payload.sub)
|
|
1048
|
+
vars.DEV_USER_ID = payload.sub;
|
|
1049
|
+
if (payload.email)
|
|
1050
|
+
vars.DEV_USER_EMAIL = payload.email;
|
|
1051
|
+
}
|
|
1052
|
+
catch {
|
|
1053
|
+
/* ignore decode errors */
|
|
1054
|
+
}
|
|
1055
|
+
if (meshCreds.accessToken) {
|
|
1056
|
+
vars.DEV_USER_ACCESS_TOKEN = meshCreds.accessToken;
|
|
1057
|
+
}
|
|
1058
|
+
return vars;
|
|
1059
|
+
}
|
|
1060
|
+
// ============================================================================
|
|
1061
|
+
// SSM tunnel fallback (when VPN is not connected)
|
|
1062
|
+
// ============================================================================
|
|
1063
|
+
/**
|
|
1064
|
+
* Base port for SSM tunnels. Preferred ports use 20000 + original_port to
|
|
1065
|
+
* avoid collisions with dev services (1000-9999 range). If the preferred
|
|
1066
|
+
* port is already reserved or occupied, mesh dev walks upward until it finds
|
|
1067
|
+
* an available port.
|
|
1068
|
+
*
|
|
1069
|
+
* Examples:
|
|
1070
|
+
* PostgreSQL 5432 → 25432
|
|
1071
|
+
* Temporal 7233 → 27233
|
|
1072
|
+
* Temporal UI 8080 → 28080
|
|
1073
|
+
*/
|
|
1074
|
+
const SSM_TUNNEL_BASE_PORT = 20000;
|
|
1075
|
+
/** How long to wait for an SSM tunnel's local port to accept TCP before failing. */
|
|
1076
|
+
const TUNNEL_READY_TIMEOUT_MS = 60_000;
|
|
1077
|
+
/**
|
|
1078
|
+
* Build the client-facing `host:port` for an SSM tunnel.
|
|
1079
|
+
*
|
|
1080
|
+
* The `aws ssm start-session` port-forwarder binds IPv4 `127.0.0.1` only.
|
|
1081
|
+
* Advertising `localhost` lets Node/grpc-js resolve to IPv6 `::1` first, which
|
|
1082
|
+
* the tunnel isn't listening on — so e.g. the Temporal gRPC client fails with
|
|
1083
|
+
* `ECONNREFUSED ::1:<port>` and the Hub's Automations/workflow lists never load.
|
|
1084
|
+
* Maps the literal `localhost` (how tunnels are advertised today) to `127.0.0.1`
|
|
1085
|
+
* so clients hit the bound listener. Any other host — VPN-direct VPC endpoints,
|
|
1086
|
+
* or a different loopback literal like `::1`/`127.0.0.1` — passes through unchanged.
|
|
1087
|
+
*/
|
|
1088
|
+
export function tunnelClientAddress(tunnel) {
|
|
1089
|
+
const host = tunnel.host === 'localhost' ? '127.0.0.1' : tunnel.host;
|
|
1090
|
+
return `${host}:${tunnel.port}`;
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Render the Connections section (live tunnel health) as status-window lines.
|
|
1094
|
+
*
|
|
1095
|
+
* The status window refreshes every ~2s, so this is where a mid-session tunnel
|
|
1096
|
+
* death surfaces (MESH-2016): a dead tunnel shows `⚠ … DOWN` and a SINGLE
|
|
1097
|
+
* remediation line explains the `ECONNREFUSED` retry spam the dependent service
|
|
1098
|
+
* windows emit — instead of leaving the operator to decode that spam themselves.
|
|
1099
|
+
* Returns `[]` when there are no tunnels (nothing to show).
|
|
1100
|
+
*/
|
|
1101
|
+
export function formatTunnelHealth(health) {
|
|
1102
|
+
if (health.length === 0)
|
|
1103
|
+
return [];
|
|
1104
|
+
const lines = ['Connections:'];
|
|
1105
|
+
for (const h of health) {
|
|
1106
|
+
const icon = h.reachable ? '✓' : '⚠';
|
|
1107
|
+
const suffix = h.reachable ? '' : ' DOWN';
|
|
1108
|
+
lines.push(` ${icon} ${h.name.padEnd(16)} ${h.address}${suffix}`);
|
|
1109
|
+
}
|
|
1110
|
+
const down = health.filter((h) => !h.reachable).map((h) => h.name);
|
|
1111
|
+
if (down.length > 0) {
|
|
1112
|
+
const noun = down.length === 1 ? 'tunnel' : 'tunnels';
|
|
1113
|
+
lines.push('');
|
|
1114
|
+
lines.push(` ⚠ ${down.length} ${noun} down (${down.join(', ')}) — dependent services are ` +
|
|
1115
|
+
`retrying (ECONNREFUSED spam is expected). Relaunch: mesh dev`);
|
|
1116
|
+
}
|
|
1117
|
+
return lines;
|
|
1118
|
+
}
|
|
1119
|
+
/** Probe every tunnel's client address in parallel and resolve its health. */
|
|
1120
|
+
async function probeTunnelHealth(tunnels) {
|
|
1121
|
+
return Promise.all(Object.entries(tunnels).map(async ([name, tun]) => ({
|
|
1122
|
+
name,
|
|
1123
|
+
address: tunnelClientAddress(tun),
|
|
1124
|
+
// Dial the SAME address the client uses (localhost→127.0.0.1) so the probe
|
|
1125
|
+
// sees exactly what a dependent service would. Use the connection-HOLDS
|
|
1126
|
+
// probe, not a bare accept: a forwarder binds its local port even when its
|
|
1127
|
+
// upstream leg is dead (a dropped SSM session, or a Tailscale SOCKS bridge
|
|
1128
|
+
// dialing a stale port — MESH-2169), so "port accepts" would report a dead
|
|
1129
|
+
// tunnel as healthy. Holding briefly catches the accept-then-close teardown.
|
|
1130
|
+
reachable: await probeConnectionHolds(tun.host === 'localhost' ? '127.0.0.1' : tun.host, tun.port, 800),
|
|
1131
|
+
})));
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Probe the routing-critical tunnel of a (Tailscale-rewritten) devOutput and
|
|
1135
|
+
* return its name if the forwarder binds but does not ROUTE, else null.
|
|
1136
|
+
*
|
|
1137
|
+
* Only `temporal` is gated — it's the load-bearing VPC dependency (services die
|
|
1138
|
+
* on a gRPC deadline when it's dead), and keying on it mirrors the vpn-direct
|
|
1139
|
+
* reachability gate ({@link gateVpnDirectReachability}). A Tailscale forwarder
|
|
1140
|
+
* accepts its local port even when its SOCKS upstream is dead or the joined
|
|
1141
|
+
* tailnet doesn't route the VPC; such a forwarder tears the connection down
|
|
1142
|
+
* within the grace window, so it fails the connection-HOLDS probe that a bare
|
|
1143
|
+
* accept (the readiness gate's `waitForPort`) would pass (MESH-2169/MESH-2268).
|
|
1144
|
+
*/
|
|
1145
|
+
export async function firstUnroutableTunnel(tunnels) {
|
|
1146
|
+
const temporal = tunnels['temporal'];
|
|
1147
|
+
if (!temporal || temporal.host !== 'localhost')
|
|
1148
|
+
return null;
|
|
1149
|
+
const holds = await probeConnectionHolds('127.0.0.1', temporal.port, 800);
|
|
1150
|
+
return holds ? null : 'temporal';
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Resolve which tunnel backing `mesh dev` should use.
|
|
1154
|
+
*
|
|
1155
|
+
* An explicit `--transport` flag wins. Both `auto` and the default (unset)
|
|
1156
|
+
* prefer Tailscale when it's available (brew `tailscaled` present), else fall
|
|
1157
|
+
* back to the VPN-direct/SSM choice: VPN-direct when the VPN is connected, else
|
|
1158
|
+
* SSM when the session-manager plugin is present. Where Tailscale isn't
|
|
1159
|
+
* available the behavior is unchanged from before this feature.
|
|
1160
|
+
*/
|
|
1161
|
+
export function resolveTransport(flag, ctx) {
|
|
1162
|
+
if (flag === "tailscale" || flag === "ssm")
|
|
1163
|
+
return flag;
|
|
1164
|
+
if (flag !== undefined && flag !== "auto") {
|
|
1165
|
+
throw new Error(`Invalid --transport '${flag}'. Valid values: auto (default), tailscale, ssm.`);
|
|
1166
|
+
}
|
|
1167
|
+
const legacy = ctx.vpnConnected ? "vpn-direct" : ctx.hasSsmPlugin ? "ssm" : "vpn-direct";
|
|
1168
|
+
return ctx.tailscaleAvailable ? "tailscale" : legacy;
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Correct an auto-selected `vpn-direct` transport against ACTUAL reachability of
|
|
1172
|
+
* the Temporal VPC endpoint (MESH-2016). `resolveTransport` uses a presence check
|
|
1173
|
+
* (any Tailscale/VPN up) that's tailnet-blind — Tailscale-for-SSH looks connected
|
|
1174
|
+
* but doesn't route the mesh VPC. Only `vpn-direct` is reachability-gated (it's
|
|
1175
|
+
* never an explicit choice); every other transport passes through unchanged.
|
|
1176
|
+
*
|
|
1177
|
+
* Pure so the fallback logic is testable independently of the TCP probe.
|
|
1178
|
+
*/
|
|
1179
|
+
export function reachabilityFallbackTransport(transport, temporalReachable, hasSsmPlugin) {
|
|
1180
|
+
if (transport !== "vpn-direct")
|
|
1181
|
+
return { transport, reason: "not-vpn-direct" };
|
|
1182
|
+
if (temporalReachable)
|
|
1183
|
+
return { transport, reason: "reachable" };
|
|
1184
|
+
if (hasSsmPlugin)
|
|
1185
|
+
return { transport: "ssm", reason: "fallback-ssm" };
|
|
1186
|
+
return { transport, reason: "unreachable-no-plugin" };
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Apply the reachability gate to a (possibly auto-selected) transport: if it's
|
|
1190
|
+
* `vpn-direct`, TCP-dial the Temporal VPC endpoint and downgrade to SSM when it's
|
|
1191
|
+
* actually unreachable, logging the decision. Returns the effective transport;
|
|
1192
|
+
* a no-op for any non-`vpn-direct` transport.
|
|
1193
|
+
*
|
|
1194
|
+
* Called on BOTH auto-select paths — the normal resolve result AND the
|
|
1195
|
+
* Tailscale→legacy fallback (when no mesh login config exists for the derived
|
|
1196
|
+
* context) — so a `vpn-direct` can never reach `startServices` unprobed
|
|
1197
|
+
* (MESH-2016).
|
|
1198
|
+
*/
|
|
1199
|
+
async function gateVpnDirectReachability(transport, devOutput) {
|
|
1200
|
+
if (transport !== "vpn-direct")
|
|
1201
|
+
return transport;
|
|
1202
|
+
const temporalTunnel = devOutput.tunnels?.["temporal"];
|
|
1203
|
+
if (!temporalTunnel)
|
|
1204
|
+
return transport;
|
|
1205
|
+
const { host, port } = temporalTunnel;
|
|
1206
|
+
const reachable = await probeTcpReachable(host, port, 1500);
|
|
1207
|
+
const decision = reachabilityFallbackTransport(transport, reachable, hasSessionManagerPlugin());
|
|
1208
|
+
if (decision.reason === "reachable") {
|
|
1209
|
+
logInfo(`Transport: VPN-direct — Temporal VPC endpoint ${host}:${port} is reachable.`);
|
|
1210
|
+
}
|
|
1211
|
+
else if (decision.reason === "fallback-ssm") {
|
|
1212
|
+
logWarn(`Transport: VPN reports connected but the Temporal VPC endpoint ${host}:${port} is unreachable ` +
|
|
1213
|
+
`(tailnet-blind presence check) — falling back to SSM tunnels (2XXXX port range; VPN-only features ` +
|
|
1214
|
+
`like in-cluster kubectl are unavailable). Pin with --transport=ssm to skip this probe.`);
|
|
1215
|
+
}
|
|
1216
|
+
else if (decision.reason === "unreachable-no-plugin") {
|
|
1217
|
+
logWarn(`Transport: Temporal VPC endpoint ${host}:${port} is unreachable and the SSM session-manager ` +
|
|
1218
|
+
`plugin is missing — VPN-direct will likely fail. Fix VPN routing or install the plugin ` +
|
|
1219
|
+
`(see: mesh dev doctor).`);
|
|
1220
|
+
}
|
|
1221
|
+
return decision.transport;
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Derive the Headscale login-server URL from a login context's issuer
|
|
1225
|
+
* (`identity.<env>.<domain>` → `vpn.<env>.<domain>`). Used for the Tailscale
|
|
1226
|
+
* transport's OIDC join. Returns null if the context has no cached login config.
|
|
1227
|
+
*/
|
|
1228
|
+
function deriveLoginServer(context) {
|
|
1229
|
+
const cfg = getContextConfig(context);
|
|
1230
|
+
if (!cfg?.issuer)
|
|
1231
|
+
return null;
|
|
1232
|
+
try {
|
|
1233
|
+
const u = new URL(cfg.issuer);
|
|
1234
|
+
const parts = u.hostname.split(".");
|
|
1235
|
+
parts[0] = "vpn";
|
|
1236
|
+
return `https://${parts.join(".")}`;
|
|
1237
|
+
}
|
|
1238
|
+
catch {
|
|
1239
|
+
return null;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
export function preferredSsmLocalPort(remotePort) {
|
|
1243
|
+
const preferred = SSM_TUNNEL_BASE_PORT + remotePort;
|
|
1244
|
+
if (preferred > 65535) {
|
|
1245
|
+
throw new Error(`Cannot allocate SSM tunnel port for remote port ${remotePort}: preferred local port ${preferred} exceeds 65535`);
|
|
1246
|
+
}
|
|
1247
|
+
return preferred;
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Reserve a deterministic local-port candidate for an SSM tunnel.
|
|
1251
|
+
*
|
|
1252
|
+
* This prevents two tunnels with the same remote port (common for HTTPS
|
|
1253
|
+
* endpoints) from being assigned the same localhost port before the AWS CLI
|
|
1254
|
+
* has a chance to bind it.
|
|
1255
|
+
*/
|
|
1256
|
+
export function reserveSsmLocalPortCandidate(remotePort, reservedPorts, startAt = preferredSsmLocalPort(remotePort)) {
|
|
1257
|
+
for (let port = startAt; port <= 65535; port += 1) {
|
|
1258
|
+
if (!reservedPorts.has(port)) {
|
|
1259
|
+
reservedPorts.add(port);
|
|
1260
|
+
return port;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
throw new Error(`Cannot allocate SSM tunnel port for remote port ${remotePort}: no free candidate ports remain`);
|
|
1264
|
+
}
|
|
1265
|
+
async function allocateSsmLocalPort(remotePort, reservedPorts) {
|
|
1266
|
+
let nextCandidate = preferredSsmLocalPort(remotePort);
|
|
1267
|
+
while (nextCandidate <= 65535) {
|
|
1268
|
+
const candidate = reserveSsmLocalPortCandidate(remotePort, reservedPorts, nextCandidate);
|
|
1269
|
+
if (await isPortFree(candidate)) {
|
|
1270
|
+
return candidate;
|
|
1271
|
+
}
|
|
1272
|
+
nextCandidate = candidate + 1;
|
|
1273
|
+
}
|
|
1274
|
+
throw new Error(`Cannot allocate SSM tunnel port for remote port ${remotePort}: no local ports are available`);
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Check if the session-manager-plugin is installed (required for SSM tunnels).
|
|
1278
|
+
*/
|
|
1279
|
+
function hasSessionManagerPlugin() {
|
|
1280
|
+
try {
|
|
1281
|
+
execFileSync('which', ['session-manager-plugin'], { stdio: 'ignore' });
|
|
1282
|
+
return true;
|
|
1283
|
+
}
|
|
1284
|
+
catch {
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Start SSM port-forwarding tunnels as tmux windows within the dev session.
|
|
1290
|
+
*
|
|
1291
|
+
* For each tunnel defined in devOutput (e.g., temporal, rds), this:
|
|
1292
|
+
* 1. Reads the bastion instance ID and service endpoints from SSM
|
|
1293
|
+
* 2. Spawns `aws ssm start-session` in a dedicated tmux window
|
|
1294
|
+
* 3. Rewrites devOutput.tunnels to point at localhost:2XXXX
|
|
1295
|
+
*
|
|
1296
|
+
* Returns the rewritten devOutput with tunnels pointing to localhost.
|
|
1297
|
+
*/
|
|
1298
|
+
async function startSsmTunnels(sessionName, devOutput) {
|
|
1299
|
+
const platformEnv = devOutput.platform?.env ?? 'dev';
|
|
1300
|
+
// The bastion is a HUB platform resource (/mesh-platform/<hub>/<env>/platform-bastion),
|
|
1301
|
+
// not an app-tenant resource — devOutput.platform.tenant is the APP tenant (e.g. acme),
|
|
1302
|
+
// which for shared tenants has no bastion. Resolve under the platform/hub name; default
|
|
1303
|
+
// to "mesh" (matching how resolveTemporalAuth reads platform-level params).
|
|
1304
|
+
const bastionTenant = devOutput.platform?.name ?? 'mesh';
|
|
1305
|
+
// Read bastion info from SSM
|
|
1306
|
+
const bastion = await getPlatformBastionInfo(bastionTenant, platformEnv).catch(() => null);
|
|
1307
|
+
if (!bastion) {
|
|
1308
|
+
logError('Could not read platform bastion info from SSM.');
|
|
1309
|
+
logInfo('Make sure PlatformBastion is deployed in your platform stack.');
|
|
1310
|
+
logInfo("If you don't have a bastion, connect via VPN instead: mesh vpn connect");
|
|
1311
|
+
throw new Error('SSM tunnel fallback unavailable: no bastion found');
|
|
1312
|
+
}
|
|
1313
|
+
const rewritten = JSON.parse(JSON.stringify(devOutput));
|
|
1314
|
+
const tunnelCount = Object.keys(devOutput.tunnels).length;
|
|
1315
|
+
if (tunnelCount === 0) {
|
|
1316
|
+
logInfo('No tunnels defined — SSM fallback not needed.');
|
|
1317
|
+
return rewritten;
|
|
1318
|
+
}
|
|
1319
|
+
logInfo(`Starting ${tunnelCount} SSM tunnel(s) via bastion ${bastion.instanceId}...`);
|
|
1320
|
+
// Map devOutput tunnel names to bastion service keys
|
|
1321
|
+
// devOutput uses names like "temporal", "rds"; bastion uses "temporal-frontend", "rds"
|
|
1322
|
+
const tunnelToBastionKey = {
|
|
1323
|
+
temporal: 'temporal-frontend',
|
|
1324
|
+
'temporal-ui': 'temporal-ui',
|
|
1325
|
+
rds: 'rds',
|
|
1326
|
+
db: 'rds',
|
|
1327
|
+
database: 'rds',
|
|
1328
|
+
};
|
|
1329
|
+
// Observability tunnels are non-fatal under SSM fallback — skip with a warning
|
|
1330
|
+
// rather than aborting the dev loop when the bastion doesn't expose them.
|
|
1331
|
+
const OPTIONAL_TUNNEL_NAMES = new Set([
|
|
1332
|
+
'loki',
|
|
1333
|
+
'prometheus',
|
|
1334
|
+
'tempo',
|
|
1335
|
+
'pushgateway',
|
|
1336
|
+
'grafana',
|
|
1337
|
+
]);
|
|
1338
|
+
const missingEndpoints = [];
|
|
1339
|
+
const skippedOptional = [];
|
|
1340
|
+
const reservedPorts = new Set();
|
|
1341
|
+
const plannedTunnels = [];
|
|
1342
|
+
for (const [tunnelName] of Object.entries(devOutput.tunnels)) {
|
|
1343
|
+
const bastionKey = tunnelToBastionKey[tunnelName] ?? tunnelName;
|
|
1344
|
+
const endpoint = bastion.services[bastionKey];
|
|
1345
|
+
if (!endpoint) {
|
|
1346
|
+
// Observability tunnels degrade gracefully under SSM fallback: without
|
|
1347
|
+
// them the consuming app's monitoring views (logs/metrics/traces) won't
|
|
1348
|
+
// work, but they must not block the core dev loop. Only critical tunnels
|
|
1349
|
+
// (temporal, rds, ...) are fatal when the bastion can't reach them.
|
|
1350
|
+
if (OPTIONAL_TUNNEL_NAMES.has(tunnelName)) {
|
|
1351
|
+
skippedOptional.push(`${tunnelName} (bastion key: ${bastionKey})`);
|
|
1352
|
+
}
|
|
1353
|
+
else {
|
|
1354
|
+
missingEndpoints.push(`${tunnelName} (bastion key: ${bastionKey})`);
|
|
1355
|
+
}
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
plannedTunnels.push({
|
|
1359
|
+
tunnelName,
|
|
1360
|
+
endpoint,
|
|
1361
|
+
localPort: await allocateSsmLocalPort(endpoint.port, reservedPorts),
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
if (skippedOptional.length > 0) {
|
|
1365
|
+
logWarn('Skipping optional observability tunnel(s) not exposed by the bastion (SSM fallback):');
|
|
1366
|
+
for (const skipped of skippedOptional) {
|
|
1367
|
+
logInfo(` Skipped: ${skipped}`);
|
|
1368
|
+
}
|
|
1369
|
+
logInfo(' Monitoring views (logs/metrics/traces) stay unavailable until VPN is connected.');
|
|
1370
|
+
}
|
|
1371
|
+
if (missingEndpoints.length > 0) {
|
|
1372
|
+
logError('SSM tunnel fallback cannot cover every required dev tunnel.');
|
|
1373
|
+
for (const missing of missingEndpoints) {
|
|
1374
|
+
logInfo(` Missing: ${missing}`);
|
|
1375
|
+
}
|
|
1376
|
+
logInfo(` Available bastion services: ${Object.keys(bastion.services).join(', ') || '(none)'}`);
|
|
1377
|
+
logInfo('Connect VPN instead or deploy/update PlatformBastion with the missing service endpoints.');
|
|
1378
|
+
throw new Error('SSM tunnel fallback unavailable: missing bastion endpoints');
|
|
1379
|
+
}
|
|
1380
|
+
for (const { tunnelName, endpoint, localPort } of plannedTunnels) {
|
|
1381
|
+
// Create a tmux window for this tunnel
|
|
1382
|
+
const windowName = `tunnel-${tunnelName}`;
|
|
1383
|
+
const ssmCmd = [
|
|
1384
|
+
'aws',
|
|
1385
|
+
'ssm',
|
|
1386
|
+
'start-session',
|
|
1387
|
+
'--target',
|
|
1388
|
+
bastion.instanceId,
|
|
1389
|
+
'--document-name',
|
|
1390
|
+
'AWS-StartPortForwardingSessionToRemoteHost',
|
|
1391
|
+
'--parameters',
|
|
1392
|
+
`'${JSON.stringify({
|
|
1393
|
+
host: [endpoint.host],
|
|
1394
|
+
portNumber: [String(endpoint.port)],
|
|
1395
|
+
localPortNumber: [String(localPort)],
|
|
1396
|
+
})}'`,
|
|
1397
|
+
].join(' ');
|
|
1398
|
+
execFileSync('tmux', ['new-window', '-t', sessionName, '-n', windowName]);
|
|
1399
|
+
// Keep window alive so tunnel errors are visible
|
|
1400
|
+
execFileSync('tmux', ['set-option', '-t', `${sessionName}:${windowName}`, 'remain-on-exit', 'on'], { stdio: 'ignore' });
|
|
1401
|
+
execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${windowName}`, ssmCmd, 'Enter']);
|
|
1402
|
+
// Rewrite the tunnel to point at localhost
|
|
1403
|
+
rewritten.tunnels[tunnelName] = {
|
|
1404
|
+
host: 'localhost',
|
|
1405
|
+
port: localPort,
|
|
1406
|
+
};
|
|
1407
|
+
logSuccess(` ${tunnelName}: localhost:${localPort} → ${endpoint.host}:${endpoint.port} (via SSM)`);
|
|
1408
|
+
}
|
|
1409
|
+
return rewritten;
|
|
1410
|
+
}
|
|
535
1411
|
/**
|
|
536
1412
|
* Update the DevBox host's Caddy reverse proxy config.
|
|
537
1413
|
*
|
|
@@ -541,9 +1417,9 @@ async function resolveSecrets(secrets, tunnels) {
|
|
|
541
1417
|
*/
|
|
542
1418
|
async function updateDevboxProxy(devOutput, appRoot) {
|
|
543
1419
|
const appName = devOutput.app ?? path.basename(appRoot);
|
|
544
|
-
const tenant = devOutput.platform?.tenant ??
|
|
545
|
-
const env = devOutput.platform?.env ??
|
|
546
|
-
const stack = devOutput.stack ??
|
|
1420
|
+
const tenant = devOutput.platform?.tenant ?? 'mesh';
|
|
1421
|
+
const env = devOutput.platform?.env ?? 'dev';
|
|
1422
|
+
const stack = devOutput.stack ?? 'dev';
|
|
547
1423
|
const baseDomain = `${tenant}-${env}.mesh.local`;
|
|
548
1424
|
// Build hostname → port map
|
|
549
1425
|
const services = {};
|
|
@@ -553,148 +1429,462 @@ async function updateDevboxProxy(devOutput, appRoot) {
|
|
|
553
1429
|
}
|
|
554
1430
|
}
|
|
555
1431
|
for (const [name, tunnel] of Object.entries(devOutput.tunnels)) {
|
|
556
|
-
if (tunnel.
|
|
557
|
-
services[`${name}.${stack}.${appName}.${baseDomain}`] = tunnel.
|
|
1432
|
+
if (tunnel.port) {
|
|
1433
|
+
services[`${name}.${stack}.${appName}.${baseDomain}`] = tunnel.port;
|
|
558
1434
|
}
|
|
559
1435
|
}
|
|
560
1436
|
if (Object.keys(services).length === 0)
|
|
561
1437
|
return;
|
|
562
1438
|
// Generate Caddyfile
|
|
563
1439
|
const lines = [
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
1440
|
+
'# Auto-generated by mesh dev — do not edit manually.',
|
|
1441
|
+
'',
|
|
1442
|
+
':8080 {',
|
|
567
1443
|
' respond /health "OK" 200',
|
|
568
|
-
|
|
569
|
-
|
|
1444
|
+
'}',
|
|
1445
|
+
'',
|
|
570
1446
|
];
|
|
571
1447
|
for (const [hostname, port] of Object.entries(services)) {
|
|
572
1448
|
lines.push(`http://${hostname} {`);
|
|
573
1449
|
lines.push(` reverse_proxy localhost:${port}`);
|
|
574
|
-
lines.push(
|
|
575
|
-
lines.push(
|
|
1450
|
+
lines.push('}');
|
|
1451
|
+
lines.push('');
|
|
576
1452
|
}
|
|
577
|
-
const caddyfile = lines.join(
|
|
1453
|
+
const caddyfile = lines.join('\n');
|
|
578
1454
|
// Write to host's /etc/caddy via a throwaway container with a host volume mount.
|
|
579
1455
|
// The Docker socket is the HOST's, so -v /etc/caddy mounts the HOST path.
|
|
580
|
-
execFileSync(
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
1456
|
+
execFileSync('docker', [
|
|
1457
|
+
'run',
|
|
1458
|
+
'--rm',
|
|
1459
|
+
'-v',
|
|
1460
|
+
'/etc/caddy:/etc/caddy',
|
|
1461
|
+
'busybox',
|
|
1462
|
+
'sh',
|
|
1463
|
+
'-c',
|
|
584
1464
|
`cat > /etc/caddy/Caddyfile << 'CADDYEOF'\n${caddyfile}\nCADDYEOF`,
|
|
585
|
-
], { stdio:
|
|
1465
|
+
], { stdio: 'pipe' });
|
|
586
1466
|
// Reload Caddy on the host via nsenter into PID 1's mount namespace
|
|
587
|
-
execFileSync(
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
1467
|
+
execFileSync('docker', [
|
|
1468
|
+
'run',
|
|
1469
|
+
'--rm',
|
|
1470
|
+
'--pid=host',
|
|
1471
|
+
'--privileged',
|
|
1472
|
+
'busybox',
|
|
1473
|
+
'nsenter',
|
|
1474
|
+
'-t',
|
|
1475
|
+
'1',
|
|
1476
|
+
'-m',
|
|
1477
|
+
'--',
|
|
1478
|
+
'caddy',
|
|
1479
|
+
'reload',
|
|
1480
|
+
'--config',
|
|
1481
|
+
'/etc/caddy/Caddyfile',
|
|
1482
|
+
'--adapter',
|
|
1483
|
+
'caddyfile',
|
|
1484
|
+
], { stdio: 'pipe' });
|
|
1485
|
+
logSuccess('DevBox proxy updated:');
|
|
593
1486
|
for (const [hostname, port] of Object.entries(services)) {
|
|
594
1487
|
logInfo(` http://${hostname} → localhost:${port}`);
|
|
595
1488
|
}
|
|
596
1489
|
}
|
|
597
|
-
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1490
|
+
async function setupSubdomainRouting(devOutput, appRoot) {
|
|
1491
|
+
// 1. Get Tailscale info
|
|
1492
|
+
const tsInfo = await getTailscaleInfo();
|
|
1493
|
+
if (!tsInfo)
|
|
1494
|
+
return null;
|
|
1495
|
+
const appName = devOutput.app ?? path.basename(appRoot);
|
|
1496
|
+
const stack = devOutput.stack ?? 'dev';
|
|
1497
|
+
const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;
|
|
1498
|
+
// 2. Derive Headscale DNS config from platform context (same convention as `mesh vpn`)
|
|
1499
|
+
const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };
|
|
1500
|
+
const dnsConfig = headscaleDnsConfig(platform.name ?? platform.tenant, platform.env);
|
|
1501
|
+
// 3. Build DNS records for all services + tunnels
|
|
1502
|
+
const records = [];
|
|
1503
|
+
for (const [name, service] of Object.entries(devOutput.services)) {
|
|
1504
|
+
if (service.port) {
|
|
1505
|
+
records.push({ name: `${name}.${baseDomain}`, type: 'A', value: tsInfo.ip });
|
|
1506
|
+
}
|
|
607
1507
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1508
|
+
for (const [name] of Object.entries(devOutput.tunnels)) {
|
|
1509
|
+
records.push({ name: `${name}.${baseDomain}`, type: 'A', value: tsInfo.ip });
|
|
1510
|
+
}
|
|
1511
|
+
// 4. Remove stale DNS records (best-effort)
|
|
1512
|
+
try {
|
|
1513
|
+
unregisterDnsRecords(dnsConfig, baseDomain);
|
|
1514
|
+
}
|
|
1515
|
+
catch {
|
|
1516
|
+
/* ignore */
|
|
1517
|
+
}
|
|
1518
|
+
// 5. Generate Caddyfile
|
|
1519
|
+
const caddyDir = '/tmp/mesh-dev-caddy';
|
|
1520
|
+
const caddyfile = path.join(caddyDir, 'Caddyfile');
|
|
1521
|
+
if (!fs.existsSync(caddyDir))
|
|
1522
|
+
fs.mkdirSync(caddyDir, { recursive: true });
|
|
1523
|
+
// Build Caddy config — bind directly to the Tailscale IP on port 80.
|
|
1524
|
+
// In kernel tun mode, tailscale0 is a real interface so Caddy can listen
|
|
1525
|
+
// on it without any tailscale serve layer in between.
|
|
1526
|
+
const caddyLines = [`# mesh-dev: ${appName}/${stack} (${tsInfo.hostname})`, ''];
|
|
1527
|
+
for (const [name, service] of Object.entries(devOutput.services)) {
|
|
1528
|
+
if (service.port) {
|
|
1529
|
+
caddyLines.push(`http://${name}.${baseDomain} {`);
|
|
1530
|
+
caddyLines.push(` bind ${tsInfo.ip}`);
|
|
1531
|
+
caddyLines.push(` reverse_proxy localhost:${service.port}`);
|
|
1532
|
+
caddyLines.push('}');
|
|
1533
|
+
caddyLines.push('');
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
for (const [name, tunnel] of Object.entries(devOutput.tunnels)) {
|
|
1537
|
+
caddyLines.push(`http://${name}.${baseDomain} {`);
|
|
1538
|
+
caddyLines.push(` bind ${tsInfo.ip}`);
|
|
1539
|
+
caddyLines.push(` reverse_proxy ${tunnel.host}:${tunnel.port}`);
|
|
1540
|
+
caddyLines.push('}');
|
|
1541
|
+
caddyLines.push('');
|
|
1542
|
+
}
|
|
1543
|
+
fs.writeFileSync(caddyfile, caddyLines.join('\n'));
|
|
1544
|
+
// 6. Start or reload Caddy
|
|
1545
|
+
let caddyRunning = false;
|
|
1546
|
+
try {
|
|
1547
|
+
execFileSync('pgrep', ['-f', 'caddy run.*mesh-dev-caddy'], { stdio: 'pipe' });
|
|
1548
|
+
caddyRunning = true;
|
|
1549
|
+
}
|
|
1550
|
+
catch {
|
|
1551
|
+
/* not running */
|
|
1552
|
+
}
|
|
1553
|
+
if (caddyRunning) {
|
|
1554
|
+
try {
|
|
1555
|
+
execFileSync('caddy', ['reload', '--config', caddyfile, '--adapter', 'caddyfile'], {
|
|
1556
|
+
stdio: 'pipe',
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
catch {
|
|
1560
|
+
logWarn('Caddy reload failed — check Caddyfile syntax');
|
|
1561
|
+
return null;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
else {
|
|
1565
|
+
try {
|
|
1566
|
+
execFileSync('which', ['caddy'], { stdio: 'pipe' });
|
|
1567
|
+
}
|
|
1568
|
+
catch {
|
|
1569
|
+
logWarn('Caddy not found. Install for subdomain routing: curl -fsSL https://caddyserver.com/api/download?os=linux&arch=arm64 -o /usr/local/bin/caddy && chmod +x /usr/local/bin/caddy');
|
|
1570
|
+
return null;
|
|
1571
|
+
}
|
|
1572
|
+
const caddy = spawn('caddy', ['run', '--config', caddyfile, '--adapter', 'caddyfile'], {
|
|
1573
|
+
stdio: 'ignore',
|
|
1574
|
+
detached: true,
|
|
1575
|
+
cwd: caddyDir,
|
|
1576
|
+
});
|
|
1577
|
+
caddy.unref();
|
|
1578
|
+
}
|
|
1579
|
+
// 7. Register DNS records (after Caddy is confirmed running)
|
|
1580
|
+
try {
|
|
1581
|
+
const existing = readDnsRecords(dnsConfig);
|
|
1582
|
+
const allRecords = existing.filter((r) => !r.name.endsWith(baseDomain));
|
|
1583
|
+
allRecords.push(...records);
|
|
1584
|
+
registerDnsRecords(dnsConfig, allRecords);
|
|
1585
|
+
}
|
|
1586
|
+
catch {
|
|
1587
|
+
logWarn('DNS registration failed — Headscale pod may be unreachable. Dev routing may not work.');
|
|
1588
|
+
return null;
|
|
1589
|
+
}
|
|
1590
|
+
return { tsHostname: tsInfo.hostname, tsIp: tsInfo.ip, baseDomain, dnsConfig };
|
|
1591
|
+
}
|
|
1592
|
+
// ============================================================================
|
|
1593
|
+
// Core actions
|
|
1594
|
+
// ============================================================================
|
|
1595
|
+
/**
|
|
1596
|
+
* Start all services in tmux windows.
|
|
1597
|
+
*/
|
|
1598
|
+
async function startServices(sessionName, appRoot, devOutput, headless, awsEnv, tunnelPlan = { transport: 'vpn-direct' }, worktreeRoot, taskQueueSuffix = '') {
|
|
1599
|
+
if (!hasTmux()) {
|
|
1600
|
+
logError('tmux is required. Install with: brew install tmux');
|
|
1601
|
+
process.exit(1);
|
|
1602
|
+
}
|
|
1603
|
+
// Always kill any existing session and recreate for a clean start.
|
|
1604
|
+
// Tunnels (if any) are set up as part of this flow below.
|
|
1605
|
+
if (sessionExists(sessionName)) {
|
|
1606
|
+
logInfo(`Killing existing session: ${sessionName}`);
|
|
1607
|
+
killSession(sessionName);
|
|
612
1608
|
}
|
|
613
1609
|
const serviceNames = Object.keys(devOutput.services);
|
|
614
1610
|
if (serviceNames.length === 0) {
|
|
615
|
-
logWarn(
|
|
616
|
-
return;
|
|
1611
|
+
logWarn('No services defined in dev output.');
|
|
1612
|
+
return devOutput;
|
|
617
1613
|
}
|
|
618
|
-
|
|
619
|
-
// Create session with a status window
|
|
1614
|
+
// Create tmux session with a status window (always fresh).
|
|
620
1615
|
logInfo(`Creating tmux session: ${sessionName}`);
|
|
621
|
-
execFileSync(
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
1616
|
+
execFileSync('tmux', ['new-session', '-d', '-s', sessionName, '-n', 'status', '-c', appRoot]);
|
|
1617
|
+
// Set AWS credentials on the session BEFORE starting anything that needs them
|
|
1618
|
+
// (SSM tunnels, services). tmux windows inherit the session environment.
|
|
1619
|
+
setTmuxEnv(sessionName, awsEnv);
|
|
1620
|
+
// Bring up VPC tunnels (rewriting devOutput.tunnels to localhost:PORT) via the
|
|
1621
|
+
// chosen backing. Tailscale (shared per-tenant userspace daemon): an explicit
|
|
1622
|
+
// `--transport tailscale` now hard-fails on any bring-up error (see below); only
|
|
1623
|
+
// an auto-resolved tailscale choice falls back to SSM. vpn-direct needs no tunnels.
|
|
1624
|
+
const hasTunnels = Object.keys(devOutput.tunnels).length > 0;
|
|
1625
|
+
let effectiveTransport = tunnelPlan.transport;
|
|
1626
|
+
if (hasTunnels && effectiveTransport === 'tailscale' && tunnelPlan.tailscale) {
|
|
1627
|
+
try {
|
|
1628
|
+
const rewritten = await startTailscaleTunnels(devOutput, tunnelPlan.tailscale);
|
|
1629
|
+
// Verify the forwarders actually ROUTE, not just bind (MESH-2268). Transport
|
|
1630
|
+
// selection picks tailscale on PRESENCE (a tailscaled binary exists), so an
|
|
1631
|
+
// auto-selected daemon can come up joined to a tailnet that doesn't route the
|
|
1632
|
+
// mesh VPC, or with a dead SOCKS upstream (MESH-2169) — the forwarder binds
|
|
1633
|
+
// its port either way and the bare-accept readiness gate below (waitForPort)
|
|
1634
|
+
// would pass it, letting services start and then die on a gRPC deadline.
|
|
1635
|
+
// Throw on a dead critical tunnel so the catch below applies the same
|
|
1636
|
+
// explicit-hard-fail-vs-auto-SSM-fallback policy as a bring-up failure — and
|
|
1637
|
+
// only commit the rewrite (reassign devOutput) once it holds, so an SSM
|
|
1638
|
+
// fallback runs startSsmTunnels on the ORIGINAL, un-rewritten tunnels.
|
|
1639
|
+
const dead = await firstUnroutableTunnel(rewritten.tunnels);
|
|
1640
|
+
if (dead) {
|
|
1641
|
+
throw new Error(`forwarder for '${dead}' bound but does not route the VPC ` +
|
|
1642
|
+
`(dead SOCKS upstream or non-routing tailnet)`);
|
|
1643
|
+
}
|
|
1644
|
+
devOutput = rewritten;
|
|
1645
|
+
// Announce ONLY after the join + forwarders actually came up — the previous
|
|
1646
|
+
// pre-flight "Using userspace-Tailscale tunnels" printed before this even ran,
|
|
1647
|
+
// so a fallback still read as success (MESH-2098).
|
|
1648
|
+
logInfo('Connected: userspace-Tailscale tunnels for VPC resources (shared per tenant).');
|
|
632
1649
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1650
|
+
catch (err) {
|
|
1651
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1652
|
+
if (tunnelPlan.explicit) {
|
|
1653
|
+
// The user explicitly asked for --transport=tailscale. Don't silently
|
|
1654
|
+
// override that with SSM — surface the real reason (often a pending VPN
|
|
1655
|
+
// node registration whose URL was printed above) and let them act.
|
|
1656
|
+
logError(`Tailscale transport failed: ${reason}`);
|
|
1657
|
+
logInfo('If a VPN registration URL was shown above, open it to authorize this machine, then re-run.');
|
|
1658
|
+
logInfo('Or switch backing explicitly: mesh dev --transport ssm');
|
|
1659
|
+
// We already killed the prior session and created a fresh (empty) one
|
|
1660
|
+
// above; tear it down so an explicit-tailscale failure leaves no orphaned
|
|
1661
|
+
// half-session behind.
|
|
1662
|
+
killSession(sessionName);
|
|
1663
|
+
process.exit(1);
|
|
1664
|
+
}
|
|
1665
|
+
logWarn(`Tailscale transport unavailable (${reason}) — falling back to SSM tunnels.`);
|
|
1666
|
+
effectiveTransport = 'ssm';
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
if (hasTunnels && effectiveTransport === 'ssm') {
|
|
1670
|
+
devOutput = await startSsmTunnels(sessionName, devOutput);
|
|
1671
|
+
}
|
|
1672
|
+
if (hasTunnels && effectiveTransport !== 'vpn-direct') {
|
|
1673
|
+
// Gate service start on tunnel readiness: poll each tunnel's local port
|
|
1674
|
+
// until it accepts TCP. Without this, Temporal-connected services (api,
|
|
1675
|
+
// worker, vcs) raced the SSM tunnel and died connection-refused at launch.
|
|
651
1676
|
for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
1677
|
+
if (tunnel.host !== 'localhost')
|
|
1678
|
+
continue; // not SSM-rewritten
|
|
1679
|
+
logInfo(`Waiting for tunnel ${tName} (localhost:${tunnel.port})...`);
|
|
1680
|
+
const ready = await waitForPort('localhost', tunnel.port, TUNNEL_READY_TIMEOUT_MS);
|
|
1681
|
+
if (!ready) {
|
|
1682
|
+
logError(`Tunnel '${tName}' did not become ready on localhost:${tunnel.port} within ${TUNNEL_READY_TIMEOUT_MS / 1000}s.`);
|
|
1683
|
+
logInfo(` Check the tunnel window for errors: tmux attach -t ${sessionName} (window tunnel-${tName})`);
|
|
1684
|
+
logInfo(' Common causes: expired AWS credentials, bastion stopped, session-manager-plugin errors.');
|
|
1685
|
+
throw new Error(`SSM tunnel '${tName}' not ready on localhost:${tunnel.port} after ${TUNNEL_READY_TIMEOUT_MS / 1000}s`);
|
|
1686
|
+
}
|
|
1687
|
+
logSuccess(` Tunnel ready: ${tName} (localhost:${tunnel.port})`);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
const tunnelNames = Object.keys(devOutput.tunnels);
|
|
1691
|
+
// Set up subdomain routing if Tailscale is available
|
|
1692
|
+
const subdomainCtx = await setupSubdomainRouting(devOutput, appRoot);
|
|
1693
|
+
// Log VPC connections
|
|
1694
|
+
if (tunnelNames.length > 0) {
|
|
1695
|
+
const isSSM = Object.values(devOutput.tunnels).some((t) => t.host === 'localhost' && t.port >= SSM_TUNNEL_BASE_PORT);
|
|
1696
|
+
logSuccess(isSSM ? 'SSM tunnel connections:' : 'VPN direct connections:');
|
|
1697
|
+
for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {
|
|
1698
|
+
if (subdomainCtx) {
|
|
1699
|
+
logInfo(` ${tName} \u2192 http://${tName}.${subdomainCtx.baseDomain}`);
|
|
1700
|
+
}
|
|
1701
|
+
else {
|
|
1702
|
+
logInfo(` ${tName} \u2192 ${tunnel.host}:${tunnel.port}`);
|
|
1703
|
+
}
|
|
656
1704
|
}
|
|
657
|
-
// Give tunnels a moment to establish before starting services
|
|
658
|
-
logInfo("Waiting for tunnels to establish...");
|
|
659
|
-
spawnSync("sleep", ["3"]);
|
|
660
1705
|
}
|
|
661
1706
|
// Resolve secrets from Secrets Manager
|
|
662
1707
|
let secretEnvVars = {};
|
|
663
1708
|
if (devOutput.secrets && Object.keys(devOutput.secrets).length > 0) {
|
|
664
|
-
secretEnvVars = await resolveSecrets(devOutput.secrets
|
|
1709
|
+
secretEnvVars = await resolveSecrets(devOutput.secrets);
|
|
1710
|
+
}
|
|
1711
|
+
// Resolve Temporal auth credentials from SSM when a temporal endpoint is configured.
|
|
1712
|
+
const tenant = devOutput.platform?.tenant ?? 'mesh';
|
|
1713
|
+
const platformEnv = devOutput.platform?.env ?? 'dev';
|
|
1714
|
+
// Write a session-scoped kubeconfig (aws eks get-token) so the kubectl calls
|
|
1715
|
+
// below (encoding-key read, temporal-pod env fallback) don't depend on an
|
|
1716
|
+
// ambient ~/.kube/config. Best-effort: continue without it if unresolved.
|
|
1717
|
+
// The local platform (`mesh start`) has no cluster — skip entirely.
|
|
1718
|
+
const isLocalPlatform = tenant === 'local';
|
|
1719
|
+
if (!isLocalPlatform) {
|
|
1720
|
+
// Hub platform name — the EKS export lives under it, not under a hardcoded
|
|
1721
|
+
// "mesh" (MESH-2515); same axis as the bastion resolution.
|
|
1722
|
+
const platformName = resolveHubPlatformName(devOutput.platform);
|
|
1723
|
+
const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {
|
|
1724
|
+
onError: ({ parameter, error }) => logWarn(`EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`),
|
|
1725
|
+
});
|
|
1726
|
+
if (kubeconfigPath) {
|
|
1727
|
+
process.env.KUBECONFIG = kubeconfigPath;
|
|
1728
|
+
setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });
|
|
1729
|
+
logSuccess(`Kubeconfig resolved from SSM (hub ${platformName}/${platformEnv}) → ${kubeconfigPath}`);
|
|
1730
|
+
}
|
|
1731
|
+
else {
|
|
1732
|
+
logWarn('Could not resolve EKS kubeconfig from SSM; kubectl calls will use ambient config (if any).');
|
|
1733
|
+
}
|
|
665
1734
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
1735
|
+
let temporalAuthVars = {};
|
|
1736
|
+
if (devOutput.tunnels['temporal']) {
|
|
1737
|
+
// Deliberately NOT the `?? 'mesh'` fallback the EKS path above uses: the
|
|
1738
|
+
// Temporal-auth fallback is `?? tenant` (dedicated tenants may run their
|
|
1739
|
+
// own Temporal). Whether that difference is correct is MESH-2551.
|
|
1740
|
+
temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
|
|
1741
|
+
// Resolve the encoding key from K8s so local dev can decrypt
|
|
1742
|
+
// payloads created by deployed workers.
|
|
1743
|
+
const appName = devOutput.app ?? '';
|
|
1744
|
+
if (appName) {
|
|
1745
|
+
const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);
|
|
1746
|
+
if (encodingKey) {
|
|
1747
|
+
temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
// Set TEMPORAL_ADDRESS to the VPC endpoint for direct VPN access (or the
|
|
1751
|
+
// pinned loopback address under SSM fallback — see tunnelClientAddress).
|
|
1752
|
+
const temporalTunnel = devOutput.tunnels['temporal'];
|
|
1753
|
+
temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);
|
|
1754
|
+
}
|
|
1755
|
+
// Set shared env vars (secrets + temporal auth) on the tmux session.
|
|
1756
|
+
// AWS creds were already set earlier (before SSM tunnels started).
|
|
669
1757
|
setTmuxEnv(sessionName, secretEnvVars);
|
|
1758
|
+
setTmuxEnv(sessionName, temporalAuthVars);
|
|
1759
|
+
// Inject dev user identity from mesh login credentials so services can
|
|
1760
|
+
// emulate the oauth2-proxy auth headers locally.
|
|
1761
|
+
//
|
|
1762
|
+
// The credentials are stored under the platform context (e.g. 'mesh.dev'),
|
|
1763
|
+
// not the tenant name. Use 'mesh' as the platform name since all tenants
|
|
1764
|
+
// share the same Zitadel instance.
|
|
1765
|
+
//
|
|
1766
|
+
// Services (e.g., Hub UI) use these env vars as a fallback when the real
|
|
1767
|
+
// oauth2-proxy `X-Forwarded-*` request headers are absent:
|
|
1768
|
+
// DEV_USER_ID - subject from idToken, matches conversation ownership
|
|
1769
|
+
// DEV_USER_EMAIL - user email from idToken
|
|
1770
|
+
// DEV_USER_ID_TOKEN - full idToken JWT (profile claims, authorization hint)
|
|
1771
|
+
// DEV_USER_ACCESS_TOKEN - full accessToken JWT (has project:roles claim)
|
|
1772
|
+
// Local platform creds live under the `local` context (mesh login local).
|
|
1773
|
+
const credContext = devOutput.platform
|
|
1774
|
+
? isLocalPlatform
|
|
1775
|
+
? 'local'
|
|
1776
|
+
: `mesh.${devOutput.platform.env}`
|
|
1777
|
+
: null;
|
|
1778
|
+
const devUserVars = resolveDevUserVars(devOutput);
|
|
1779
|
+
if (Object.keys(devUserVars).length > 0) {
|
|
1780
|
+
setTmuxEnv(sessionName, devUserVars);
|
|
1781
|
+
logSuccess(`Dev user injected from mesh login: ${credContext}`);
|
|
1782
|
+
// Serve a continuously-fresh dev-user token so services survive past the
|
|
1783
|
+
// ~1h Zitadel horizon without a restart (MESH-2039). The static vars above
|
|
1784
|
+
// remain the fallback when this URL is absent. App-agnostic: gated on the
|
|
1785
|
+
// platform context, not the app — any `mesh dev` app inherits it.
|
|
1786
|
+
const tokenPort = await findFreePort();
|
|
1787
|
+
const tokenUrl = `http://127.0.0.1:${tokenPort}`;
|
|
1788
|
+
setTmuxEnv(sessionName, { DEV_USER_TOKEN_URL: tokenUrl });
|
|
1789
|
+
execFileSync('tmux', ['new-window', '-t', sessionName, '-n', 'token-server']);
|
|
1790
|
+
execFileSync('tmux', ['set-option', '-t', `${sessionName}:token-server`, 'remain-on-exit', 'on'], { stdio: 'ignore' });
|
|
1791
|
+
execFileSync('tmux', [
|
|
1792
|
+
'send-keys',
|
|
1793
|
+
'-t',
|
|
1794
|
+
`${sessionName}:token-server`,
|
|
1795
|
+
`npx mesh dev __token-server ${tokenPort} ${credContext}`,
|
|
1796
|
+
'Enter',
|
|
1797
|
+
]);
|
|
1798
|
+
// Best-effort readiness so the first requests land (non-fatal).
|
|
1799
|
+
for (let i = 0; i < 15 && !(await isPortListening(tokenPort)); i++) {
|
|
1800
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1801
|
+
}
|
|
1802
|
+
logSuccess(`Dev-user token-server: ${tokenUrl} (context ${credContext})`);
|
|
1803
|
+
}
|
|
1804
|
+
else if (credContext) {
|
|
1805
|
+
logWarn(`No mesh login credentials found for '${credContext}'.`);
|
|
1806
|
+
logInfo(` Run: mesh login ${credContext}`);
|
|
1807
|
+
logInfo(' Hub UI will show unauthenticated state without valid credentials.');
|
|
1808
|
+
}
|
|
1809
|
+
const monorepoRoot = findMonorepoRoot();
|
|
670
1810
|
for (const [name, service] of Object.entries(devOutput.services)) {
|
|
671
|
-
const cmd = service.command.join(
|
|
1811
|
+
const cmd = service.command.join(' ');
|
|
672
1812
|
// Skip services with no dev command (e.g., deployed-only like ApiDocs)
|
|
673
1813
|
if (!cmd) {
|
|
674
1814
|
logInfo(`Skipped: ${name} (no dev command, deployed to K8s)`);
|
|
675
1815
|
continue;
|
|
676
1816
|
}
|
|
677
|
-
const serviceDir = path.resolve(appRoot, service.src);
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
1817
|
+
const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, monorepoRoot));
|
|
1818
|
+
// Wrong-worktree guard (MESH-2013): if `rebaseServiceSrc` couldn't re-root the
|
|
1819
|
+
// deploy-time absolute path onto THIS worktree, `serviceDir` can point into a
|
|
1820
|
+
// DIFFERENT worktree — silently running stale code. Refuse rather than launch it.
|
|
1821
|
+
if (worktreeRoot && fs.existsSync(serviceDir)) {
|
|
1822
|
+
const wtRootWithSep = worktreeRoot.endsWith(path.sep) ? worktreeRoot : worktreeRoot + path.sep;
|
|
1823
|
+
if (serviceDir !== worktreeRoot && !serviceDir.startsWith(wtRootWithSep)) {
|
|
1824
|
+
logError(`Refusing to launch '${name}': its source resolved to ${serviceDir}, ` +
|
|
1825
|
+
`outside this worktree (${worktreeRoot}). The stack was likely deployed ` +
|
|
1826
|
+
`from another worktree. Re-run \`mesh deploy up\` here, or pass --app.`);
|
|
1827
|
+
process.exit(1);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
// Skip services whose source directory doesn't exist in this worktree.
|
|
1831
|
+
// This happens when the Pulumi stack was deployed from a different branch
|
|
1832
|
+
// that added a service not yet present here (e.g., hub-worker added on
|
|
1833
|
+
// feature/hub-credential-management but absent from feature/app-detail-redesign).
|
|
1834
|
+
if (!fs.existsSync(serviceDir)) {
|
|
1835
|
+
logWarn(`Skipped: ${name} (source directory not found: ${serviceDir}` +
|
|
1836
|
+
`${serviceDir !== service.src ? ` — rebased from ${service.src}` : ''})`);
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
// Per-service env vars are written to a 0600 env file and `source`d by
|
|
1840
|
+
// the launch command, scoping them to the process (no cross-service
|
|
1841
|
+
// leaks through the tmux session environment) while keeping the
|
|
1842
|
+
// send-keys string short — inlining the full env used to intermittently
|
|
1843
|
+
// exceed what tmux send-keys delivers intact, silently truncating the
|
|
1844
|
+
// command so the service never started.
|
|
1845
|
+
//
|
|
1846
|
+
// Overlay the RESOLVED Temporal vars (real encoding key + tunnel address +
|
|
1847
|
+
// M2M auth, fetched from K8s/SSM) on top of the dev-output env. The dev
|
|
1848
|
+
// output carries placeholder values from the Agent's Temporal link (e.g.
|
|
1849
|
+
// a `dev-encoding-key-*` placeholder), and the per-service env would
|
|
1850
|
+
// otherwise shadow the session-level resolved values — desyncing services
|
|
1851
|
+
// that DO carry the placeholder (worker) from those that don't (api),
|
|
1852
|
+
// which breaks payload encryption (mismatched encoding keys → decode null).
|
|
1853
|
+
const serviceVars = {
|
|
1854
|
+
...getServiceEnvVars(service, devOutput.tunnels),
|
|
1855
|
+
...temporalAuthVars,
|
|
1856
|
+
// Overlay the resolved dev-user identity for the same reason as the
|
|
1857
|
+
// Temporal vars: the dev output may carry a placeholder (e.g. hub ui's
|
|
1858
|
+
// DEV_USER_ACCESS_TOKEN="dev-local"), and since the sourced env file
|
|
1859
|
+
// wins over the tmux session env, the placeholder would clobber the
|
|
1860
|
+
// real token injected above — silently 401ing tenant-scoped Hub views.
|
|
1861
|
+
...devUserVars,
|
|
1862
|
+
// Per-worktree Temporal task-queue isolation (MESH-2013 Slice 2): agent
|
|
1863
|
+
// workers/apps append this to their task-queue names so concurrent
|
|
1864
|
+
// worktrees don't steal each other's workflow tasks. Empty on the primary
|
|
1865
|
+
// checkout → omitted → unchanged historical behavior.
|
|
1866
|
+
...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
|
|
1867
|
+
};
|
|
1868
|
+
const envFilePath = getServiceEnvFilePath(sessionName, name);
|
|
1869
|
+
writeEnvFile(envFilePath, serviceVars);
|
|
1870
|
+
// Local mode ships the service's stdout to the platform's log pipeline
|
|
1871
|
+
// via OTLP (assets/log-shipper.mjs): the OTEL_* vars in the env file
|
|
1872
|
+
// carry the hosted k8s_* attribution, so the Hub's logs view covers
|
|
1873
|
+
// local runs with zero file plumbing.
|
|
1874
|
+
const launchCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined);
|
|
1875
|
+
execFileSync('tmux', ['new-window', '-t', sessionName, '-n', name, '-c', serviceDir]);
|
|
685
1876
|
// Keep window alive on exit so errors are visible
|
|
686
|
-
execFileSync(
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
execFileSync(
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
1877
|
+
execFileSync('tmux', ['set-option', '-t', `${sessionName}:${name}`, 'remain-on-exit', 'on'], {
|
|
1878
|
+
stdio: 'ignore',
|
|
1879
|
+
});
|
|
1880
|
+
execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${name}`, launchCmd, 'Enter']);
|
|
1881
|
+
// Log the ACTUAL launch dir (`serviceDir`, after the worktree rebase), not the
|
|
1882
|
+
// raw deploy-time `service.src` — printing the pre-rebase path made a correctly
|
|
1883
|
+
// re-rooted service look like it was running from a different (stale) worktree.
|
|
1884
|
+
logSuccess(`Started: ${name} (${serviceDir}, port ${service.port})`);
|
|
693
1885
|
}
|
|
694
1886
|
// Generate reverse proxy config for DevBox hostname-based routing.
|
|
695
|
-
|
|
696
|
-
// Caddy config so services are accessible via {service}.mesh.local.
|
|
697
|
-
if (process.env.DEVCONTAINER === "1") {
|
|
1887
|
+
if (process.env.DEVCONTAINER === '1') {
|
|
698
1888
|
try {
|
|
699
1889
|
await updateDevboxProxy(devOutput, appRoot);
|
|
700
1890
|
}
|
|
@@ -702,56 +1892,65 @@ async function startServices(sessionName, appRoot, devOutput, headless) {
|
|
|
702
1892
|
logWarn(`Could not update devbox proxy: ${e.message}`);
|
|
703
1893
|
}
|
|
704
1894
|
}
|
|
705
|
-
//
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
execFileSync("tmux", [
|
|
710
|
-
"send-keys", "-t", `${sessionName}:status`, statusCmd, "Enter",
|
|
711
|
-
]);
|
|
712
|
-
}
|
|
713
|
-
console.log("");
|
|
1895
|
+
// Run live status updates in the status window
|
|
1896
|
+
const statusCmd = `watch -n2 -t npx mesh dev --status --session '${sessionName}'`;
|
|
1897
|
+
execFileSync('tmux', ['send-keys', '-t', `${sessionName}:status`, statusCmd, 'Enter']);
|
|
1898
|
+
console.log('');
|
|
714
1899
|
logSuccess(`Dev session started: ${sessionName}`);
|
|
715
|
-
console.log(
|
|
1900
|
+
console.log('');
|
|
716
1901
|
if (tunnelNames.length > 0) {
|
|
717
|
-
console.log(
|
|
1902
|
+
console.log('Connections (VPN direct):');
|
|
718
1903
|
for (const tName of tunnelNames) {
|
|
719
1904
|
const t = devOutput.tunnels[tName];
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1905
|
+
if (subdomainCtx) {
|
|
1906
|
+
console.log(` ${tName.padEnd(20)} http://${tName}.${subdomainCtx.baseDomain}`);
|
|
1907
|
+
}
|
|
1908
|
+
else {
|
|
1909
|
+
console.log(` ${tName.padEnd(20)} ${t.host}:${t.port}`);
|
|
1910
|
+
}
|
|
725
1911
|
}
|
|
726
|
-
console.log(
|
|
1912
|
+
console.log('');
|
|
727
1913
|
}
|
|
728
|
-
console.log(
|
|
1914
|
+
console.log('Services:');
|
|
729
1915
|
for (const name of serviceNames) {
|
|
730
1916
|
const s = devOutput.services[name];
|
|
731
1917
|
const hasCmd = s.command && s.command.length > 0;
|
|
732
1918
|
if (hasCmd) {
|
|
733
|
-
|
|
734
|
-
|
|
1919
|
+
if (subdomainCtx && s.port) {
|
|
1920
|
+
console.log(` ${name.padEnd(20)} http://${name}.${subdomainCtx.baseDomain}`);
|
|
1921
|
+
}
|
|
1922
|
+
else {
|
|
1923
|
+
const addr = s.port ? `http://localhost:${s.port}` : '(no port)';
|
|
1924
|
+
console.log(` ${name.padEnd(20)} ${addr}`);
|
|
1925
|
+
}
|
|
735
1926
|
}
|
|
736
1927
|
else {
|
|
737
1928
|
console.log(` ${name.padEnd(20)} (deployed)`);
|
|
738
1929
|
}
|
|
739
1930
|
}
|
|
740
|
-
console.log(
|
|
1931
|
+
console.log('');
|
|
741
1932
|
if (!headless) {
|
|
742
|
-
logInfo(
|
|
743
|
-
|
|
1933
|
+
logInfo('Attaching to tmux session...');
|
|
1934
|
+
if (process.env.TMUX) {
|
|
1935
|
+
spawnSync('tmux', ['switch-client', '-t', sessionName], { stdio: 'inherit' });
|
|
1936
|
+
}
|
|
1937
|
+
else {
|
|
1938
|
+
spawnSync('tmux', ['attach', '-t', sessionName], { stdio: 'inherit' });
|
|
1939
|
+
}
|
|
744
1940
|
}
|
|
745
1941
|
else {
|
|
746
1942
|
console.log(`Attach with: tmux attach -t ${sessionName}`);
|
|
747
1943
|
console.log(`Stop with: mesh dev --kill`);
|
|
748
|
-
console.log(
|
|
1944
|
+
console.log('');
|
|
749
1945
|
}
|
|
1946
|
+
// Return the (possibly SSM-tunnel-rewritten) devOutput so the caller can
|
|
1947
|
+
// persist it in session state for --status / restart.
|
|
1948
|
+
return devOutput;
|
|
750
1949
|
}
|
|
751
1950
|
/**
|
|
752
1951
|
* Show status of running services.
|
|
753
1952
|
*/
|
|
754
|
-
function showStatus(sessionName, devOutput, asJson) {
|
|
1953
|
+
async function showStatus(sessionName, devOutput, asJson) {
|
|
755
1954
|
if (!sessionExists(sessionName)) {
|
|
756
1955
|
if (asJson) {
|
|
757
1956
|
console.log(JSON.stringify({ running: false, session: sessionName }));
|
|
@@ -764,106 +1963,135 @@ function showStatus(sessionName, devOutput, asJson) {
|
|
|
764
1963
|
// Get tmux window list
|
|
765
1964
|
let windows = [];
|
|
766
1965
|
try {
|
|
767
|
-
const raw = execFileSync(
|
|
768
|
-
|
|
769
|
-
], { encoding: "utf-8" });
|
|
770
|
-
windows = raw.trim().split("\n");
|
|
1966
|
+
const raw = execFileSync('tmux', ['list-windows', '-t', sessionName, '-F', '#{window_name} #{pane_dead}'], { encoding: 'utf-8' });
|
|
1967
|
+
windows = raw.trim().split('\n');
|
|
771
1968
|
}
|
|
772
1969
|
catch {
|
|
773
1970
|
// Session might have disappeared
|
|
774
1971
|
}
|
|
775
1972
|
const windowStatus = {};
|
|
776
1973
|
for (const line of windows) {
|
|
777
|
-
const [name, dead] = line.split(
|
|
1974
|
+
const [name, dead] = line.split(' ');
|
|
778
1975
|
if (name)
|
|
779
|
-
windowStatus[name] = dead ===
|
|
1976
|
+
windowStatus[name] = dead === '1' ? 'exited' : 'running';
|
|
780
1977
|
}
|
|
781
1978
|
const tunnelNames = Object.keys(devOutput.tunnels);
|
|
1979
|
+
// Live-probe each tunnel so a mid-session tunnel death shows as DOWN here
|
|
1980
|
+
// (this render runs every ~2s in the status window) — MESH-2016.
|
|
1981
|
+
const tunnelHealth = await probeTunnelHealth(devOutput.tunnels);
|
|
1982
|
+
const healthByName = new Map(tunnelHealth.map((h) => [h.name, h]));
|
|
782
1983
|
if (asJson) {
|
|
783
1984
|
const services = {};
|
|
784
1985
|
for (const [name, svc] of Object.entries(devOutput.services)) {
|
|
785
1986
|
services[name] = {
|
|
786
|
-
status: windowStatus[name] ??
|
|
1987
|
+
status: windowStatus[name] ?? 'unknown',
|
|
787
1988
|
port: svc.port,
|
|
788
1989
|
src: svc.src,
|
|
789
1990
|
};
|
|
790
1991
|
}
|
|
791
1992
|
const tunnels = {};
|
|
792
1993
|
for (const [name, tun] of Object.entries(devOutput.tunnels)) {
|
|
793
|
-
const target = tun.type === "kubectl"
|
|
794
|
-
? `${tun.namespace ?? "default"}/${tun.resource ?? name}:${tun.port}`
|
|
795
|
-
: `${tun.host}:${tun.port}`;
|
|
796
|
-
const listening = isPortListeningSync(tun.localPort);
|
|
797
1994
|
tunnels[name] = {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
type: tun.type ?? "bastion",
|
|
1995
|
+
host: tun.host,
|
|
1996
|
+
port: tun.port,
|
|
1997
|
+
reachable: healthByName.get(name)?.reachable ?? false,
|
|
802
1998
|
};
|
|
803
1999
|
}
|
|
804
2000
|
console.log(JSON.stringify({ running: true, session: sessionName, services, tunnels }, null, 2));
|
|
805
2001
|
}
|
|
806
2002
|
else {
|
|
807
|
-
console.log(
|
|
808
|
-
console.log(
|
|
2003
|
+
console.log('\u2500\u2500 mesh dev \u2500\u2500');
|
|
2004
|
+
console.log('');
|
|
809
2005
|
console.log(`Session: ${sessionName}`);
|
|
810
2006
|
if (tunnelNames.length > 0) {
|
|
811
|
-
|
|
812
|
-
const
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
const listening = isPortListeningSync(tun.localPort);
|
|
818
|
-
const icon = listening ? "●" : "✗";
|
|
819
|
-
const status = listening ? "up" : "down";
|
|
820
|
-
const target = tun.type === "kubectl"
|
|
821
|
-
? `${tun.namespace ?? "default"}/${tun.resource ?? name}:${tun.port}`
|
|
822
|
-
: `${tun.host}:${tun.port}`;
|
|
823
|
-
const label = tun.type === "kubectl" ? " kubectl" : "";
|
|
824
|
-
console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(10)} :${tun.localPort} → ${target}${label}`);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
console.log("");
|
|
828
|
-
console.log("Services:");
|
|
2007
|
+
console.log('');
|
|
2008
|
+
for (const line of formatTunnelHealth(tunnelHealth))
|
|
2009
|
+
console.log(line);
|
|
2010
|
+
}
|
|
2011
|
+
console.log('');
|
|
2012
|
+
console.log('Services:');
|
|
829
2013
|
for (const [name, svc] of Object.entries(devOutput.services)) {
|
|
830
2014
|
const hasCmd = svc.command && svc.command.length > 0;
|
|
831
2015
|
if (!hasCmd) {
|
|
832
2016
|
console.log(` ☁ ${name.padEnd(16)} deployed ${svc.src}`);
|
|
833
2017
|
continue;
|
|
834
2018
|
}
|
|
835
|
-
const status = windowStatus[name] ??
|
|
836
|
-
const icon = status ===
|
|
837
|
-
const addr = svc.port ? `:${svc.port}` :
|
|
2019
|
+
const status = windowStatus[name] ?? 'unknown';
|
|
2020
|
+
const icon = status === 'running' ? '●' : status === 'exited' ? '✗' : '?';
|
|
2021
|
+
const addr = svc.port ? `:${svc.port}` : '';
|
|
838
2022
|
console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(10)} ${addr.padEnd(8)} ${svc.src}`);
|
|
839
2023
|
}
|
|
840
|
-
console.log(
|
|
841
|
-
console.log(
|
|
842
|
-
console.log(
|
|
843
|
-
console.log(
|
|
844
|
-
console.log(
|
|
845
|
-
console.log(
|
|
846
|
-
console.log(
|
|
2024
|
+
console.log('');
|
|
2025
|
+
console.log('Commands:');
|
|
2026
|
+
console.log(' mesh dev restart <s> Restart a service');
|
|
2027
|
+
console.log(' mesh dev logs <s> Tail service logs');
|
|
2028
|
+
console.log(' mesh dev --kill Stop everything');
|
|
2029
|
+
console.log('');
|
|
2030
|
+
console.log('tmux: Ctrl+b n/p switch windows, d detach');
|
|
847
2031
|
}
|
|
848
2032
|
}
|
|
849
2033
|
/**
|
|
850
2034
|
* Restart a single service window.
|
|
851
2035
|
*/
|
|
852
|
-
function restartService(sessionName, serviceName,
|
|
853
|
-
|
|
2036
|
+
async function restartService(sessionName, serviceName, appRoot, devOutput, awsEnv, opts) {
|
|
2037
|
+
let service = devOutput.services[serviceName];
|
|
854
2038
|
if (!service) {
|
|
855
2039
|
logError(`Unknown service: ${serviceName}`);
|
|
856
|
-
logInfo(`Available: ${Object.keys(devOutput.services).join(
|
|
2040
|
+
logInfo(`Available: ${Object.keys(devOutput.services).join(', ')}`);
|
|
857
2041
|
process.exit(1);
|
|
858
2042
|
}
|
|
859
2043
|
if (!sessionExists(sessionName)) {
|
|
860
2044
|
logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);
|
|
861
2045
|
process.exit(1);
|
|
862
2046
|
}
|
|
2047
|
+
// --refresh-env: regenerate this service's env file from CURRENT stack/SSM
|
|
2048
|
+
// outputs BEFORE killing the running process, so a failed read leaves the
|
|
2049
|
+
// good env file (and the running service) untouched (fail-closed).
|
|
2050
|
+
if (opts?.refreshEnv) {
|
|
2051
|
+
if (!opts.stack) {
|
|
2052
|
+
logError('--refresh-env: no stack resolved; keeping existing env and NOT restarting.');
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
// The session's allocated devOutput pins the ports/tunnels to reuse.
|
|
2056
|
+
const pinned = opts.sessionState?.devOutput ?? devOutput;
|
|
2057
|
+
let fresh;
|
|
2058
|
+
try {
|
|
2059
|
+
fresh = getDevOutput(appRoot, opts.stack, awsEnv);
|
|
2060
|
+
}
|
|
2061
|
+
catch (err) {
|
|
2062
|
+
logError(`--refresh-env: failed to read stack output for '${opts.stack}'. Keeping the existing env file for '${serviceName}' and NOT restarting.`);
|
|
2063
|
+
logInfo(String(err?.message ?? err));
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
const merged = mergePinnedPorts(fresh, pinned);
|
|
2067
|
+
const mergedService = merged.services[serviceName];
|
|
2068
|
+
if (!mergedService) {
|
|
2069
|
+
logError(`--refresh-env: service '${serviceName}' is absent from the fresh stack output. Keeping the existing env file and NOT restarting.`);
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
const temporalAuthVars = await resolveTemporalAuthVars(sessionName, merged);
|
|
2073
|
+
// Recompute the per-worktree suffix (deterministic from the worktree path,
|
|
2074
|
+
// so it matches what launch wrote) — a restarted service must keep polling
|
|
2075
|
+
// this worktree's isolated task queue (MESH-2013 Slice 2).
|
|
2076
|
+
const { taskQueueSuffix } = resolveWorktreeIdentity(appRoot);
|
|
2077
|
+
const serviceVars = {
|
|
2078
|
+
...getServiceEnvVars(mergedService, merged.tunnels),
|
|
2079
|
+
...temporalAuthVars,
|
|
2080
|
+
// Same overlay as launch: without it, --refresh-env rewrites the env
|
|
2081
|
+
// file from dev output and its DEV_USER_* placeholders clobber the real
|
|
2082
|
+
// token from `mesh login` (the "restart silently 401s the Hub" trap).
|
|
2083
|
+
...resolveDevUserVars(merged),
|
|
2084
|
+
...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
|
|
2085
|
+
};
|
|
2086
|
+
writeEnvFile(getServiceEnvFilePath(sessionName, serviceName), serviceVars);
|
|
2087
|
+
// Relaunch from the refreshed service definition (command/src may have changed).
|
|
2088
|
+
service = mergedService;
|
|
2089
|
+
logSuccess(`Regenerated env for '${serviceName}' from current stack/SSM outputs.`);
|
|
2090
|
+
}
|
|
863
2091
|
const target = `${sessionName}:${serviceName}`;
|
|
864
2092
|
// Kill the current pane process and respawn
|
|
865
2093
|
try {
|
|
866
|
-
execFileSync(
|
|
2094
|
+
execFileSync('tmux', ['respawn-pane', '-k', '-t', target], { stdio: 'ignore' });
|
|
867
2095
|
}
|
|
868
2096
|
catch {
|
|
869
2097
|
// Window might not exist yet
|
|
@@ -871,12 +2099,27 @@ function restartService(sessionName, serviceName, _appRoot, devOutput) {
|
|
|
871
2099
|
process.exit(1);
|
|
872
2100
|
}
|
|
873
2101
|
// Refresh shared env (AWS creds may have rotated) on the session.
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
2102
|
+
setTmuxEnv(sessionName, awsEnv);
|
|
2103
|
+
const cmd = service.command.join(' ');
|
|
2104
|
+
// Source the SAME env file written at launch — it is the session's env
|
|
2105
|
+
// contract for this service, guaranteeing a byte-identical environment.
|
|
2106
|
+
// Reconstructing env here has historically diverged from launch (e.g.
|
|
2107
|
+
// TEMPORAL_ENCODING_KEY drift → "cannot decrypt payload" / missing key →
|
|
2108
|
+
// "Unknown encoding: binary/encrypted").
|
|
2109
|
+
const envFilePath = getServiceEnvFilePath(sessionName, serviceName);
|
|
2110
|
+
let restartCmd;
|
|
2111
|
+
if (fs.existsSync(envFilePath)) {
|
|
2112
|
+
const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, findMonorepoRoot()));
|
|
2113
|
+
// Keep the local log pipeline attached across restarts.
|
|
2114
|
+
restartCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined);
|
|
2115
|
+
}
|
|
2116
|
+
else {
|
|
2117
|
+
logWarn(`No launch env file for '${serviceName}' (${envFilePath}) — session predates env-file launches.`);
|
|
2118
|
+
logWarn('Falling back to reconstructed env; restart `mesh dev` for a faithful environment.');
|
|
2119
|
+
const serviceVars = getServiceEnvVars(service, devOutput.tunnels);
|
|
2120
|
+
restartCmd = `${envPrefix(serviceVars)}${cmd}`;
|
|
2121
|
+
}
|
|
2122
|
+
execFileSync('tmux', ['send-keys', '-t', target, restartCmd, 'Enter']);
|
|
880
2123
|
logSuccess(`Restarted: ${serviceName}`);
|
|
881
2124
|
}
|
|
882
2125
|
/**
|
|
@@ -889,216 +2132,121 @@ function showLogs(sessionName, serviceName, tail) {
|
|
|
889
2132
|
}
|
|
890
2133
|
const target = `${sessionName}:${serviceName}`;
|
|
891
2134
|
try {
|
|
892
|
-
const result = execFileSync(
|
|
893
|
-
|
|
894
|
-
|
|
2135
|
+
const result = execFileSync('tmux', ['capture-pane', '-t', target, '-p', '-S', `-${tail}`], {
|
|
2136
|
+
encoding: 'utf-8',
|
|
2137
|
+
});
|
|
895
2138
|
process.stdout.write(result);
|
|
896
2139
|
}
|
|
897
2140
|
catch {
|
|
898
2141
|
logError(`Could not capture logs for '${serviceName}'.`);
|
|
899
|
-
logInfo(
|
|
2142
|
+
logInfo('Is the service name correct? Check with: mesh dev --status');
|
|
900
2143
|
process.exit(1);
|
|
901
2144
|
}
|
|
902
2145
|
}
|
|
903
2146
|
// ============================================================================
|
|
904
|
-
//
|
|
2147
|
+
// Command registration
|
|
905
2148
|
// ============================================================================
|
|
906
|
-
import { spawn } from "child_process";
|
|
907
2149
|
/**
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
* Runs in a single tmux window instead of one window per tunnel.
|
|
2150
|
+
* Assemble a {@link DoctorContext} for the current app/stack/session, resolving
|
|
2151
|
+
* the deployer role the same way the `restart`/launch paths do (default role +
|
|
2152
|
+
* Zitadel-claim admin selection). Used by `mesh dev doctor`.
|
|
912
2153
|
*/
|
|
913
|
-
function
|
|
914
|
-
const
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
"--target", config.bastionId,
|
|
926
|
-
"--document-name", "AWS-StartPortForwardingSessionToRemoteHost",
|
|
927
|
-
"--parameters", JSON.stringify({
|
|
928
|
-
host: [tunnel.host],
|
|
929
|
-
portNumber: [String(tunnel.port)],
|
|
930
|
-
localPortNumber: [String(tunnel.localPort)],
|
|
931
|
-
}),
|
|
932
|
-
];
|
|
933
|
-
}
|
|
934
|
-
else {
|
|
935
|
-
const ns = tunnel.namespace ?? "default";
|
|
936
|
-
const resource = tunnel.resource ?? `svc/${name}`;
|
|
937
|
-
argv = ["kubectl", "port-forward", "-n", ns, resource, `${tunnel.localPort}:${tunnel.port}`];
|
|
938
|
-
}
|
|
939
|
-
return {
|
|
940
|
-
name,
|
|
941
|
-
tunnel,
|
|
942
|
-
process: null,
|
|
943
|
-
status: "starting",
|
|
944
|
-
restarts: 0,
|
|
945
|
-
lastStart: 0,
|
|
946
|
-
argv,
|
|
947
|
-
};
|
|
948
|
-
});
|
|
949
|
-
function startTunnel(state) {
|
|
950
|
-
state.status = "starting";
|
|
951
|
-
state.lastStart = Date.now();
|
|
952
|
-
const [cmd, ...args] = state.argv;
|
|
953
|
-
const child = spawn(cmd, args, {
|
|
954
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
955
|
-
});
|
|
956
|
-
child.stdout?.on("data", () => {
|
|
957
|
-
// SSM/kubectl output — tunnel is establishing
|
|
958
|
-
if (state.status === "starting") {
|
|
959
|
-
state.status = "up";
|
|
960
|
-
}
|
|
961
|
-
});
|
|
962
|
-
child.stderr?.on("data", (data) => {
|
|
963
|
-
const msg = data.toString().trim();
|
|
964
|
-
if (msg && !msg.includes("Starting session") && !msg.includes("Forwarding from")) {
|
|
965
|
-
console.log(` [${state.name}] ${msg}`);
|
|
966
|
-
}
|
|
2154
|
+
function buildDoctorContext(appRoot, stack, sessionName) {
|
|
2155
|
+
const state = loadSessionState(sessionName);
|
|
2156
|
+
const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
|
|
2157
|
+
const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
|
|
2158
|
+
const platformContext = derivePlatformContext(appRoot, stack);
|
|
2159
|
+
let deployerRole = defaultDeployerRole;
|
|
2160
|
+
if (defaultDeployerRole) {
|
|
2161
|
+
const meshCreds = platformContext ? readCredentials(platformContext) : null;
|
|
2162
|
+
const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;
|
|
2163
|
+
deployerRole = selectRoleForCaller(idToken, {
|
|
2164
|
+
defaultRole: defaultDeployerRole,
|
|
2165
|
+
adminRole: adminDeployerRole ?? undefined,
|
|
967
2166
|
});
|
|
968
|
-
child.on("exit", (code) => {
|
|
969
|
-
state.process = null;
|
|
970
|
-
if (shuttingDown)
|
|
971
|
-
return;
|
|
972
|
-
state.status = "down";
|
|
973
|
-
state.restarts++;
|
|
974
|
-
// Back off: wait longer between restarts (1s, 2s, 4s, 8s, max 30s)
|
|
975
|
-
const delay = Math.min(1000 * Math.pow(2, Math.min(state.restarts - 1, 4)), 30000);
|
|
976
|
-
console.log(` [${state.name}] exited (code ${code}), restarting in ${delay / 1000}s...`);
|
|
977
|
-
state.status = "restarting";
|
|
978
|
-
setTimeout(() => {
|
|
979
|
-
if (!shuttingDown)
|
|
980
|
-
startTunnel(state);
|
|
981
|
-
}, delay);
|
|
982
|
-
});
|
|
983
|
-
state.process = child;
|
|
984
|
-
// Mark as "up" after a short delay if still running
|
|
985
|
-
setTimeout(() => {
|
|
986
|
-
if (state.process && state.status === "starting") {
|
|
987
|
-
state.status = "up";
|
|
988
|
-
}
|
|
989
|
-
}, 2000);
|
|
990
|
-
}
|
|
991
|
-
let shuttingDown = false;
|
|
992
|
-
function shutdown() {
|
|
993
|
-
if (shuttingDown)
|
|
994
|
-
return;
|
|
995
|
-
shuttingDown = true;
|
|
996
|
-
console.log("\nShutting down tunnels...");
|
|
997
|
-
for (const state of states) {
|
|
998
|
-
if (state.process) {
|
|
999
|
-
state.process.kill("SIGTERM");
|
|
1000
|
-
}
|
|
1001
|
-
}
|
|
1002
|
-
setTimeout(() => process.exit(0), 1000);
|
|
1003
|
-
}
|
|
1004
|
-
process.on("SIGTERM", shutdown);
|
|
1005
|
-
process.on("SIGINT", shutdown);
|
|
1006
|
-
// Print header
|
|
1007
|
-
console.log("mesh dev — tunnel manager");
|
|
1008
|
-
console.log("─".repeat(60));
|
|
1009
|
-
for (const state of states) {
|
|
1010
|
-
const target = state.tunnel.type === "kubectl"
|
|
1011
|
-
? `${state.tunnel.namespace ?? "default"}/${state.tunnel.resource ?? state.name}:${state.tunnel.port}`
|
|
1012
|
-
: `${state.tunnel.host}:${state.tunnel.port}`;
|
|
1013
|
-
const label = state.tunnel.type === "kubectl" ? "kubectl" : "bastion";
|
|
1014
|
-
console.log(` ${state.name.padEnd(16)} :${state.tunnel.localPort} → ${target} (${label})`);
|
|
1015
|
-
}
|
|
1016
|
-
console.log("─".repeat(60));
|
|
1017
|
-
console.log("");
|
|
1018
|
-
// Start all tunnels
|
|
1019
|
-
for (const state of states) {
|
|
1020
|
-
startTunnel(state);
|
|
1021
|
-
}
|
|
1022
|
-
// Periodically print full status (tunnels + services)
|
|
1023
|
-
function printStatus() {
|
|
1024
|
-
if (shuttingDown)
|
|
1025
|
-
return;
|
|
1026
|
-
// Clear screen and move cursor to top
|
|
1027
|
-
process.stdout.write("\x1b[2J\x1b[H");
|
|
1028
|
-
console.log("── mesh dev ──");
|
|
1029
|
-
console.log("");
|
|
1030
|
-
// Tunnels
|
|
1031
|
-
console.log("Tunnels:");
|
|
1032
|
-
for (const s of states) {
|
|
1033
|
-
const icon = s.status === "up" ? "●" : s.status === "restarting" ? "↻" : s.status === "starting" ? "…" : "✗";
|
|
1034
|
-
const statusLabel = s.status === "up" ? "up" : s.status === "restarting" ? `restart(${s.restarts})` : s.status;
|
|
1035
|
-
const target = s.tunnel.type === "kubectl"
|
|
1036
|
-
? `${s.tunnel.namespace ?? "default"}/${s.tunnel.resource ?? s.name}:${s.tunnel.port}`
|
|
1037
|
-
: `${s.tunnel.host}:${s.tunnel.port}`;
|
|
1038
|
-
const label = s.tunnel.type === "kubectl" ? " kubectl" : "";
|
|
1039
|
-
console.log(` ${icon} ${s.name.padEnd(16)} ${statusLabel.padEnd(14)} :${s.tunnel.localPort} → ${target}${label}`);
|
|
1040
|
-
}
|
|
1041
|
-
// Services (check tmux window status if session name provided)
|
|
1042
|
-
if (sessionName) {
|
|
1043
|
-
let windows = [];
|
|
1044
|
-
try {
|
|
1045
|
-
const raw = execFileSync("tmux", [
|
|
1046
|
-
"list-windows", "-t", sessionName, "-F", "#{window_name} #{pane_dead}",
|
|
1047
|
-
], { encoding: "utf-8" });
|
|
1048
|
-
windows = raw.trim().split("\n");
|
|
1049
|
-
}
|
|
1050
|
-
catch { }
|
|
1051
|
-
const windowStatus = {};
|
|
1052
|
-
for (const line of windows) {
|
|
1053
|
-
const [name, dead] = line.split(" ");
|
|
1054
|
-
if (name)
|
|
1055
|
-
windowStatus[name] = dead === "1" ? "exited" : "running";
|
|
1056
|
-
}
|
|
1057
|
-
// Get service names from session state
|
|
1058
|
-
const state = loadSessionState(sessionName);
|
|
1059
|
-
if (state?.devOutput?.services) {
|
|
1060
|
-
console.log("");
|
|
1061
|
-
console.log("Services:");
|
|
1062
|
-
for (const [name, svc] of Object.entries(state.devOutput.services)) {
|
|
1063
|
-
const hasCmd = svc.command && svc.command.length > 0;
|
|
1064
|
-
if (!hasCmd) {
|
|
1065
|
-
console.log(` ☁ ${name.padEnd(16)} deployed ${svc.src}`);
|
|
1066
|
-
continue;
|
|
1067
|
-
}
|
|
1068
|
-
const status = windowStatus[name] ?? "unknown";
|
|
1069
|
-
const icon = status === "running" ? "●" : status === "exited" ? "✗" : "?";
|
|
1070
|
-
const addr = svc.port ? `:${svc.port}` : "";
|
|
1071
|
-
console.log(` ${icon} ${name.padEnd(16)} ${status.padEnd(14)} ${addr.padEnd(8)} ${svc.src}`);
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
console.log("");
|
|
1076
|
-
console.log("mesh dev --status / --kill | Ctrl+b n/p switch windows");
|
|
1077
2167
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
2168
|
+
return {
|
|
2169
|
+
appRoot,
|
|
2170
|
+
stack,
|
|
2171
|
+
sessionName,
|
|
2172
|
+
deployerRole,
|
|
2173
|
+
platformContext,
|
|
2174
|
+
credMethod: null,
|
|
2175
|
+
sessionState: state,
|
|
2176
|
+
};
|
|
1081
2177
|
}
|
|
1082
|
-
// ============================================================================
|
|
1083
|
-
// Command registration
|
|
1084
|
-
// ============================================================================
|
|
1085
2178
|
export function registerDevCommand(program) {
|
|
1086
2179
|
const dev = program
|
|
1087
|
-
.command(
|
|
1088
|
-
.description(
|
|
1089
|
-
.option(
|
|
1090
|
-
.option(
|
|
1091
|
-
.
|
|
1092
|
-
.option(
|
|
1093
|
-
.option(
|
|
1094
|
-
.option(
|
|
1095
|
-
.option(
|
|
2180
|
+
.command('dev')
|
|
2181
|
+
.description('Start local dev environment (reads Pulumi stack outputs)')
|
|
2182
|
+
.option('--app <path>', 'Path to a Pulumi app (relative to monorepo root or cwd)')
|
|
2183
|
+
.option('--stack <stack>', 'Pulumi stack name (default: auto-detect)')
|
|
2184
|
+
.addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
|
|
2185
|
+
.option('--headless', 'Start without attaching to tmux')
|
|
2186
|
+
.option('--kill', 'Kill existing dev session')
|
|
2187
|
+
.option('--status', 'Show service status')
|
|
2188
|
+
.option('--json', 'Output status as JSON (with --status)')
|
|
2189
|
+
.option('--session <name>', 'Override the tmux session name (auto-derived per git worktree by default — omit to keep concurrent worktrees isolated)')
|
|
2190
|
+
.option('--dry-run', 'Print the resolved worktree/session/port plan and exit without launching')
|
|
2191
|
+
.option('--force', 'Relaunch even if workflow code changed since launch (may strand in-flight conversations)')
|
|
2192
|
+
.option('--profile <name>', 'AWS SSO profile to use (e.g., mesh-dev)')
|
|
2193
|
+
.addOption(new Option('--transport <mode>', 'Tunnel backing (default: auto — Tailscale when available, else SSM/VPN)').choices(['auto', 'ssm', 'tailscale']))
|
|
2194
|
+
.option('--local', 'Run against the local Mesh platform from `mesh start` (no AWS, no VPN, no Pulumi state). Auto-selected when the app has no Pulumi.yaml.')
|
|
2195
|
+
.option('--externals [names]', "Also realize the app's declared external services (package.json → mesh.externals): all of them, or a comma-separated subset of name[=mode] entries. Modes: mock (emulate — OpenAPI spec via Prism, or a mock process), local (a local version via docker compose, e.g. a vendor DB replica), remote (connect to the actual service — vendor sandbox credentials, or the external configured in the app's tenant environment). name=mode overrides the declaration's default for this run (e.g. plaid-db=remote). Each realization seeds the ExternalService credential secret so resolveCredentials() runs unchanged. Local mode only.")
|
|
2196
|
+
.option('--mock [names]', 'Alias for --externals.')
|
|
2197
|
+
.option('--runner <runner>', "Process runner for local mode: 'tmux' (default — dev machines) or 'docker' (CI/headless: services run as a docker compose project with host networking; Linux semantics).", 'tmux');
|
|
2198
|
+
// Hidden: the dev-user token-server (MESH-2039). Launched into its own tmux
|
|
2199
|
+
// window by the dev launcher; serves a continuously-fresh dev-user token.
|
|
2200
|
+
dev
|
|
2201
|
+
.command('__token-server <port> <context>', { hidden: true })
|
|
2202
|
+
.action(async (port, context) => {
|
|
2203
|
+
const { startTokenServer } = await import('./dev-token-server.js');
|
|
2204
|
+
await startTokenServer(Number(port), context);
|
|
2205
|
+
});
|
|
1096
2206
|
// Default action: start or show status
|
|
1097
2207
|
dev.action(async (options) => {
|
|
2208
|
+
// An explicit --profile must win over stale shell-exported STS credentials.
|
|
2209
|
+
// The AWS SDK/CLI prefer AWS_ACCESS_KEY_ID over AWS_PROFILE, so clear any
|
|
2210
|
+
// static credentials before resolving the deployer role.
|
|
2211
|
+
if (options.profile) {
|
|
2212
|
+
applyAwsProfileOverride(options.profile);
|
|
2213
|
+
}
|
|
1098
2214
|
// Fast-path: --status/--kill with explicit --session can skip app root resolution
|
|
1099
2215
|
if (options.session && (options.status || options.kill)) {
|
|
1100
2216
|
const sessionName = options.session;
|
|
2217
|
+
if (loadSessionState(sessionName)?.runner === 'docker') {
|
|
2218
|
+
if (options.kill) {
|
|
2219
|
+
dockerDevDown(sessionName);
|
|
2220
|
+
composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
|
|
2221
|
+
localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
|
|
2222
|
+
removeSessionState(sessionName);
|
|
2223
|
+
logSuccess(`Killed docker dev session: ${sessionName}`);
|
|
2224
|
+
}
|
|
2225
|
+
else {
|
|
2226
|
+
console.log(dockerDevPs(sessionName));
|
|
2227
|
+
}
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
1101
2230
|
if (options.kill) {
|
|
2231
|
+
composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
|
|
2232
|
+
localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
|
|
2233
|
+
// Clean up DNS records for this app
|
|
2234
|
+
try {
|
|
2235
|
+
const devOutput = loadSessionState(sessionName)?.devOutput;
|
|
2236
|
+
if (devOutput) {
|
|
2237
|
+
const tsInfo = await getTailscaleInfo();
|
|
2238
|
+
if (tsInfo) {
|
|
2239
|
+
const appName = devOutput.app ?? sessionName.replace(/-dev$/, '');
|
|
2240
|
+
const stack = devOutput.stack ?? 'dev';
|
|
2241
|
+
const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;
|
|
2242
|
+
const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };
|
|
2243
|
+
unregisterDnsRecords(headscaleDnsConfig(platform.name ?? platform.tenant, platform.env), baseDomain);
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
catch {
|
|
2248
|
+
/* best effort */
|
|
2249
|
+
}
|
|
1102
2250
|
if (sessionExists(sessionName)) {
|
|
1103
2251
|
killSession(sessionName);
|
|
1104
2252
|
logSuccess(`Killed session: ${sessionName}`);
|
|
@@ -1112,16 +2260,50 @@ export function registerDevCommand(program) {
|
|
|
1112
2260
|
// --status with --session: use state file, no Pulumi needed
|
|
1113
2261
|
const state = loadSessionState(sessionName);
|
|
1114
2262
|
if (state) {
|
|
1115
|
-
showStatus(sessionName, state.devOutput, !!options.json);
|
|
2263
|
+
await showStatus(sessionName, state.devOutput, !!options.json);
|
|
1116
2264
|
return;
|
|
1117
2265
|
}
|
|
1118
2266
|
// No state — fall through to normal flow
|
|
1119
2267
|
}
|
|
1120
2268
|
const appRoot = findAppRoot(options.app);
|
|
1121
2269
|
const projectName = getProjectName(appRoot);
|
|
1122
|
-
|
|
2270
|
+
// Port block scoped to (worktree, app): concurrent sibling-app sessions
|
|
2271
|
+
// must draw disjoint ports (probing races slow boots). Scoped once here so
|
|
2272
|
+
// local and tethered modes resolve the SAME plan — every other field stays
|
|
2273
|
+
// worktree-scoped, so session naming and task queues are unaffected.
|
|
2274
|
+
const worktree = withAppScopedPortBlock(resolveWorktreeIdentity(appRoot), appRoot);
|
|
2275
|
+
const sessionName = options.session ?? deriveSessionName(projectName, worktree);
|
|
1123
2276
|
// --kill
|
|
1124
2277
|
if (options.kill) {
|
|
2278
|
+
composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
|
|
2279
|
+
localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
|
|
2280
|
+
if (loadSessionState(sessionName)?.runner === 'docker') {
|
|
2281
|
+
dockerDevDown(sessionName);
|
|
2282
|
+
removeSessionState(sessionName);
|
|
2283
|
+
logSuccess(`Killed docker dev session: ${sessionName}`);
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
// Tailscale VPN tunnels are machine-global + shared across sessions, so
|
|
2287
|
+
// they persist on purpose after a kill. Capture the tenant before state
|
|
2288
|
+
// is removed so we can point at the explicit stop below.
|
|
2289
|
+
const killTsTenant = loadSessionState(sessionName)?.devOutput.platform?.name ?? 'mesh';
|
|
2290
|
+
// Clean up DNS records for this app
|
|
2291
|
+
try {
|
|
2292
|
+
const devOutput = loadSessionState(sessionName)?.devOutput;
|
|
2293
|
+
if (devOutput) {
|
|
2294
|
+
const tsInfo = await getTailscaleInfo();
|
|
2295
|
+
if (tsInfo) {
|
|
2296
|
+
const appName = devOutput.app ?? projectName;
|
|
2297
|
+
const stack = devOutput.stack ?? 'dev';
|
|
2298
|
+
const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;
|
|
2299
|
+
const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };
|
|
2300
|
+
unregisterDnsRecords(headscaleDnsConfig(platform.name ?? platform.tenant, platform.env), baseDomain);
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
catch {
|
|
2305
|
+
/* best effort */
|
|
2306
|
+
}
|
|
1125
2307
|
if (sessionExists(sessionName)) {
|
|
1126
2308
|
killSession(sessionName);
|
|
1127
2309
|
logSuccess(`Killed session: ${sessionName}`);
|
|
@@ -1130,37 +2312,589 @@ export function registerDevCommand(program) {
|
|
|
1130
2312
|
logInfo(`No active session: ${sessionName}`);
|
|
1131
2313
|
}
|
|
1132
2314
|
removeSessionState(sessionName);
|
|
2315
|
+
if (readTailscaleState(killTsTenant)) {
|
|
2316
|
+
logInfo(`VPN tunnels persist across sessions — stop them with: mesh vpn tunnel down --tenant ${killTsTenant}`);
|
|
2317
|
+
}
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
// ── Local mode (design §5.2) ─────────────────────────────────────
|
|
2321
|
+
// Explicit --local, or auto when the app has no Pulumi program at all
|
|
2322
|
+
// (hasStackBacking also checks the monorepo ROOT — apps like apps/hub
|
|
2323
|
+
// deploy via a root program and must keep the tethered default).
|
|
2324
|
+
// Synthesizes DevOutput from repo conventions and wires services to the
|
|
2325
|
+
// `mesh start` platform: no AWS credentials, no VPN, no Pulumi state.
|
|
2326
|
+
const localMode = !!options.local || !hasStackBacking(appRoot);
|
|
2327
|
+
if (localMode) {
|
|
2328
|
+
// Read-only status query: report and exit BEFORE any side effect
|
|
2329
|
+
// (platform boot, Zitadel provisioning, registry writes) — parity
|
|
2330
|
+
// with the tethered path's early --status short-circuit.
|
|
2331
|
+
if (options.status) {
|
|
2332
|
+
const state = loadSessionState(sessionName);
|
|
2333
|
+
if (state?.runner === 'docker') {
|
|
2334
|
+
console.log(dockerDevPs(sessionName));
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
const statusOutput = state?.devOutput ??
|
|
2338
|
+
buildLocalDevOutput(appRoot, detectLocalTenant(appRoot), { mocks: {} });
|
|
2339
|
+
showStatus(sessionName, statusOutput, !!options.json);
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
const dockerRunner = options.runner === 'docker';
|
|
2343
|
+
if (!dockerRunner && !hasTmux()) {
|
|
2344
|
+
logError('tmux is not installed. Fix: brew install tmux (or use --runner docker)');
|
|
2345
|
+
process.exit(1);
|
|
2346
|
+
}
|
|
2347
|
+
await ensureLocalPlatformRunning();
|
|
2348
|
+
const localTenant = detectLocalTenant(appRoot);
|
|
2349
|
+
logInfo(`Project: ${projectName}, Stack: local (mesh start platform)`);
|
|
2350
|
+
// --externals (alias --mock): resolve the requested subset of the
|
|
2351
|
+
// app's declared externals (package.json → mesh.externals). Entries
|
|
2352
|
+
// are name[=mode] — an explicit mode overrides the declaration's
|
|
2353
|
+
// default for this run (materialized onto the decl so every
|
|
2354
|
+
// downstream externalMode() call agrees). Mock-mode externals join
|
|
2355
|
+
// the same orchestration as services named mock-{name}; all
|
|
2356
|
+
// realizations get their credential secret seeded after ports are
|
|
2357
|
+
// final.
|
|
2358
|
+
const externalsRequest = options.externals ?? options.mock;
|
|
2359
|
+
let selectedMocks = {};
|
|
2360
|
+
// The externals the developer NAMED (empty for the bare flag, which
|
|
2361
|
+
// selects everything declared). Failing to realize one of these is
|
|
2362
|
+
// fatal; failing to realize one the developer never asked for by name
|
|
2363
|
+
// only costs that external.
|
|
2364
|
+
const requestedExplicitly = new Set();
|
|
2365
|
+
if (externalsRequest) {
|
|
2366
|
+
const declared = readLocalMocks(appRoot);
|
|
2367
|
+
let requested;
|
|
2368
|
+
let overrides = new Map();
|
|
2369
|
+
if (externalsRequest === true) {
|
|
2370
|
+
requested = Object.keys(declared);
|
|
2371
|
+
}
|
|
2372
|
+
else {
|
|
2373
|
+
({ names: requested, overrides } = parseExternalsSelection(String(externalsRequest)));
|
|
2374
|
+
for (const name of requested)
|
|
2375
|
+
requestedExplicitly.add(name);
|
|
2376
|
+
}
|
|
2377
|
+
const unknown = requested.filter((name) => !declared[name]);
|
|
2378
|
+
if (unknown.length > 0) {
|
|
2379
|
+
throw new MeshCliError(`Unknown external(s): ${unknown.join(', ')} — declared in package.json mesh.externals: ${Object.keys(declared).join(', ') || '(none)'}`, { remediation: { docs: 'package.json → "mesh": { "externals": { … } }' } });
|
|
2380
|
+
}
|
|
2381
|
+
if (requested.length === 0) {
|
|
2382
|
+
logWarn('No externals declared (package.json → mesh.externals) — continuing without.');
|
|
2383
|
+
}
|
|
2384
|
+
selectedMocks = Object.fromEntries(requested.map((name) => [
|
|
2385
|
+
name,
|
|
2386
|
+
overrides.has(name) ? { ...declared[name], mode: overrides.get(name) } : declared[name],
|
|
2387
|
+
]));
|
|
2388
|
+
// Validate every declaration's resolved mode up front — a bad decl
|
|
2389
|
+
// (or an override the decl can't satisfy) should fail here, not
|
|
2390
|
+
// after half the orchestration is up.
|
|
2391
|
+
for (const [name, decl] of Object.entries(selectedMocks))
|
|
2392
|
+
externalMode(name, decl);
|
|
2393
|
+
}
|
|
2394
|
+
const rawDevOutput = buildLocalDevOutput(appRoot, localTenant, {
|
|
2395
|
+
mocks: selectedMocks,
|
|
2396
|
+
});
|
|
2397
|
+
// --dry-run (worktree-plan parity with tethered mode): print the
|
|
2398
|
+
// resolved session/port plan and exit before any side effect —
|
|
2399
|
+
// no Zitadel provisioning, no registry writes, no launch.
|
|
2400
|
+
if (options.dryRun) {
|
|
2401
|
+
printDevPlan(sessionName, appRoot, worktree, await allocatePorts(rawDevOutput, worktree));
|
|
2402
|
+
// Non-service realizations (local/remote) don't appear in the port
|
|
2403
|
+
// plan — list them so the dry run shows the whole picture.
|
|
2404
|
+
for (const [name, decl] of Object.entries(selectedMocks)) {
|
|
2405
|
+
const mode = externalMode(name, decl);
|
|
2406
|
+
if (mode === 'local') {
|
|
2407
|
+
console.log(` external ${name.padEnd(18)} local — docker compose (${decl.compose}) → localhost:${decl.port}`);
|
|
2408
|
+
}
|
|
2409
|
+
else if (mode === 'remote') {
|
|
2410
|
+
console.log(` external ${name.padEnd(18)} remote — actual service credentials (no local process)`);
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
// Auto-provision the app tenant's auth config (approved Zitadel
|
|
2416
|
+
// tenant-auth pattern: org per tenant → project per app → application
|
|
2417
|
+
// per service + credential secret). Best-effort: the dev loop still
|
|
2418
|
+
// works if Zitadel isn't seeded yet.
|
|
2419
|
+
try {
|
|
2420
|
+
const tenant = localTenant;
|
|
2421
|
+
const app = rawDevOutput.app ?? projectName;
|
|
2422
|
+
logInfo(`Provisioning auth config for tenant '${tenant}', app '${app}'…`);
|
|
2423
|
+
const services = Object.keys(rawDevOutput.services);
|
|
2424
|
+
// Mocks are stand-ins, not services of the app — no Zitadel
|
|
2425
|
+
// application for them (the registry still lists them for the Hub).
|
|
2426
|
+
const authServices = services.filter((name) => !name.startsWith('mock-'));
|
|
2427
|
+
// App-declared project roles (package.json → mesh.auth.roles): created
|
|
2428
|
+
// on the project and granted to the local `{app}-m2m` caller, so
|
|
2429
|
+
// role-scoped services (roles claim → scopes) accept its tokens.
|
|
2430
|
+
let authRoles = [];
|
|
2431
|
+
try {
|
|
2432
|
+
const appPkg = JSON.parse(fs.readFileSync(path.join(appRoot, 'package.json'), 'utf-8'));
|
|
2433
|
+
if (Array.isArray(appPkg?.mesh?.auth?.roles)) {
|
|
2434
|
+
authRoles = appPkg.mesh.auth.roles.filter((r) => typeof r === 'string');
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
catch {
|
|
2438
|
+
// no package.json / unparsable — roles stay empty
|
|
2439
|
+
}
|
|
2440
|
+
await ensureAppTenantAuth({ tenant, app, services: authServices, roles: authRoles });
|
|
2441
|
+
// The app's own Temporal namespace ({tenant}-{env}-{app}) — the same
|
|
2442
|
+
// name the cloud provisioner creates and the Hub's per-app workflow
|
|
2443
|
+
// view resolves.
|
|
2444
|
+
await ensureTemporalNamespace(localAppNamespace(tenant, app));
|
|
2445
|
+
}
|
|
2446
|
+
catch (err) {
|
|
2447
|
+
logWarn(`Auth auto-provisioning skipped: ${err instanceof Error ? err.message : err}` +
|
|
2448
|
+
` — if the local Zitadel predates seeding, run: mesh stop --destroy && mesh start`);
|
|
2449
|
+
}
|
|
2450
|
+
const devOutput = await allocatePorts(rawDevOutput, worktree);
|
|
2451
|
+
// Register AFTER allocatePorts: the registry must carry the ports the
|
|
2452
|
+
// services actually listen on, or StackPort/Hub (and anything reading
|
|
2453
|
+
// /mesh-platform/…/stacks/local) points at dead URLs.
|
|
2454
|
+
try {
|
|
2455
|
+
await registerLocalApp({
|
|
2456
|
+
tenant: localTenant,
|
|
2457
|
+
app: devOutput.app ?? projectName,
|
|
2458
|
+
services: Object.keys(devOutput.services),
|
|
2459
|
+
ports: Object.fromEntries(Object.entries(devOutput.services).map(([name, svc]) => [name, svc.port])),
|
|
2460
|
+
// ExternalService.link() parity: consuming services carry the
|
|
2461
|
+
// external names, so the Hub shows consumers + per-app uptime.
|
|
2462
|
+
links: Object.values(selectedMocks)
|
|
2463
|
+
.map((decl) => decl.external)
|
|
2464
|
+
.filter((n) => !!n),
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
catch (err) {
|
|
2468
|
+
logWarn(`Local registry registration skipped: ${err instanceof Error ? err.message : err}`);
|
|
2469
|
+
}
|
|
2470
|
+
// Wire realizations AFTER ports are final: realize each declaration
|
|
2471
|
+
// by mode, then seed the external's credential secret and register it
|
|
2472
|
+
// in the Hub — the app's resolveCredentials() path runs unchanged.
|
|
2473
|
+
// mock → mock-{name} service URL (Prism / mock process)
|
|
2474
|
+
// local → docker compose up -d --wait, endpoint from decl.port
|
|
2475
|
+
// remote → actual-service credentials seeded (inline, or pulled
|
|
2476
|
+
// from the app's tenant environment)
|
|
2477
|
+
const composeExternals = [];
|
|
2478
|
+
const externalProbeFiles = [];
|
|
2479
|
+
for (const [name, decl] of Object.entries(selectedMocks)) {
|
|
2480
|
+
const mode = externalMode(name, decl);
|
|
2481
|
+
try {
|
|
2482
|
+
let probeFile;
|
|
2483
|
+
if (mode === 'local') {
|
|
2484
|
+
logInfo(`Starting docker external '${name}' (${decl.compose})…`);
|
|
2485
|
+
// undefined = adopted a realization another checkout owns —
|
|
2486
|
+
// not recorded, so --kill leaves it running.
|
|
2487
|
+
const composeRef = await composeExternalUp(appRoot, sessionName, name, decl);
|
|
2488
|
+
if (composeRef)
|
|
2489
|
+
composeExternals.push(composeRef);
|
|
2490
|
+
probeFile = await seedLocalMock({
|
|
2491
|
+
tenant: localTenant,
|
|
2492
|
+
app: devOutput.app ?? projectName,
|
|
2493
|
+
name,
|
|
2494
|
+
decl,
|
|
2495
|
+
endpoint: { url: `http://localhost:${decl.port}`, host: 'localhost', port: decl.port },
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
else if (mode === 'remote') {
|
|
2499
|
+
probeFile = await seedLocalMock({
|
|
2500
|
+
tenant: localTenant,
|
|
2501
|
+
app: devOutput.app ?? projectName,
|
|
2502
|
+
name,
|
|
2503
|
+
decl,
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
else {
|
|
2507
|
+
const mockService = devOutput.services[`mock-${name}`];
|
|
2508
|
+
if (!mockService)
|
|
2509
|
+
continue;
|
|
2510
|
+
probeFile = await seedLocalMock({
|
|
2511
|
+
tenant: localTenant,
|
|
2512
|
+
app: devOutput.app ?? projectName,
|
|
2513
|
+
name,
|
|
2514
|
+
decl,
|
|
2515
|
+
endpoint: {
|
|
2516
|
+
url: `http://localhost:${mockService.port}`,
|
|
2517
|
+
host: 'localhost',
|
|
2518
|
+
port: mockService.port,
|
|
2519
|
+
},
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
if (probeFile)
|
|
2523
|
+
externalProbeFiles.push(probeFile);
|
|
2524
|
+
}
|
|
2525
|
+
catch (err) {
|
|
2526
|
+
// A remote external the developer didn't name costs only itself:
|
|
2527
|
+
// the bare `--externals` selects everything declared, and a
|
|
2528
|
+
// machine with no AWS identity (or no vendor key in the shell)
|
|
2529
|
+
// must still get its mock and local externals — that's the
|
|
2530
|
+
// zero-access local loop. Naming it keeps the failure fatal.
|
|
2531
|
+
if (mode === 'remote' && !requestedExplicitly.has(name) && err instanceof MeshCliError) {
|
|
2532
|
+
logWarn(`External '${name}': skipped — ${err.message}` +
|
|
2533
|
+
` (re-run with \`mesh dev --externals ${name}=remote\` to make this fatal).`);
|
|
2534
|
+
continue;
|
|
2535
|
+
}
|
|
2536
|
+
if (err instanceof MeshCliError)
|
|
2537
|
+
throw err; // bad decl / missing env var / failed remote pull — hard stop
|
|
2538
|
+
if (mode === 'local') {
|
|
2539
|
+
// The app's services depend on this external — starting them
|
|
2540
|
+
// against a dead dependency helps nobody.
|
|
2541
|
+
throw new MeshCliError(`Docker external '${name}' failed to start: ${err instanceof Error ? err.message : err}`, { remediation: { command: `docker compose -f ${decl.compose} up # debug it directly` } });
|
|
2542
|
+
}
|
|
2543
|
+
logWarn(`External '${name}' credential seeding failed: ${err instanceof Error ? err.message : err}`);
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
if (dockerRunner) {
|
|
2547
|
+
// C2: render the same DevOutput as a docker compose project.
|
|
2548
|
+
const composePath = writeDevCompose(sessionName, appRoot, devOutput.services);
|
|
2549
|
+
logInfo(`Docker runner: ${composePath}`);
|
|
2550
|
+
dockerDevUp(sessionName);
|
|
2551
|
+
saveSessionState(sessionName, {
|
|
2552
|
+
appRoot,
|
|
2553
|
+
stack: 'local',
|
|
2554
|
+
devOutput,
|
|
2555
|
+
startedAt: new Date().toISOString(),
|
|
2556
|
+
runner: 'docker',
|
|
2557
|
+
composeExternals,
|
|
2558
|
+
externalProbeFiles,
|
|
2559
|
+
});
|
|
2560
|
+
console.log('');
|
|
2561
|
+
logSuccess(`Dev session started (docker): ${sessionName}`);
|
|
2562
|
+
console.log('');
|
|
2563
|
+
console.log('Services:');
|
|
2564
|
+
for (const [name, service] of Object.entries(devOutput.services)) {
|
|
2565
|
+
console.log(` ${name.padEnd(20)} http://localhost:${service.port}`);
|
|
2566
|
+
}
|
|
2567
|
+
console.log('');
|
|
2568
|
+
console.log(`Status with: mesh dev --status --session '${sessionName}'`);
|
|
2569
|
+
console.log('Stop with: mesh dev --kill');
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
const finalDevOutput = await startServices(sessionName, appRoot, devOutput, !!options.headless, localAwsEnv(), // ministack wiring — the only "AWS" children need
|
|
2573
|
+
{ transport: 'vpn-direct' });
|
|
2574
|
+
saveSessionState(sessionName, {
|
|
2575
|
+
appRoot,
|
|
2576
|
+
stack: 'local',
|
|
2577
|
+
devOutput: finalDevOutput,
|
|
2578
|
+
startedAt: new Date().toISOString(),
|
|
2579
|
+
runner: 'tmux',
|
|
2580
|
+
composeExternals,
|
|
2581
|
+
externalProbeFiles,
|
|
2582
|
+
});
|
|
1133
2583
|
return;
|
|
1134
2584
|
}
|
|
1135
|
-
|
|
2585
|
+
if (options.mock || options.externals) {
|
|
2586
|
+
logWarn('--externals/--mock is local-mode only for now (tethered dev outputs do not carry external declarations yet — MESH-1885). Continuing without them.');
|
|
2587
|
+
}
|
|
2588
|
+
const stack = detectStack(appRoot, resolveStackOption(options));
|
|
1136
2589
|
logInfo(`Project: ${projectName}, Stack: ${stack}`);
|
|
1137
|
-
|
|
2590
|
+
// Detect if the user accidentally ran `mesh dev` from the monorepo root
|
|
2591
|
+
// instead of an app directory. The monorepo root has a Pulumi.yaml but
|
|
2592
|
+
// no services/apps to run. Standalone apps (create-app scaffolds) ARE
|
|
2593
|
+
// their own workspace root — an api/ or worker/ package right here means
|
|
2594
|
+
// this is an app, not a monorepo root.
|
|
2595
|
+
const monorepoRoot = findMonorepoRoot();
|
|
2596
|
+
const looksLikeStandaloneApp = ['api', 'worker', 'web'].some((dir) => fs.existsSync(path.join(appRoot, dir, 'package.json')));
|
|
2597
|
+
if (monorepoRoot && path.resolve(appRoot) === path.resolve(monorepoRoot) && !looksLikeStandaloneApp) {
|
|
2598
|
+
logError(`mesh dev should be run from an app directory, not the monorepo root.\n`);
|
|
2599
|
+
logInfo('Try:');
|
|
2600
|
+
logInfo(' cd apps/hub && mesh dev');
|
|
2601
|
+
logInfo('');
|
|
2602
|
+
logInfo('Or specify the app path:');
|
|
2603
|
+
logInfo(' mesh dev --app apps/hub');
|
|
2604
|
+
logInfo('');
|
|
2605
|
+
logInfo('List available apps:');
|
|
2606
|
+
logInfo(' mesh dev list');
|
|
2607
|
+
process.exit(1);
|
|
2608
|
+
}
|
|
2609
|
+
// ── Prerequisite checks ──────────────────────────────────────────
|
|
2610
|
+
// Check all prerequisites upfront so the user gets one clear list of
|
|
2611
|
+
// what needs fixing, rather than failing one step at a time.
|
|
2612
|
+
const issues = [];
|
|
2613
|
+
const profileHint = options.profile ?? process.env.AWS_PROFILE ?? 'mesh-dev';
|
|
2614
|
+
// AWS role + credential resolution.
|
|
2615
|
+
// The stack can declare two role ARNs:
|
|
2616
|
+
// - `mesh:deployerRole` — the default per-tenant deployer
|
|
2617
|
+
// - `mesh:adminDeployerRole` — optional admin variant assumed when the
|
|
2618
|
+
// caller's Zitadel JWT carries an admin-tier role (e.g.
|
|
2619
|
+
// `mesh.platform:admin`). selectRoleForCaller picks between them.
|
|
2620
|
+
// tmux + credential validation/diagnosis is handled below by the shared
|
|
2621
|
+
// doctor preflight so `mesh dev` and `mesh dev doctor` agree.
|
|
2622
|
+
const platformContext = derivePlatformContext(appRoot, stack);
|
|
2623
|
+
const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
|
|
2624
|
+
const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
|
|
2625
|
+
let deployerRole = defaultDeployerRole;
|
|
2626
|
+
if (defaultDeployerRole) {
|
|
2627
|
+
const meshCreds = platformContext ? readCredentials(platformContext) : null;
|
|
2628
|
+
const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;
|
|
2629
|
+
deployerRole = selectRoleForCaller(idToken, {
|
|
2630
|
+
defaultRole: defaultDeployerRole,
|
|
2631
|
+
adminRole: adminDeployerRole ?? undefined,
|
|
2632
|
+
});
|
|
2633
|
+
if (adminDeployerRole && deployerRole === adminDeployerRole) {
|
|
2634
|
+
logInfo(`Caller has admin Zitadel role — assuming ${deployerRole.split('/').pop()} (admin variant)`);
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
let awsEnv;
|
|
2638
|
+
let credMethod = 'ambient';
|
|
2639
|
+
// Method fed to the doctor context: the resolved method on success, or
|
|
2640
|
+
// `null` on a failed resolution so the `creds` check probes for the exact
|
|
2641
|
+
// reason (no-session / expired / assume-denied / stale-env-override).
|
|
2642
|
+
let credMethodForDoctor = 'ambient';
|
|
2643
|
+
if (deployerRole) {
|
|
2644
|
+
const resolved = await resolveAwsCredentials(deployerRole, appRoot, stack);
|
|
2645
|
+
if (!resolved) {
|
|
2646
|
+
awsEnv = {};
|
|
2647
|
+
credMethodForDoctor = null;
|
|
2648
|
+
}
|
|
2649
|
+
else {
|
|
2650
|
+
awsEnv = resolved.env;
|
|
2651
|
+
credMethod = resolved.method;
|
|
2652
|
+
credMethodForDoctor = resolved.method;
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
else {
|
|
2656
|
+
awsEnv = getAwsEnvVars();
|
|
2657
|
+
credMethodForDoctor = 'ambient';
|
|
2658
|
+
}
|
|
2659
|
+
// 3. VPN or SSM fallback
|
|
2660
|
+
const vpnConnected = await isVpnConnected();
|
|
2661
|
+
// `let`: the auto-selected `vpn-direct` may be downgraded to `ssm` below by a
|
|
2662
|
+
// reachability probe once devOutput's Temporal endpoint is known (MESH-2016).
|
|
2663
|
+
let transport = resolveTransport(options.transport, {
|
|
2664
|
+
vpnConnected,
|
|
2665
|
+
hasSsmPlugin: hasSessionManagerPlugin(),
|
|
2666
|
+
tailscaleAvailable: tailscaleAvailable(),
|
|
2667
|
+
});
|
|
2668
|
+
if (options.transport === 'tailscale' && !tailscaleAvailable()) {
|
|
2669
|
+
issues.push(' \u2718 --transport=tailscale but the tailscaled binary is missing.\n' +
|
|
2670
|
+
' Fix: brew install tailscale');
|
|
2671
|
+
}
|
|
2672
|
+
if (transport === 'ssm') {
|
|
2673
|
+
if (hasSessionManagerPlugin()) {
|
|
2674
|
+
if (!vpnConnected) {
|
|
2675
|
+
logWarn('VPN not connected \u2014 will use SSM port-forwarding tunnels for VPC resources.');
|
|
2676
|
+
}
|
|
2677
|
+
logInfo(' Tunnels use the 2XXXX port range (e.g., PostgreSQL on 25432, Temporal on 27233).');
|
|
2678
|
+
logInfo(' Limitations: no kubectl exec into pods, no internal ingress/ALB access.');
|
|
2679
|
+
logInfo(' For full VPC access, connect VPN: mesh vpn connect');
|
|
2680
|
+
}
|
|
2681
|
+
else {
|
|
2682
|
+
issues.push(' \u2718 VPN is not connected and session-manager-plugin is not installed.\n' +
|
|
2683
|
+
' Option 1 (VPN): mesh vpn connect\n' +
|
|
2684
|
+
' Option 2 (SSM): brew install --cask session-manager-plugin\n' +
|
|
2685
|
+
' Option 3 (Tailscale): mesh dev --transport=tailscale');
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
else if (transport === 'tailscale') {
|
|
2689
|
+
// The daemon bring-up + join happen in startServices; announce success there,
|
|
2690
|
+
// not here — otherwise a fallback to SSM still reads as "using Tailscale"
|
|
2691
|
+
// (MESH-2098). A pending node registration prints its own URL during the join.
|
|
2692
|
+
logInfo('Bringing up userspace-Tailscale tunnels for VPC resources (shared per tenant)…');
|
|
2693
|
+
}
|
|
2694
|
+
// Shared doctor preflight: the same checks as `mesh dev doctor`, so launch
|
|
2695
|
+
// and on-demand diagnosis name failures identically. Errors block launch;
|
|
2696
|
+
// warnings (config/worktree) print and proceed.
|
|
2697
|
+
const preflightCtx = {
|
|
2698
|
+
appRoot,
|
|
2699
|
+
stack,
|
|
2700
|
+
sessionName,
|
|
2701
|
+
deployerRole,
|
|
2702
|
+
platformContext,
|
|
2703
|
+
credMethod: credMethodForDoctor,
|
|
2704
|
+
sessionState: null,
|
|
2705
|
+
};
|
|
2706
|
+
const preflight = await runChecks(preflightCtx, 'preflight', ALL_CHECKS);
|
|
2707
|
+
const preflightReport = renderHuman(preflight);
|
|
2708
|
+
if (preflightReport)
|
|
2709
|
+
console.log(preflightReport);
|
|
2710
|
+
const preflightStatus = aggregateStatus(preflight.map((r) => r.result));
|
|
2711
|
+
if (preflightStatus === 'error' || issues.length > 0) {
|
|
2712
|
+
logError('\nPrerequisites not met. Fix the above before running mesh dev, then retry: mesh dev');
|
|
2713
|
+
for (const issue of issues)
|
|
2714
|
+
console.log(issue);
|
|
2715
|
+
process.exit(1);
|
|
2716
|
+
}
|
|
2717
|
+
// ── Credentials resolved, inject into environment ──────────────
|
|
2718
|
+
// Ensure AWS_REGION is set from the Pulumi stack config. Without this, AWS
|
|
2719
|
+
// SDK clients (SSM, Secrets Manager) would default to us-east-1.
|
|
2720
|
+
const awsRegion = readStackConfig(appRoot, stack, 'aws:region');
|
|
2721
|
+
if (awsRegion && !awsEnv.AWS_REGION) {
|
|
2722
|
+
awsEnv.AWS_REGION = awsRegion;
|
|
2723
|
+
}
|
|
2724
|
+
Object.assign(process.env, awsEnv);
|
|
2725
|
+
// Build the env that long-lived child services (ui, api, ai-assistant) and
|
|
2726
|
+
// SSM tunnels will see. When credentials came from AWS SSO, prefer passing
|
|
2727
|
+
// AWS_PROFILE + AWS_REGION so the AWS SDK v3 default provider chain can
|
|
2728
|
+
// auto-refresh from the SSO cache (~8h session) as short-lived STS tokens
|
|
2729
|
+
// expire. Without this, the hub API starts throwing ExpiredTokenException
|
|
2730
|
+
// about an hour in and tenants disappear from the UI. For non-SSO paths
|
|
2731
|
+
// (Zitadel JWT, CI/CD env keys) there is no profile to fall back to, so
|
|
2732
|
+
// pass the STS keys directly and accept that the user must restart
|
|
2733
|
+
// `mesh dev` when they expire.
|
|
2734
|
+
// For the Zitadel deployer path, hand children a self-refreshing
|
|
2735
|
+
// credential_process profile (context + role from the resolution above,
|
|
2736
|
+
// written into this session's scratch dir) instead of a frozen STS triple.
|
|
2737
|
+
const zitadelOpts = credMethod === 'zitadel' && deployerRole
|
|
2738
|
+
? (() => {
|
|
2739
|
+
const context = derivePlatformContext(appRoot, stack);
|
|
2740
|
+
return context
|
|
2741
|
+
? {
|
|
2742
|
+
context,
|
|
2743
|
+
roleArn: deployerRole,
|
|
2744
|
+
sessionScratchDir: getSessionEnvDir(sessionName),
|
|
2745
|
+
meshBin: resolveStableMeshBin(process.argv[1]),
|
|
2746
|
+
}
|
|
2747
|
+
: undefined;
|
|
2748
|
+
})()
|
|
2749
|
+
: undefined;
|
|
2750
|
+
const childAwsEnv = buildChildAwsEnv(awsEnv, credMethod, profileHint, zitadelOpts);
|
|
2751
|
+
let rawDevOutput;
|
|
2752
|
+
try {
|
|
2753
|
+
rawDevOutput = getDevOutput(appRoot, stack, awsEnv);
|
|
2754
|
+
}
|
|
2755
|
+
catch (err) {
|
|
2756
|
+
if (err instanceof MissingStackOutputError) {
|
|
2757
|
+
// Only the launch path can conclude "nothing to run" — print the
|
|
2758
|
+
// forward-chaining first-run fix and exit cleanly (MESH-2097). The
|
|
2759
|
+
// suggested commands carry --stack because the bare forms error right
|
|
2760
|
+
// after `stack init` (matching stack.ts's next-step output).
|
|
2761
|
+
logError(`No Pulumi stack output for '${stack}' — mesh dev has nothing to run.`);
|
|
2762
|
+
logInfo('If this is a new app, initialize and materialize it first:');
|
|
2763
|
+
logInfo(' mesh stack init # create your personal dev stack');
|
|
2764
|
+
logInfo(` mesh deploy up --stack ${stack} --yes # produce the app output mesh dev reads`);
|
|
2765
|
+
logInfo(`Then: mesh dev --stage ${stack} (the stack must export an \`app\` or \`dev\` output.)`);
|
|
2766
|
+
logInfo('No deployed stack yet? Run against the local platform: mesh dev --local (needs `mesh start`).');
|
|
2767
|
+
// Surface the underlying read error so an auth/network/JSON failure isn't
|
|
2768
|
+
// silently misdiagnosed as "fresh app" with zero forensics.
|
|
2769
|
+
const cause = err.cause?.message;
|
|
2770
|
+
if (cause)
|
|
2771
|
+
logInfo(`(underlying stack-read error: ${cause})`);
|
|
2772
|
+
process.exit(1);
|
|
2773
|
+
}
|
|
2774
|
+
throw err;
|
|
2775
|
+
}
|
|
1138
2776
|
// --status — use persisted state (has allocated ports), fall back to raw
|
|
1139
2777
|
if (options.status) {
|
|
1140
2778
|
const state = loadSessionState(sessionName);
|
|
1141
|
-
showStatus(sessionName, state?.devOutput ?? rawDevOutput, !!options.json);
|
|
2779
|
+
await showStatus(sessionName, state?.devOutput ?? rawDevOutput, !!options.json);
|
|
2780
|
+
return;
|
|
2781
|
+
}
|
|
2782
|
+
// Allocate dynamic ports (avoid collisions with other running apps).
|
|
2783
|
+
// devOutput is passed by value and may be rewritten inside startServices
|
|
2784
|
+
// if SSM tunnels are used (to point tunnels at localhost:2XXXX).
|
|
2785
|
+
const devOutput = await allocatePorts(rawDevOutput, worktree);
|
|
2786
|
+
// Reachability gate (MESH-2016): the transport chosen above used a PRESENCE
|
|
2787
|
+
// check (isVpnConnected → "is any Tailscale/VPN up"), which is tailnet-blind —
|
|
2788
|
+
// Tailscale-for-SSH looks connected but doesn't route the mesh VPC. Now that
|
|
2789
|
+
// devOutput is known, probe the real Temporal VPC endpoint and downgrade an
|
|
2790
|
+
// auto-selected vpn-direct to SSM when it's unreachable.
|
|
2791
|
+
transport = await gateVpnDirectReachability(transport, devOutput);
|
|
2792
|
+
// --dry-run: show the resolved worktree/session/port plan, launch nothing.
|
|
2793
|
+
if (options.dryRun) {
|
|
2794
|
+
printDevPlan(sessionName, appRoot, worktree, devOutput);
|
|
1142
2795
|
return;
|
|
1143
2796
|
}
|
|
1144
|
-
//
|
|
1145
|
-
|
|
2797
|
+
// Build the tunnel plan for the chosen transport. For Tailscale, resolve the
|
|
2798
|
+
// shared per-tenant daemon context (tenant = hub platform name, since the
|
|
2799
|
+
// bastion is a hub resource); reuse the daemon's SOCKS port if it's already up.
|
|
2800
|
+
let tunnelPlan = { transport };
|
|
2801
|
+
if (transport === 'tailscale') {
|
|
2802
|
+
const tsTenant = devOutput.platform?.name ?? 'mesh';
|
|
2803
|
+
const tsEnv = devOutput.platform?.env ?? 'dev';
|
|
2804
|
+
const tsContext = derivePlatformContext(appRoot, stack) ?? `${tsTenant}.${tsEnv}`;
|
|
2805
|
+
const loginServer = deriveLoginServer(tsContext);
|
|
2806
|
+
if (!loginServer) {
|
|
2807
|
+
// Implicit selection (auto/unset) must degrade to the legacy transport,
|
|
2808
|
+
// not dead-end. Only an explicit --transport=tailscale may hard-error.
|
|
2809
|
+
const msg = `No login config for context '${tsContext}' — run: mesh login ${tsContext}`;
|
|
2810
|
+
if (options.transport === 'tailscale') {
|
|
2811
|
+
logError(msg);
|
|
2812
|
+
process.exit(1);
|
|
2813
|
+
}
|
|
2814
|
+
const legacy = vpnConnected
|
|
2815
|
+
? 'vpn-direct'
|
|
2816
|
+
: hasSessionManagerPlugin()
|
|
2817
|
+
? 'ssm'
|
|
2818
|
+
: 'vpn-direct';
|
|
2819
|
+
// Reachability-gate this legacy fallback too (MESH-2016): otherwise a
|
|
2820
|
+
// machine with tailscaled (→ auto picks tailscale) but no mesh login
|
|
2821
|
+
// config, with any tailnet up (vpnConnected), would land on an UNPROBED
|
|
2822
|
+
// vpn-direct and reproduce the exact ECONNREFUSED this feature fixes.
|
|
2823
|
+
const gatedLegacy = await gateVpnDirectReachability(legacy, devOutput);
|
|
2824
|
+
logWarn(`${msg} — falling back to ${gatedLegacy} transport.`);
|
|
2825
|
+
tunnelPlan = { transport: gatedLegacy };
|
|
2826
|
+
}
|
|
2827
|
+
else {
|
|
2828
|
+
const prior = readTailscaleState(tsTenant);
|
|
2829
|
+
const daemonUp = tailscaleDaemonState(tsTenant).backendState !== 'Down';
|
|
2830
|
+
// Reuse the running daemon's real SOCKS port (daemon.json survives
|
|
2831
|
+
// `down`); never mint a fresh one the daemon isn't listening on.
|
|
2832
|
+
const socksPort = daemonUp
|
|
2833
|
+
? (readTailscaleDaemonMeta(tsTenant)?.socksPort ?? prior?.socksPort ?? (await findFreePort()))
|
|
2834
|
+
: await findFreePort();
|
|
2835
|
+
// Zero-touch VPN join (MESH-2099): try to mint a single-use pre-auth key
|
|
2836
|
+
// from the platform's VpnJoinBroker so `tailscale up --authkey` joins
|
|
2837
|
+
// silently. Any failure leaves preAuthKey undefined → the interactive
|
|
2838
|
+
// browser-relay join (MESH-2098) is used instead.
|
|
2839
|
+
let preAuthKey;
|
|
2840
|
+
const brokerUrl = resolveVpnJoinBroker(tsContext);
|
|
2841
|
+
if (brokerUrl) {
|
|
2842
|
+
const minted = await mintPreAuthKey(tsContext, brokerUrl, { getValidToken });
|
|
2843
|
+
if (minted?.authKey) {
|
|
2844
|
+
preAuthKey = minted.authKey;
|
|
2845
|
+
}
|
|
2846
|
+
else {
|
|
2847
|
+
logInfo('zero-touch VPN join unavailable, falling back to browser registration');
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
tunnelPlan = {
|
|
2851
|
+
transport,
|
|
2852
|
+
explicit: options.transport === 'tailscale',
|
|
2853
|
+
tailscale: {
|
|
2854
|
+
tenant: tsTenant,
|
|
2855
|
+
env: tsEnv,
|
|
2856
|
+
region: awsRegion ?? 'us-east-2',
|
|
2857
|
+
loginServer,
|
|
2858
|
+
socksPort,
|
|
2859
|
+
preAuthKey,
|
|
2860
|
+
},
|
|
2861
|
+
};
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
// Relaunch safety (MESH-2017): a relaunch recreates the worker onto current
|
|
2865
|
+
// code. If the workflow source changed since this session started, warn before
|
|
2866
|
+
// it replays in-flight conversations against it (require --force). Checked
|
|
2867
|
+
// BEFORE startServices, which kills+recreates the session.
|
|
2868
|
+
if (workflowChangeWouldStrand(loadSessionState(sessionName), appRoot, !!options.force)) {
|
|
2869
|
+
process.exit(1);
|
|
2870
|
+
}
|
|
2871
|
+
// Start services. startServices handles tmux session creation, tunnel setup
|
|
2872
|
+
// (SSM or Tailscale, per tunnelPlan), and service windows. It returns the
|
|
2873
|
+
// final devOutput with tunnels rewritten to localhost ports.
|
|
2874
|
+
const finalDevOutput = await startServices(sessionName, appRoot, devOutput, !!options.headless, childAwsEnv, tunnelPlan, worktree.worktreeRoot, worktree.taskQueueSuffix);
|
|
1146
2875
|
// Persist session state so --status and restart see allocated ports
|
|
2876
|
+
// and the final tunnel endpoints (localhost for SSM, VPC for VPN).
|
|
1147
2877
|
saveSessionState(sessionName, {
|
|
1148
2878
|
appRoot,
|
|
1149
2879
|
stack,
|
|
1150
|
-
devOutput,
|
|
2880
|
+
devOutput: finalDevOutput,
|
|
1151
2881
|
startedAt: new Date().toISOString(),
|
|
2882
|
+
workflowFingerprint: fingerprintWorkflowSource(appRoot, finalDevOutput.services),
|
|
1152
2883
|
});
|
|
1153
|
-
// Start services
|
|
1154
|
-
await startServices(sessionName, appRoot, devOutput, !!options.headless);
|
|
1155
2884
|
});
|
|
1156
2885
|
// Subcommand: logs
|
|
1157
2886
|
dev
|
|
1158
|
-
.command(
|
|
1159
|
-
.description(
|
|
1160
|
-
.option(
|
|
2887
|
+
.command('logs <service>')
|
|
2888
|
+
.description('Show logs for a service')
|
|
2889
|
+
.option('--tail <lines>', 'Number of lines', '100')
|
|
1161
2890
|
.action((service, opts) => {
|
|
1162
2891
|
// --session is on the parent dev command
|
|
1163
2892
|
const sessionName = dev.opts().session;
|
|
2893
|
+
const resolvedSession = sessionName ?? `${getProjectName(findAppRoot())}-dev`;
|
|
2894
|
+
if (loadSessionState(resolvedSession)?.runner === 'docker') {
|
|
2895
|
+
console.log(dockerDevLogs(resolvedSession, service, parseInt(opts.tail, 10)));
|
|
2896
|
+
return;
|
|
2897
|
+
}
|
|
1164
2898
|
if (sessionName) {
|
|
1165
2899
|
showLogs(sessionName, service, parseInt(opts.tail, 10));
|
|
1166
2900
|
}
|
|
@@ -1172,31 +2906,154 @@ export function registerDevCommand(program) {
|
|
|
1172
2906
|
});
|
|
1173
2907
|
// Subcommand: restart
|
|
1174
2908
|
dev
|
|
1175
|
-
.command(
|
|
1176
|
-
.description(
|
|
1177
|
-
.option(
|
|
1178
|
-
.
|
|
1179
|
-
|
|
1180
|
-
|
|
2909
|
+
.command('restart <service>')
|
|
2910
|
+
.description('Restart a service')
|
|
2911
|
+
.option('--stack <stack>', 'Pulumi stack name')
|
|
2912
|
+
.addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
|
|
2913
|
+
.option('--refresh-env', "Regenerate this service's env from current stack/SSM outputs before restarting (for config/deploy changes)")
|
|
2914
|
+
.option('--force', 'Restart the worker even if workflow code changed since launch (may strand in-flight conversations)')
|
|
2915
|
+
.action(async (service, opts) => {
|
|
2916
|
+
// Hoisted so the catch can name the right login context (not a hardcoded
|
|
2917
|
+
// one) even when a throw happens before cred resolution. MESH-2089.
|
|
2918
|
+
let ctx = null;
|
|
2919
|
+
try {
|
|
2920
|
+
// --session is on the parent dev command
|
|
2921
|
+
const parentSession = dev.opts().session;
|
|
2922
|
+
const parentProfile = dev.opts().profile;
|
|
2923
|
+
if (parentProfile) {
|
|
2924
|
+
applyAwsProfileOverride(parentProfile);
|
|
2925
|
+
}
|
|
2926
|
+
const appRoot = findAppRoot();
|
|
2927
|
+
const projectName = getProjectName(appRoot);
|
|
2928
|
+
const sessionName = parentSession ?? `${projectName}-dev`;
|
|
2929
|
+
const state = loadSessionState(sessionName);
|
|
2930
|
+
if (state?.runner === 'docker') {
|
|
2931
|
+
dockerDevRestart(sessionName, service);
|
|
2932
|
+
logSuccess(`Restarted: ${service}`);
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
// Relaunch safety (MESH-2017): restarting the WORKER onto changed workflow
|
|
2936
|
+
// code strands in-flight conversations; restarting other services (ui/api)
|
|
2937
|
+
// doesn't. Gate the worker restart on the workflow-source fingerprint.
|
|
2938
|
+
if (/worker/i.test(service) && workflowChangeWouldStrand(state, appRoot, !!opts.force)) {
|
|
2939
|
+
process.exit(1);
|
|
2940
|
+
}
|
|
2941
|
+
const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));
|
|
2942
|
+
ctx = derivePlatformContext(appRoot, stack);
|
|
2943
|
+
// Resolve credentials BEFORE reading any Pulumi/SSM/kubeconfig output
|
|
2944
|
+
// (MESH-2090). With ambient AWS_* cleared (the documented restart
|
|
2945
|
+
// hygiene), the parent-side AWS calls the restart path makes — the
|
|
2946
|
+
// getDevOutput fallback below, and under --refresh-env the
|
|
2947
|
+
// resolveTemporalAuthVars/ensureKubeconfig calls — read the default SDK
|
|
2948
|
+
// chain (process.env). Resolving first lets us thread the resolved creds
|
|
2949
|
+
// into this process's env, and lets the precise login preflight fire
|
|
2950
|
+
// before the devOutput fallback (which was the MESH-2089 crash site).
|
|
2951
|
+
const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
|
|
2952
|
+
const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
|
|
2953
|
+
let deployerRole = defaultDeployerRole;
|
|
2954
|
+
if (defaultDeployerRole) {
|
|
2955
|
+
const meshCreds = ctx ? readCredentials(ctx) : null;
|
|
2956
|
+
const idToken = meshCreds && new Date(meshCreds.expiresAt) > new Date() ? meshCreds.idToken : null;
|
|
2957
|
+
deployerRole = selectRoleForCaller(idToken, {
|
|
2958
|
+
defaultRole: defaultDeployerRole,
|
|
2959
|
+
adminRole: adminDeployerRole ?? undefined,
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2962
|
+
const resolved = deployerRole ? await resolveAwsCredentials(deployerRole, appRoot, stack) : null;
|
|
2963
|
+
// Login preflight (MESH-2089): if a deployer role is configured but creds
|
|
2964
|
+
// couldn't be resolved AND there are no ambient AWS creds to fall back on,
|
|
2965
|
+
// the restart would proceed with no credentials and fail downstream. Name
|
|
2966
|
+
// the real problem (e.g. "run mesh login <ctx> --device") instead.
|
|
2967
|
+
if (deployerRole && !resolved && !process.env.AWS_ACCESS_KEY_ID) {
|
|
2968
|
+
const pf = ctx ? credProbeToPreflightError(await probeCredentials(ctx, deployerRole), ctx) : null;
|
|
2969
|
+
logError(pf?.message ??
|
|
2970
|
+
`Couldn't resolve AWS credentials to restart '${service}'. Run: mesh login ${ctx ?? 'mesh.dev'} --device`);
|
|
2971
|
+
process.exit(1);
|
|
2972
|
+
}
|
|
2973
|
+
const baseAwsEnv = resolved?.env ?? getAwsEnvVars();
|
|
2974
|
+
// Ensure AWS_REGION is set from stack config so parent-side SDK clients
|
|
2975
|
+
// (SSM, EKS) don't default to us-east-1. Mirrors the launch path.
|
|
2976
|
+
const awsRegion = readStackConfig(appRoot, stack, 'aws:region');
|
|
2977
|
+
if (awsRegion && !baseAwsEnv.AWS_REGION) {
|
|
2978
|
+
baseAwsEnv.AWS_REGION = awsRegion;
|
|
2979
|
+
}
|
|
2980
|
+
// Inject the resolved creds into THIS process's env so the parent-side
|
|
2981
|
+
// AWS calls below run credentialed even when ambient AWS_* was cleared
|
|
2982
|
+
// (MESH-2090). Mirrors the launch path's inject (see startServices).
|
|
2983
|
+
Object.assign(process.env, baseAwsEnv);
|
|
2984
|
+
const profileHint = parentProfile ?? process.env.AWS_PROFILE ?? 'mesh-dev';
|
|
2985
|
+
const zitadelOpts = resolved?.method === 'zitadel' && deployerRole && ctx
|
|
2986
|
+
? {
|
|
2987
|
+
context: ctx,
|
|
2988
|
+
roleArn: deployerRole,
|
|
2989
|
+
sessionScratchDir: getSessionEnvDir(sessionName),
|
|
2990
|
+
meshBin: resolveStableMeshBin(process.argv[1]),
|
|
2991
|
+
}
|
|
2992
|
+
: undefined;
|
|
2993
|
+
const awsEnv = buildChildAwsEnv(baseAwsEnv, resolved?.method ?? 'ambient', profileHint, zitadelOpts);
|
|
2994
|
+
// Use persisted state (has allocated ports), fall back to raw Pulumi
|
|
2995
|
+
// output — now credentialed via the resolved env (MESH-2090).
|
|
2996
|
+
const devOutput = state?.devOutput ?? getDevOutput(appRoot, stack, baseAwsEnv);
|
|
2997
|
+
await restartService(sessionName, service, appRoot, devOutput, awsEnv, {
|
|
2998
|
+
refreshEnv: !!opts.refreshEnv,
|
|
2999
|
+
stack,
|
|
3000
|
+
sessionState: state,
|
|
3001
|
+
});
|
|
3002
|
+
// Re-baseline the workflow fingerprint after a worker restart (MESH-2017):
|
|
3003
|
+
// the worker now runs the current code, so a subsequent unchanged restart
|
|
3004
|
+
// must NOT nag — otherwise a good-citizen `--force` earns a session of
|
|
3005
|
+
// false warnings, training the reflex this guard exists to prevent.
|
|
3006
|
+
if (/worker/i.test(service) && state) {
|
|
3007
|
+
saveSessionState(sessionName, {
|
|
3008
|
+
...state,
|
|
3009
|
+
workflowFingerprint: fingerprintWorkflowSource(appRoot, state.devOutput?.services),
|
|
3010
|
+
});
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
catch (err) {
|
|
3014
|
+
// Never crash uncaught (MESH-2089): a cred/kubeconfig/tmux failure should
|
|
3015
|
+
// name itself, not dump a bare Node stack. The underlying error is logged
|
|
3016
|
+
// so the exact cause is pinned if this recurs.
|
|
3017
|
+
logError(`Failed to restart '${service}': ${err?.message ?? String(err)}`);
|
|
3018
|
+
logInfo(`If this is a credential/kubeconfig issue, run \`mesh login ${ctx ?? '<your platform context>'} --device\`, ` +
|
|
3019
|
+
'or `mesh dev` to relaunch the session cleanly.');
|
|
3020
|
+
process.exit(1);
|
|
3021
|
+
}
|
|
3022
|
+
});
|
|
3023
|
+
// Subcommand: doctor — diagnose the dev session
|
|
3024
|
+
dev
|
|
3025
|
+
.command('doctor')
|
|
3026
|
+
.description('Diagnose the dev session (creds, tunnels, config, ports, worktree, Temporal) — names the fix')
|
|
3027
|
+
.option('--stack <stack>', 'Pulumi stack name')
|
|
3028
|
+
.addOption(new Option('--stage <stack>', 'Deprecated alias for --stack').hideHelp())
|
|
3029
|
+
.option('--json', 'Machine-readable output')
|
|
3030
|
+
.action(async (opts) => {
|
|
3031
|
+
const parentProfile = dev.opts().profile;
|
|
3032
|
+
if (parentProfile) {
|
|
3033
|
+
applyAwsProfileOverride(parentProfile);
|
|
3034
|
+
}
|
|
1181
3035
|
const appRoot = findAppRoot();
|
|
1182
3036
|
const projectName = getProjectName(appRoot);
|
|
1183
|
-
const sessionName =
|
|
1184
|
-
// Use persisted state (has allocated ports), fall back to raw Pulumi output
|
|
3037
|
+
const sessionName = dev.opts().session ?? `${projectName}-dev`;
|
|
1185
3038
|
const state = loadSessionState(sessionName);
|
|
1186
|
-
const
|
|
1187
|
-
|
|
1188
|
-
|
|
3039
|
+
const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));
|
|
3040
|
+
// `--json` may land on either the subcommand or the parent `dev`
|
|
3041
|
+
// command (both declare it), so honor both.
|
|
3042
|
+
const asJson = !!opts.json || !!dev.opts().json;
|
|
3043
|
+
const ctx = buildDoctorContext(appRoot, stack, sessionName);
|
|
3044
|
+
const status = await runDoctor(ctx, { json: asJson });
|
|
3045
|
+
process.exit(status === 'error' ? 1 : 0);
|
|
1189
3046
|
});
|
|
1190
3047
|
// Subcommand: list — discover apps in the monorepo
|
|
1191
3048
|
dev
|
|
1192
|
-
.command(
|
|
1193
|
-
.description(
|
|
3049
|
+
.command('list')
|
|
3050
|
+
.description('List Pulumi apps in the monorepo')
|
|
1194
3051
|
.action(() => {
|
|
1195
3052
|
// --json is on the parent dev command
|
|
1196
3053
|
const asJson = dev.opts().json;
|
|
1197
3054
|
const mono = findMonorepoRoot();
|
|
1198
3055
|
if (!mono) {
|
|
1199
|
-
logError(
|
|
3056
|
+
logError('Could not find monorepo root (pnpm-workspace.yaml or .git)');
|
|
1200
3057
|
process.exit(1);
|
|
1201
3058
|
}
|
|
1202
3059
|
const apps = discoverApps(mono);
|
|
@@ -1214,35 +3071,105 @@ export function registerDevCommand(program) {
|
|
|
1214
3071
|
}
|
|
1215
3072
|
else {
|
|
1216
3073
|
if (apps.length === 0) {
|
|
1217
|
-
logInfo(
|
|
3074
|
+
logInfo('No Pulumi apps found.');
|
|
1218
3075
|
return;
|
|
1219
3076
|
}
|
|
1220
|
-
console.log(
|
|
1221
|
-
console.log(
|
|
3077
|
+
console.log('Apps in monorepo:');
|
|
3078
|
+
console.log('');
|
|
1222
3079
|
for (const app of apps) {
|
|
1223
3080
|
const sessionName = `${app.name}-dev`;
|
|
1224
3081
|
const running = sessionExists(sessionName);
|
|
1225
|
-
const icon = running ?
|
|
1226
|
-
const stacks = app.stacks.length > 0
|
|
1227
|
-
? ` (${app.stacks.join(", ")})`
|
|
1228
|
-
: "";
|
|
3082
|
+
const icon = running ? '●' : '○';
|
|
3083
|
+
const stacks = app.stacks.length > 0 ? ` (${app.stacks.join(', ')})` : '';
|
|
1229
3084
|
console.log(` ${icon} ${app.name.padEnd(24)} ${app.tenant.padEnd(12)} ${app.relativePath}${stacks}`);
|
|
1230
3085
|
}
|
|
1231
|
-
console.log(
|
|
3086
|
+
console.log('');
|
|
1232
3087
|
}
|
|
1233
3088
|
});
|
|
1234
|
-
//
|
|
3089
|
+
// Subcommand: test-user — retrieve Temporal test user credentials
|
|
1235
3090
|
dev
|
|
1236
|
-
.command(
|
|
1237
|
-
.description(
|
|
1238
|
-
.option(
|
|
1239
|
-
.option(
|
|
1240
|
-
.
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
3091
|
+
.command('test-user [name]')
|
|
3092
|
+
.description('Get Temporal test user credentials (from Pulumi-managed test users)')
|
|
3093
|
+
.option('--tenant <tenant>', 'Tenant name', 'mesh')
|
|
3094
|
+
.option('--env <env>', 'Environment', 'dev')
|
|
3095
|
+
.option('--region <region>', 'AWS region', 'us-east-2')
|
|
3096
|
+
.action(async (name, opts) => {
|
|
3097
|
+
const { SSMClient, GetParameterCommand, GetParametersByPathCommand } = await import('@aws-sdk/client-ssm');
|
|
3098
|
+
const ssm = new SSMClient({ region: opts.region });
|
|
3099
|
+
const basePath = `/mesh-platform/${opts.tenant}/${opts.env}/temporal/test-users`;
|
|
3100
|
+
if (!name) {
|
|
3101
|
+
// List all test users
|
|
3102
|
+
try {
|
|
3103
|
+
const resp = await ssm.send(new GetParametersByPathCommand({
|
|
3104
|
+
Path: basePath,
|
|
3105
|
+
Recursive: true,
|
|
3106
|
+
WithDecryption: true,
|
|
3107
|
+
}));
|
|
3108
|
+
if (!resp.Parameters || resp.Parameters.length === 0) {
|
|
3109
|
+
logWarn(`No test users found at ${basePath}`);
|
|
3110
|
+
logInfo('Test users are defined in Pulumi config under mesh:temporal.authorization.testUsers');
|
|
3111
|
+
logInfo("They're only available on dev stacks.");
|
|
3112
|
+
return;
|
|
3113
|
+
}
|
|
3114
|
+
console.log('Temporal test users:');
|
|
3115
|
+
console.log('');
|
|
3116
|
+
for (const param of resp.Parameters) {
|
|
3117
|
+
const userName = param.Name.split('/').pop();
|
|
3118
|
+
const creds = JSON.parse(param.Value);
|
|
3119
|
+
console.log(` ${userName}`);
|
|
3120
|
+
console.log(` Email: ${creds.email}`);
|
|
3121
|
+
console.log(` Password: ${creds.password}`);
|
|
3122
|
+
console.log(` Roles: ${creds.roles.join(', ')}`);
|
|
3123
|
+
if (creds.description)
|
|
3124
|
+
console.log(` Note: ${creds.description}`);
|
|
3125
|
+
console.log('');
|
|
3126
|
+
}
|
|
3127
|
+
logInfo('Login at: https://temporal.dev.mesh-platform.trabian.com');
|
|
3128
|
+
}
|
|
3129
|
+
catch (err) {
|
|
3130
|
+
logError(`Failed to list test users: ${err instanceof Error ? err.message : String(err)}`);
|
|
3131
|
+
process.exit(1);
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
else {
|
|
3135
|
+
// Get a specific test user
|
|
3136
|
+
try {
|
|
3137
|
+
const resp = await ssm.send(new GetParameterCommand({
|
|
3138
|
+
Name: `${basePath}/${name}`,
|
|
3139
|
+
WithDecryption: true,
|
|
3140
|
+
}));
|
|
3141
|
+
if (!resp.Parameter?.Value) {
|
|
3142
|
+
logError(`Test user '${name}' not found at ${basePath}/${name}`);
|
|
3143
|
+
return;
|
|
3144
|
+
}
|
|
3145
|
+
const creds = JSON.parse(resp.Parameter.Value);
|
|
3146
|
+
// Check if --json is set on parent
|
|
3147
|
+
const asJson = dev.opts().json;
|
|
3148
|
+
if (asJson) {
|
|
3149
|
+
console.log(JSON.stringify(creds, null, 2));
|
|
3150
|
+
}
|
|
3151
|
+
else {
|
|
3152
|
+
console.log(`Email: ${creds.email}`);
|
|
3153
|
+
console.log(`Password: ${creds.password}`);
|
|
3154
|
+
console.log(`Roles: ${creds.roles.join(', ')}`);
|
|
3155
|
+
if (creds.description)
|
|
3156
|
+
console.log(`Note: ${creds.description}`);
|
|
3157
|
+
console.log('');
|
|
3158
|
+
logInfo('Login at: https://temporal.dev.mesh-platform.trabian.com');
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
catch (err) {
|
|
3162
|
+
const msg = err.message ?? String(err);
|
|
3163
|
+
if (msg.includes('ParameterNotFound') || err.name === 'ParameterNotFound') {
|
|
3164
|
+
logError(`Test user '${name}' not found.`);
|
|
3165
|
+
logInfo(`List available users with: mesh dev test-user`);
|
|
3166
|
+
}
|
|
3167
|
+
else {
|
|
3168
|
+
logError(`Failed to get test user: ${msg}`);
|
|
3169
|
+
}
|
|
3170
|
+
process.exit(1);
|
|
3171
|
+
}
|
|
1244
3172
|
}
|
|
1245
|
-
runTunnelManager(opts.config, opts.tmuxSession);
|
|
1246
3173
|
});
|
|
1247
3174
|
}
|
|
1248
3175
|
//# sourceMappingURL=dev.js.map
|