@mesh-tech/mesh-cli 0.20.2 → 0.20.3
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/dist/bin/mesh.d.ts +0 -12
- package/dist/bin/mesh.js +12 -12
- package/dist/bin/mesh.js.map +0 -1
- package/dist/build-info.json +2 -2
- package/dist/src/commands/agent-api-client.d.ts +0 -70
- package/dist/src/commands/agent-api-client.js +0 -80
- package/dist/src/commands/app-check.d.ts +0 -56
- package/dist/src/commands/app-check.js +0 -72
- package/dist/src/commands/artifacts.d.ts +0 -11
- package/dist/src/commands/artifacts.js +0 -33
- package/dist/src/commands/cluster.d.ts +0 -39
- package/dist/src/commands/cluster.js +0 -73
- package/dist/src/commands/conversations.d.ts +0 -36
- package/dist/src/commands/conversations.js +0 -59
- package/dist/src/commands/create-app.d.ts +0 -98
- package/dist/src/commands/create-app.js +1 -163
- package/dist/src/commands/db/connect.d.ts +0 -7
- package/dist/src/commands/db/connect.js +0 -9
- package/dist/src/commands/db/credentials.d.ts +0 -7
- package/dist/src/commands/db/credentials.js +0 -10
- package/dist/src/commands/db/env.d.ts +0 -6
- package/dist/src/commands/db/env.js +0 -14
- package/dist/src/commands/db/exec.d.ts +0 -43
- package/dist/src/commands/db/exec.js +0 -69
- package/dist/src/commands/db/index.d.ts +0 -10
- package/dist/src/commands/db/index.js +0 -10
- package/dist/src/commands/db/psql.d.ts +0 -7
- package/dist/src/commands/db/psql.js +0 -18
- package/dist/src/commands/deploy.d.ts +0 -38
- package/dist/src/commands/deploy.js +1 -76
- package/dist/src/commands/dev-doctor.d.ts +0 -82
- package/dist/src/commands/dev-doctor.js +3 -132
- package/dist/src/commands/dev-launch.d.ts +0 -52
- package/dist/src/commands/dev-launch.js +0 -56
- package/dist/src/commands/dev-token-server.d.ts +0 -18
- package/dist/src/commands/dev-token-server.js +1 -24
- package/dist/src/commands/dev.d.ts +0 -246
- package/dist/src/commands/dev.js +5 -963
- package/dist/src/commands/docs.d.ts +0 -1
- package/dist/src/commands/docs.js +0 -12
- package/dist/src/commands/hub/index.d.ts +0 -60
- package/dist/src/commands/hub/index.js +2 -139
- package/dist/src/commands/init/wizard.d.ts +0 -128
- package/dist/src/commands/init/wizard.js +0 -109
- package/dist/src/commands/init.d.ts +0 -55
- package/dist/src/commands/init.js +0 -70
- package/dist/src/commands/install-shim.d.ts +0 -23
- package/dist/src/commands/install-shim.js +2 -29
- package/dist/src/commands/local/auth-provision.d.ts +0 -80
- package/dist/src/commands/local/auth-provision.js +0 -109
- package/dist/src/commands/local/dev-local.d.ts +0 -91
- package/dist/src/commands/local/dev-local.js +0 -147
- package/dist/src/commands/local/docker-runner.d.ts +0 -22
- package/dist/src/commands/local/docker-runner.js +2 -35
- package/dist/src/commands/local/helpers.d.ts +0 -14
- package/dist/src/commands/local/helpers.js +0 -13
- package/dist/src/commands/local/hub-local.d.ts +0 -96
- package/dist/src/commands/local/hub-local.js +0 -111
- package/dist/src/commands/local/index.d.ts +0 -14
- package/dist/src/commands/local/index.js +0 -86
- package/dist/src/commands/local/mocks.d.ts +0 -269
- package/dist/src/commands/local/mocks.js +1 -274
- package/dist/src/commands/local/seed-hub-catalog.d.ts +0 -25
- package/dist/src/commands/local/seed-hub-catalog.js +0 -41
- package/dist/src/commands/local/seed-zitadel.d.ts +0 -82
- package/dist/src/commands/local/seed-zitadel.js +0 -158
- package/dist/src/commands/local/seed.d.ts +0 -64
- package/dist/src/commands/local/seed.js +0 -78
- package/dist/src/commands/local/stack.d.ts +0 -141
- package/dist/src/commands/local/stack.js +0 -153
- package/dist/src/commands/login.d.ts +0 -265
- package/dist/src/commands/login.js +4 -418
- package/dist/src/commands/peer-addressing.d.ts +0 -39
- package/dist/src/commands/peer-addressing.js +0 -37
- package/dist/src/commands/port-ownership.d.ts +0 -44
- package/dist/src/commands/port-ownership.js +0 -47
- package/dist/src/commands/registry-publish.d.ts +0 -1
- package/dist/src/commands/registry-publish.js +0 -46
- package/dist/src/commands/registry.d.ts +4 -237
- package/dist/src/commands/registry.js +2 -316
- package/dist/src/commands/secrets/exec.d.ts +0 -16
- package/dist/src/commands/secrets/exec.js +0 -24
- package/dist/src/commands/secrets/index.d.ts +0 -9
- package/dist/src/commands/secrets/index.js +0 -10
- package/dist/src/commands/secrets/migrate-config.d.ts +0 -17
- package/dist/src/commands/secrets/migrate-config.js +0 -40
- package/dist/src/commands/secrets/reindex.d.ts +0 -38
- package/dist/src/commands/secrets/reindex.js +0 -39
- package/dist/src/commands/secrets/set.d.ts +0 -47
- package/dist/src/commands/secrets/set.js +0 -107
- package/dist/src/commands/site.d.ts +0 -1
- package/dist/src/commands/site.js +0 -61
- package/dist/src/commands/skills.d.ts +0 -61
- package/dist/src/commands/skills.js +0 -83
- package/dist/src/commands/stack.d.ts +0 -15
- package/dist/src/commands/stack.js +1 -128
- package/dist/src/commands/temporal.d.ts +0 -30
- package/dist/src/commands/temporal.js.map +0 -1
- package/dist/src/commands/tenant.d.ts +0 -31
- package/dist/src/commands/tenant.js +0 -55
- package/dist/src/commands/tunnel/index.d.ts +0 -40
- package/dist/src/commands/tunnel/index.js +0 -141
- package/dist/src/commands/vcs/clone.d.ts +0 -1
- package/dist/src/commands/vcs/clone.js +0 -4
- package/dist/src/commands/vcs/common.d.ts +0 -32
- package/dist/src/commands/vcs/common.js +0 -41
- package/dist/src/commands/vcs/drafts.d.ts +0 -1
- package/dist/src/commands/vcs/drafts.js +0 -1
- package/dist/src/commands/vcs/get.d.ts +0 -1
- package/dist/src/commands/vcs/get.js +0 -11
- package/dist/src/commands/vcs/index.d.ts +0 -1
- package/dist/src/commands/vcs/index.js +0 -1
- package/dist/src/commands/vcs/propose.d.ts +0 -26
- package/dist/src/commands/vcs/propose.js +3 -34
- package/dist/src/commands/vcs/review.d.ts +0 -1
- package/dist/src/commands/vcs/review.js +0 -1
- package/dist/src/commands/vcs/rm.d.ts +0 -11
- package/dist/src/commands/vcs/rm.js +0 -14
- package/dist/src/commands/vcs-enrich.d.ts +0 -12
- package/dist/src/commands/vcs-enrich.js +0 -12
- package/dist/src/commands/vpn/index.d.ts +0 -17
- package/dist/src/commands/vpn/index.js +0 -102
- package/dist/src/commands/vpn/tunnel.d.ts +0 -9
- package/dist/src/commands/vpn/tunnel.js +0 -37
- package/dist/src/commands/workflow.d.ts +0 -22
- package/dist/src/commands/workflow.js +0 -80
- package/dist/src/docs/assemble.d.ts +0 -117
- package/dist/src/docs/assemble.js +0 -142
- package/dist/src/docs/cli-reference.d.ts +0 -58
- package/dist/src/docs/cli-reference.js +0 -77
- package/dist/src/docs/discover.d.ts +0 -40
- package/dist/src/docs/discover.js +2 -60
- package/dist/src/docs/portal.d.ts +0 -44
- package/dist/src/docs/portal.js +0 -49
- package/dist/src/docs/registry-docs.d.ts +0 -29
- package/dist/src/docs/registry-docs.js +0 -40
- package/dist/src/docs/schema.d.ts +0 -36
- package/dist/src/docs/schema.js +0 -52
- package/dist/src/docs/serve.d.ts +0 -18
- package/dist/src/docs/serve.js +2 -30
- package/dist/src/docs/start.d.ts +0 -42
- package/dist/src/docs/start.js +0 -70
- package/dist/src/eval/agent-eval.d.mts +0 -12
- package/dist/src/eval/agent-eval.mjs +0 -22
- package/dist/src/index.d.ts +0 -6
- package/dist/src/index.js.map +0 -1
- package/dist/src/program.d.ts +0 -20
- package/dist/src/program.js +0 -24
- package/dist/src/utils/auth-preflight.d.ts +0 -137
- package/dist/src/utils/auth-preflight.js +0 -149
- package/dist/src/utils/aws-auth.d.ts +0 -163
- package/dist/src/utils/aws-auth.js +3 -232
- package/dist/src/utils/bastion.d.ts +0 -27
- package/dist/src/utils/bastion.js +0 -28
- package/dist/src/utils/build-info.d.ts +0 -22
- package/dist/src/utils/build-info.js +0 -27
- package/dist/src/utils/cache-home.d.ts +0 -23
- package/dist/src/utils/cache-home.js +0 -25
- package/dist/src/utils/capture-history.d.ts +0 -54
- package/dist/src/utils/capture-history.js +1 -55
- package/dist/src/utils/context.d.ts +0 -23
- package/dist/src/utils/context.js +0 -43
- package/dist/src/utils/credentials.d.ts +0 -38
- package/dist/src/utils/credentials.js +0 -39
- package/dist/src/utils/deploy-preflight.d.ts +0 -38
- package/dist/src/utils/deploy-preflight.js +1 -71
- package/dist/src/utils/errors.d.ts +0 -53
- package/dist/src/utils/errors.js +0 -49
- package/dist/src/utils/first-party-contexts.d.ts +0 -38
- package/dist/src/utils/first-party-contexts.js +0 -44
- package/dist/src/utils/index.d.ts +0 -4
- package/dist/src/utils/index.js +0 -4
- package/dist/src/utils/kubeconfig.d.ts +0 -40
- package/dist/src/utils/kubeconfig.js +0 -44
- package/dist/src/utils/log.d.ts +0 -20
- package/dist/src/utils/log.js +0 -20
- package/dist/src/utils/mesh-json.d.ts +0 -46
- package/dist/src/utils/mesh-json.js +0 -39
- package/dist/src/utils/pid.d.ts +0 -42
- package/dist/src/utils/pid.js +0 -48
- package/dist/src/utils/pulumi-run.d.ts +0 -14
- package/dist/src/utils/pulumi-run.js +0 -42
- package/dist/src/utils/pulumi.d.ts +0 -34
- package/dist/src/utils/pulumi.js +0 -38
- package/dist/src/utils/reachability.d.ts +0 -27
- package/dist/src/utils/reachability.js +0 -40
- package/dist/src/utils/recover-conversation.d.ts +0 -110
- package/dist/src/utils/recover-conversation.js +0 -108
- package/dist/src/utils/registry-broker.d.ts +5 -84
- package/dist/src/utils/registry-broker.js +0 -62
- package/dist/src/utils/registry-identity.d.ts +0 -149
- package/dist/src/utils/registry-identity.js +0 -143
- package/dist/src/utils/scaffold-versions.d.ts +0 -41
- package/dist/src/utils/scaffold-versions.js +0 -42
- package/dist/src/utils/socks-forward.d.ts +0 -24
- package/dist/src/utils/socks-forward.js +2 -25
- package/dist/src/utils/stack-flag.d.ts +0 -13
- package/dist/src/utils/stack-flag.js +0 -21
- package/dist/src/utils/tailscale-targets.d.ts +0 -37
- package/dist/src/utils/tailscale-targets.js +0 -33
- package/dist/src/utils/tailscale.d.ts +0 -177
- package/dist/src/utils/tailscale.js +6 -337
- package/dist/src/utils/temporal-auth.d.ts +0 -41
- package/dist/src/utils/temporal-auth.js +0 -52
- package/dist/src/utils/temporal-codec.d.ts +0 -89
- package/dist/src/utils/temporal-codec.js.map +0 -1
- package/dist/src/utils/tunnel-ownership.d.ts +0 -79
- package/dist/src/utils/tunnel-ownership.js +0 -74
- package/dist/src/utils/vpn-join.d.ts +0 -16
- package/dist/src/utils/vpn-join.js +0 -17
- package/dist/src/utils/vpn.d.ts +0 -42
- package/dist/src/utils/vpn.js +0 -52
- package/dist/src/utils/workflow-fingerprint.d.ts +0 -39
- package/dist/src/utils/workflow-fingerprint.js +1 -63
- package/dist/src/utils/worktree-identity.d.ts +0 -62
- package/dist/src/utils/worktree-identity.js +0 -88
- package/package.json +1 -1
- package/skills/core/SKILL.md +9 -10
- package/templates/api-auth/api/src/index.ts.hbs +1 -1
- package/templates/api-auth/api/src/spicedb-http-provider.ts.hbs +2 -2
- package/templates/api-role-gating/api/src/authn.ts.hbs +2 -2
- package/templates/api-role-gating/index.ts.hbs +1 -1
- package/templates/api-role-gating/skills/develop/SKILL.md.hbs +1 -1
- package/templates/platform-repo/core/Pulumi.{{tenant}}-{{env}}.yaml.hbs +2 -2
- package/templates/platform-repo/platform/index.ts +1 -1
- package/dist/bin/mesh.d.ts.map +0 -1
- package/dist/src/commands/agent-api-client.d.ts.map +0 -1
- package/dist/src/commands/agent-api-client.js.map +0 -1
- package/dist/src/commands/app-check.d.ts.map +0 -1
- package/dist/src/commands/app-check.js.map +0 -1
- package/dist/src/commands/artifacts.d.ts.map +0 -1
- package/dist/src/commands/artifacts.js.map +0 -1
- package/dist/src/commands/cluster.d.ts.map +0 -1
- package/dist/src/commands/cluster.js.map +0 -1
- package/dist/src/commands/conversations.d.ts.map +0 -1
- package/dist/src/commands/conversations.js.map +0 -1
- package/dist/src/commands/create-app.d.ts.map +0 -1
- package/dist/src/commands/create-app.js.map +0 -1
- package/dist/src/commands/db/connect.d.ts.map +0 -1
- package/dist/src/commands/db/connect.js.map +0 -1
- package/dist/src/commands/db/credentials.d.ts.map +0 -1
- package/dist/src/commands/db/credentials.js.map +0 -1
- package/dist/src/commands/db/env.d.ts.map +0 -1
- package/dist/src/commands/db/env.js.map +0 -1
- package/dist/src/commands/db/exec.d.ts.map +0 -1
- package/dist/src/commands/db/exec.js.map +0 -1
- package/dist/src/commands/db/index.d.ts.map +0 -1
- package/dist/src/commands/db/index.js.map +0 -1
- package/dist/src/commands/db/psql.d.ts.map +0 -1
- package/dist/src/commands/db/psql.js.map +0 -1
- package/dist/src/commands/deploy.d.ts.map +0 -1
- package/dist/src/commands/deploy.js.map +0 -1
- package/dist/src/commands/dev-doctor.d.ts.map +0 -1
- package/dist/src/commands/dev-doctor.js.map +0 -1
- package/dist/src/commands/dev-launch.d.ts.map +0 -1
- package/dist/src/commands/dev-launch.js.map +0 -1
- package/dist/src/commands/dev-token-server.d.ts.map +0 -1
- package/dist/src/commands/dev-token-server.js.map +0 -1
- package/dist/src/commands/dev.d.ts.map +0 -1
- package/dist/src/commands/dev.js.map +0 -1
- package/dist/src/commands/docs.d.ts.map +0 -1
- package/dist/src/commands/docs.js.map +0 -1
- package/dist/src/commands/hub/index.d.ts.map +0 -1
- package/dist/src/commands/hub/index.js.map +0 -1
- package/dist/src/commands/init/wizard.d.ts.map +0 -1
- package/dist/src/commands/init/wizard.js.map +0 -1
- package/dist/src/commands/init.d.ts.map +0 -1
- package/dist/src/commands/init.js.map +0 -1
- package/dist/src/commands/install-shim.d.ts.map +0 -1
- package/dist/src/commands/install-shim.js.map +0 -1
- package/dist/src/commands/local/auth-provision.d.ts.map +0 -1
- package/dist/src/commands/local/auth-provision.js.map +0 -1
- package/dist/src/commands/local/dev-local.d.ts.map +0 -1
- package/dist/src/commands/local/dev-local.js.map +0 -1
- package/dist/src/commands/local/docker-runner.d.ts.map +0 -1
- package/dist/src/commands/local/docker-runner.js.map +0 -1
- package/dist/src/commands/local/helpers.d.ts.map +0 -1
- package/dist/src/commands/local/helpers.js.map +0 -1
- package/dist/src/commands/local/hub-local.d.ts.map +0 -1
- package/dist/src/commands/local/hub-local.js.map +0 -1
- package/dist/src/commands/local/index.d.ts.map +0 -1
- package/dist/src/commands/local/index.js.map +0 -1
- package/dist/src/commands/local/mocks.d.ts.map +0 -1
- package/dist/src/commands/local/mocks.js.map +0 -1
- package/dist/src/commands/local/seed-hub-catalog.d.ts.map +0 -1
- package/dist/src/commands/local/seed-hub-catalog.js.map +0 -1
- package/dist/src/commands/local/seed-zitadel.d.ts.map +0 -1
- package/dist/src/commands/local/seed-zitadel.js.map +0 -1
- package/dist/src/commands/local/seed.d.ts.map +0 -1
- package/dist/src/commands/local/seed.js.map +0 -1
- package/dist/src/commands/local/stack.d.ts.map +0 -1
- package/dist/src/commands/local/stack.js.map +0 -1
- package/dist/src/commands/login.d.ts.map +0 -1
- package/dist/src/commands/login.js.map +0 -1
- package/dist/src/commands/peer-addressing.d.ts.map +0 -1
- package/dist/src/commands/peer-addressing.js.map +0 -1
- package/dist/src/commands/port-ownership.d.ts.map +0 -1
- package/dist/src/commands/port-ownership.js.map +0 -1
- package/dist/src/commands/registry-publish.d.ts.map +0 -1
- package/dist/src/commands/registry-publish.js.map +0 -1
- package/dist/src/commands/registry.d.ts.map +0 -1
- package/dist/src/commands/registry.js.map +0 -1
- package/dist/src/commands/secrets/exec.d.ts.map +0 -1
- package/dist/src/commands/secrets/exec.js.map +0 -1
- package/dist/src/commands/secrets/index.d.ts.map +0 -1
- package/dist/src/commands/secrets/index.js.map +0 -1
- package/dist/src/commands/secrets/migrate-config.d.ts.map +0 -1
- package/dist/src/commands/secrets/migrate-config.js.map +0 -1
- package/dist/src/commands/secrets/reindex.d.ts.map +0 -1
- package/dist/src/commands/secrets/reindex.js.map +0 -1
- package/dist/src/commands/secrets/set.d.ts.map +0 -1
- package/dist/src/commands/secrets/set.js.map +0 -1
- package/dist/src/commands/site.d.ts.map +0 -1
- package/dist/src/commands/site.js.map +0 -1
- package/dist/src/commands/skills.d.ts.map +0 -1
- package/dist/src/commands/skills.js.map +0 -1
- package/dist/src/commands/stack.d.ts.map +0 -1
- package/dist/src/commands/stack.js.map +0 -1
- package/dist/src/commands/temporal.d.ts.map +0 -1
- package/dist/src/commands/tenant.d.ts.map +0 -1
- package/dist/src/commands/tenant.js.map +0 -1
- package/dist/src/commands/tunnel/index.d.ts.map +0 -1
- package/dist/src/commands/tunnel/index.js.map +0 -1
- package/dist/src/commands/vcs/clone.d.ts.map +0 -1
- package/dist/src/commands/vcs/clone.js.map +0 -1
- package/dist/src/commands/vcs/common.d.ts.map +0 -1
- package/dist/src/commands/vcs/common.js.map +0 -1
- package/dist/src/commands/vcs/drafts.d.ts.map +0 -1
- package/dist/src/commands/vcs/drafts.js.map +0 -1
- package/dist/src/commands/vcs/get.d.ts.map +0 -1
- package/dist/src/commands/vcs/get.js.map +0 -1
- package/dist/src/commands/vcs/index.d.ts.map +0 -1
- package/dist/src/commands/vcs/index.js.map +0 -1
- package/dist/src/commands/vcs/propose.d.ts.map +0 -1
- package/dist/src/commands/vcs/propose.js.map +0 -1
- package/dist/src/commands/vcs/review.d.ts.map +0 -1
- package/dist/src/commands/vcs/review.js.map +0 -1
- package/dist/src/commands/vcs/rm.d.ts.map +0 -1
- package/dist/src/commands/vcs/rm.js.map +0 -1
- package/dist/src/commands/vcs-enrich.d.ts.map +0 -1
- package/dist/src/commands/vcs-enrich.js.map +0 -1
- package/dist/src/commands/vpn/index.d.ts.map +0 -1
- package/dist/src/commands/vpn/index.js.map +0 -1
- package/dist/src/commands/vpn/tunnel.d.ts.map +0 -1
- package/dist/src/commands/vpn/tunnel.js.map +0 -1
- package/dist/src/commands/workflow.d.ts.map +0 -1
- package/dist/src/commands/workflow.js.map +0 -1
- package/dist/src/docs/assemble.d.ts.map +0 -1
- package/dist/src/docs/assemble.js.map +0 -1
- package/dist/src/docs/cli-reference.d.ts.map +0 -1
- package/dist/src/docs/cli-reference.js.map +0 -1
- package/dist/src/docs/discover.d.ts.map +0 -1
- package/dist/src/docs/discover.js.map +0 -1
- package/dist/src/docs/portal.d.ts.map +0 -1
- package/dist/src/docs/portal.js.map +0 -1
- package/dist/src/docs/registry-docs.d.ts.map +0 -1
- package/dist/src/docs/registry-docs.js.map +0 -1
- package/dist/src/docs/schema.d.ts.map +0 -1
- package/dist/src/docs/schema.js.map +0 -1
- package/dist/src/docs/serve.d.ts.map +0 -1
- package/dist/src/docs/serve.js.map +0 -1
- package/dist/src/docs/start.d.ts.map +0 -1
- package/dist/src/docs/start.js.map +0 -1
- package/dist/src/eval/agent-eval.d.mts.map +0 -1
- package/dist/src/eval/agent-eval.mjs.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/program.d.ts.map +0 -1
- package/dist/src/program.js.map +0 -1
- package/dist/src/utils/auth-preflight.d.ts.map +0 -1
- package/dist/src/utils/auth-preflight.js.map +0 -1
- package/dist/src/utils/aws-auth.d.ts.map +0 -1
- package/dist/src/utils/aws-auth.js.map +0 -1
- package/dist/src/utils/bastion.d.ts.map +0 -1
- package/dist/src/utils/bastion.js.map +0 -1
- package/dist/src/utils/build-info.d.ts.map +0 -1
- package/dist/src/utils/build-info.js.map +0 -1
- package/dist/src/utils/cache-home.d.ts.map +0 -1
- package/dist/src/utils/cache-home.js.map +0 -1
- package/dist/src/utils/capture-history.d.ts.map +0 -1
- package/dist/src/utils/capture-history.js.map +0 -1
- package/dist/src/utils/context.d.ts.map +0 -1
- package/dist/src/utils/context.js.map +0 -1
- package/dist/src/utils/credentials.d.ts.map +0 -1
- package/dist/src/utils/credentials.js.map +0 -1
- package/dist/src/utils/deploy-preflight.d.ts.map +0 -1
- package/dist/src/utils/deploy-preflight.js.map +0 -1
- package/dist/src/utils/errors.d.ts.map +0 -1
- package/dist/src/utils/errors.js.map +0 -1
- package/dist/src/utils/first-party-contexts.d.ts.map +0 -1
- package/dist/src/utils/first-party-contexts.js.map +0 -1
- package/dist/src/utils/index.d.ts.map +0 -1
- package/dist/src/utils/index.js.map +0 -1
- package/dist/src/utils/kubeconfig.d.ts.map +0 -1
- package/dist/src/utils/kubeconfig.js.map +0 -1
- package/dist/src/utils/log.d.ts.map +0 -1
- package/dist/src/utils/log.js.map +0 -1
- package/dist/src/utils/mesh-json.d.ts.map +0 -1
- package/dist/src/utils/mesh-json.js.map +0 -1
- package/dist/src/utils/pid.d.ts.map +0 -1
- package/dist/src/utils/pid.js.map +0 -1
- package/dist/src/utils/pulumi-run.d.ts.map +0 -1
- package/dist/src/utils/pulumi-run.js.map +0 -1
- package/dist/src/utils/pulumi.d.ts.map +0 -1
- package/dist/src/utils/pulumi.js.map +0 -1
- package/dist/src/utils/reachability.d.ts.map +0 -1
- package/dist/src/utils/reachability.js.map +0 -1
- package/dist/src/utils/recover-conversation.d.ts.map +0 -1
- package/dist/src/utils/recover-conversation.js.map +0 -1
- package/dist/src/utils/registry-broker.d.ts.map +0 -1
- package/dist/src/utils/registry-broker.js.map +0 -1
- package/dist/src/utils/registry-identity.d.ts.map +0 -1
- package/dist/src/utils/registry-identity.js.map +0 -1
- package/dist/src/utils/scaffold-versions.d.ts.map +0 -1
- package/dist/src/utils/scaffold-versions.js.map +0 -1
- package/dist/src/utils/socks-forward.d.ts.map +0 -1
- package/dist/src/utils/socks-forward.js.map +0 -1
- package/dist/src/utils/stack-flag.d.ts.map +0 -1
- package/dist/src/utils/stack-flag.js.map +0 -1
- package/dist/src/utils/tailscale-targets.d.ts.map +0 -1
- package/dist/src/utils/tailscale-targets.js.map +0 -1
- package/dist/src/utils/tailscale.d.ts.map +0 -1
- package/dist/src/utils/tailscale.js.map +0 -1
- package/dist/src/utils/temporal-auth.d.ts.map +0 -1
- package/dist/src/utils/temporal-auth.js.map +0 -1
- package/dist/src/utils/temporal-codec.d.ts.map +0 -1
- package/dist/src/utils/tunnel-ownership.d.ts.map +0 -1
- package/dist/src/utils/tunnel-ownership.js.map +0 -1
- package/dist/src/utils/vpn-join.d.ts.map +0 -1
- package/dist/src/utils/vpn-join.js.map +0 -1
- package/dist/src/utils/vpn.d.ts.map +0 -1
- package/dist/src/utils/vpn.js.map +0 -1
- package/dist/src/utils/workflow-fingerprint.d.ts.map +0 -1
- package/dist/src/utils/workflow-fingerprint.js.map +0 -1
- package/dist/src/utils/worktree-identity.d.ts.map +0 -1
- package/dist/src/utils/worktree-identity.js.map +0 -1
package/dist/src/commands/dev.js
CHANGED
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* mesh dev — Pulumi-output-driven local development orchestrator
|
|
3
|
-
*
|
|
4
|
-
* Reads `pulumi stack output dev --json` to discover services and VPC endpoints,
|
|
5
|
-
* then launches each service's dev command in a tmux window.
|
|
6
|
-
* Requires VPN connection for direct access to VPC resources.
|
|
7
|
-
*
|
|
8
|
-
* Usage:
|
|
9
|
-
* mesh dev Start all services, attach tmux
|
|
10
|
-
* mesh dev --headless Start without attaching
|
|
11
|
-
* mesh dev --kill Stop everything
|
|
12
|
-
* mesh dev --status Show service health (--json for structured)
|
|
13
|
-
* mesh dev logs <service> Tail a service's output
|
|
14
|
-
* mesh dev restart <svc> Restart a single service
|
|
15
|
-
*/
|
|
16
1
|
import { execFileSync, spawn, spawnSync } from 'child_process';
|
|
17
2
|
import * as fs from 'fs';
|
|
18
3
|
import * as net from 'net';
|
|
@@ -44,16 +29,6 @@ import { resolveStackOption } from '../utils/stack-flag.js';
|
|
|
44
29
|
import { rewriteClusterHostsToLocal } from './peer-addressing.js';
|
|
45
30
|
import { ALL_CHECKS, aggregateStatus, renderHuman, runChecks, runDoctor, } from './dev-doctor.js';
|
|
46
31
|
import { resolveWorktreeIdentity, withAppScopedPortBlock, blockBasePort, PORT_BLOCK_SERVICE_SUBRANGE, } from '../utils/worktree-identity.js';
|
|
47
|
-
/**
|
|
48
|
-
* The app's own services, as blackbox probe targets, so local uptime is real.
|
|
49
|
-
*
|
|
50
|
-
* Mock stand-ins are excluded: `--externals` already registers each of those
|
|
51
|
-
* under its own external-service labels, and probing them twice would show one
|
|
52
|
-
* dependency as two rows — one of which disappears when the mock does.
|
|
53
|
-
*
|
|
54
|
-
* Best-effort. A probe file is monitoring, not the app: failing to write one
|
|
55
|
-
* must never take down a dev session that is otherwise working.
|
|
56
|
-
*/
|
|
57
32
|
export function registerServiceProbes(devOutput, tenant, probeFiles) {
|
|
58
33
|
const services = {};
|
|
59
34
|
for (const [name, service] of Object.entries(devOutput.services)) {
|
|
@@ -75,20 +50,9 @@ export function registerServiceProbes(devOutput, tenant, probeFiles) {
|
|
|
75
50
|
logWarn(`Could not register uptime probes for this session (${err instanceof Error ? err.message : err}) — the Hub will show no uptime for these services.`);
|
|
76
51
|
}
|
|
77
52
|
}
|
|
78
|
-
/**
|
|
79
|
-
* tmux session name for a dev launch. The primary checkout keeps the historical
|
|
80
|
-
* `${projectName}-dev`; linked worktrees get a `${projectName}-${slug}` so two
|
|
81
|
-
* worktrees of the same app don't share (and kill) each other's session, state,
|
|
82
|
-
* and env-file dir (MESH-2013).
|
|
83
|
-
*/
|
|
84
53
|
function deriveSessionName(projectName, wt) {
|
|
85
54
|
return wt.isPrimary ? `${projectName}-dev` : `${projectName}-${wt.slug}`;
|
|
86
55
|
}
|
|
87
|
-
/**
|
|
88
|
-
* Render the resolved per-worktree plan (`mesh dev --dry-run`): identity,
|
|
89
|
-
* session/state/env paths, and the port assignment — everything that must be
|
|
90
|
-
* collision-free across concurrent worktrees, without launching anything.
|
|
91
|
-
*/
|
|
92
56
|
function printDevPlan(sessionName, appRoot, wt, devOutput) {
|
|
93
57
|
const kind = wt.isPrimary ? 'primary checkout' : `linked worktree (block ${wt.portBlock})`;
|
|
94
58
|
console.log(`\nmesh dev plan — ${kind}\n`);
|
|
@@ -139,16 +103,6 @@ function loadSessionState(sessionName) {
|
|
|
139
103
|
return null;
|
|
140
104
|
}
|
|
141
105
|
}
|
|
142
|
-
/**
|
|
143
|
-
* Relaunch safety (MESH-2017): if the workflow source changed since this session
|
|
144
|
-
* launched, restarting the worker replays any in-flight conversations against the
|
|
145
|
-
* NEW code — a replay-incompatible change strands them. Warn and require `--force`.
|
|
146
|
-
*
|
|
147
|
-
* Silent no-op when there's no prior session, no stored fingerprint (pre-feature
|
|
148
|
-
* session), or nothing changed — so a restart of unchanged code never nags.
|
|
149
|
-
* Returns `true` if the caller should ABORT (changed && !force).
|
|
150
|
-
*/
|
|
151
|
-
/** Pure: did the workflow source change since launch (→ risk of stranding)? */
|
|
152
106
|
export function workflowChanged(prior, current) {
|
|
153
107
|
return Boolean(prior) && Boolean(current) && current !== prior;
|
|
154
108
|
}
|
|
@@ -171,11 +125,6 @@ function workflowChangeWouldStrand(priorState, appRoot, force) {
|
|
|
171
125
|
logError(' Refusing to restart the worker. Re-run with --force once the change is replay-safe.');
|
|
172
126
|
return true;
|
|
173
127
|
}
|
|
174
|
-
/**
|
|
175
|
-
* Per-session directory holding one sourceable env file per service
|
|
176
|
-
* (`<service>.env.sh`, mode 0600). Written at launch; `mesh dev restart`
|
|
177
|
-
* sources the same file so a restarted service gets a byte-identical env.
|
|
178
|
-
*/
|
|
179
128
|
function getSessionEnvDir(sessionName) {
|
|
180
129
|
return path.join(os.tmpdir(), 'mesh-dev-sessions', sessionName);
|
|
181
130
|
}
|
|
@@ -192,28 +141,6 @@ function removeSessionState(sessionName) {
|
|
|
192
141
|
}
|
|
193
142
|
catch { }
|
|
194
143
|
}
|
|
195
|
-
/**
|
|
196
|
-
* Check whether a TCP port is available — probing the way services actually bind.
|
|
197
|
-
*
|
|
198
|
-
* This used to bind `127.0.0.1` only, and that made it blind to exactly the
|
|
199
|
-
* processes it exists to find. Node's `listen(port)` (and `@hono/node-server`,
|
|
200
|
-
* which every agent-api uses) binds the WILDCARD address `::` in dual-stack mode,
|
|
201
|
-
* holding the port on every interface — yet a probe bound to `127.0.0.1` still
|
|
202
|
-
* succeeds against such a holder. So the check returned `true` for a port already
|
|
203
|
-
* held by a prior `mesh dev` service, `allocatePorts` kept the preferred port, and
|
|
204
|
-
* the service then died on `EADDRINUSE` at its real bind.
|
|
205
|
-
*
|
|
206
|
-
* Observed 2026-07-31: the `studio-api` pane died on `EADDRINUSE :::40161` while a
|
|
207
|
-
* stale agent-api from an earlier boot kept serving on the same port, so the Studio
|
|
208
|
-
* UI drove a process from a previous session's configuration and the conversation
|
|
209
|
-
* list came back empty. Reproduced in isolation — holder on `::`, loopback probe
|
|
210
|
-
* reports free, real bind fails (MESH-2590).
|
|
211
|
-
*
|
|
212
|
-
* Both addresses are probed: the wildcard catches all-interface holders, and the
|
|
213
|
-
* loopback probe still catches a loopback-only squatter. A false "busy" is cheap —
|
|
214
|
-
* `allocatePorts` simply picks another port — while a false "free" crashes the
|
|
215
|
-
* service, so this deliberately errs toward busy.
|
|
216
|
-
*/
|
|
217
144
|
export async function isPortFree(port) {
|
|
218
145
|
const bindSucceeds = (host) => new Promise((resolve) => {
|
|
219
146
|
const server = net.createServer();
|
|
@@ -224,14 +151,10 @@ export async function isPortFree(port) {
|
|
|
224
151
|
else
|
|
225
152
|
server.listen(port, host, onListening);
|
|
226
153
|
});
|
|
227
|
-
// Wildcard first: it is how the services bind, so it is the probe that matters.
|
|
228
154
|
if (!(await bindSucceeds()))
|
|
229
155
|
return false;
|
|
230
156
|
return bindSucceeds('127.0.0.1');
|
|
231
157
|
}
|
|
232
|
-
/**
|
|
233
|
-
* Ask the OS for an ephemeral free port.
|
|
234
|
-
*/
|
|
235
158
|
async function findFreePort() {
|
|
236
159
|
return new Promise((resolve, reject) => {
|
|
237
160
|
const server = net.createServer();
|
|
@@ -242,7 +165,6 @@ async function findFreePort() {
|
|
|
242
165
|
});
|
|
243
166
|
});
|
|
244
167
|
}
|
|
245
|
-
/** Resolve true if something is accepting connections on 127.0.0.1:port. */
|
|
246
168
|
function isPortListening(port) {
|
|
247
169
|
return new Promise((resolve) => {
|
|
248
170
|
const s = new net.Socket();
|
|
@@ -261,16 +183,6 @@ function isPortListening(port) {
|
|
|
261
183
|
s.connect(port, '127.0.0.1');
|
|
262
184
|
});
|
|
263
185
|
}
|
|
264
|
-
/**
|
|
265
|
-
* Rewrite a service's OWN listen-port env var(s) from `oldPort` to `newPort`
|
|
266
|
-
* when `allocatePorts` reassigns its port (e.g. a worktree block). Matches any
|
|
267
|
-
* port-ish var (`PORT` or `*_PORT`, e.g. `VCS_PORT`/`AGENT_API_PORT`) whose value
|
|
268
|
-
* is exactly the old port — so a service that reads its own env var still binds
|
|
269
|
-
* the reassigned port instead of its deploy-time default (which would collide
|
|
270
|
-
* with another worktree/session on that default). The name + exact-value match
|
|
271
|
-
* keeps this from touching cross-service references or unrelated vars.
|
|
272
|
-
* Mutates `service.env`. Pure otherwise — exported for unit testing. (MESH-2088)
|
|
273
|
-
*/
|
|
274
186
|
export function rewriteOwnServicePort(service, oldPort, newPort) {
|
|
275
187
|
for (const [key, spec] of Object.entries(service.env ?? {})) {
|
|
276
188
|
if ((key === 'PORT' || key.endsWith('_PORT')) && spec.value === String(oldPort)) {
|
|
@@ -278,30 +190,15 @@ export function rewriteOwnServicePort(service, oldPort, newPort) {
|
|
|
278
190
|
}
|
|
279
191
|
}
|
|
280
192
|
}
|
|
281
|
-
/**
|
|
282
|
-
* Clone devOutput and assign each service a local port.
|
|
283
|
-
*
|
|
284
|
-
* The PRIMARY checkout keeps the app's preferred ports (falling back to an
|
|
285
|
-
* OS-ephemeral port only if one is busy). LINKED worktrees draw from a reserved
|
|
286
|
-
* port block keyed on (worktree, app) — MESH-2013 for the worktree axis, #2889
|
|
287
|
-
* for the app axis — so concurrent sessions don't collide, where the
|
|
288
|
-
* `EADDRINUSE on 8080/8081/9000` class only happened because every worktree
|
|
289
|
-
* reused the same preferred ports.
|
|
290
|
-
*
|
|
291
|
-
* Disjoint by DEFAULT, not by construction: the block is a hash bucket, so two
|
|
292
|
-
* (worktree, app) pairs can share one. The in-block scan below plus the
|
|
293
|
-
* ephemeral fallback are the backstop; MESH-2375 tracks making it deterministic.
|
|
294
|
-
*/
|
|
295
193
|
async function allocatePorts(devOutput, worktree) {
|
|
296
194
|
const allocated = JSON.parse(JSON.stringify(devOutput));
|
|
297
195
|
const usedPorts = new Set();
|
|
298
196
|
const portRemaps = new Map();
|
|
299
197
|
const blockBase = worktree.isPrimary ? 0 : blockBasePort(worktree.portBlock);
|
|
300
|
-
let blockCursor = 0;
|
|
198
|
+
let blockCursor = 0;
|
|
301
199
|
for (const [name, service] of Object.entries(allocated.services)) {
|
|
302
200
|
if (service.port <= 0)
|
|
303
201
|
continue;
|
|
304
|
-
// Skip deployed services (no dev command) — they run in K8s, no local port.
|
|
305
202
|
if (!service.command || service.command.length === 0)
|
|
306
203
|
continue;
|
|
307
204
|
const preferred = service.port;
|
|
@@ -316,7 +213,6 @@ async function allocatePorts(devOutput, worktree) {
|
|
|
316
213
|
}
|
|
317
214
|
}
|
|
318
215
|
else {
|
|
319
|
-
// Reserved block: next free port in [blockBase, blockBase+SERVICE_SUBRANGE).
|
|
320
216
|
for (let i = blockCursor; i < PORT_BLOCK_SERVICE_SUBRANGE; i++) {
|
|
321
217
|
const candidate = blockBase + i;
|
|
322
218
|
if (!usedPorts.has(candidate) && (await isPortFree(candidate))) {
|
|
@@ -326,36 +222,21 @@ async function allocatePorts(devOutput, worktree) {
|
|
|
326
222
|
}
|
|
327
223
|
}
|
|
328
224
|
if (target < 0) {
|
|
329
|
-
// Block exhausted or contended — degrade to an OS-ephemeral port.
|
|
330
225
|
target = await findFreePort();
|
|
331
226
|
logWarn(`Worktree port block exhausted for '${name}', using ${target}`);
|
|
332
227
|
}
|
|
333
228
|
}
|
|
334
229
|
if (target !== preferred) {
|
|
335
|
-
// Rewrite the service's OWN port env var(s) — not just a var literally
|
|
336
|
-
// named `PORT`. Without this, a service reading `VCS_PORT`/`AGENT_API_PORT`
|
|
337
|
-
// keeps its deploy-time default and collides across worktrees (MESH-2088).
|
|
338
230
|
rewriteOwnServicePort(service, preferred, target);
|
|
339
231
|
portRemaps.set(preferred, target);
|
|
340
232
|
service.port = target;
|
|
341
233
|
}
|
|
342
234
|
usedPorts.add(target);
|
|
343
235
|
}
|
|
344
|
-
// Cross-service URL rewriting: when a service port is reassigned,
|
|
345
|
-
// update any env var in other services that references the old port
|
|
346
|
-
// (e.g., API_URL from a link() pointing to the API's original port).
|
|
347
236
|
rewriteCrossServicePorts(allocated, portRemaps);
|
|
348
237
|
rewriteClusterHostsToLocal(allocated);
|
|
349
238
|
return allocated;
|
|
350
239
|
}
|
|
351
|
-
/**
|
|
352
|
-
* Rewrite `localhost:${oldPort}` → `localhost:${newPort}` in EVERY service's env
|
|
353
|
-
* values, for each `oldPort → newPort` entry in `portRemaps`. Mutates the given
|
|
354
|
-
* `devOutput` in place — callers pass a clone they own, so this stays safe.
|
|
355
|
-
*
|
|
356
|
-
* Shared by `allocatePorts` (launch-time reallocation) and `mergePinnedPorts`
|
|
357
|
-
* (refresh-time re-pinning) so both apply identical cross-service rewrites.
|
|
358
|
-
*/
|
|
359
240
|
function rewriteCrossServicePorts(devOutput, portRemaps) {
|
|
360
241
|
if (portRemaps.size === 0)
|
|
361
242
|
return;
|
|
@@ -371,42 +252,10 @@ function rewriteCrossServicePorts(devOutput, portRemaps) {
|
|
|
371
252
|
}
|
|
372
253
|
}
|
|
373
254
|
}
|
|
374
|
-
/**
|
|
375
|
-
* Merge a session's PINNED ports/tunnels into a freshly-read DevOutput.
|
|
376
|
-
*
|
|
377
|
-
* `mesh dev restart <svc> --refresh-env` re-reads `pulumi stack output` to pick
|
|
378
|
-
* up config/deploy changes, but the running session already allocated local
|
|
379
|
-
* ports (`allocatePorts`) and opened SSM tunnels. Re-reading the raw stack
|
|
380
|
-
* output would carry the DEPLOYED (pre-allocation) ports, so writing those into
|
|
381
|
-
* the env file would point the service — and its peers — at the wrong ports and
|
|
382
|
-
* break the live tunnels.
|
|
383
|
-
*
|
|
384
|
-
* This returns `fresh` (so new/changed non-port env values win) but overrides:
|
|
385
|
-
* - EVERY service present in both `fresh` and `pinned` keeps its pinned/
|
|
386
|
-
* allocated PORT (both `service.port` and `service.env.PORT.value`) — not
|
|
387
|
-
* just the refreshed one, so a regenerated service's peer references land on
|
|
388
|
-
* the session-allocated ports, and
|
|
389
|
-
* - every tunnel present in `pinned` with the pinned host:port (the live SSM
|
|
390
|
-
* endpoints); tunnels that exist only in `fresh` survive untouched.
|
|
391
|
-
*
|
|
392
|
-
* It then re-applies BOTH of the peer-addressing rewrites `allocatePorts` does —
|
|
393
|
-
* there are two now, and naming only one here is how they drift apart:
|
|
394
|
-
* - PORT: for each service whose port differs between `fresh` (preferred) and
|
|
395
|
-
* `pinned` (allocated), `localhost:${freshPort}` → `localhost:${pinnedPort}`
|
|
396
|
-
* across EVERY service's env (e.g. a `ui`'s `API_URL` pointing at `api`).
|
|
397
|
-
* - HOST: an in-cluster address for a service running LOCALLY becomes its local
|
|
398
|
-
* one. `pulumi stack output` carries cluster hosts verbatim, so a refresh
|
|
399
|
-
* without this restores exactly the value MESH-2597 exists to remove.
|
|
400
|
-
*
|
|
401
|
-
* Pure: deep-clones and never mutates its arguments.
|
|
402
|
-
*/
|
|
403
255
|
export function mergePinnedPorts(fresh, pinned) {
|
|
404
256
|
const merged = JSON.parse(JSON.stringify(fresh));
|
|
405
|
-
// Pinned tunnels win (live SSM/VPC endpoints); fresh-only tunnels survive.
|
|
406
257
|
const pinnedTunnels = JSON.parse(JSON.stringify(pinned.tunnels ?? {}));
|
|
407
258
|
merged.tunnels = { ...(merged.tunnels ?? {}), ...pinnedTunnels };
|
|
408
|
-
// Pin EVERY service's allocated port (never re-allocate). Track fresh→pinned
|
|
409
|
-
// port remaps so peer references (localhost:<freshPort>) can be rewritten.
|
|
410
259
|
const portRemaps = new Map();
|
|
411
260
|
for (const [name, mergedSvc] of Object.entries(merged.services ?? {})) {
|
|
412
261
|
const pinnedSvc = pinned.services?.[name];
|
|
@@ -421,35 +270,18 @@ export function mergePinnedPorts(fresh, pinned) {
|
|
|
421
270
|
portRemaps.set(freshPort, pinnedSvc.port);
|
|
422
271
|
}
|
|
423
272
|
}
|
|
424
|
-
// Re-apply cross-service URL rewrite (mirrors allocatePorts): peer references
|
|
425
|
-
// to a preferred port become references to the session-allocated port.
|
|
426
273
|
rewriteCrossServicePorts(merged, portRemaps);
|
|
427
|
-
// BOTH rewrites, or `--refresh-env` undoes the other one. This path re-reads
|
|
428
|
-
// `pulumi stack output`, which carries in-cluster hosts verbatim, so without
|
|
429
|
-
// this a refresh restores `AGENT_API_INTERNAL_URL` to
|
|
430
|
-
// `http://studio-api.<ns>.svc.cluster.local:8080` for a service running locally
|
|
431
|
-
// — reintroducing MESH-2597 on the command whose entire purpose is refreshing
|
|
432
|
-
// env.
|
|
433
274
|
rewriteClusterHostsToLocal(merged);
|
|
434
275
|
return merged;
|
|
435
276
|
}
|
|
436
|
-
// ============================================================================
|
|
437
|
-
// Pulumi helpers
|
|
438
|
-
// ============================================================================
|
|
439
|
-
/**
|
|
440
|
-
* Find the monorepo root by walking up from cwd looking for the root
|
|
441
|
-
* package.json (has "workspaces" or the well-known repo markers).
|
|
442
|
-
*/
|
|
443
277
|
function findMonorepoRoot() {
|
|
444
278
|
let dir = process.cwd();
|
|
445
279
|
const root = path.parse(dir).root;
|
|
446
280
|
while (dir !== root) {
|
|
447
|
-
// Look for pnpm-workspace.yaml (pnpm monorepo) or .git (repo root)
|
|
448
281
|
if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) ||
|
|
449
282
|
fs.existsSync(path.join(dir, 'turbo.json'))) {
|
|
450
283
|
return dir;
|
|
451
284
|
}
|
|
452
|
-
// .git as fallback — but only if it's a directory (not a worktree file)
|
|
453
285
|
const gitPath = path.join(dir, '.git');
|
|
454
286
|
if (fs.existsSync(gitPath)) {
|
|
455
287
|
return dir;
|
|
@@ -458,15 +290,8 @@ function findMonorepoRoot() {
|
|
|
458
290
|
}
|
|
459
291
|
return null;
|
|
460
292
|
}
|
|
461
|
-
/**
|
|
462
|
-
* Discover Pulumi apps in the monorepo.
|
|
463
|
-
* Scans:
|
|
464
|
-
* {tenants,tests/tenants}/<tenant>/apps/<app>/Pulumi.yaml
|
|
465
|
-
* apps/<app>/Pulumi.yaml (flat layout, tenant from stack config)
|
|
466
|
-
*/
|
|
467
293
|
function discoverApps(monorepoRoot) {
|
|
468
294
|
const apps = [];
|
|
469
|
-
// Helper: extract stacks and add an app entry
|
|
470
295
|
function addApp(appDir, tenant) {
|
|
471
296
|
const projectName = getProjectName(appDir);
|
|
472
297
|
const stacks = fs
|
|
@@ -481,7 +306,6 @@ function discoverApps(monorepoRoot) {
|
|
|
481
306
|
stacks,
|
|
482
307
|
});
|
|
483
308
|
}
|
|
484
|
-
// 1. Tenant-scoped: {tenants,tests/tenants}/<tenant>/apps/<app>/
|
|
485
309
|
const searchDirs = ['tenants', 'tests/tenants'];
|
|
486
310
|
for (const searchDir of searchDirs) {
|
|
487
311
|
const base = path.join(monorepoRoot, searchDir);
|
|
@@ -499,7 +323,6 @@ function discoverApps(monorepoRoot) {
|
|
|
499
323
|
}
|
|
500
324
|
}
|
|
501
325
|
}
|
|
502
|
-
// 2. Flat layout: apps/<app>/ (tenant read from first stack config)
|
|
503
326
|
const flatAppsDir = path.join(monorepoRoot, 'apps');
|
|
504
327
|
if (fs.existsSync(flatAppsDir) && fs.statSync(flatAppsDir).isDirectory()) {
|
|
505
328
|
for (const app of fs.readdirSync(flatAppsDir)) {
|
|
@@ -508,7 +331,6 @@ function discoverApps(monorepoRoot) {
|
|
|
508
331
|
continue;
|
|
509
332
|
if (!fs.existsSync(path.join(appDir, 'Pulumi.yaml')))
|
|
510
333
|
continue;
|
|
511
|
-
// Read tenant from first stack config's mesh:tenant
|
|
512
334
|
let tenant = 'unknown';
|
|
513
335
|
const stackConfigs = fs
|
|
514
336
|
.readdirSync(appDir)
|
|
@@ -527,16 +349,11 @@ function discoverApps(monorepoRoot) {
|
|
|
527
349
|
}
|
|
528
350
|
return apps;
|
|
529
351
|
}
|
|
530
|
-
/**
|
|
531
|
-
* Find the Pulumi app root by walking up from cwd looking for Pulumi.yaml.
|
|
532
|
-
*/
|
|
533
352
|
function findAppRoot(appPath) {
|
|
534
353
|
if (appPath) {
|
|
535
|
-
// Resolve relative to cwd or monorepo root
|
|
536
354
|
const resolved = path.resolve(appPath);
|
|
537
355
|
if (fs.existsSync(path.join(resolved, 'Pulumi.yaml')))
|
|
538
356
|
return resolved;
|
|
539
|
-
// Try relative to monorepo root
|
|
540
357
|
const mono = findMonorepoRoot();
|
|
541
358
|
if (mono) {
|
|
542
359
|
const fromMono = path.resolve(mono, appPath);
|
|
@@ -556,9 +373,6 @@ function findAppRoot(appPath) {
|
|
|
556
373
|
}
|
|
557
374
|
return process.cwd();
|
|
558
375
|
}
|
|
559
|
-
/**
|
|
560
|
-
* Read the Pulumi project name from Pulumi.yaml.
|
|
561
|
-
*/
|
|
562
376
|
function getProjectName(appRoot) {
|
|
563
377
|
const yamlPath = path.join(appRoot, 'Pulumi.yaml');
|
|
564
378
|
if (!fs.existsSync(yamlPath))
|
|
@@ -567,13 +381,6 @@ function getProjectName(appRoot) {
|
|
|
567
381
|
const match = content.match(/^name:\s*(.+)$/m);
|
|
568
382
|
return match?.[1]?.trim() ?? path.basename(appRoot);
|
|
569
383
|
}
|
|
570
|
-
/**
|
|
571
|
-
* Detect the current Pulumi stack from:
|
|
572
|
-
* 1. --stage flag
|
|
573
|
-
* 2. MESH_STAGE env var
|
|
574
|
-
* 3. `pulumi stack --show-name` (currently-selected stack)
|
|
575
|
-
* 4. Pulumi.<stack>.yaml files in the app root (if exactly one, use it)
|
|
576
|
-
*/
|
|
577
384
|
function detectStack(appRoot, stageArg) {
|
|
578
385
|
if (stageArg)
|
|
579
386
|
return stageArg;
|
|
@@ -589,9 +396,7 @@ function detectStack(appRoot, stageArg) {
|
|
|
589
396
|
return result;
|
|
590
397
|
}
|
|
591
398
|
catch {
|
|
592
|
-
// Fall through
|
|
593
399
|
}
|
|
594
|
-
// Infer from Pulumi.<stack>.yaml config files
|
|
595
400
|
try {
|
|
596
401
|
const files = fs
|
|
597
402
|
.readdirSync(appRoot)
|
|
@@ -605,17 +410,10 @@ function detectStack(appRoot, stageArg) {
|
|
|
605
410
|
}
|
|
606
411
|
}
|
|
607
412
|
catch {
|
|
608
|
-
// Fall through
|
|
609
413
|
}
|
|
610
414
|
return 'dev';
|
|
611
415
|
}
|
|
612
|
-
/**
|
|
613
|
-
* Build the `--stack <name>` args for Pulumi commands.
|
|
614
|
-
* Newer Pulumi versions require fully-qualified names (organization/project/stack)
|
|
615
|
-
* with `--stack`. If the stack is already selected, we can omit the flag entirely.
|
|
616
|
-
*/
|
|
617
416
|
function stackArgs(appRoot, stack) {
|
|
618
|
-
// Check if the requested stack is already the selected one — if so, skip --stack
|
|
619
417
|
try {
|
|
620
418
|
const selected = execFileSync('pulumi', ['stack', '--show-name'], {
|
|
621
419
|
cwd: appRoot,
|
|
@@ -626,9 +424,7 @@ function stackArgs(appRoot, stack) {
|
|
|
626
424
|
return [];
|
|
627
425
|
}
|
|
628
426
|
catch {
|
|
629
|
-
// ignore
|
|
630
427
|
}
|
|
631
|
-
// Try selecting the stack first (works with short names), then omit --stack
|
|
632
428
|
try {
|
|
633
429
|
execFileSync('pulumi', ['stack', 'select', stack], {
|
|
634
430
|
cwd: appRoot,
|
|
@@ -638,22 +434,9 @@ function stackArgs(appRoot, stack) {
|
|
|
638
434
|
return [];
|
|
639
435
|
}
|
|
640
436
|
catch {
|
|
641
|
-
// Fall through — pass --stack and hope the name is fully qualified
|
|
642
437
|
}
|
|
643
438
|
return ['--stack', stack];
|
|
644
439
|
}
|
|
645
|
-
/**
|
|
646
|
-
* Read the dev output from Pulumi stack.
|
|
647
|
-
* Tries `app` output first (new env.register() format), falls back to `dev`.
|
|
648
|
-
*/
|
|
649
|
-
/**
|
|
650
|
-
* Thrown by `getDevOutput` when no readable Pulumi stack output exists (usually a
|
|
651
|
-
* fresh app that hasn't been `stack init` + `deploy`ed yet). Typed so each caller
|
|
652
|
-
* presents it in its OWN context: only the launch path can conclude "nothing to
|
|
653
|
-
* run" and print first-run guidance; the `--refresh-env`/restart handlers must
|
|
654
|
-
* keep the running session's env untouched (MESH-2097). Carries the underlying
|
|
655
|
-
* read error so an auth/network/JSON failure isn't misdiagnosed as "fresh app".
|
|
656
|
-
*/
|
|
657
440
|
class MissingStackOutputError extends Error {
|
|
658
441
|
stackName;
|
|
659
442
|
constructor(stackName, cause) {
|
|
@@ -664,7 +447,6 @@ class MissingStackOutputError extends Error {
|
|
|
664
447
|
}
|
|
665
448
|
function getDevOutput(appRoot, stack, awsEnv) {
|
|
666
449
|
const sa = stackArgs(appRoot, stack);
|
|
667
|
-
// Try new format: export const app = env.register() → { dev: { ... } }
|
|
668
450
|
try {
|
|
669
451
|
const result = pulumiStackOutput(appRoot, 'app', sa, awsEnv);
|
|
670
452
|
const appOutput = JSON.parse(result);
|
|
@@ -673,25 +455,15 @@ function getDevOutput(appRoot, stack, awsEnv) {
|
|
|
673
455
|
}
|
|
674
456
|
}
|
|
675
457
|
catch {
|
|
676
|
-
// Fall through to legacy format
|
|
677
458
|
}
|
|
678
|
-
// Legacy format: export const dev = ...
|
|
679
459
|
try {
|
|
680
460
|
const result = pulumiStackOutput(appRoot, 'dev', sa, awsEnv);
|
|
681
461
|
return JSON.parse(result);
|
|
682
462
|
}
|
|
683
463
|
catch (err) {
|
|
684
|
-
// No readable stack output. Throw a TYPED error so each caller decides how to
|
|
685
|
-
// present it (MESH-2097): the launch path prints first-run guidance + exits;
|
|
686
|
-
// the --refresh-env/restart handlers keep the running session untouched.
|
|
687
|
-
// (process.exit() here previously hijacked control flow past those fail-closed
|
|
688
|
-
// catches, and a bare `throw` escaped as an uncaught node stacktrace.)
|
|
689
464
|
throw new MissingStackOutputError(stack, err);
|
|
690
465
|
}
|
|
691
466
|
}
|
|
692
|
-
// ============================================================================
|
|
693
|
-
// tmux helpers
|
|
694
|
-
// ============================================================================
|
|
695
467
|
function hasTmux() {
|
|
696
468
|
try {
|
|
697
469
|
execFileSync('which', ['tmux'], { stdio: 'ignore' });
|
|
@@ -732,9 +504,6 @@ function applyAwsProfileOverride(profile) {
|
|
|
732
504
|
delete process.env[key];
|
|
733
505
|
}
|
|
734
506
|
}
|
|
735
|
-
/**
|
|
736
|
-
* Collect AWS credentials from the current environment.
|
|
737
|
-
*/
|
|
738
507
|
function getAwsEnvVars() {
|
|
739
508
|
const result = {};
|
|
740
509
|
const vars = [
|
|
@@ -752,28 +521,6 @@ function getAwsEnvVars() {
|
|
|
752
521
|
}
|
|
753
522
|
return result;
|
|
754
523
|
}
|
|
755
|
-
/**
|
|
756
|
-
* Build the AWS env vars to hand to long-lived child services (ui, api,
|
|
757
|
-
* ai-assistant) and SSM tunnels.
|
|
758
|
-
*
|
|
759
|
-
* For the AWS SSO credential path, returns AWS_PROFILE + AWS_REGION only so
|
|
760
|
-
* the AWS SDK's default provider chain can auto-refresh credentials from the
|
|
761
|
-
* SSO cache (~8h session) as short-lived STS tokens expire. Without this,
|
|
762
|
-
* child processes inherit a frozen STS triple and start throwing
|
|
763
|
-
* ExpiredTokenException ~1h into a dev session.
|
|
764
|
-
*
|
|
765
|
-
* For the Zitadel web-identity path, when `opts` is supplied we write an AWS
|
|
766
|
-
* config file whose `credential_process` re-invokes `mesh login … --credential-process`,
|
|
767
|
-
* so the AWS SDK's default provider chain auto-refreshes the underlying STS
|
|
768
|
-
* triple (the `mesh login` process re-issues the Zitadel token as needed).
|
|
769
|
-
* This removes the ~1h forced `mesh dev` restart. When `opts` is absent we
|
|
770
|
-
* fall back to the frozen STS triple (used by CI/`environment` and any caller
|
|
771
|
-
* that doesn't thread the context/role through).
|
|
772
|
-
*
|
|
773
|
-
* For the remaining non-SSO paths (CI/CD env keys, or no deployer role),
|
|
774
|
-
* returns the explicit STS triple — there is no profile to refresh from. The
|
|
775
|
-
* user must restart `mesh dev` when those tokens expire.
|
|
776
|
-
*/
|
|
777
524
|
export function buildChildAwsEnv(awsEnv, method, profile, opts) {
|
|
778
525
|
const result = {};
|
|
779
526
|
if (awsEnv.AWS_REGION)
|
|
@@ -784,8 +531,6 @@ export function buildChildAwsEnv(awsEnv, method, profile, opts) {
|
|
|
784
531
|
result.AWS_PROFILE = profile;
|
|
785
532
|
return result;
|
|
786
533
|
}
|
|
787
|
-
// Zitadel path with a credential_process profile: hand children a profile
|
|
788
|
-
// that self-refreshes via `mesh login`, never a frozen triple.
|
|
789
534
|
if (method === 'zitadel' && opts) {
|
|
790
535
|
const sanitizedContext = opts.context.replace(/[^A-Za-z0-9_-]/g, '-');
|
|
791
536
|
const profileName = `mesh-dev-${sanitizedContext}`;
|
|
@@ -799,12 +544,6 @@ export function buildChildAwsEnv(awsEnv, method, profile, opts) {
|
|
|
799
544
|
region,
|
|
800
545
|
meshBin: opts.meshBin,
|
|
801
546
|
});
|
|
802
|
-
// UPSERT a managed section; never rewrite the file. This file has a second
|
|
803
|
-
// writer — `mesh login --export` (the worker-start recipe evals it) — and
|
|
804
|
-
// the AWS SDK reads it ONCE per process, so a wholesale write here deletes
|
|
805
|
-
// that profile under a running worker, which then fails every LLM call until
|
|
806
|
-
// it is restarted. The key carries "(mesh dev)" so the two writers own two
|
|
807
|
-
// sections rather than replacing each other's.
|
|
808
547
|
const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
|
|
809
548
|
const next = upsertManagedAwsConfigSection(stripBareProfile(existing, profileName), `${opts.context} (mesh dev)`, profileBlock);
|
|
810
549
|
if (next !== existing)
|
|
@@ -826,20 +565,6 @@ export function buildChildAwsEnv(awsEnv, method, profile, opts) {
|
|
|
826
565
|
}
|
|
827
566
|
return result;
|
|
828
567
|
}
|
|
829
|
-
/**
|
|
830
|
-
* Resolve a service's source dir onto the CURRENT worktree (MESH-2013).
|
|
831
|
-
*
|
|
832
|
-
* `mesh dev` reads the service `src` from `pulumi stack output`. Two shapes exist:
|
|
833
|
-
* - New (post relative-src fix in infra-components): a monorepo-RELATIVE dir
|
|
834
|
-
* like `packages/agent-sdk` → just resolve it against this worktree's root.
|
|
835
|
-
* - Legacy: an ABSOLUTE path baked at the deploying `pulumi up`, e.g.
|
|
836
|
-
* `/…/mesh-platform/.worktrees/streaming-ui-builder/packages/agent-sdk`. The
|
|
837
|
-
* old logic stripped only up to `/mesh-platform/`, keeping the
|
|
838
|
-
* `.worktrees/<name>/` segment — so it re-rooted to a *sibling worktree* that
|
|
839
|
-
* also exists, silently running the wrong worktree's code. We now reduce to
|
|
840
|
-
* the monorepo-relative portion (dropping any `.worktrees/<name>/`) and
|
|
841
|
-
* re-root here, so a stack deployed from any worktree runs from THIS one.
|
|
842
|
-
*/
|
|
843
568
|
function rebaseServiceSrc(src, monorepoRoot) {
|
|
844
569
|
if (!monorepoRoot)
|
|
845
570
|
return src;
|
|
@@ -849,32 +574,7 @@ function rebaseServiceSrc(src, monorepoRoot) {
|
|
|
849
574
|
const rebased = path.join(monorepoRoot, rel);
|
|
850
575
|
return fs.existsSync(rebased) ? rebased : src;
|
|
851
576
|
}
|
|
852
|
-
/**
|
|
853
|
-
* Is `serviceDir` a package this app DEV-LINKED, rather than a stale sibling
|
|
854
|
-
* worktree?
|
|
855
|
-
*
|
|
856
|
-
* Both look identical to the wrong-worktree guard — a source directory outside
|
|
857
|
-
* the app's worktree — but only one is a mistake. A module that contributes a
|
|
858
|
-
* dev service (`InfraContribution.devServices`) reports `resolvePackageRoot()`,
|
|
859
|
-
* which is the package's REAL path; when that package is dev-linked from another
|
|
860
|
-
* repo it legitimately lives there. Refusing it means a devService can run only
|
|
861
|
-
* while its package is published — i.e. never while you are developing it, which
|
|
862
|
-
* is the only time you need it.
|
|
863
|
-
*
|
|
864
|
-
* The test is proof, not a heuristic: read the directory's own package name and
|
|
865
|
-
* ask whether THIS app resolves that name to THIS directory. A stale sibling
|
|
866
|
-
* worktree cannot satisfy that — nothing in the app's `node_modules` points at
|
|
867
|
-
* it — so the wrong-worktree guard keeps its teeth.
|
|
868
|
-
*
|
|
869
|
-
* @example
|
|
870
|
-
* isLinkedDependencyDir("/repo/libs/infra-components/src/components/ai/redis", "/repo/apps/demo");
|
|
871
|
-
* // → true when the app resolves @mesh-tech/infra-components to that directory
|
|
872
|
-
*/
|
|
873
577
|
export function isLinkedDependencyDir(serviceDir, appRoot) {
|
|
874
|
-
// The service source is often INSIDE a package rather than its root — a redis
|
|
875
|
-
// component ships as `<infra-components>/src/components/ai/redis`. So find the
|
|
876
|
-
// enclosing package first (nearest ancestor with a named manifest), then ask
|
|
877
|
-
// whether the app resolves THAT name to THAT directory.
|
|
878
578
|
let pkgRoot = null;
|
|
879
579
|
let name = '';
|
|
880
580
|
for (let dir = serviceDir;; dir = path.dirname(dir)) {
|
|
@@ -887,7 +587,6 @@ export function isLinkedDependencyDir(serviceDir, appRoot) {
|
|
|
887
587
|
}
|
|
888
588
|
}
|
|
889
589
|
catch {
|
|
890
|
-
/* no manifest here; keep walking up */
|
|
891
590
|
}
|
|
892
591
|
const parent = path.dirname(dir);
|
|
893
592
|
if (parent === dir)
|
|
@@ -895,44 +594,27 @@ export function isLinkedDependencyDir(serviceDir, appRoot) {
|
|
|
895
594
|
}
|
|
896
595
|
if (!pkgRoot)
|
|
897
596
|
return false;
|
|
898
|
-
// Resolve from the app outward, so a package linked for a DIFFERENT app in the
|
|
899
|
-
// same repo does not vouch for this one.
|
|
900
597
|
for (let dir = appRoot;; dir = path.dirname(dir)) {
|
|
901
598
|
try {
|
|
902
599
|
if (fs.realpathSync(path.join(dir, 'node_modules', name)) === pkgRoot)
|
|
903
600
|
return true;
|
|
904
601
|
}
|
|
905
602
|
catch {
|
|
906
|
-
/* not resolvable here; keep walking up */
|
|
907
603
|
}
|
|
908
604
|
const parent = path.dirname(dir);
|
|
909
605
|
if (parent === dir)
|
|
910
606
|
return false;
|
|
911
607
|
}
|
|
912
608
|
}
|
|
913
|
-
/**
|
|
914
|
-
* Reduce a service `src` to its monorepo-relative form (pure; no filesystem).
|
|
915
|
-
* - relative (`./packages/x` or `packages/x`) → normalized `packages/x`
|
|
916
|
-
* - legacy absolute (`/…/mesh-platform[/.worktrees/<name>]/packages/x`) →
|
|
917
|
-
* `packages/x` (drops the repo root AND any deploying-worktree segment)
|
|
918
|
-
* Returns null when an absolute path has no recognizable monorepo marker.
|
|
919
|
-
*/
|
|
920
609
|
export function monorepoRelativeSrc(src) {
|
|
921
610
|
if (!path.isAbsolute(src))
|
|
922
611
|
return src.replace(/^\.\//, '') || '.';
|
|
923
|
-
// Match the LAST `/mesh-platform` at a segment boundary — covers the repo root
|
|
924
|
-
// itself (nothing follows → '.'), a nested `.worktrees/<name>/`, and normal
|
|
925
|
-
// subpaths. A path ending at the repo root (e.g. a service whose src IS the
|
|
926
|
-
// monorepo root) previously slipped through and stayed absolute (MESH-2069).
|
|
927
612
|
const m = src.match(/^.*\/mesh-platform(?:\/(.*))?$/);
|
|
928
613
|
if (!m)
|
|
929
614
|
return null;
|
|
930
615
|
const rest = (m[1] ?? '').replace(/^\.worktrees\/[^/]+(?:\/|$)/, '');
|
|
931
616
|
return rest === '' ? '.' : rest;
|
|
932
617
|
}
|
|
933
|
-
/**
|
|
934
|
-
* Resolve env vars for a service, rewriting tunnel references to VPC endpoints.
|
|
935
|
-
*/
|
|
936
618
|
function getServiceEnvVars(service, tunnels) {
|
|
937
619
|
const result = {};
|
|
938
620
|
if (service.env) {
|
|
@@ -941,7 +623,6 @@ function getServiceEnvVars(service, tunnels) {
|
|
|
941
623
|
if (spec.tunnel) {
|
|
942
624
|
const tunnel = tunnels[spec.tunnel];
|
|
943
625
|
if (tunnel) {
|
|
944
|
-
// Rewrite to VPC endpoint (VPN provides direct access)
|
|
945
626
|
try {
|
|
946
627
|
const url = new URL(value);
|
|
947
628
|
if (url.hostname) {
|
|
@@ -963,24 +644,11 @@ function getServiceEnvVars(service, tunnels) {
|
|
|
963
644
|
}
|
|
964
645
|
return result;
|
|
965
646
|
}
|
|
966
|
-
/**
|
|
967
|
-
* Set env vars on a tmux session. New windows inherit them.
|
|
968
|
-
*
|
|
969
|
-
* Use for truly shared vars (AWS creds, secrets). For per-service
|
|
970
|
-
* vars like PORT, use envPrefix() instead to avoid cross-service leaks.
|
|
971
|
-
*/
|
|
972
647
|
function setTmuxEnv(sessionName, vars) {
|
|
973
648
|
for (const [key, value] of Object.entries(vars)) {
|
|
974
649
|
execFileSync('tmux', ['set-environment', '-t', sessionName, key, value], { stdio: 'ignore' });
|
|
975
650
|
}
|
|
976
651
|
}
|
|
977
|
-
/**
|
|
978
|
-
* Build an `env K=V …` prefix string for per-service env vars.
|
|
979
|
-
*
|
|
980
|
-
* This scopes variables to the process instead of polluting the
|
|
981
|
-
* tmux session environment (where the last service's PORT would
|
|
982
|
-
* leak to every other service on restart).
|
|
983
|
-
*/
|
|
984
652
|
function envPrefix(vars) {
|
|
985
653
|
const entries = Object.entries(vars);
|
|
986
654
|
if (entries.length === 0)
|
|
@@ -988,21 +656,11 @@ function envPrefix(vars) {
|
|
|
988
656
|
const parts = entries.map(([k, v]) => `${k}=${shellEscape(v)}`);
|
|
989
657
|
return `env ${parts.join(' ')} `;
|
|
990
658
|
}
|
|
991
|
-
/** Escape a value for shell interpolation. */
|
|
992
659
|
function shellEscape(s) {
|
|
993
|
-
// If the value is safe, return as-is
|
|
994
660
|
if (/^[a-zA-Z0-9_./:@=+,-]+$/.test(s))
|
|
995
661
|
return s;
|
|
996
|
-
// Otherwise single-quote it, escaping any embedded single quotes
|
|
997
662
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
998
663
|
}
|
|
999
|
-
// ============================================================================
|
|
1000
|
-
// Secret resolution
|
|
1001
|
-
// ============================================================================
|
|
1002
|
-
/**
|
|
1003
|
-
* Resolve secrets from Secrets Manager and return env var key→value pairs.
|
|
1004
|
-
* VPN provides direct access — no URL rewriting needed.
|
|
1005
|
-
*/
|
|
1006
664
|
async function resolveSecrets(secrets) {
|
|
1007
665
|
const client = new SecretsManagerClient({});
|
|
1008
666
|
const resolved = {};
|
|
@@ -1026,16 +684,6 @@ async function resolveSecrets(secrets) {
|
|
|
1026
684
|
}
|
|
1027
685
|
return resolved;
|
|
1028
686
|
}
|
|
1029
|
-
/**
|
|
1030
|
-
* Resolve the Temporal encoding key from the K8s secret in the app namespace.
|
|
1031
|
-
*
|
|
1032
|
-
* The Pulumi-generated devEncodingKey is a placeholder that differs from the
|
|
1033
|
-
* deployed key, causing decryption failures when the local worker encounters
|
|
1034
|
-
* existing Temporal payloads. Reading the real key from K8s ensures local dev
|
|
1035
|
-
* can decrypt data created by deployed workers.
|
|
1036
|
-
*
|
|
1037
|
-
* Returns the encoding key value, or undefined if not available.
|
|
1038
|
-
*/
|
|
1039
687
|
function resolveTemporalEncodingKey(tenant, env, appName) {
|
|
1040
688
|
const namespace = `${tenant}-${env}-${appName}`;
|
|
1041
689
|
const secretName = `${namespace}-temporal-encoding-key`;
|
|
@@ -1056,23 +704,12 @@ function resolveTemporalEncodingKey(tenant, env, appName) {
|
|
|
1056
704
|
}
|
|
1057
705
|
}
|
|
1058
706
|
catch {
|
|
1059
|
-
// Non-fatal — secret may not exist yet or kubectl may not have access
|
|
1060
707
|
}
|
|
1061
708
|
return undefined;
|
|
1062
709
|
}
|
|
1063
|
-
/**
|
|
1064
|
-
* Re-resolve the session-level Temporal auth vars the SAME way launch does
|
|
1065
|
-
* (see startServices): kubeconfig from SSM, M2M auth, encoding key from the
|
|
1066
|
-
* K8s secret, and TEMPORAL_ADDRESS from the (pinned) tunnel. Used by
|
|
1067
|
-
* `mesh dev restart --refresh-env` so a regenerated env file carries the live
|
|
1068
|
-
* resolved values, not the dev-output placeholders (which would desync payload
|
|
1069
|
-
* encryption between services).
|
|
1070
|
-
*/
|
|
1071
710
|
async function resolveTemporalAuthVars(sessionName, devOutput) {
|
|
1072
711
|
const tenant = devOutput.platform?.tenant ?? 'mesh';
|
|
1073
712
|
const platformEnv = devOutput.platform?.env ?? 'dev';
|
|
1074
|
-
// Hub platform name — the EKS export lives under it, not under a hardcoded
|
|
1075
|
-
// "mesh" (MESH-2515); same axis as the bastion resolution.
|
|
1076
713
|
const platformName = resolveHubPlatformName(devOutput.platform);
|
|
1077
714
|
const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {
|
|
1078
715
|
onError: ({ parameter, error }) => logWarn(`EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`),
|
|
@@ -1082,25 +719,13 @@ async function resolveTemporalAuthVars(sessionName, devOutput) {
|
|
|
1082
719
|
setTmuxEnv(sessionName, { KUBECONFIG: kubeconfigPath });
|
|
1083
720
|
}
|
|
1084
721
|
else if (!process.env.KUBECONFIG) {
|
|
1085
|
-
// ensureKubeconfig couldn't re-resolve the cluster from SSM (transient /
|
|
1086
|
-
// stale creds). Fall back to the session kubeconfig written at launch so the
|
|
1087
|
-
// kubectl calls below don't default to ~/.kube/config — which macOS TCC
|
|
1088
|
-
// denies for shells without Full Disk Access ("operation not permitted").
|
|
1089
722
|
const existing = sessionKubeconfigPath(sessionName);
|
|
1090
723
|
if (fs.existsSync(existing)) {
|
|
1091
|
-
// Set only this process's env — deliberately NOT setTmuxEnv: this is a
|
|
1092
|
-
// best-effort fallback to a possibly-stale last-good kubeconfig, so we use
|
|
1093
|
-
// it for the kubectl calls below without persisting it into the session
|
|
1094
|
-
// (a later successful re-resolve is the one that should win the session).
|
|
1095
724
|
process.env.KUBECONFIG = existing;
|
|
1096
725
|
}
|
|
1097
726
|
}
|
|
1098
727
|
let temporalAuthVars = {};
|
|
1099
728
|
if (devOutput.tunnels['temporal']) {
|
|
1100
|
-
// Deliberately NOT `platformName` from above: the Temporal-auth fallback is
|
|
1101
|
-
// `?? tenant` (dedicated tenants may run their own Temporal), while the EKS
|
|
1102
|
-
// fallback is `?? 'mesh'`. Whether that difference is correct is MESH-2551;
|
|
1103
|
-
// don't "fix" either default to match the other without reading it.
|
|
1104
729
|
temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
|
|
1105
730
|
const appName = devOutput.app ?? '';
|
|
1106
731
|
if (appName) {
|
|
@@ -1114,43 +739,6 @@ async function resolveTemporalAuthVars(sessionName, devOutput) {
|
|
|
1114
739
|
}
|
|
1115
740
|
return temporalAuthVars;
|
|
1116
741
|
}
|
|
1117
|
-
/**
|
|
1118
|
-
* Resolve the dev-user identity vars (DEV_USER_*) from cached `mesh login`
|
|
1119
|
-
* credentials for the platform context (e.g. 'mesh.dev'). Credentials are
|
|
1120
|
-
* stored under the platform context, not the tenant name — all tenants share
|
|
1121
|
-
* the same Zitadel instance.
|
|
1122
|
-
*
|
|
1123
|
-
* Services (e.g., Hub UI) use these as a fallback when the real oauth2-proxy
|
|
1124
|
-
* `X-Forwarded-*` request headers are absent:
|
|
1125
|
-
* DEV_USER_ID - subject from idToken, matches conversation ownership
|
|
1126
|
-
* DEV_USER_EMAIL - user email from idToken
|
|
1127
|
-
* DEV_USER_ID_TOKEN - full idToken JWT (profile claims, authorization hint)
|
|
1128
|
-
* DEV_USER_ACCESS_TOKEN - full accessToken JWT (has project:roles claim)
|
|
1129
|
-
*
|
|
1130
|
-
* Returned vars must be overlaid onto each service's env FILE (not just the
|
|
1131
|
-
* tmux session env): the dev output may carry a placeholder value (e.g. hub
|
|
1132
|
-
* ui's DEV_USER_ACCESS_TOKEN="dev-local"), and the sourced per-service env
|
|
1133
|
-
* file wins over the session env, so a session-env-only injection gets
|
|
1134
|
-
* clobbered and every tenant-scoped Hub view silently 401s.
|
|
1135
|
-
*/
|
|
1136
|
-
/**
|
|
1137
|
-
* The `mesh login` context that issues this app's dev-user identity.
|
|
1138
|
-
*
|
|
1139
|
-
* It is the PLATFORM's name and env — `trabian.dev`, `mesh.dev` — because the
|
|
1140
|
-
* platform owns the Zitadel the app's services verify against, so a token from
|
|
1141
|
-
* any other issuer is well-formed and rejected. Not every app runs on the `mesh`
|
|
1142
|
-
* platform: a shared tenant on the `trabian` platform whose dev user came from
|
|
1143
|
-
* `identity.dev.mesh-platform.trabian.com` would be verified against
|
|
1144
|
-
* `identity.dev.trabian.meshtech.io`.
|
|
1145
|
-
*
|
|
1146
|
-
* That failure is invisible at injection time and only surfaces at the far end
|
|
1147
|
-
* of a cross-app call, as `Missing or invalid Authorization header — expected
|
|
1148
|
-
* Bearer token`: the token is well-formed and unexpired, just from the wrong
|
|
1149
|
-
* issuer.
|
|
1150
|
-
*
|
|
1151
|
-
* `name` is optional on the dev output, so fall back to `mesh`, which keeps
|
|
1152
|
-
* mesh-platform apps on the context they already use.
|
|
1153
|
-
*/
|
|
1154
742
|
function loginContextFor(platform) {
|
|
1155
743
|
return `${platform.name ?? 'mesh'}.${platform.env}`;
|
|
1156
744
|
}
|
|
@@ -1174,54 +762,18 @@ function resolveDevUserVars(devOutput) {
|
|
|
1174
762
|
vars.DEV_USER_EMAIL = payload.email;
|
|
1175
763
|
}
|
|
1176
764
|
catch {
|
|
1177
|
-
/* ignore decode errors */
|
|
1178
765
|
}
|
|
1179
766
|
if (meshCreds.accessToken) {
|
|
1180
767
|
vars.DEV_USER_ACCESS_TOKEN = meshCreds.accessToken;
|
|
1181
768
|
}
|
|
1182
769
|
return vars;
|
|
1183
770
|
}
|
|
1184
|
-
// ============================================================================
|
|
1185
|
-
// SSM tunnel fallback (when VPN is not connected)
|
|
1186
|
-
// ============================================================================
|
|
1187
|
-
/**
|
|
1188
|
-
* Base port for SSM tunnels. Preferred ports use 20000 + original_port to
|
|
1189
|
-
* avoid collisions with dev services (1000-9999 range). If the preferred
|
|
1190
|
-
* port is already reserved or occupied, mesh dev walks upward until it finds
|
|
1191
|
-
* an available port.
|
|
1192
|
-
*
|
|
1193
|
-
* Examples:
|
|
1194
|
-
* PostgreSQL 5432 → 25432
|
|
1195
|
-
* Temporal 7233 → 27233
|
|
1196
|
-
* Temporal UI 8080 → 28080
|
|
1197
|
-
*/
|
|
1198
771
|
const SSM_TUNNEL_BASE_PORT = 20000;
|
|
1199
|
-
/** How long to wait for an SSM tunnel's local port to accept TCP before failing. */
|
|
1200
772
|
const TUNNEL_READY_TIMEOUT_MS = 60_000;
|
|
1201
|
-
/**
|
|
1202
|
-
* Build the client-facing `host:port` for an SSM tunnel.
|
|
1203
|
-
*
|
|
1204
|
-
* The `aws ssm start-session` port-forwarder binds IPv4 `127.0.0.1` only.
|
|
1205
|
-
* Advertising `localhost` lets Node/grpc-js resolve to IPv6 `::1` first, which
|
|
1206
|
-
* the tunnel isn't listening on — so e.g. the Temporal gRPC client fails with
|
|
1207
|
-
* `ECONNREFUSED ::1:<port>` and the Hub's Automations/workflow lists never load.
|
|
1208
|
-
* Maps the literal `localhost` (how tunnels are advertised today) to `127.0.0.1`
|
|
1209
|
-
* so clients hit the bound listener. Any other host — VPN-direct VPC endpoints,
|
|
1210
|
-
* or a different loopback literal like `::1`/`127.0.0.1` — passes through unchanged.
|
|
1211
|
-
*/
|
|
1212
773
|
export function tunnelClientAddress(tunnel) {
|
|
1213
774
|
const host = tunnel.host === 'localhost' ? '127.0.0.1' : tunnel.host;
|
|
1214
775
|
return `${host}:${tunnel.port}`;
|
|
1215
776
|
}
|
|
1216
|
-
/**
|
|
1217
|
-
* Render the Connections section (live tunnel health) as status-window lines.
|
|
1218
|
-
*
|
|
1219
|
-
* The status window refreshes every ~2s, so this is where a mid-session tunnel
|
|
1220
|
-
* death surfaces (MESH-2016): a dead tunnel shows `⚠ … DOWN` and a SINGLE
|
|
1221
|
-
* remediation line explains the `ECONNREFUSED` retry spam the dependent service
|
|
1222
|
-
* windows emit — instead of leaving the operator to decode that spam themselves.
|
|
1223
|
-
* Returns `[]` when there are no tunnels (nothing to show).
|
|
1224
|
-
*/
|
|
1225
777
|
export function formatTunnelHealth(health) {
|
|
1226
778
|
if (health.length === 0)
|
|
1227
779
|
return [];
|
|
@@ -1240,32 +792,13 @@ export function formatTunnelHealth(health) {
|
|
|
1240
792
|
}
|
|
1241
793
|
return lines;
|
|
1242
794
|
}
|
|
1243
|
-
/** Probe every tunnel's client address in parallel and resolve its health. */
|
|
1244
795
|
async function probeTunnelHealth(tunnels) {
|
|
1245
796
|
return Promise.all(Object.entries(tunnels).map(async ([name, tun]) => ({
|
|
1246
797
|
name,
|
|
1247
798
|
address: tunnelClientAddress(tun),
|
|
1248
|
-
// Dial the SAME address the client uses (localhost→127.0.0.1) so the probe
|
|
1249
|
-
// sees exactly what a dependent service would. Use the connection-HOLDS
|
|
1250
|
-
// probe, not a bare accept: a forwarder binds its local port even when its
|
|
1251
|
-
// upstream leg is dead (a dropped SSM session, or a Tailscale SOCKS bridge
|
|
1252
|
-
// dialing a stale port — MESH-2169), so "port accepts" would report a dead
|
|
1253
|
-
// tunnel as healthy. Holding briefly catches the accept-then-close teardown.
|
|
1254
799
|
reachable: await probeConnectionHolds(tun.host === 'localhost' ? '127.0.0.1' : tun.host, tun.port, 800),
|
|
1255
800
|
})));
|
|
1256
801
|
}
|
|
1257
|
-
/**
|
|
1258
|
-
* Probe the routing-critical tunnel of a (Tailscale-rewritten) devOutput and
|
|
1259
|
-
* return its name if the forwarder binds but does not ROUTE, else null.
|
|
1260
|
-
*
|
|
1261
|
-
* Only `temporal` is gated — it's the load-bearing VPC dependency (services die
|
|
1262
|
-
* on a gRPC deadline when it's dead), and keying on it mirrors the vpn-direct
|
|
1263
|
-
* reachability gate ({@link gateVpnDirectReachability}). A Tailscale forwarder
|
|
1264
|
-
* accepts its local port even when its SOCKS upstream is dead or the joined
|
|
1265
|
-
* tailnet doesn't route the VPC; such a forwarder tears the connection down
|
|
1266
|
-
* within the grace window, so it fails the connection-HOLDS probe that a bare
|
|
1267
|
-
* accept (the readiness gate's `waitForPort`) would pass (MESH-2169/MESH-2268).
|
|
1268
|
-
*/
|
|
1269
802
|
export async function firstUnroutableTunnel(tunnels) {
|
|
1270
803
|
const temporal = tunnels['temporal'];
|
|
1271
804
|
if (!temporal || temporal.host !== 'localhost')
|
|
@@ -1273,15 +806,6 @@ export async function firstUnroutableTunnel(tunnels) {
|
|
|
1273
806
|
const holds = await probeConnectionHolds('127.0.0.1', temporal.port, 800);
|
|
1274
807
|
return holds ? null : 'temporal';
|
|
1275
808
|
}
|
|
1276
|
-
/**
|
|
1277
|
-
* Resolve which tunnel backing `mesh dev` should use.
|
|
1278
|
-
*
|
|
1279
|
-
* An explicit `--transport` flag wins. Both `auto` and the default (unset)
|
|
1280
|
-
* prefer Tailscale when it's available (brew `tailscaled` present), else fall
|
|
1281
|
-
* back to the VPN-direct/SSM choice: VPN-direct when the VPN is connected, else
|
|
1282
|
-
* SSM when the session-manager plugin is present. Where Tailscale isn't
|
|
1283
|
-
* available the behavior is unchanged from before this feature.
|
|
1284
|
-
*/
|
|
1285
809
|
export function resolveTransport(flag, ctx) {
|
|
1286
810
|
if (flag === "tailscale" || flag === "ssm")
|
|
1287
811
|
return flag;
|
|
@@ -1291,15 +815,6 @@ export function resolveTransport(flag, ctx) {
|
|
|
1291
815
|
const legacy = ctx.vpnConnected ? "vpn-direct" : ctx.hasSsmPlugin ? "ssm" : "vpn-direct";
|
|
1292
816
|
return ctx.tailscaleAvailable ? "tailscale" : legacy;
|
|
1293
817
|
}
|
|
1294
|
-
/**
|
|
1295
|
-
* Correct an auto-selected `vpn-direct` transport against ACTUAL reachability of
|
|
1296
|
-
* the Temporal VPC endpoint (MESH-2016). `resolveTransport` uses a presence check
|
|
1297
|
-
* (any Tailscale/VPN up) that's tailnet-blind — Tailscale-for-SSH looks connected
|
|
1298
|
-
* but doesn't route the mesh VPC. Only `vpn-direct` is reachability-gated (it's
|
|
1299
|
-
* never an explicit choice); every other transport passes through unchanged.
|
|
1300
|
-
*
|
|
1301
|
-
* Pure so the fallback logic is testable independently of the TCP probe.
|
|
1302
|
-
*/
|
|
1303
818
|
export function reachabilityFallbackTransport(transport, temporalReachable, hasSsmPlugin) {
|
|
1304
819
|
if (transport !== "vpn-direct")
|
|
1305
820
|
return { transport, reason: "not-vpn-direct" };
|
|
@@ -1309,17 +824,6 @@ export function reachabilityFallbackTransport(transport, temporalReachable, hasS
|
|
|
1309
824
|
return { transport: "ssm", reason: "fallback-ssm" };
|
|
1310
825
|
return { transport, reason: "unreachable-no-plugin" };
|
|
1311
826
|
}
|
|
1312
|
-
/**
|
|
1313
|
-
* Apply the reachability gate to a (possibly auto-selected) transport: if it's
|
|
1314
|
-
* `vpn-direct`, TCP-dial the Temporal VPC endpoint and downgrade to SSM when it's
|
|
1315
|
-
* actually unreachable, logging the decision. Returns the effective transport;
|
|
1316
|
-
* a no-op for any non-`vpn-direct` transport.
|
|
1317
|
-
*
|
|
1318
|
-
* Called on BOTH auto-select paths — the normal resolve result AND the
|
|
1319
|
-
* Tailscale→legacy fallback (when no mesh login config exists for the derived
|
|
1320
|
-
* context) — so a `vpn-direct` can never reach `startServices` unprobed
|
|
1321
|
-
* (MESH-2016).
|
|
1322
|
-
*/
|
|
1323
827
|
async function gateVpnDirectReachability(transport, devOutput) {
|
|
1324
828
|
if (transport !== "vpn-direct")
|
|
1325
829
|
return transport;
|
|
@@ -1344,11 +848,6 @@ async function gateVpnDirectReachability(transport, devOutput) {
|
|
|
1344
848
|
}
|
|
1345
849
|
return decision.transport;
|
|
1346
850
|
}
|
|
1347
|
-
/**
|
|
1348
|
-
* Derive the Headscale login-server URL from a login context's issuer
|
|
1349
|
-
* (`identity.<env>.<domain>` → `vpn.<env>.<domain>`). Used for the Tailscale
|
|
1350
|
-
* transport's OIDC join. Returns null if the context has no cached login config.
|
|
1351
|
-
*/
|
|
1352
851
|
function deriveLoginServer(context) {
|
|
1353
852
|
const cfg = getContextConfig(context);
|
|
1354
853
|
if (!cfg?.issuer)
|
|
@@ -1370,13 +869,6 @@ export function preferredSsmLocalPort(remotePort) {
|
|
|
1370
869
|
}
|
|
1371
870
|
return preferred;
|
|
1372
871
|
}
|
|
1373
|
-
/**
|
|
1374
|
-
* Reserve a deterministic local-port candidate for an SSM tunnel.
|
|
1375
|
-
*
|
|
1376
|
-
* This prevents two tunnels with the same remote port (common for HTTPS
|
|
1377
|
-
* endpoints) from being assigned the same localhost port before the AWS CLI
|
|
1378
|
-
* has a chance to bind it.
|
|
1379
|
-
*/
|
|
1380
872
|
export function reserveSsmLocalPortCandidate(remotePort, reservedPorts, startAt = preferredSsmLocalPort(remotePort)) {
|
|
1381
873
|
for (let port = startAt; port <= 65535; port += 1) {
|
|
1382
874
|
if (!reservedPorts.has(port)) {
|
|
@@ -1397,9 +889,6 @@ async function allocateSsmLocalPort(remotePort, reservedPorts) {
|
|
|
1397
889
|
}
|
|
1398
890
|
throw new Error(`Cannot allocate SSM tunnel port for remote port ${remotePort}: no local ports are available`);
|
|
1399
891
|
}
|
|
1400
|
-
/**
|
|
1401
|
-
* Check if the session-manager-plugin is installed (required for SSM tunnels).
|
|
1402
|
-
*/
|
|
1403
892
|
function hasSessionManagerPlugin() {
|
|
1404
893
|
try {
|
|
1405
894
|
execFileSync('which', ['session-manager-plugin'], { stdio: 'ignore' });
|
|
@@ -1409,24 +898,9 @@ function hasSessionManagerPlugin() {
|
|
|
1409
898
|
return false;
|
|
1410
899
|
}
|
|
1411
900
|
}
|
|
1412
|
-
/**
|
|
1413
|
-
* Start SSM port-forwarding tunnels as tmux windows within the dev session.
|
|
1414
|
-
*
|
|
1415
|
-
* For each tunnel defined in devOutput (e.g., temporal, rds), this:
|
|
1416
|
-
* 1. Reads the bastion instance ID and service endpoints from SSM
|
|
1417
|
-
* 2. Spawns `aws ssm start-session` in a dedicated tmux window
|
|
1418
|
-
* 3. Rewrites devOutput.tunnels to point at localhost:2XXXX
|
|
1419
|
-
*
|
|
1420
|
-
* Returns the rewritten devOutput with tunnels pointing to localhost.
|
|
1421
|
-
*/
|
|
1422
901
|
async function startSsmTunnels(sessionName, devOutput) {
|
|
1423
902
|
const platformEnv = devOutput.platform?.env ?? 'dev';
|
|
1424
|
-
// The bastion is a HUB platform resource (/mesh-platform/<hub>/<env>/platform-bastion),
|
|
1425
|
-
// not an app-tenant resource — devOutput.platform.tenant is the APP tenant (e.g. acme),
|
|
1426
|
-
// which for shared tenants has no bastion. Resolve under the platform/hub name; default
|
|
1427
|
-
// to "mesh" (matching how resolveTemporalAuth reads platform-level params).
|
|
1428
903
|
const bastionTenant = devOutput.platform?.name ?? 'mesh';
|
|
1429
|
-
// Read bastion info from SSM
|
|
1430
904
|
const bastion = await getPlatformBastionInfo(bastionTenant, platformEnv).catch(() => null);
|
|
1431
905
|
if (!bastion) {
|
|
1432
906
|
logError('Could not read platform bastion info from SSM.');
|
|
@@ -1441,8 +915,6 @@ async function startSsmTunnels(sessionName, devOutput) {
|
|
|
1441
915
|
return rewritten;
|
|
1442
916
|
}
|
|
1443
917
|
logInfo(`Starting ${tunnelCount} SSM tunnel(s) via bastion ${bastion.instanceId}...`);
|
|
1444
|
-
// Map devOutput tunnel names to bastion service keys
|
|
1445
|
-
// devOutput uses names like "temporal", "rds"; bastion uses "temporal-frontend", "rds"
|
|
1446
918
|
const tunnelToBastionKey = {
|
|
1447
919
|
temporal: 'temporal-frontend',
|
|
1448
920
|
'temporal-ui': 'temporal-ui',
|
|
@@ -1450,8 +922,6 @@ async function startSsmTunnels(sessionName, devOutput) {
|
|
|
1450
922
|
db: 'rds',
|
|
1451
923
|
database: 'rds',
|
|
1452
924
|
};
|
|
1453
|
-
// Observability tunnels are non-fatal under SSM fallback — skip with a warning
|
|
1454
|
-
// rather than aborting the dev loop when the bastion doesn't expose them.
|
|
1455
925
|
const OPTIONAL_TUNNEL_NAMES = new Set([
|
|
1456
926
|
'loki',
|
|
1457
927
|
'prometheus',
|
|
@@ -1467,10 +937,6 @@ async function startSsmTunnels(sessionName, devOutput) {
|
|
|
1467
937
|
const bastionKey = tunnelToBastionKey[tunnelName] ?? tunnelName;
|
|
1468
938
|
const endpoint = bastion.services[bastionKey];
|
|
1469
939
|
if (!endpoint) {
|
|
1470
|
-
// Observability tunnels degrade gracefully under SSM fallback: without
|
|
1471
|
-
// them the consuming app's monitoring views (logs/metrics/traces) won't
|
|
1472
|
-
// work, but they must not block the core dev loop. Only critical tunnels
|
|
1473
|
-
// (temporal, rds, ...) are fatal when the bastion can't reach them.
|
|
1474
940
|
if (OPTIONAL_TUNNEL_NAMES.has(tunnelName)) {
|
|
1475
941
|
skippedOptional.push(`${tunnelName} (bastion key: ${bastionKey})`);
|
|
1476
942
|
}
|
|
@@ -1502,7 +968,6 @@ async function startSsmTunnels(sessionName, devOutput) {
|
|
|
1502
968
|
throw new Error('SSM tunnel fallback unavailable: missing bastion endpoints');
|
|
1503
969
|
}
|
|
1504
970
|
for (const { tunnelName, endpoint, localPort } of plannedTunnels) {
|
|
1505
|
-
// Create a tmux window for this tunnel
|
|
1506
971
|
const windowName = `tunnel-${tunnelName}`;
|
|
1507
972
|
const ssmCmd = [
|
|
1508
973
|
'aws',
|
|
@@ -1520,10 +985,8 @@ async function startSsmTunnels(sessionName, devOutput) {
|
|
|
1520
985
|
})}'`,
|
|
1521
986
|
].join(' ');
|
|
1522
987
|
execFileSync('tmux', ['new-window', '-t', sessionName, '-n', windowName]);
|
|
1523
|
-
// Keep window alive so tunnel errors are visible
|
|
1524
988
|
execFileSync('tmux', ['set-option', '-t', `${sessionName}:${windowName}`, 'remain-on-exit', 'on'], { stdio: 'ignore' });
|
|
1525
989
|
execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${windowName}`, ssmCmd, 'Enter']);
|
|
1526
|
-
// Rewrite the tunnel to point at localhost
|
|
1527
990
|
rewritten.tunnels[tunnelName] = {
|
|
1528
991
|
host: 'localhost',
|
|
1529
992
|
port: localPort,
|
|
@@ -1532,20 +995,12 @@ async function startSsmTunnels(sessionName, devOutput) {
|
|
|
1532
995
|
}
|
|
1533
996
|
return rewritten;
|
|
1534
997
|
}
|
|
1535
|
-
/**
|
|
1536
|
-
* Update the DevBox host's Caddy reverse proxy config.
|
|
1537
|
-
*
|
|
1538
|
-
* Generates a Caddyfile so services are accessible at
|
|
1539
|
-
* http://{service}.{stack}.{app}.{tenant}-{env}.mesh.local
|
|
1540
|
-
* from the developer's Mac (via Tailscale + dnsmasq).
|
|
1541
|
-
*/
|
|
1542
998
|
async function updateDevboxProxy(devOutput, appRoot) {
|
|
1543
999
|
const appName = devOutput.app ?? path.basename(appRoot);
|
|
1544
1000
|
const tenant = devOutput.platform?.tenant ?? 'mesh';
|
|
1545
1001
|
const env = devOutput.platform?.env ?? 'dev';
|
|
1546
1002
|
const stack = devOutput.stack ?? 'dev';
|
|
1547
1003
|
const baseDomain = `${tenant}-${env}.mesh.local`;
|
|
1548
|
-
// Build hostname → port map
|
|
1549
1004
|
const services = {};
|
|
1550
1005
|
for (const [name, service] of Object.entries(devOutput.services)) {
|
|
1551
1006
|
if (service.port) {
|
|
@@ -1559,7 +1014,6 @@ async function updateDevboxProxy(devOutput, appRoot) {
|
|
|
1559
1014
|
}
|
|
1560
1015
|
if (Object.keys(services).length === 0)
|
|
1561
1016
|
return;
|
|
1562
|
-
// Generate Caddyfile
|
|
1563
1017
|
const lines = [
|
|
1564
1018
|
'# Auto-generated by mesh dev — do not edit manually.',
|
|
1565
1019
|
'',
|
|
@@ -1575,8 +1029,6 @@ async function updateDevboxProxy(devOutput, appRoot) {
|
|
|
1575
1029
|
lines.push('');
|
|
1576
1030
|
}
|
|
1577
1031
|
const caddyfile = lines.join('\n');
|
|
1578
|
-
// Write to host's /etc/caddy via a throwaway container with a host volume mount.
|
|
1579
|
-
// The Docker socket is the HOST's, so -v /etc/caddy mounts the HOST path.
|
|
1580
1032
|
execFileSync('docker', [
|
|
1581
1033
|
'run',
|
|
1582
1034
|
'--rm',
|
|
@@ -1587,7 +1039,6 @@ async function updateDevboxProxy(devOutput, appRoot) {
|
|
|
1587
1039
|
'-c',
|
|
1588
1040
|
`cat > /etc/caddy/Caddyfile << 'CADDYEOF'\n${caddyfile}\nCADDYEOF`,
|
|
1589
1041
|
], { stdio: 'pipe' });
|
|
1590
|
-
// Reload Caddy on the host via nsenter into PID 1's mount namespace
|
|
1591
1042
|
execFileSync('docker', [
|
|
1592
1043
|
'run',
|
|
1593
1044
|
'--rm',
|
|
@@ -1612,17 +1063,14 @@ async function updateDevboxProxy(devOutput, appRoot) {
|
|
|
1612
1063
|
}
|
|
1613
1064
|
}
|
|
1614
1065
|
async function setupSubdomainRouting(devOutput, appRoot) {
|
|
1615
|
-
// 1. Get Tailscale info
|
|
1616
1066
|
const tsInfo = await getTailscaleInfo();
|
|
1617
1067
|
if (!tsInfo)
|
|
1618
1068
|
return null;
|
|
1619
1069
|
const appName = devOutput.app ?? path.basename(appRoot);
|
|
1620
1070
|
const stack = devOutput.stack ?? 'dev';
|
|
1621
1071
|
const baseDomain = `${appName}.${stack}.${tsInfo.hostname}.vpn.internal`;
|
|
1622
|
-
// 2. Derive Headscale DNS config from platform context (same convention as `mesh vpn`)
|
|
1623
1072
|
const platform = devOutput.platform ?? { tenant: 'mesh', env: 'dev' };
|
|
1624
1073
|
const dnsConfig = headscaleDnsConfig(platform.name ?? platform.tenant, platform.env);
|
|
1625
|
-
// 3. Build DNS records for all services + tunnels
|
|
1626
1074
|
const records = [];
|
|
1627
1075
|
for (const [name, service] of Object.entries(devOutput.services)) {
|
|
1628
1076
|
if (service.port) {
|
|
@@ -1632,21 +1080,15 @@ async function setupSubdomainRouting(devOutput, appRoot) {
|
|
|
1632
1080
|
for (const [name] of Object.entries(devOutput.tunnels)) {
|
|
1633
1081
|
records.push({ name: `${name}.${baseDomain}`, type: 'A', value: tsInfo.ip });
|
|
1634
1082
|
}
|
|
1635
|
-
// 4. Remove stale DNS records (best-effort)
|
|
1636
1083
|
try {
|
|
1637
1084
|
unregisterDnsRecords(dnsConfig, baseDomain);
|
|
1638
1085
|
}
|
|
1639
1086
|
catch {
|
|
1640
|
-
/* ignore */
|
|
1641
1087
|
}
|
|
1642
|
-
// 5. Generate Caddyfile
|
|
1643
1088
|
const caddyDir = '/tmp/mesh-dev-caddy';
|
|
1644
1089
|
const caddyfile = path.join(caddyDir, 'Caddyfile');
|
|
1645
1090
|
if (!fs.existsSync(caddyDir))
|
|
1646
1091
|
fs.mkdirSync(caddyDir, { recursive: true });
|
|
1647
|
-
// Build Caddy config — bind directly to the Tailscale IP on port 80.
|
|
1648
|
-
// In kernel tun mode, tailscale0 is a real interface so Caddy can listen
|
|
1649
|
-
// on it without any tailscale serve layer in between.
|
|
1650
1092
|
const caddyLines = [`# mesh-dev: ${appName}/${stack} (${tsInfo.hostname})`, ''];
|
|
1651
1093
|
for (const [name, service] of Object.entries(devOutput.services)) {
|
|
1652
1094
|
if (service.port) {
|
|
@@ -1665,14 +1107,12 @@ async function setupSubdomainRouting(devOutput, appRoot) {
|
|
|
1665
1107
|
caddyLines.push('');
|
|
1666
1108
|
}
|
|
1667
1109
|
fs.writeFileSync(caddyfile, caddyLines.join('\n'));
|
|
1668
|
-
// 6. Start or reload Caddy
|
|
1669
1110
|
let caddyRunning = false;
|
|
1670
1111
|
try {
|
|
1671
1112
|
execFileSync('pgrep', ['-f', 'caddy run.*mesh-dev-caddy'], { stdio: 'pipe' });
|
|
1672
1113
|
caddyRunning = true;
|
|
1673
1114
|
}
|
|
1674
1115
|
catch {
|
|
1675
|
-
/* not running */
|
|
1676
1116
|
}
|
|
1677
1117
|
if (caddyRunning) {
|
|
1678
1118
|
try {
|
|
@@ -1700,7 +1140,6 @@ async function setupSubdomainRouting(devOutput, appRoot) {
|
|
|
1700
1140
|
});
|
|
1701
1141
|
caddy.unref();
|
|
1702
1142
|
}
|
|
1703
|
-
// 7. Register DNS records (after Caddy is confirmed running)
|
|
1704
1143
|
try {
|
|
1705
1144
|
const existing = readDnsRecords(dnsConfig);
|
|
1706
1145
|
const allRecords = existing.filter((r) => !r.name.endsWith(baseDomain));
|
|
@@ -1713,19 +1152,11 @@ async function setupSubdomainRouting(devOutput, appRoot) {
|
|
|
1713
1152
|
}
|
|
1714
1153
|
return { tsHostname: tsInfo.hostname, tsIp: tsInfo.ip, baseDomain, dnsConfig };
|
|
1715
1154
|
}
|
|
1716
|
-
// ============================================================================
|
|
1717
|
-
// Core actions
|
|
1718
|
-
// ============================================================================
|
|
1719
|
-
/**
|
|
1720
|
-
* Start all services in tmux windows.
|
|
1721
|
-
*/
|
|
1722
1155
|
async function startServices(sessionName, appRoot, devOutput, headless, awsEnv, tunnelPlan = { transport: 'vpn-direct' }, worktreeRoot, taskQueueSuffix = '') {
|
|
1723
1156
|
if (!hasTmux()) {
|
|
1724
1157
|
logError('tmux is required. Install with: brew install tmux');
|
|
1725
1158
|
process.exit(1);
|
|
1726
1159
|
}
|
|
1727
|
-
// Always kill any existing session and recreate for a clean start.
|
|
1728
|
-
// Tunnels (if any) are set up as part of this flow below.
|
|
1729
1160
|
if (sessionExists(sessionName)) {
|
|
1730
1161
|
logInfo(`Killing existing session: ${sessionName}`);
|
|
1731
1162
|
killSession(sessionName);
|
|
@@ -1735,54 +1166,28 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
|
|
|
1735
1166
|
logWarn('No services defined in dev output.');
|
|
1736
1167
|
return devOutput;
|
|
1737
1168
|
}
|
|
1738
|
-
// Create tmux session with a status window (always fresh).
|
|
1739
1169
|
logInfo(`Creating tmux session: ${sessionName}`);
|
|
1740
1170
|
execFileSync('tmux', ['new-session', '-d', '-s', sessionName, '-n', 'status', '-c', appRoot]);
|
|
1741
|
-
// Set AWS credentials on the session BEFORE starting anything that needs them
|
|
1742
|
-
// (SSM tunnels, services). tmux windows inherit the session environment.
|
|
1743
1171
|
setTmuxEnv(sessionName, awsEnv);
|
|
1744
|
-
// Bring up VPC tunnels (rewriting devOutput.tunnels to localhost:PORT) via the
|
|
1745
|
-
// chosen backing. Tailscale (shared per-tenant userspace daemon): an explicit
|
|
1746
|
-
// `--transport tailscale` now hard-fails on any bring-up error (see below); only
|
|
1747
|
-
// an auto-resolved tailscale choice falls back to SSM. vpn-direct needs no tunnels.
|
|
1748
1172
|
const hasTunnels = Object.keys(devOutput.tunnels).length > 0;
|
|
1749
1173
|
let effectiveTransport = tunnelPlan.transport;
|
|
1750
1174
|
if (hasTunnels && effectiveTransport === 'tailscale' && tunnelPlan.tailscale) {
|
|
1751
1175
|
try {
|
|
1752
1176
|
const rewritten = await startTailscaleTunnels(devOutput, tunnelPlan.tailscale);
|
|
1753
|
-
// Verify the forwarders actually ROUTE, not just bind (MESH-2268). Transport
|
|
1754
|
-
// selection picks tailscale on PRESENCE (a tailscaled binary exists), so an
|
|
1755
|
-
// auto-selected daemon can come up joined to a tailnet that doesn't route the
|
|
1756
|
-
// mesh VPC, or with a dead SOCKS upstream (MESH-2169) — the forwarder binds
|
|
1757
|
-
// its port either way and the bare-accept readiness gate below (waitForPort)
|
|
1758
|
-
// would pass it, letting services start and then die on a gRPC deadline.
|
|
1759
|
-
// Throw on a dead critical tunnel so the catch below applies the same
|
|
1760
|
-
// explicit-hard-fail-vs-auto-SSM-fallback policy as a bring-up failure — and
|
|
1761
|
-
// only commit the rewrite (reassign devOutput) once it holds, so an SSM
|
|
1762
|
-
// fallback runs startSsmTunnels on the ORIGINAL, un-rewritten tunnels.
|
|
1763
1177
|
const dead = await firstUnroutableTunnel(rewritten.tunnels);
|
|
1764
1178
|
if (dead) {
|
|
1765
1179
|
throw new Error(`forwarder for '${dead}' bound but does not route the VPC ` +
|
|
1766
1180
|
`(dead SOCKS upstream or non-routing tailnet)`);
|
|
1767
1181
|
}
|
|
1768
1182
|
devOutput = rewritten;
|
|
1769
|
-
// Announce ONLY after the join + forwarders actually came up — the previous
|
|
1770
|
-
// pre-flight "Using userspace-Tailscale tunnels" printed before this even ran,
|
|
1771
|
-
// so a fallback still read as success (MESH-2098).
|
|
1772
1183
|
logInfo('Connected: userspace-Tailscale tunnels for VPC resources (shared per tenant).');
|
|
1773
1184
|
}
|
|
1774
1185
|
catch (err) {
|
|
1775
1186
|
const reason = err instanceof Error ? err.message : String(err);
|
|
1776
1187
|
if (tunnelPlan.explicit) {
|
|
1777
|
-
// The user explicitly asked for --transport=tailscale. Don't silently
|
|
1778
|
-
// override that with SSM — surface the real reason (often a pending VPN
|
|
1779
|
-
// node registration whose URL was printed above) and let them act.
|
|
1780
1188
|
logError(`Tailscale transport failed: ${reason}`);
|
|
1781
1189
|
logInfo('If a VPN registration URL was shown above, open it to authorize this machine, then re-run.');
|
|
1782
1190
|
logInfo('Or switch backing explicitly: mesh dev --transport ssm');
|
|
1783
|
-
// We already killed the prior session and created a fresh (empty) one
|
|
1784
|
-
// above; tear it down so an explicit-tailscale failure leaves no orphaned
|
|
1785
|
-
// half-session behind.
|
|
1786
1191
|
killSession(sessionName);
|
|
1787
1192
|
process.exit(1);
|
|
1788
1193
|
}
|
|
@@ -1794,12 +1199,9 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
|
|
|
1794
1199
|
devOutput = await startSsmTunnels(sessionName, devOutput);
|
|
1795
1200
|
}
|
|
1796
1201
|
if (hasTunnels && effectiveTransport !== 'vpn-direct') {
|
|
1797
|
-
// Gate service start on tunnel readiness: poll each tunnel's local port
|
|
1798
|
-
// until it accepts TCP. Without this, Temporal-connected services (api,
|
|
1799
|
-
// worker, vcs) raced the SSM tunnel and died connection-refused at launch.
|
|
1800
1202
|
for (const [tName, tunnel] of Object.entries(devOutput.tunnels)) {
|
|
1801
1203
|
if (tunnel.host !== 'localhost')
|
|
1802
|
-
continue;
|
|
1204
|
+
continue;
|
|
1803
1205
|
logInfo(`Waiting for tunnel ${tName} (localhost:${tunnel.port})...`);
|
|
1804
1206
|
const ready = await waitForPort('localhost', tunnel.port, TUNNEL_READY_TIMEOUT_MS);
|
|
1805
1207
|
if (!ready) {
|
|
@@ -1812,9 +1214,7 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
|
|
|
1812
1214
|
}
|
|
1813
1215
|
}
|
|
1814
1216
|
const tunnelNames = Object.keys(devOutput.tunnels);
|
|
1815
|
-
// Set up subdomain routing if Tailscale is available
|
|
1816
1217
|
const subdomainCtx = await setupSubdomainRouting(devOutput, appRoot);
|
|
1817
|
-
// Log VPC connections
|
|
1818
1218
|
if (tunnelNames.length > 0) {
|
|
1819
1219
|
const isSSM = Object.values(devOutput.tunnels).some((t) => t.host === 'localhost' && t.port >= SSM_TUNNEL_BASE_PORT);
|
|
1820
1220
|
logSuccess(isSSM ? 'SSM tunnel connections:' : 'VPN direct connections:');
|
|
@@ -1827,22 +1227,14 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
|
|
|
1827
1227
|
}
|
|
1828
1228
|
}
|
|
1829
1229
|
}
|
|
1830
|
-
// Resolve secrets from Secrets Manager
|
|
1831
1230
|
let secretEnvVars = {};
|
|
1832
1231
|
if (devOutput.secrets && Object.keys(devOutput.secrets).length > 0) {
|
|
1833
1232
|
secretEnvVars = await resolveSecrets(devOutput.secrets);
|
|
1834
1233
|
}
|
|
1835
|
-
// Resolve Temporal auth credentials from SSM when a temporal endpoint is configured.
|
|
1836
1234
|
const tenant = devOutput.platform?.tenant ?? 'mesh';
|
|
1837
1235
|
const platformEnv = devOutput.platform?.env ?? 'dev';
|
|
1838
|
-
// Write a session-scoped kubeconfig (aws eks get-token) so the kubectl calls
|
|
1839
|
-
// below (encoding-key read, temporal-pod env fallback) don't depend on an
|
|
1840
|
-
// ambient ~/.kube/config. Best-effort: continue without it if unresolved.
|
|
1841
|
-
// The local platform (`mesh start`) has no cluster — skip entirely.
|
|
1842
1236
|
const isLocalPlatform = tenant === 'local';
|
|
1843
1237
|
if (!isLocalPlatform) {
|
|
1844
|
-
// Hub platform name — the EKS export lives under it, not under a hardcoded
|
|
1845
|
-
// "mesh" (MESH-2515); same axis as the bastion resolution.
|
|
1846
1238
|
const platformName = resolveHubPlatformName(devOutput.platform);
|
|
1847
1239
|
const kubeconfigPath = await ensureKubeconfig(platformName, platformEnv, sessionName, {
|
|
1848
1240
|
onError: ({ parameter, error }) => logWarn(`EKS cluster resolve failed (${parameter}): ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`),
|
|
@@ -1858,12 +1250,7 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
|
|
|
1858
1250
|
}
|
|
1859
1251
|
let temporalAuthVars = {};
|
|
1860
1252
|
if (devOutput.tunnels['temporal']) {
|
|
1861
|
-
// Deliberately NOT the `?? 'mesh'` fallback the EKS path above uses: the
|
|
1862
|
-
// Temporal-auth fallback is `?? tenant` (dedicated tenants may run their
|
|
1863
|
-
// own Temporal). Whether that difference is correct is MESH-2551.
|
|
1864
1253
|
temporalAuthVars = await resolveTemporalAuth(tenant, platformEnv, devOutput.platform?.name ?? tenant);
|
|
1865
|
-
// Resolve the encoding key from K8s so local dev can decrypt
|
|
1866
|
-
// payloads created by deployed workers.
|
|
1867
1254
|
const appName = devOutput.app ?? '';
|
|
1868
1255
|
if (appName) {
|
|
1869
1256
|
const encodingKey = resolveTemporalEncodingKey(tenant, platformEnv, appName);
|
|
@@ -1871,29 +1258,11 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
|
|
|
1871
1258
|
temporalAuthVars.TEMPORAL_ENCODING_KEY = encodingKey;
|
|
1872
1259
|
}
|
|
1873
1260
|
}
|
|
1874
|
-
// Set TEMPORAL_ADDRESS to the VPC endpoint for direct VPN access (or the
|
|
1875
|
-
// pinned loopback address under SSM fallback — see tunnelClientAddress).
|
|
1876
1261
|
const temporalTunnel = devOutput.tunnels['temporal'];
|
|
1877
1262
|
temporalAuthVars.TEMPORAL_ADDRESS = tunnelClientAddress(temporalTunnel);
|
|
1878
1263
|
}
|
|
1879
|
-
// Set shared env vars (secrets + temporal auth) on the tmux session.
|
|
1880
|
-
// AWS creds were already set earlier (before SSM tunnels started).
|
|
1881
1264
|
setTmuxEnv(sessionName, secretEnvVars);
|
|
1882
1265
|
setTmuxEnv(sessionName, temporalAuthVars);
|
|
1883
|
-
// Inject dev user identity from mesh login credentials so services can
|
|
1884
|
-
// emulate the oauth2-proxy auth headers locally.
|
|
1885
|
-
//
|
|
1886
|
-
// The credentials are stored under the platform context (e.g. 'mesh.dev'),
|
|
1887
|
-
// not the tenant name. Use 'mesh' as the platform name since all tenants
|
|
1888
|
-
// share the same Zitadel instance.
|
|
1889
|
-
//
|
|
1890
|
-
// Services (e.g., Hub UI) use these env vars as a fallback when the real
|
|
1891
|
-
// oauth2-proxy `X-Forwarded-*` request headers are absent:
|
|
1892
|
-
// DEV_USER_ID - subject from idToken, matches conversation ownership
|
|
1893
|
-
// DEV_USER_EMAIL - user email from idToken
|
|
1894
|
-
// DEV_USER_ID_TOKEN - full idToken JWT (profile claims, authorization hint)
|
|
1895
|
-
// DEV_USER_ACCESS_TOKEN - full accessToken JWT (has project:roles claim)
|
|
1896
|
-
// Local platform creds live under the `local` context (mesh login local).
|
|
1897
1266
|
const credContext = devOutput.platform
|
|
1898
1267
|
? isLocalPlatform
|
|
1899
1268
|
? 'local'
|
|
@@ -1903,10 +1272,6 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
|
|
|
1903
1272
|
if (Object.keys(devUserVars).length > 0) {
|
|
1904
1273
|
setTmuxEnv(sessionName, devUserVars);
|
|
1905
1274
|
logSuccess(`Dev user injected from mesh login: ${credContext}`);
|
|
1906
|
-
// Serve a continuously-fresh dev-user token so services survive past the
|
|
1907
|
-
// ~1h Zitadel horizon without a restart (MESH-2039). The static vars above
|
|
1908
|
-
// remain the fallback when this URL is absent. App-agnostic: gated on the
|
|
1909
|
-
// platform context, not the app — any `mesh dev` app inherits it.
|
|
1910
1275
|
const tokenPort = await findFreePort();
|
|
1911
1276
|
const tokenUrl = `http://127.0.0.1:${tokenPort}`;
|
|
1912
1277
|
setTmuxEnv(sessionName, { DEV_USER_TOKEN_URL: tokenUrl });
|
|
@@ -1919,7 +1284,6 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
|
|
|
1919
1284
|
`npx mesh dev __token-server ${tokenPort} ${credContext}`,
|
|
1920
1285
|
'Enter',
|
|
1921
1286
|
]);
|
|
1922
|
-
// Best-effort readiness so the first requests land (non-fatal).
|
|
1923
1287
|
for (let i = 0; i < 15 && !(await isPortListening(tokenPort)); i++) {
|
|
1924
1288
|
await new Promise((r) => setTimeout(r, 200));
|
|
1925
1289
|
}
|
|
@@ -1933,21 +1297,14 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
|
|
|
1933
1297
|
const monorepoRoot = findMonorepoRoot();
|
|
1934
1298
|
for (const [name, service] of Object.entries(devOutput.services)) {
|
|
1935
1299
|
const cmd = service.command.join(' ');
|
|
1936
|
-
// Skip services with no dev command (e.g., deployed-only like ApiDocs)
|
|
1937
1300
|
if (!cmd) {
|
|
1938
1301
|
logInfo(`Skipped: ${name} (no dev command, deployed to K8s)`);
|
|
1939
1302
|
continue;
|
|
1940
1303
|
}
|
|
1941
1304
|
const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, monorepoRoot));
|
|
1942
|
-
// Wrong-worktree guard (MESH-2013): if `rebaseServiceSrc` couldn't re-root the
|
|
1943
|
-
// deploy-time absolute path onto THIS worktree, `serviceDir` can point into a
|
|
1944
|
-
// DIFFERENT worktree — silently running stale code. Refuse rather than launch it.
|
|
1945
1305
|
if (worktreeRoot && fs.existsSync(serviceDir)) {
|
|
1946
1306
|
const wtRootWithSep = worktreeRoot.endsWith(path.sep) ? worktreeRoot : worktreeRoot + path.sep;
|
|
1947
1307
|
const outside = serviceDir !== worktreeRoot && !serviceDir.startsWith(wtRootWithSep);
|
|
1948
|
-
// A dev-LINKED package is outside the worktree by construction, and is what
|
|
1949
|
-
// this app runs either way — see `isLinkedDependencyDir`. Only an
|
|
1950
|
-
// unexplained outside path is the wrong-worktree failure.
|
|
1951
1308
|
if (outside && isLinkedDependencyDir(serviceDir, appRoot)) {
|
|
1952
1309
|
logInfo(`${name}: source is a dev-linked package (${serviceDir})`);
|
|
1953
1310
|
}
|
|
@@ -1959,74 +1316,28 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
|
|
|
1959
1316
|
process.exit(1);
|
|
1960
1317
|
}
|
|
1961
1318
|
}
|
|
1962
|
-
// Skip services whose source directory doesn't exist in this worktree.
|
|
1963
|
-
// This happens when the Pulumi stack was deployed from a different branch
|
|
1964
|
-
// that added a service not yet present here (e.g., hub-worker added on
|
|
1965
|
-
// feature/hub-credential-management but absent from feature/app-detail-redesign).
|
|
1966
1319
|
if (!fs.existsSync(serviceDir)) {
|
|
1967
1320
|
logWarn(`Skipped: ${name} (source directory not found: ${serviceDir}` +
|
|
1968
1321
|
`${serviceDir !== service.src ? ` — rebased from ${service.src}` : ''})`);
|
|
1969
1322
|
continue;
|
|
1970
1323
|
}
|
|
1971
|
-
// Per-service env vars are written to a 0600 env file and `source`d by
|
|
1972
|
-
// the launch command, scoping them to the process (no cross-service
|
|
1973
|
-
// leaks through the tmux session environment) while keeping the
|
|
1974
|
-
// send-keys string short — inlining the full env used to intermittently
|
|
1975
|
-
// exceed what tmux send-keys delivers intact, silently truncating the
|
|
1976
|
-
// command so the service never started.
|
|
1977
|
-
//
|
|
1978
|
-
// Overlay the RESOLVED Temporal vars (real encoding key + tunnel address +
|
|
1979
|
-
// M2M auth, fetched from K8s/SSM) on top of the dev-output env. The dev
|
|
1980
|
-
// output carries placeholder values from the Agent's Temporal link (e.g.
|
|
1981
|
-
// a `dev-encoding-key-*` placeholder), and the per-service env would
|
|
1982
|
-
// otherwise shadow the session-level resolved values — desyncing services
|
|
1983
|
-
// that DO carry the placeholder (worker) from those that don't (api),
|
|
1984
|
-
// which breaks payload encryption (mismatched encoding keys → decode null).
|
|
1985
1324
|
const serviceVars = {
|
|
1986
1325
|
...getServiceEnvVars(service, devOutput.tunnels),
|
|
1987
1326
|
...temporalAuthVars,
|
|
1988
|
-
// Overlay the resolved dev-user identity for the same reason as the
|
|
1989
|
-
// Temporal vars: the dev output may carry a placeholder (e.g. hub ui's
|
|
1990
|
-
// DEV_USER_ACCESS_TOKEN="dev-local"), and since the sourced env file
|
|
1991
|
-
// wins over the tmux session env, the placeholder would clobber the
|
|
1992
|
-
// real token injected above — silently 401ing tenant-scoped Hub views.
|
|
1993
1327
|
...devUserVars,
|
|
1994
|
-
// Per-worktree Temporal task-queue isolation (MESH-2013 Slice 2): agent
|
|
1995
|
-
// workers/apps append this to their task-queue names so concurrent
|
|
1996
|
-
// worktrees don't steal each other's workflow tasks. Empty on the primary
|
|
1997
|
-
// checkout → omitted → unchanged historical behavior.
|
|
1998
1328
|
...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
|
|
1999
|
-
// AWS, for the same reason as the Temporal and dev-user overlays above: the
|
|
2000
|
-
// sourced env file WINS over the tmux session env, so a service that reads this
|
|
2001
|
-
// file gets whatever the file says and nothing else.
|
|
2002
|
-
//
|
|
2003
|
-
// It matters beyond the service's own process. A module dev service can hand
|
|
2004
|
-
// credentials to something it starts — the workspaces manager gives each
|
|
2005
|
-
// workspace's sidecar an AWS profile so Claude Code can reach Bedrock — and with
|
|
2006
|
-
// no AWS_* here it silently has none to give. `buildChildAwsEnv` has already
|
|
2007
|
-
// chosen the SELF-REFRESHING shape (a profile with `credential_process`, not a
|
|
2008
|
-
// frozen STS triple), which is exactly what a long-lived child needs.
|
|
2009
1329
|
...awsEnv,
|
|
2010
1330
|
};
|
|
2011
1331
|
const envFilePath = getServiceEnvFilePath(sessionName, name);
|
|
2012
1332
|
writeEnvFile(envFilePath, serviceVars);
|
|
2013
|
-
// Local mode ships the service's stdout to the platform's log pipeline
|
|
2014
|
-
// via OTLP (assets/log-shipper.mjs): the OTEL_* vars in the env file
|
|
2015
|
-
// carry the hosted k8s_* attribution, so the Hub's logs view covers
|
|
2016
|
-
// local runs with zero file plumbing.
|
|
2017
1333
|
const launchCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined);
|
|
2018
1334
|
execFileSync('tmux', ['new-window', '-t', sessionName, '-n', name, '-c', serviceDir]);
|
|
2019
|
-
// Keep window alive on exit so errors are visible
|
|
2020
1335
|
execFileSync('tmux', ['set-option', '-t', `${sessionName}:${name}`, 'remain-on-exit', 'on'], {
|
|
2021
1336
|
stdio: 'ignore',
|
|
2022
1337
|
});
|
|
2023
1338
|
execFileSync('tmux', ['send-keys', '-t', `${sessionName}:${name}`, launchCmd, 'Enter']);
|
|
2024
|
-
// Log the ACTUAL launch dir (`serviceDir`, after the worktree rebase), not the
|
|
2025
|
-
// raw deploy-time `service.src` — printing the pre-rebase path made a correctly
|
|
2026
|
-
// re-rooted service look like it was running from a different (stale) worktree.
|
|
2027
1339
|
logSuccess(`Started: ${name} (${serviceDir}, port ${service.port})`);
|
|
2028
1340
|
}
|
|
2029
|
-
// Generate reverse proxy config for DevBox hostname-based routing.
|
|
2030
1341
|
if (process.env.DEVCONTAINER === '1') {
|
|
2031
1342
|
try {
|
|
2032
1343
|
await updateDevboxProxy(devOutput, appRoot);
|
|
@@ -2035,7 +1346,6 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
|
|
|
2035
1346
|
logWarn(`Could not update devbox proxy: ${e.message}`);
|
|
2036
1347
|
}
|
|
2037
1348
|
}
|
|
2038
|
-
// Run live status updates in the status window
|
|
2039
1349
|
const statusCmd = `watch -n2 -t npx mesh dev --status --session '${sessionName}'`;
|
|
2040
1350
|
execFileSync('tmux', ['send-keys', '-t', `${sessionName}:status`, statusCmd, 'Enter']);
|
|
2041
1351
|
console.log('');
|
|
@@ -2086,13 +1396,8 @@ async function startServices(sessionName, appRoot, devOutput, headless, awsEnv,
|
|
|
2086
1396
|
console.log(`Stop with: mesh dev --kill`);
|
|
2087
1397
|
console.log('');
|
|
2088
1398
|
}
|
|
2089
|
-
// Return the (possibly SSM-tunnel-rewritten) devOutput so the caller can
|
|
2090
|
-
// persist it in session state for --status / restart.
|
|
2091
1399
|
return devOutput;
|
|
2092
1400
|
}
|
|
2093
|
-
/**
|
|
2094
|
-
* Show status of running services.
|
|
2095
|
-
*/
|
|
2096
1401
|
async function showStatus(sessionName, devOutput, asJson) {
|
|
2097
1402
|
if (!sessionExists(sessionName)) {
|
|
2098
1403
|
if (asJson) {
|
|
@@ -2103,14 +1408,12 @@ async function showStatus(sessionName, devOutput, asJson) {
|
|
|
2103
1408
|
}
|
|
2104
1409
|
return;
|
|
2105
1410
|
}
|
|
2106
|
-
// Get tmux window list
|
|
2107
1411
|
let windows = [];
|
|
2108
1412
|
try {
|
|
2109
1413
|
const raw = execFileSync('tmux', ['list-windows', '-t', sessionName, '-F', '#{window_name} #{pane_dead}'], { encoding: 'utf-8' });
|
|
2110
1414
|
windows = raw.trim().split('\n');
|
|
2111
1415
|
}
|
|
2112
1416
|
catch {
|
|
2113
|
-
// Session might have disappeared
|
|
2114
1417
|
}
|
|
2115
1418
|
const windowStatus = {};
|
|
2116
1419
|
for (const line of windows) {
|
|
@@ -2119,8 +1422,6 @@ async function showStatus(sessionName, devOutput, asJson) {
|
|
|
2119
1422
|
windowStatus[name] = dead === '1' ? 'exited' : 'running';
|
|
2120
1423
|
}
|
|
2121
1424
|
const tunnelNames = Object.keys(devOutput.tunnels);
|
|
2122
|
-
// Live-probe each tunnel so a mid-session tunnel death shows as DOWN here
|
|
2123
|
-
// (this render runs every ~2s in the status window) — MESH-2016.
|
|
2124
1425
|
const tunnelHealth = await probeTunnelHealth(devOutput.tunnels);
|
|
2125
1426
|
const healthByName = new Map(tunnelHealth.map((h) => [h.name, h]));
|
|
2126
1427
|
if (asJson) {
|
|
@@ -2173,9 +1474,6 @@ async function showStatus(sessionName, devOutput, asJson) {
|
|
|
2173
1474
|
console.log('tmux: Ctrl+b n/p switch windows, d detach');
|
|
2174
1475
|
}
|
|
2175
1476
|
}
|
|
2176
|
-
/**
|
|
2177
|
-
* Restart a single service window.
|
|
2178
|
-
*/
|
|
2179
1477
|
async function restartService(sessionName, serviceName, appRoot, devOutput, awsEnv, opts) {
|
|
2180
1478
|
let service = devOutput.services[serviceName];
|
|
2181
1479
|
if (!service) {
|
|
@@ -2187,15 +1485,11 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
|
|
|
2187
1485
|
logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);
|
|
2188
1486
|
process.exit(1);
|
|
2189
1487
|
}
|
|
2190
|
-
// --refresh-env: regenerate this service's env file from CURRENT stack/SSM
|
|
2191
|
-
// outputs BEFORE killing the running process, so a failed read leaves the
|
|
2192
|
-
// good env file (and the running service) untouched (fail-closed).
|
|
2193
1488
|
if (opts?.refreshEnv) {
|
|
2194
1489
|
if (!opts.stack) {
|
|
2195
1490
|
logError('--refresh-env: no stack resolved; keeping existing env and NOT restarting.');
|
|
2196
1491
|
return;
|
|
2197
1492
|
}
|
|
2198
|
-
// The session's allocated devOutput pins the ports/tunnels to reuse.
|
|
2199
1493
|
const pinned = opts.sessionState?.devOutput ?? devOutput;
|
|
2200
1494
|
let fresh;
|
|
2201
1495
|
try {
|
|
@@ -2213,51 +1507,32 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
|
|
|
2213
1507
|
return;
|
|
2214
1508
|
}
|
|
2215
1509
|
const temporalAuthVars = await resolveTemporalAuthVars(sessionName, merged);
|
|
2216
|
-
// Recompute the per-worktree suffix (deterministic from the worktree path,
|
|
2217
|
-
// so it matches what launch wrote) — a restarted service must keep polling
|
|
2218
|
-
// this worktree's isolated task queue (MESH-2013 Slice 2).
|
|
2219
1510
|
const { taskQueueSuffix } = resolveWorktreeIdentity(appRoot);
|
|
2220
1511
|
const serviceVars = {
|
|
2221
1512
|
...getServiceEnvVars(mergedService, merged.tunnels),
|
|
2222
1513
|
...temporalAuthVars,
|
|
2223
|
-
// Same overlay as launch: without it, --refresh-env rewrites the env
|
|
2224
|
-
// file from dev output and its DEV_USER_* placeholders clobber the real
|
|
2225
|
-
// token from `mesh login` (the "restart silently 401s the Hub" trap).
|
|
2226
1514
|
...resolveDevUserVars(merged),
|
|
2227
1515
|
...(taskQueueSuffix ? { MESH_TASK_QUEUE_SUFFIX: taskQueueSuffix } : {}),
|
|
2228
|
-
// Same overlay as launch — a refresh that dropped these would take a working
|
|
2229
|
-
// service's credentials away on restart, which is the hardest kind of change to
|
|
2230
|
-
// attribute afterwards.
|
|
2231
1516
|
...awsEnv,
|
|
2232
1517
|
};
|
|
2233
1518
|
writeEnvFile(getServiceEnvFilePath(sessionName, serviceName), serviceVars);
|
|
2234
|
-
// Relaunch from the refreshed service definition (command/src may have changed).
|
|
2235
1519
|
service = mergedService;
|
|
2236
1520
|
logSuccess(`Regenerated env for '${serviceName}' from current stack/SSM outputs.`);
|
|
2237
1521
|
}
|
|
2238
1522
|
const target = `${sessionName}:${serviceName}`;
|
|
2239
|
-
// Kill the current pane process and respawn
|
|
2240
1523
|
try {
|
|
2241
1524
|
execFileSync('tmux', ['respawn-pane', '-k', '-t', target], { stdio: 'ignore' });
|
|
2242
1525
|
}
|
|
2243
1526
|
catch {
|
|
2244
|
-
// Window might not exist yet
|
|
2245
1527
|
logError(`Window '${serviceName}' not found in session.`);
|
|
2246
1528
|
process.exit(1);
|
|
2247
1529
|
}
|
|
2248
|
-
// Refresh shared env (AWS creds may have rotated) on the session.
|
|
2249
1530
|
setTmuxEnv(sessionName, awsEnv);
|
|
2250
1531
|
const cmd = service.command.join(' ');
|
|
2251
|
-
// Source the SAME env file written at launch — it is the session's env
|
|
2252
|
-
// contract for this service, guaranteeing a byte-identical environment.
|
|
2253
|
-
// Reconstructing env here has historically diverged from launch (e.g.
|
|
2254
|
-
// TEMPORAL_ENCODING_KEY drift → "cannot decrypt payload" / missing key →
|
|
2255
|
-
// "Unknown encoding: binary/encrypted").
|
|
2256
1532
|
const envFilePath = getServiceEnvFilePath(sessionName, serviceName);
|
|
2257
1533
|
let restartCmd;
|
|
2258
1534
|
if (fs.existsSync(envFilePath)) {
|
|
2259
1535
|
const serviceDir = path.resolve(appRoot, rebaseServiceSrc(service.src, findMonorepoRoot()));
|
|
2260
|
-
// Keep the local log pipeline attached across restarts.
|
|
2261
1536
|
restartCmd = buildLaunchCommand(envFilePath, serviceDir, cmd, service.env?.OTEL_RESOURCE_ATTRIBUTES ? logShipperPath() : undefined);
|
|
2262
1537
|
}
|
|
2263
1538
|
else {
|
|
@@ -2269,9 +1544,6 @@ async function restartService(sessionName, serviceName, appRoot, devOutput, awsE
|
|
|
2269
1544
|
execFileSync('tmux', ['send-keys', '-t', target, restartCmd, 'Enter']);
|
|
2270
1545
|
logSuccess(`Restarted: ${serviceName}`);
|
|
2271
1546
|
}
|
|
2272
|
-
/**
|
|
2273
|
-
* Capture and display logs from a service window.
|
|
2274
|
-
*/
|
|
2275
1547
|
function showLogs(sessionName, serviceName, tail) {
|
|
2276
1548
|
if (!sessionExists(sessionName)) {
|
|
2277
1549
|
logError(`No active session: ${sessionName}. Run 'mesh dev' first.`);
|
|
@@ -2290,14 +1562,6 @@ function showLogs(sessionName, serviceName, tail) {
|
|
|
2290
1562
|
process.exit(1);
|
|
2291
1563
|
}
|
|
2292
1564
|
}
|
|
2293
|
-
// ============================================================================
|
|
2294
|
-
// Command registration
|
|
2295
|
-
// ============================================================================
|
|
2296
|
-
/**
|
|
2297
|
-
* Assemble a {@link DoctorContext} for the current app/stack/session, resolving
|
|
2298
|
-
* the deployer role the same way the `restart`/launch paths do (default role +
|
|
2299
|
-
* Zitadel-claim admin selection). Used by `mesh dev doctor`.
|
|
2300
|
-
*/
|
|
2301
1565
|
function buildDoctorContext(appRoot, stack, sessionName) {
|
|
2302
1566
|
const state = loadSessionState(sessionName);
|
|
2303
1567
|
const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
|
|
@@ -2342,23 +1606,16 @@ export function registerDevCommand(program) {
|
|
|
2342
1606
|
.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.")
|
|
2343
1607
|
.option('--mock [names]', 'Alias for --externals.')
|
|
2344
1608
|
.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');
|
|
2345
|
-
// Hidden: the dev-user token-server (MESH-2039). Launched into its own tmux
|
|
2346
|
-
// window by the dev launcher; serves a continuously-fresh dev-user token.
|
|
2347
1609
|
dev
|
|
2348
1610
|
.command('__token-server <port> <context>', { hidden: true })
|
|
2349
1611
|
.action(async (port, context) => {
|
|
2350
1612
|
const { startTokenServer } = await import('./dev-token-server.js');
|
|
2351
1613
|
await startTokenServer(Number(port), context);
|
|
2352
1614
|
});
|
|
2353
|
-
// Default action: start or show status
|
|
2354
1615
|
dev.action(async (options) => {
|
|
2355
|
-
// An explicit --profile must win over stale shell-exported STS credentials.
|
|
2356
|
-
// The AWS SDK/CLI prefer AWS_ACCESS_KEY_ID over AWS_PROFILE, so clear any
|
|
2357
|
-
// static credentials before resolving the deployer role.
|
|
2358
1616
|
if (options.profile) {
|
|
2359
1617
|
applyAwsProfileOverride(options.profile);
|
|
2360
1618
|
}
|
|
2361
|
-
// Fast-path: --status/--kill with explicit --session can skip app root resolution
|
|
2362
1619
|
if (options.session && (options.status || options.kill)) {
|
|
2363
1620
|
const sessionName = options.session;
|
|
2364
1621
|
if (loadSessionState(sessionName)?.runner === 'docker') {
|
|
@@ -2377,7 +1634,6 @@ export function registerDevCommand(program) {
|
|
|
2377
1634
|
if (options.kill) {
|
|
2378
1635
|
composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
|
|
2379
1636
|
localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
|
|
2380
|
-
// Clean up DNS records for this app
|
|
2381
1637
|
try {
|
|
2382
1638
|
const devOutput = loadSessionState(sessionName)?.devOutput;
|
|
2383
1639
|
if (devOutput) {
|
|
@@ -2392,7 +1648,6 @@ export function registerDevCommand(program) {
|
|
|
2392
1648
|
}
|
|
2393
1649
|
}
|
|
2394
1650
|
catch {
|
|
2395
|
-
/* best effort */
|
|
2396
1651
|
}
|
|
2397
1652
|
if (sessionExists(sessionName)) {
|
|
2398
1653
|
killSession(sessionName);
|
|
@@ -2404,23 +1659,16 @@ export function registerDevCommand(program) {
|
|
|
2404
1659
|
removeSessionState(sessionName);
|
|
2405
1660
|
return;
|
|
2406
1661
|
}
|
|
2407
|
-
// --status with --session: use state file, no Pulumi needed
|
|
2408
1662
|
const state = loadSessionState(sessionName);
|
|
2409
1663
|
if (state) {
|
|
2410
1664
|
await showStatus(sessionName, state.devOutput, !!options.json);
|
|
2411
1665
|
return;
|
|
2412
1666
|
}
|
|
2413
|
-
// No state — fall through to normal flow
|
|
2414
1667
|
}
|
|
2415
1668
|
const appRoot = findAppRoot(options.app);
|
|
2416
1669
|
const projectName = getProjectName(appRoot);
|
|
2417
|
-
// Port block scoped to (worktree, app): concurrent sibling-app sessions
|
|
2418
|
-
// must draw disjoint ports (probing races slow boots). Scoped once here so
|
|
2419
|
-
// local and tethered modes resolve the SAME plan — every other field stays
|
|
2420
|
-
// worktree-scoped, so session naming and task queues are unaffected.
|
|
2421
1670
|
const worktree = withAppScopedPortBlock(resolveWorktreeIdentity(appRoot), appRoot);
|
|
2422
1671
|
const sessionName = options.session ?? deriveSessionName(projectName, worktree);
|
|
2423
|
-
// --kill
|
|
2424
1672
|
if (options.kill) {
|
|
2425
1673
|
composeExternalsDown(loadSessionState(sessionName)?.composeExternals);
|
|
2426
1674
|
localProbesRemove(loadSessionState(sessionName)?.externalProbeFiles);
|
|
@@ -2430,11 +1678,7 @@ export function registerDevCommand(program) {
|
|
|
2430
1678
|
logSuccess(`Killed docker dev session: ${sessionName}`);
|
|
2431
1679
|
return;
|
|
2432
1680
|
}
|
|
2433
|
-
// Tailscale VPN tunnels are machine-global + shared across sessions, so
|
|
2434
|
-
// they persist on purpose after a kill. Capture the tenant before state
|
|
2435
|
-
// is removed so we can point at the explicit stop below.
|
|
2436
1681
|
const killTsTenant = loadSessionState(sessionName)?.devOutput.platform?.name ?? 'mesh';
|
|
2437
|
-
// Clean up DNS records for this app
|
|
2438
1682
|
try {
|
|
2439
1683
|
const devOutput = loadSessionState(sessionName)?.devOutput;
|
|
2440
1684
|
if (devOutput) {
|
|
@@ -2449,7 +1693,6 @@ export function registerDevCommand(program) {
|
|
|
2449
1693
|
}
|
|
2450
1694
|
}
|
|
2451
1695
|
catch {
|
|
2452
|
-
/* best effort */
|
|
2453
1696
|
}
|
|
2454
1697
|
if (sessionExists(sessionName)) {
|
|
2455
1698
|
killSession(sessionName);
|
|
@@ -2464,17 +1707,8 @@ export function registerDevCommand(program) {
|
|
|
2464
1707
|
}
|
|
2465
1708
|
return;
|
|
2466
1709
|
}
|
|
2467
|
-
// ── Local mode (design §5.2) ─────────────────────────────────────
|
|
2468
|
-
// Explicit --local, or auto when the app has no Pulumi program at all
|
|
2469
|
-
// (hasStackBacking also checks the monorepo ROOT — apps like apps/hub
|
|
2470
|
-
// deploy via a root program and must keep the tethered default).
|
|
2471
|
-
// Synthesizes DevOutput from repo conventions and wires services to the
|
|
2472
|
-
// `mesh start` platform: no AWS credentials, no VPN, no Pulumi state.
|
|
2473
1710
|
const localMode = !!options.local || !hasStackBacking(appRoot);
|
|
2474
1711
|
if (localMode) {
|
|
2475
|
-
// Read-only status query: report and exit BEFORE any side effect
|
|
2476
|
-
// (platform boot, Zitadel provisioning, registry writes) — parity
|
|
2477
|
-
// with the tethered path's early --status short-circuit.
|
|
2478
1712
|
if (options.status) {
|
|
2479
1713
|
const state = loadSessionState(sessionName);
|
|
2480
1714
|
if (state?.runner === 'docker') {
|
|
@@ -2494,20 +1728,8 @@ export function registerDevCommand(program) {
|
|
|
2494
1728
|
await ensureLocalPlatformRunning();
|
|
2495
1729
|
const localTenant = detectLocalTenant(appRoot);
|
|
2496
1730
|
logInfo(`Project: ${projectName}, Stack: local (mesh start platform)`);
|
|
2497
|
-
// --externals (alias --mock): resolve the requested subset of the
|
|
2498
|
-
// app's declared externals (package.json → mesh.externals). Entries
|
|
2499
|
-
// are name[=mode] — an explicit mode overrides the declaration's
|
|
2500
|
-
// default for this run (materialized onto the decl so every
|
|
2501
|
-
// downstream externalMode() call agrees). Mock-mode externals join
|
|
2502
|
-
// the same orchestration as services named mock-{name}; all
|
|
2503
|
-
// realizations get their credential secret seeded after ports are
|
|
2504
|
-
// final.
|
|
2505
1731
|
const externalsRequest = options.externals ?? options.mock;
|
|
2506
1732
|
let selectedMocks = {};
|
|
2507
|
-
// The externals the developer NAMED (empty for the bare flag, which
|
|
2508
|
-
// selects everything declared). Failing to realize one of these is
|
|
2509
|
-
// fatal; failing to realize one the developer never asked for by name
|
|
2510
|
-
// only costs that external.
|
|
2511
1733
|
const requestedExplicitly = new Set();
|
|
2512
1734
|
if (externalsRequest) {
|
|
2513
1735
|
const declared = readLocalMocks(appRoot);
|
|
@@ -2532,22 +1754,14 @@ export function registerDevCommand(program) {
|
|
|
2532
1754
|
name,
|
|
2533
1755
|
overrides.has(name) ? { ...declared[name], mode: overrides.get(name) } : declared[name],
|
|
2534
1756
|
]));
|
|
2535
|
-
// Validate every declaration's resolved mode up front — a bad decl
|
|
2536
|
-
// (or an override the decl can't satisfy) should fail here, not
|
|
2537
|
-
// after half the orchestration is up.
|
|
2538
1757
|
for (const [name, decl] of Object.entries(selectedMocks))
|
|
2539
1758
|
externalMode(name, decl);
|
|
2540
1759
|
}
|
|
2541
1760
|
const rawDevOutput = buildLocalDevOutput(appRoot, localTenant, {
|
|
2542
1761
|
mocks: selectedMocks,
|
|
2543
1762
|
});
|
|
2544
|
-
// --dry-run (worktree-plan parity with tethered mode): print the
|
|
2545
|
-
// resolved session/port plan and exit before any side effect —
|
|
2546
|
-
// no Zitadel provisioning, no registry writes, no launch.
|
|
2547
1763
|
if (options.dryRun) {
|
|
2548
1764
|
printDevPlan(sessionName, appRoot, worktree, await allocatePorts(rawDevOutput, worktree));
|
|
2549
|
-
// Non-service realizations (local/remote) don't appear in the port
|
|
2550
|
-
// plan — list them so the dry run shows the whole picture.
|
|
2551
1765
|
for (const [name, decl] of Object.entries(selectedMocks)) {
|
|
2552
1766
|
const mode = externalMode(name, decl);
|
|
2553
1767
|
if (mode === 'local') {
|
|
@@ -2560,26 +1774,14 @@ export function registerDevCommand(program) {
|
|
|
2560
1774
|
return;
|
|
2561
1775
|
}
|
|
2562
1776
|
let signInServices = [];
|
|
2563
|
-
/** The app's own version — stamped on the registration like a deploy. */
|
|
2564
1777
|
let appVersion;
|
|
2565
|
-
// Auto-provision the app tenant's auth config (approved Zitadel
|
|
2566
|
-
// tenant-auth pattern: org per tenant → project per app → application
|
|
2567
|
-
// per service + credential secret). Best-effort: the dev loop still
|
|
2568
|
-
// works if Zitadel isn't seeded yet.
|
|
2569
1778
|
try {
|
|
2570
1779
|
const tenant = localTenant;
|
|
2571
1780
|
const app = rawDevOutput.app ?? projectName;
|
|
2572
1781
|
logInfo(`Provisioning auth config for tenant '${tenant}', app '${app}'…`);
|
|
2573
1782
|
const services = Object.keys(rawDevOutput.services);
|
|
2574
|
-
// Mocks are stand-ins, not services of the app — no Zitadel
|
|
2575
|
-
// application for them (the registry still lists them for the Hub).
|
|
2576
1783
|
const authServices = services.filter((name) => !name.startsWith('mock-'));
|
|
2577
|
-
// App-declared project roles (package.json → mesh.auth.roles): created
|
|
2578
|
-
// on the project and granted to the local `{app}-m2m` caller, so
|
|
2579
|
-
// role-scoped services (roles claim → scopes) accept its tokens.
|
|
2580
1784
|
let authRoles = [];
|
|
2581
|
-
// Services carrying a BROWSER login (package.json → mesh.auth.signIn).
|
|
2582
|
-
// Registered after ports are allocated — the redirect URI is the port.
|
|
2583
1785
|
signInServices = [];
|
|
2584
1786
|
try {
|
|
2585
1787
|
const appPkg = JSON.parse(fs.readFileSync(path.join(appRoot, 'package.json'), 'utf-8'));
|
|
@@ -2593,12 +1795,8 @@ export function registerDevCommand(program) {
|
|
|
2593
1795
|
appVersion = appPkg.version;
|
|
2594
1796
|
}
|
|
2595
1797
|
catch {
|
|
2596
|
-
// no package.json / unparsable — roles stay empty
|
|
2597
1798
|
}
|
|
2598
1799
|
await ensureAppTenantAuth({ tenant, app, services: authServices, roles: authRoles });
|
|
2599
|
-
// The app's own Temporal namespace ({tenant}-{env}-{app}) — the same
|
|
2600
|
-
// name the cloud provisioner creates and the Hub's per-app workflow
|
|
2601
|
-
// view resolves.
|
|
2602
1800
|
await ensureTemporalNamespace(localAppNamespace(tenant, app));
|
|
2603
1801
|
}
|
|
2604
1802
|
catch (err) {
|
|
@@ -2606,10 +1804,6 @@ export function registerDevCommand(program) {
|
|
|
2606
1804
|
` — if the local Zitadel predates seeding, run: mesh stop --destroy && mesh start`);
|
|
2607
1805
|
}
|
|
2608
1806
|
const devOutput = await allocatePorts(rawDevOutput, worktree);
|
|
2609
|
-
// Browser sign-in AFTER port allocation: the callback URL is the port a
|
|
2610
|
-
// service actually listens on, and a stale URI is the difference between
|
|
2611
|
-
// a working login and an "invalid redirect_uri" nobody can debug from
|
|
2612
|
-
// the app side.
|
|
2613
1807
|
for (const service of signInServices) {
|
|
2614
1808
|
const port = devOutput.services[service]?.port;
|
|
2615
1809
|
if (!port) {
|
|
@@ -2629,9 +1823,6 @@ export function registerDevCommand(program) {
|
|
|
2629
1823
|
` — the Hub's Access → Sign-in tab will report this app has no login.`);
|
|
2630
1824
|
}
|
|
2631
1825
|
}
|
|
2632
|
-
// Register AFTER allocatePorts: the registry must carry the ports the
|
|
2633
|
-
// services actually listen on, or StackPort/Hub (and anything reading
|
|
2634
|
-
// /mesh-platform/…/stacks/local) points at dead URLs.
|
|
2635
1826
|
try {
|
|
2636
1827
|
await registerLocalApp({
|
|
2637
1828
|
tenant: localTenant,
|
|
@@ -2640,8 +1831,6 @@ export function registerDevCommand(program) {
|
|
|
2640
1831
|
services: Object.keys(devOutput.services),
|
|
2641
1832
|
ports: Object.fromEntries(Object.entries(devOutput.services).map(([name, svc]) => [name, svc.port])),
|
|
2642
1833
|
kinds: Object.fromEntries(Object.entries(devOutput.services).flatMap(([name, svc]) => svc.kind ? [[name, svc.kind]] : [])),
|
|
2643
|
-
// ExternalService.link() parity: consuming services carry the
|
|
2644
|
-
// external names, so the Hub shows consumers + per-app uptime.
|
|
2645
1834
|
links: Object.values(selectedMocks)
|
|
2646
1835
|
.map((decl) => decl.external)
|
|
2647
1836
|
.filter((n) => !!n),
|
|
@@ -2650,13 +1839,6 @@ export function registerDevCommand(program) {
|
|
|
2650
1839
|
catch (err) {
|
|
2651
1840
|
logWarn(`Local registry registration skipped: ${err instanceof Error ? err.message : err}`);
|
|
2652
1841
|
}
|
|
2653
|
-
// Wire realizations AFTER ports are final: realize each declaration
|
|
2654
|
-
// by mode, then seed the external's credential secret and register it
|
|
2655
|
-
// in the Hub — the app's resolveCredentials() path runs unchanged.
|
|
2656
|
-
// mock → mock-{name} service URL (Prism / mock process)
|
|
2657
|
-
// local → docker compose up -d --wait, endpoint from decl.port
|
|
2658
|
-
// remote → actual-service credentials seeded (inline, or pulled
|
|
2659
|
-
// from the app's tenant environment)
|
|
2660
1842
|
const composeExternals = [];
|
|
2661
1843
|
const externalProbeFiles = [];
|
|
2662
1844
|
for (const [name, decl] of Object.entries(selectedMocks)) {
|
|
@@ -2665,8 +1847,6 @@ export function registerDevCommand(program) {
|
|
|
2665
1847
|
let probeFile;
|
|
2666
1848
|
if (mode === 'local') {
|
|
2667
1849
|
logInfo(`Starting docker external '${name}' (${decl.compose})…`);
|
|
2668
|
-
// undefined = adopted a realization another checkout owns —
|
|
2669
|
-
// not recorded, so --kill leaves it running.
|
|
2670
1850
|
const composeRef = await composeExternalUp(appRoot, sessionName, name, decl);
|
|
2671
1851
|
if (composeRef)
|
|
2672
1852
|
composeExternals.push(composeRef);
|
|
@@ -2706,28 +1886,20 @@ export function registerDevCommand(program) {
|
|
|
2706
1886
|
externalProbeFiles.push(probeFile);
|
|
2707
1887
|
}
|
|
2708
1888
|
catch (err) {
|
|
2709
|
-
// A remote external the developer didn't name costs only itself:
|
|
2710
|
-
// the bare `--externals` selects everything declared, and a
|
|
2711
|
-
// machine with no AWS identity (or no vendor key in the shell)
|
|
2712
|
-
// must still get its mock and local externals — that's the
|
|
2713
|
-
// zero-access local loop. Naming it keeps the failure fatal.
|
|
2714
1889
|
if (mode === 'remote' && !requestedExplicitly.has(name) && err instanceof MeshCliError) {
|
|
2715
1890
|
logWarn(`External '${name}': skipped — ${err.message}` +
|
|
2716
1891
|
` (re-run with \`mesh dev --externals ${name}=remote\` to make this fatal).`);
|
|
2717
1892
|
continue;
|
|
2718
1893
|
}
|
|
2719
1894
|
if (err instanceof MeshCliError)
|
|
2720
|
-
throw err;
|
|
1895
|
+
throw err;
|
|
2721
1896
|
if (mode === 'local') {
|
|
2722
|
-
// The app's services depend on this external — starting them
|
|
2723
|
-
// against a dead dependency helps nobody.
|
|
2724
1897
|
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` } });
|
|
2725
1898
|
}
|
|
2726
1899
|
logWarn(`External '${name}' credential seeding failed: ${err instanceof Error ? err.message : err}`);
|
|
2727
1900
|
}
|
|
2728
1901
|
}
|
|
2729
1902
|
if (dockerRunner) {
|
|
2730
|
-
// C2: render the same DevOutput as a docker compose project.
|
|
2731
1903
|
const composePath = writeDevCompose(sessionName, appRoot, devOutput.services);
|
|
2732
1904
|
logInfo(`Docker runner: ${composePath}`);
|
|
2733
1905
|
dockerDevUp(sessionName);
|
|
@@ -2753,8 +1925,7 @@ export function registerDevCommand(program) {
|
|
|
2753
1925
|
console.log('Stop with: mesh dev --kill');
|
|
2754
1926
|
return;
|
|
2755
1927
|
}
|
|
2756
|
-
const finalDevOutput = await startServices(sessionName, appRoot, devOutput, !!options.headless, localAwsEnv(),
|
|
2757
|
-
{ transport: 'vpn-direct' });
|
|
1928
|
+
const finalDevOutput = await startServices(sessionName, appRoot, devOutput, !!options.headless, localAwsEnv(), { transport: 'vpn-direct' });
|
|
2758
1929
|
registerServiceProbes(finalDevOutput, localTenant, externalProbeFiles);
|
|
2759
1930
|
saveSessionState(sessionName, {
|
|
2760
1931
|
appRoot,
|
|
@@ -2768,15 +1939,10 @@ export function registerDevCommand(program) {
|
|
|
2768
1939
|
return;
|
|
2769
1940
|
}
|
|
2770
1941
|
if (options.mock || options.externals) {
|
|
2771
|
-
logWarn('--externals/--mock is local-mode only for now (tethered dev outputs do not carry external declarations yet
|
|
1942
|
+
logWarn('--externals/--mock is local-mode only for now (tethered dev outputs do not carry external declarations yet). Continuing without them.');
|
|
2772
1943
|
}
|
|
2773
1944
|
const stack = detectStack(appRoot, resolveStackOption(options));
|
|
2774
1945
|
logInfo(`Project: ${projectName}, Stack: ${stack}`);
|
|
2775
|
-
// Detect if the user accidentally ran `mesh dev` from the monorepo root
|
|
2776
|
-
// instead of an app directory. The monorepo root has a Pulumi.yaml but
|
|
2777
|
-
// no services/apps to run. Standalone apps (create-app scaffolds) ARE
|
|
2778
|
-
// their own workspace root — an api/ or worker/ package right here means
|
|
2779
|
-
// this is an app, not a monorepo root.
|
|
2780
1946
|
const monorepoRoot = findMonorepoRoot();
|
|
2781
1947
|
const looksLikeStandaloneApp = ['api', 'worker', 'web'].some((dir) => fs.existsSync(path.join(appRoot, dir, 'package.json')));
|
|
2782
1948
|
if (monorepoRoot && path.resolve(appRoot) === path.resolve(monorepoRoot) && !looksLikeStandaloneApp) {
|
|
@@ -2791,19 +1957,8 @@ export function registerDevCommand(program) {
|
|
|
2791
1957
|
logInfo(' mesh dev list');
|
|
2792
1958
|
process.exit(1);
|
|
2793
1959
|
}
|
|
2794
|
-
// ── Prerequisite checks ──────────────────────────────────────────
|
|
2795
|
-
// Check all prerequisites upfront so the user gets one clear list of
|
|
2796
|
-
// what needs fixing, rather than failing one step at a time.
|
|
2797
1960
|
const issues = [];
|
|
2798
1961
|
const profileHint = options.profile ?? process.env.AWS_PROFILE ?? 'mesh-dev';
|
|
2799
|
-
// AWS role + credential resolution.
|
|
2800
|
-
// The stack can declare two role ARNs:
|
|
2801
|
-
// - `mesh:deployerRole` — the default per-tenant deployer
|
|
2802
|
-
// - `mesh:adminDeployerRole` — optional admin variant assumed when the
|
|
2803
|
-
// caller's Zitadel JWT carries an admin-tier role (e.g.
|
|
2804
|
-
// `mesh.platform:admin`). selectRoleForCaller picks between them.
|
|
2805
|
-
// tmux + credential validation/diagnosis is handled below by the shared
|
|
2806
|
-
// doctor preflight so `mesh dev` and `mesh dev doctor` agree.
|
|
2807
1962
|
const platformContext = derivePlatformContext(appRoot, stack);
|
|
2808
1963
|
const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
|
|
2809
1964
|
const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
|
|
@@ -2821,9 +1976,6 @@ export function registerDevCommand(program) {
|
|
|
2821
1976
|
}
|
|
2822
1977
|
let awsEnv;
|
|
2823
1978
|
let credMethod = 'ambient';
|
|
2824
|
-
// Method fed to the doctor context: the resolved method on success, or
|
|
2825
|
-
// `null` on a failed resolution so the `creds` check probes for the exact
|
|
2826
|
-
// reason (no-session / expired / assume-denied / stale-env-override).
|
|
2827
1979
|
let credMethodForDoctor = 'ambient';
|
|
2828
1980
|
if (deployerRole) {
|
|
2829
1981
|
const resolved = await resolveAwsCredentials(deployerRole, appRoot, stack);
|
|
@@ -2841,10 +1993,7 @@ export function registerDevCommand(program) {
|
|
|
2841
1993
|
awsEnv = getAwsEnvVars();
|
|
2842
1994
|
credMethodForDoctor = 'ambient';
|
|
2843
1995
|
}
|
|
2844
|
-
// 3. VPN or SSM fallback
|
|
2845
1996
|
const vpnConnected = await isVpnConnected();
|
|
2846
|
-
// `let`: the auto-selected `vpn-direct` may be downgraded to `ssm` below by a
|
|
2847
|
-
// reachability probe once devOutput's Temporal endpoint is known (MESH-2016).
|
|
2848
1997
|
let transport = resolveTransport(options.transport, {
|
|
2849
1998
|
vpnConnected,
|
|
2850
1999
|
hasSsmPlugin: hasSessionManagerPlugin(),
|
|
@@ -2871,14 +2020,8 @@ export function registerDevCommand(program) {
|
|
|
2871
2020
|
}
|
|
2872
2021
|
}
|
|
2873
2022
|
else if (transport === 'tailscale') {
|
|
2874
|
-
// The daemon bring-up + join happen in startServices; announce success there,
|
|
2875
|
-
// not here — otherwise a fallback to SSM still reads as "using Tailscale"
|
|
2876
|
-
// (MESH-2098). A pending node registration prints its own URL during the join.
|
|
2877
2023
|
logInfo('Bringing up userspace-Tailscale tunnels for VPC resources (shared per tenant)…');
|
|
2878
2024
|
}
|
|
2879
|
-
// Shared doctor preflight: the same checks as `mesh dev doctor`, so launch
|
|
2880
|
-
// and on-demand diagnosis name failures identically. Errors block launch;
|
|
2881
|
-
// warnings (config/worktree) print and proceed.
|
|
2882
2025
|
const preflightCtx = {
|
|
2883
2026
|
appRoot,
|
|
2884
2027
|
stack,
|
|
@@ -2899,26 +2042,11 @@ export function registerDevCommand(program) {
|
|
|
2899
2042
|
console.log(issue);
|
|
2900
2043
|
process.exit(1);
|
|
2901
2044
|
}
|
|
2902
|
-
// ── Credentials resolved, inject into environment ──────────────
|
|
2903
|
-
// Ensure AWS_REGION is set from the Pulumi stack config. Without this, AWS
|
|
2904
|
-
// SDK clients (SSM, Secrets Manager) would default to us-east-1.
|
|
2905
2045
|
const awsRegion = readStackConfig(appRoot, stack, 'aws:region');
|
|
2906
2046
|
if (awsRegion && !awsEnv.AWS_REGION) {
|
|
2907
2047
|
awsEnv.AWS_REGION = awsRegion;
|
|
2908
2048
|
}
|
|
2909
2049
|
Object.assign(process.env, awsEnv);
|
|
2910
|
-
// Build the env that long-lived child services (ui, api, ai-assistant) and
|
|
2911
|
-
// SSM tunnels will see. When credentials came from AWS SSO, prefer passing
|
|
2912
|
-
// AWS_PROFILE + AWS_REGION so the AWS SDK v3 default provider chain can
|
|
2913
|
-
// auto-refresh from the SSO cache (~8h session) as short-lived STS tokens
|
|
2914
|
-
// expire. Without this, the hub API starts throwing ExpiredTokenException
|
|
2915
|
-
// about an hour in and tenants disappear from the UI. For non-SSO paths
|
|
2916
|
-
// (Zitadel JWT, CI/CD env keys) there is no profile to fall back to, so
|
|
2917
|
-
// pass the STS keys directly and accept that the user must restart
|
|
2918
|
-
// `mesh dev` when they expire.
|
|
2919
|
-
// For the Zitadel deployer path, hand children a self-refreshing
|
|
2920
|
-
// credential_process profile (context + role from the resolution above,
|
|
2921
|
-
// written into this session's scratch dir) instead of a frozen STS triple.
|
|
2922
2050
|
const zitadelOpts = credMethod === 'zitadel' && deployerRole
|
|
2923
2051
|
? (() => {
|
|
2924
2052
|
const context = derivePlatformContext(appRoot, stack);
|
|
@@ -2939,18 +2067,12 @@ export function registerDevCommand(program) {
|
|
|
2939
2067
|
}
|
|
2940
2068
|
catch (err) {
|
|
2941
2069
|
if (err instanceof MissingStackOutputError) {
|
|
2942
|
-
// Only the launch path can conclude "nothing to run" — print the
|
|
2943
|
-
// forward-chaining first-run fix and exit cleanly (MESH-2097). The
|
|
2944
|
-
// suggested commands carry --stack because the bare forms error right
|
|
2945
|
-
// after `stack init` (matching stack.ts's next-step output).
|
|
2946
2070
|
logError(`No Pulumi stack output for '${stack}' — mesh dev has nothing to run.`);
|
|
2947
2071
|
logInfo('If this is a new app, initialize and materialize it first:');
|
|
2948
2072
|
logInfo(' mesh stack init # create your personal dev stack');
|
|
2949
2073
|
logInfo(` mesh deploy up --stack ${stack} --yes # produce the app output mesh dev reads`);
|
|
2950
2074
|
logInfo(`Then: mesh dev --stage ${stack} (the stack must export an \`app\` or \`dev\` output.)`);
|
|
2951
2075
|
logInfo('No deployed stack yet? Run against the local platform: mesh dev --local (needs `mesh start`).');
|
|
2952
|
-
// Surface the underlying read error so an auth/network/JSON failure isn't
|
|
2953
|
-
// silently misdiagnosed as "fresh app" with zero forensics.
|
|
2954
2076
|
const cause = err.cause?.message;
|
|
2955
2077
|
if (cause)
|
|
2956
2078
|
logInfo(`(underlying stack-read error: ${cause})`);
|
|
@@ -2958,30 +2080,17 @@ export function registerDevCommand(program) {
|
|
|
2958
2080
|
}
|
|
2959
2081
|
throw err;
|
|
2960
2082
|
}
|
|
2961
|
-
// --status — use persisted state (has allocated ports), fall back to raw
|
|
2962
2083
|
if (options.status) {
|
|
2963
2084
|
const state = loadSessionState(sessionName);
|
|
2964
2085
|
await showStatus(sessionName, state?.devOutput ?? rawDevOutput, !!options.json);
|
|
2965
2086
|
return;
|
|
2966
2087
|
}
|
|
2967
|
-
// Allocate dynamic ports (avoid collisions with other running apps).
|
|
2968
|
-
// devOutput is passed by value and may be rewritten inside startServices
|
|
2969
|
-
// if SSM tunnels are used (to point tunnels at localhost:2XXXX).
|
|
2970
2088
|
const devOutput = await allocatePorts(rawDevOutput, worktree);
|
|
2971
|
-
// Reachability gate (MESH-2016): the transport chosen above used a PRESENCE
|
|
2972
|
-
// check (isVpnConnected → "is any Tailscale/VPN up"), which is tailnet-blind —
|
|
2973
|
-
// Tailscale-for-SSH looks connected but doesn't route the mesh VPC. Now that
|
|
2974
|
-
// devOutput is known, probe the real Temporal VPC endpoint and downgrade an
|
|
2975
|
-
// auto-selected vpn-direct to SSM when it's unreachable.
|
|
2976
2089
|
transport = await gateVpnDirectReachability(transport, devOutput);
|
|
2977
|
-
// --dry-run: show the resolved worktree/session/port plan, launch nothing.
|
|
2978
2090
|
if (options.dryRun) {
|
|
2979
2091
|
printDevPlan(sessionName, appRoot, worktree, devOutput);
|
|
2980
2092
|
return;
|
|
2981
2093
|
}
|
|
2982
|
-
// Build the tunnel plan for the chosen transport. For Tailscale, resolve the
|
|
2983
|
-
// shared per-tenant daemon context (tenant = hub platform name, since the
|
|
2984
|
-
// bastion is a hub resource); reuse the daemon's SOCKS port if it's already up.
|
|
2985
2094
|
let tunnelPlan = { transport };
|
|
2986
2095
|
if (transport === 'tailscale') {
|
|
2987
2096
|
const tsTenant = devOutput.platform?.name ?? 'mesh';
|
|
@@ -2989,8 +2098,6 @@ export function registerDevCommand(program) {
|
|
|
2989
2098
|
const tsContext = derivePlatformContext(appRoot, stack) ?? `${tsTenant}.${tsEnv}`;
|
|
2990
2099
|
const loginServer = deriveLoginServer(tsContext);
|
|
2991
2100
|
if (!loginServer) {
|
|
2992
|
-
// Implicit selection (auto/unset) must degrade to the legacy transport,
|
|
2993
|
-
// not dead-end. Only an explicit --transport=tailscale may hard-error.
|
|
2994
2101
|
const msg = `No login config for context '${tsContext}' — run: mesh login ${tsContext}`;
|
|
2995
2102
|
if (options.transport === 'tailscale') {
|
|
2996
2103
|
logError(msg);
|
|
@@ -3001,10 +2108,6 @@ export function registerDevCommand(program) {
|
|
|
3001
2108
|
: hasSessionManagerPlugin()
|
|
3002
2109
|
? 'ssm'
|
|
3003
2110
|
: 'vpn-direct';
|
|
3004
|
-
// Reachability-gate this legacy fallback too (MESH-2016): otherwise a
|
|
3005
|
-
// machine with tailscaled (→ auto picks tailscale) but no mesh login
|
|
3006
|
-
// config, with any tailnet up (vpnConnected), would land on an UNPROBED
|
|
3007
|
-
// vpn-direct and reproduce the exact ECONNREFUSED this feature fixes.
|
|
3008
2111
|
const gatedLegacy = await gateVpnDirectReachability(legacy, devOutput);
|
|
3009
2112
|
logWarn(`${msg} — falling back to ${gatedLegacy} transport.`);
|
|
3010
2113
|
tunnelPlan = { transport: gatedLegacy };
|
|
@@ -3012,15 +2115,9 @@ export function registerDevCommand(program) {
|
|
|
3012
2115
|
else {
|
|
3013
2116
|
const prior = readTailscaleState(tsTenant);
|
|
3014
2117
|
const daemonUp = tailscaleDaemonState(tsTenant).backendState !== 'Down';
|
|
3015
|
-
// Reuse the running daemon's real SOCKS port (daemon.json survives
|
|
3016
|
-
// `down`); never mint a fresh one the daemon isn't listening on.
|
|
3017
2118
|
const socksPort = daemonUp
|
|
3018
2119
|
? (readTailscaleDaemonMeta(tsTenant)?.socksPort ?? prior?.socksPort ?? (await findFreePort()))
|
|
3019
2120
|
: await findFreePort();
|
|
3020
|
-
// Zero-touch VPN join (MESH-2099): try to mint a single-use pre-auth key
|
|
3021
|
-
// from the platform's VpnJoinBroker so `tailscale up --authkey` joins
|
|
3022
|
-
// silently. Any failure leaves preAuthKey undefined → the interactive
|
|
3023
|
-
// browser-relay join (MESH-2098) is used instead.
|
|
3024
2121
|
let preAuthKey;
|
|
3025
2122
|
const brokerUrl = resolveVpnJoinBroker(tsContext);
|
|
3026
2123
|
if (brokerUrl) {
|
|
@@ -3046,19 +2143,10 @@ export function registerDevCommand(program) {
|
|
|
3046
2143
|
};
|
|
3047
2144
|
}
|
|
3048
2145
|
}
|
|
3049
|
-
// Relaunch safety (MESH-2017): a relaunch recreates the worker onto current
|
|
3050
|
-
// code. If the workflow source changed since this session started, warn before
|
|
3051
|
-
// it replays in-flight conversations against it (require --force). Checked
|
|
3052
|
-
// BEFORE startServices, which kills+recreates the session.
|
|
3053
2146
|
if (workflowChangeWouldStrand(loadSessionState(sessionName), appRoot, !!options.force)) {
|
|
3054
2147
|
process.exit(1);
|
|
3055
2148
|
}
|
|
3056
|
-
// Start services. startServices handles tmux session creation, tunnel setup
|
|
3057
|
-
// (SSM or Tailscale, per tunnelPlan), and service windows. It returns the
|
|
3058
|
-
// final devOutput with tunnels rewritten to localhost ports.
|
|
3059
2149
|
const finalDevOutput = await startServices(sessionName, appRoot, devOutput, !!options.headless, childAwsEnv, tunnelPlan, worktree.worktreeRoot, worktree.taskQueueSuffix);
|
|
3060
|
-
// Persist session state so --status and restart see allocated ports
|
|
3061
|
-
// and the final tunnel endpoints (localhost for SSM, VPC for VPN).
|
|
3062
2150
|
saveSessionState(sessionName, {
|
|
3063
2151
|
appRoot,
|
|
3064
2152
|
stack,
|
|
@@ -3067,13 +2155,11 @@ export function registerDevCommand(program) {
|
|
|
3067
2155
|
workflowFingerprint: fingerprintWorkflowSource(appRoot, finalDevOutput.services),
|
|
3068
2156
|
});
|
|
3069
2157
|
});
|
|
3070
|
-
// Subcommand: logs
|
|
3071
2158
|
dev
|
|
3072
2159
|
.command('logs <service>')
|
|
3073
2160
|
.description('Show logs for a service')
|
|
3074
2161
|
.option('--tail <lines>', 'Number of lines', '100')
|
|
3075
2162
|
.action((service, opts) => {
|
|
3076
|
-
// --session is on the parent dev command
|
|
3077
2163
|
const sessionName = dev.opts().session;
|
|
3078
2164
|
const resolvedSession = sessionName ?? `${getProjectName(findAppRoot())}-dev`;
|
|
3079
2165
|
if (loadSessionState(resolvedSession)?.runner === 'docker') {
|
|
@@ -3089,7 +2175,6 @@ export function registerDevCommand(program) {
|
|
|
3089
2175
|
showLogs(`${projectName}-dev`, service, parseInt(opts.tail, 10));
|
|
3090
2176
|
}
|
|
3091
2177
|
});
|
|
3092
|
-
// Subcommand: restart
|
|
3093
2178
|
dev
|
|
3094
2179
|
.command('restart <service>')
|
|
3095
2180
|
.description('Restart a service')
|
|
@@ -3098,11 +2183,8 @@ export function registerDevCommand(program) {
|
|
|
3098
2183
|
.option('--refresh-env', "Regenerate this service's env from current stack/SSM outputs before restarting (for config/deploy changes)")
|
|
3099
2184
|
.option('--force', 'Restart the worker even if workflow code changed since launch (may strand in-flight conversations)')
|
|
3100
2185
|
.action(async (service, opts) => {
|
|
3101
|
-
// Hoisted so the catch can name the right login context (not a hardcoded
|
|
3102
|
-
// one) even when a throw happens before cred resolution. MESH-2089.
|
|
3103
2186
|
let ctx = null;
|
|
3104
2187
|
try {
|
|
3105
|
-
// --session is on the parent dev command
|
|
3106
2188
|
const parentSession = dev.opts().session;
|
|
3107
2189
|
const parentProfile = dev.opts().profile;
|
|
3108
2190
|
if (parentProfile) {
|
|
@@ -3117,22 +2199,11 @@ export function registerDevCommand(program) {
|
|
|
3117
2199
|
logSuccess(`Restarted: ${service}`);
|
|
3118
2200
|
return;
|
|
3119
2201
|
}
|
|
3120
|
-
// Relaunch safety (MESH-2017): restarting the WORKER onto changed workflow
|
|
3121
|
-
// code strands in-flight conversations; restarting other services (ui/api)
|
|
3122
|
-
// doesn't. Gate the worker restart on the workflow-source fingerprint.
|
|
3123
2202
|
if (/worker/i.test(service) && workflowChangeWouldStrand(state, appRoot, !!opts.force)) {
|
|
3124
2203
|
process.exit(1);
|
|
3125
2204
|
}
|
|
3126
2205
|
const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));
|
|
3127
2206
|
ctx = derivePlatformContext(appRoot, stack);
|
|
3128
|
-
// Resolve credentials BEFORE reading any Pulumi/SSM/kubeconfig output
|
|
3129
|
-
// (MESH-2090). With ambient AWS_* cleared (the documented restart
|
|
3130
|
-
// hygiene), the parent-side AWS calls the restart path makes — the
|
|
3131
|
-
// getDevOutput fallback below, and under --refresh-env the
|
|
3132
|
-
// resolveTemporalAuthVars/ensureKubeconfig calls — read the default SDK
|
|
3133
|
-
// chain (process.env). Resolving first lets us thread the resolved creds
|
|
3134
|
-
// into this process's env, and lets the precise login preflight fire
|
|
3135
|
-
// before the devOutput fallback (which was the MESH-2089 crash site).
|
|
3136
2207
|
const defaultDeployerRole = readStackConfig(appRoot, stack, 'mesh:deployerRole');
|
|
3137
2208
|
const adminDeployerRole = readStackConfig(appRoot, stack, 'mesh:adminDeployerRole');
|
|
3138
2209
|
let deployerRole = defaultDeployerRole;
|
|
@@ -3145,10 +2216,6 @@ export function registerDevCommand(program) {
|
|
|
3145
2216
|
});
|
|
3146
2217
|
}
|
|
3147
2218
|
const resolved = deployerRole ? await resolveAwsCredentials(deployerRole, appRoot, stack) : null;
|
|
3148
|
-
// Login preflight (MESH-2089): if a deployer role is configured but creds
|
|
3149
|
-
// couldn't be resolved AND there are no ambient AWS creds to fall back on,
|
|
3150
|
-
// the restart would proceed with no credentials and fail downstream. Name
|
|
3151
|
-
// the real problem (e.g. "run mesh login <ctx> --device") instead.
|
|
3152
2219
|
if (deployerRole && !resolved && !process.env.AWS_ACCESS_KEY_ID) {
|
|
3153
2220
|
const pf = ctx ? credProbeToPreflightError(await probeCredentials(ctx, deployerRole), ctx) : null;
|
|
3154
2221
|
logError(pf?.message ??
|
|
@@ -3156,15 +2223,10 @@ export function registerDevCommand(program) {
|
|
|
3156
2223
|
process.exit(1);
|
|
3157
2224
|
}
|
|
3158
2225
|
const baseAwsEnv = resolved?.env ?? getAwsEnvVars();
|
|
3159
|
-
// Ensure AWS_REGION is set from stack config so parent-side SDK clients
|
|
3160
|
-
// (SSM, EKS) don't default to us-east-1. Mirrors the launch path.
|
|
3161
2226
|
const awsRegion = readStackConfig(appRoot, stack, 'aws:region');
|
|
3162
2227
|
if (awsRegion && !baseAwsEnv.AWS_REGION) {
|
|
3163
2228
|
baseAwsEnv.AWS_REGION = awsRegion;
|
|
3164
2229
|
}
|
|
3165
|
-
// Inject the resolved creds into THIS process's env so the parent-side
|
|
3166
|
-
// AWS calls below run credentialed even when ambient AWS_* was cleared
|
|
3167
|
-
// (MESH-2090). Mirrors the launch path's inject (see startServices).
|
|
3168
2230
|
Object.assign(process.env, baseAwsEnv);
|
|
3169
2231
|
const profileHint = parentProfile ?? process.env.AWS_PROFILE ?? 'mesh-dev';
|
|
3170
2232
|
const zitadelOpts = resolved?.method === 'zitadel' && deployerRole && ctx
|
|
@@ -3176,18 +2238,12 @@ export function registerDevCommand(program) {
|
|
|
3176
2238
|
}
|
|
3177
2239
|
: undefined;
|
|
3178
2240
|
const awsEnv = buildChildAwsEnv(baseAwsEnv, resolved?.method ?? 'ambient', profileHint, zitadelOpts);
|
|
3179
|
-
// Use persisted state (has allocated ports), fall back to raw Pulumi
|
|
3180
|
-
// output — now credentialed via the resolved env (MESH-2090).
|
|
3181
2241
|
const devOutput = state?.devOutput ?? getDevOutput(appRoot, stack, baseAwsEnv);
|
|
3182
2242
|
await restartService(sessionName, service, appRoot, devOutput, awsEnv, {
|
|
3183
2243
|
refreshEnv: !!opts.refreshEnv,
|
|
3184
2244
|
stack,
|
|
3185
2245
|
sessionState: state,
|
|
3186
2246
|
});
|
|
3187
|
-
// Re-baseline the workflow fingerprint after a worker restart (MESH-2017):
|
|
3188
|
-
// the worker now runs the current code, so a subsequent unchanged restart
|
|
3189
|
-
// must NOT nag — otherwise a good-citizen `--force` earns a session of
|
|
3190
|
-
// false warnings, training the reflex this guard exists to prevent.
|
|
3191
2247
|
if (/worker/i.test(service) && state) {
|
|
3192
2248
|
saveSessionState(sessionName, {
|
|
3193
2249
|
...state,
|
|
@@ -3196,16 +2252,12 @@ export function registerDevCommand(program) {
|
|
|
3196
2252
|
}
|
|
3197
2253
|
}
|
|
3198
2254
|
catch (err) {
|
|
3199
|
-
// Never crash uncaught (MESH-2089): a cred/kubeconfig/tmux failure should
|
|
3200
|
-
// name itself, not dump a bare Node stack. The underlying error is logged
|
|
3201
|
-
// so the exact cause is pinned if this recurs.
|
|
3202
2255
|
logError(`Failed to restart '${service}': ${err?.message ?? String(err)}`);
|
|
3203
2256
|
logInfo(`If this is a credential/kubeconfig issue, run \`mesh login ${ctx ?? '<your platform context>'} --device\`, ` +
|
|
3204
2257
|
'or `mesh dev` to relaunch the session cleanly.');
|
|
3205
2258
|
process.exit(1);
|
|
3206
2259
|
}
|
|
3207
2260
|
});
|
|
3208
|
-
// Subcommand: doctor — diagnose the dev session
|
|
3209
2261
|
dev
|
|
3210
2262
|
.command('doctor')
|
|
3211
2263
|
.description('Diagnose the dev session (creds, tunnels, config, ports, worktree, Temporal) — names the fix')
|
|
@@ -3222,19 +2274,15 @@ export function registerDevCommand(program) {
|
|
|
3222
2274
|
const sessionName = dev.opts().session ?? `${projectName}-dev`;
|
|
3223
2275
|
const state = loadSessionState(sessionName);
|
|
3224
2276
|
const stack = state?.stack ?? detectStack(appRoot, resolveStackOption(opts));
|
|
3225
|
-
// `--json` may land on either the subcommand or the parent `dev`
|
|
3226
|
-
// command (both declare it), so honor both.
|
|
3227
2277
|
const asJson = !!opts.json || !!dev.opts().json;
|
|
3228
2278
|
const ctx = buildDoctorContext(appRoot, stack, sessionName);
|
|
3229
2279
|
const status = await runDoctor(ctx, { json: asJson });
|
|
3230
2280
|
process.exit(status === 'error' ? 1 : 0);
|
|
3231
2281
|
});
|
|
3232
|
-
// Subcommand: list — discover apps in the monorepo
|
|
3233
2282
|
dev
|
|
3234
2283
|
.command('list')
|
|
3235
2284
|
.description('List Pulumi apps in the monorepo')
|
|
3236
2285
|
.action(() => {
|
|
3237
|
-
// --json is on the parent dev command
|
|
3238
2286
|
const asJson = dev.opts().json;
|
|
3239
2287
|
const mono = findMonorepoRoot();
|
|
3240
2288
|
if (!mono) {
|
|
@@ -3243,7 +2291,6 @@ export function registerDevCommand(program) {
|
|
|
3243
2291
|
}
|
|
3244
2292
|
const apps = discoverApps(mono);
|
|
3245
2293
|
if (asJson) {
|
|
3246
|
-
// Include running state for each app
|
|
3247
2294
|
const result = apps.map((app) => {
|
|
3248
2295
|
const sessionName = `${app.name}-dev`;
|
|
3249
2296
|
return {
|
|
@@ -3271,7 +2318,6 @@ export function registerDevCommand(program) {
|
|
|
3271
2318
|
console.log('');
|
|
3272
2319
|
}
|
|
3273
2320
|
});
|
|
3274
|
-
// Subcommand: test-user — retrieve Temporal test user credentials
|
|
3275
2321
|
dev
|
|
3276
2322
|
.command('test-user [name]')
|
|
3277
2323
|
.description('Get Temporal test user credentials (from Pulumi-managed test users)')
|
|
@@ -3283,7 +2329,6 @@ export function registerDevCommand(program) {
|
|
|
3283
2329
|
const ssm = new SSMClient({ region: opts.region });
|
|
3284
2330
|
const basePath = `/mesh-platform/${opts.tenant}/${opts.env}/temporal/test-users`;
|
|
3285
2331
|
if (!name) {
|
|
3286
|
-
// List all test users
|
|
3287
2332
|
try {
|
|
3288
2333
|
const resp = await ssm.send(new GetParametersByPathCommand({
|
|
3289
2334
|
Path: basePath,
|
|
@@ -3317,7 +2362,6 @@ export function registerDevCommand(program) {
|
|
|
3317
2362
|
}
|
|
3318
2363
|
}
|
|
3319
2364
|
else {
|
|
3320
|
-
// Get a specific test user
|
|
3321
2365
|
try {
|
|
3322
2366
|
const resp = await ssm.send(new GetParameterCommand({
|
|
3323
2367
|
Name: `${basePath}/${name}`,
|
|
@@ -3328,7 +2372,6 @@ export function registerDevCommand(program) {
|
|
|
3328
2372
|
return;
|
|
3329
2373
|
}
|
|
3330
2374
|
const creds = JSON.parse(resp.Parameter.Value);
|
|
3331
|
-
// Check if --json is set on parent
|
|
3332
2375
|
const asJson = dev.opts().json;
|
|
3333
2376
|
if (asJson) {
|
|
3334
2377
|
console.log(JSON.stringify(creds, null, 2));
|
|
@@ -3357,4 +2400,3 @@ export function registerDevCommand(program) {
|
|
|
3357
2400
|
}
|
|
3358
2401
|
});
|
|
3359
2402
|
}
|
|
3360
|
-
//# sourceMappingURL=dev.js.map
|