@mesh-tech/mesh-cli 0.1.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/intent/agents-fence.md +10 -0
- package/assets/intent/intent-claude-gate.mjs +255 -0
- package/assets/log-shipper.mjs +94 -0
- package/bin/mesh.mjs +80 -0
- package/dist/bin/mesh.d.ts +7 -9
- package/dist/bin/mesh.d.ts.map +1 -1
- package/dist/bin/mesh.js +20826 -37
- package/dist/bin/mesh.js.map +7 -1
- package/dist/build-info.json +4 -0
- package/dist/src/commands/agent-api-client.d.ts +79 -0
- package/dist/src/commands/agent-api-client.d.ts.map +1 -0
- package/dist/src/commands/agent-api-client.js +170 -0
- package/dist/src/commands/agent-api-client.js.map +1 -0
- package/dist/src/commands/artifacts.d.ts +13 -0
- package/dist/src/commands/artifacts.d.ts.map +1 -0
- package/dist/src/commands/artifacts.js +140 -0
- package/dist/src/commands/artifacts.js.map +1 -0
- package/dist/src/commands/cluster.d.ts +45 -0
- package/dist/src/commands/cluster.d.ts.map +1 -0
- package/dist/src/commands/cluster.js +259 -0
- package/dist/src/commands/cluster.js.map +1 -0
- package/dist/src/commands/conversations.d.ts +61 -0
- package/dist/src/commands/conversations.d.ts.map +1 -0
- package/dist/src/commands/conversations.js +239 -0
- package/dist/src/commands/conversations.js.map +1 -0
- package/dist/src/commands/create-app.d.ts +26 -3
- package/dist/src/commands/create-app.d.ts.map +1 -1
- package/dist/src/commands/create-app.js +465 -107
- package/dist/src/commands/create-app.js.map +1 -1
- package/dist/src/commands/deploy.d.ts +19 -0
- package/dist/src/commands/deploy.d.ts.map +1 -1
- package/dist/src/commands/deploy.js +83 -142
- package/dist/src/commands/deploy.js.map +1 -1
- package/dist/src/commands/dev-doctor.d.ts +137 -0
- package/dist/src/commands/dev-doctor.d.ts.map +1 -0
- package/dist/src/commands/dev-doctor.js +671 -0
- package/dist/src/commands/dev-doctor.js.map +1 -0
- package/dist/src/commands/dev-launch.d.ts +59 -0
- package/dist/src/commands/dev-launch.d.ts.map +1 -0
- package/dist/src/commands/dev-launch.js +118 -0
- package/dist/src/commands/dev-launch.js.map +1 -0
- package/dist/src/commands/dev-token-server.d.ts +32 -0
- package/dist/src/commands/dev-token-server.d.ts.map +1 -0
- package/dist/src/commands/dev-token-server.js +68 -0
- package/dist/src/commands/dev-token-server.js.map +1 -0
- package/dist/src/commands/dev.d.ts +289 -2
- package/dist/src/commands/dev.d.ts.map +1 -1
- package/dist/src/commands/dev.js +2501 -574
- package/dist/src/commands/dev.js.map +1 -1
- package/dist/src/commands/docs.d.ts +3 -0
- package/dist/src/commands/docs.d.ts.map +1 -0
- package/dist/src/commands/docs.js +262 -0
- package/dist/src/commands/docs.js.map +1 -0
- package/dist/src/commands/hub/index.d.ts +99 -0
- package/dist/src/commands/hub/index.d.ts.map +1 -0
- package/dist/src/commands/hub/index.js +452 -0
- package/dist/src/commands/hub/index.js.map +1 -0
- package/dist/src/commands/init.d.ts +65 -0
- package/dist/src/commands/init.d.ts.map +1 -0
- package/dist/src/commands/init.js +370 -0
- package/dist/src/commands/init.js.map +1 -0
- package/dist/src/commands/install-shim.d.ts +21 -0
- package/dist/src/commands/install-shim.d.ts.map +1 -0
- package/dist/src/commands/install-shim.js +132 -0
- package/dist/src/commands/install-shim.js.map +1 -0
- package/dist/src/commands/local/auth-provision.d.ts +71 -0
- package/dist/src/commands/local/auth-provision.d.ts.map +1 -0
- package/dist/src/commands/local/auth-provision.js +286 -0
- package/dist/src/commands/local/auth-provision.js.map +1 -0
- package/dist/src/commands/local/dev-local.d.ts +97 -0
- package/dist/src/commands/local/dev-local.d.ts.map +1 -0
- package/dist/src/commands/local/dev-local.js +306 -0
- package/dist/src/commands/local/dev-local.js.map +1 -0
- package/dist/src/commands/local/docker-runner.d.ts +37 -0
- package/dist/src/commands/local/docker-runner.d.ts.map +1 -0
- package/dist/src/commands/local/docker-runner.js +104 -0
- package/dist/src/commands/local/docker-runner.js.map +1 -0
- package/dist/src/commands/local/helpers.d.ts +22 -0
- package/dist/src/commands/local/helpers.d.ts.map +1 -0
- package/dist/src/commands/local/helpers.js +46 -0
- package/dist/src/commands/local/helpers.js.map +1 -0
- package/dist/src/commands/local/hub-local.d.ts +51 -0
- package/dist/src/commands/local/hub-local.d.ts.map +1 -0
- package/dist/src/commands/local/hub-local.js +197 -0
- package/dist/src/commands/local/hub-local.js.map +1 -0
- package/dist/src/commands/local/index.d.ts +23 -0
- package/dist/src/commands/local/index.d.ts.map +1 -0
- package/dist/src/commands/local/index.js +386 -0
- package/dist/src/commands/local/index.js.map +1 -0
- package/dist/src/commands/local/mocks.d.ts +361 -0
- package/dist/src/commands/local/mocks.d.ts.map +1 -0
- package/dist/src/commands/local/mocks.js +756 -0
- package/dist/src/commands/local/mocks.js.map +1 -0
- package/dist/src/commands/local/seed-zitadel.d.ts +151 -0
- package/dist/src/commands/local/seed-zitadel.d.ts.map +1 -0
- package/dist/src/commands/local/seed-zitadel.js +408 -0
- package/dist/src/commands/local/seed-zitadel.js.map +1 -0
- package/dist/src/commands/local/seed.d.ts +106 -0
- package/dist/src/commands/local/seed.d.ts.map +1 -0
- package/dist/src/commands/local/seed.js +252 -0
- package/dist/src/commands/local/seed.js.map +1 -0
- package/dist/src/commands/local/stack.d.ts +173 -0
- package/dist/src/commands/local/stack.d.ts.map +1 -0
- package/dist/src/commands/local/stack.js +499 -0
- package/dist/src/commands/local/stack.js.map +1 -0
- package/dist/src/commands/login.d.ts +256 -12
- package/dist/src/commands/login.d.ts.map +1 -1
- package/dist/src/commands/login.js +978 -50
- package/dist/src/commands/login.js.map +1 -1
- package/dist/src/commands/peer-addressing.d.ts +51 -0
- package/dist/src/commands/peer-addressing.d.ts.map +1 -0
- package/dist/src/commands/peer-addressing.js +64 -0
- package/dist/src/commands/peer-addressing.js.map +1 -0
- package/dist/src/commands/port-ownership.d.ts +69 -0
- package/dist/src/commands/port-ownership.d.ts.map +1 -0
- package/dist/src/commands/port-ownership.js +161 -0
- package/dist/src/commands/port-ownership.js.map +1 -0
- package/dist/src/commands/registry-publish.d.ts +3 -0
- package/dist/src/commands/registry-publish.d.ts.map +1 -0
- package/dist/src/commands/registry-publish.js +161 -0
- package/dist/src/commands/registry-publish.js.map +1 -0
- package/dist/src/commands/registry.d.ts +146 -2
- package/dist/src/commands/registry.d.ts.map +1 -1
- package/dist/src/commands/registry.js +541 -85
- package/dist/src/commands/registry.js.map +1 -1
- package/dist/src/commands/secrets/index.d.ts.map +1 -1
- package/dist/src/commands/secrets/index.js +15 -1
- package/dist/src/commands/secrets/index.js.map +1 -1
- package/dist/src/commands/secrets/migrate-config.d.ts +24 -0
- package/dist/src/commands/secrets/migrate-config.d.ts.map +1 -0
- package/dist/src/commands/secrets/migrate-config.js +277 -0
- package/dist/src/commands/secrets/migrate-config.js.map +1 -0
- package/dist/src/commands/secrets/set.d.ts +1 -0
- package/dist/src/commands/secrets/set.d.ts.map +1 -1
- package/dist/src/commands/secrets/set.js +96 -2
- package/dist/src/commands/secrets/set.js.map +1 -1
- package/dist/src/commands/skills.d.ts +34 -0
- package/dist/src/commands/skills.d.ts.map +1 -0
- package/dist/src/commands/skills.js +207 -0
- package/dist/src/commands/skills.js.map +1 -0
- package/dist/src/commands/stack.d.ts.map +1 -1
- package/dist/src/commands/stack.js +361 -23
- package/dist/src/commands/stack.js.map +1 -1
- package/dist/src/commands/temporal.d.ts +15 -0
- package/dist/src/commands/temporal.d.ts.map +1 -0
- package/dist/src/commands/temporal.js +781 -0
- package/dist/src/commands/temporal.js.map +1 -0
- package/dist/src/commands/tenant.d.ts +56 -0
- package/dist/src/commands/tenant.d.ts.map +1 -0
- package/dist/src/commands/tenant.js +233 -0
- package/dist/src/commands/tenant.js.map +1 -0
- package/dist/src/commands/tunnel/index.d.ts +49 -3
- package/dist/src/commands/tunnel/index.d.ts.map +1 -1
- package/dist/src/commands/tunnel/index.js +331 -8
- package/dist/src/commands/tunnel/index.js.map +1 -1
- package/dist/src/commands/vcs/clone.d.ts +3 -0
- package/dist/src/commands/vcs/clone.d.ts.map +1 -0
- package/dist/src/commands/vcs/clone.js +20 -0
- package/dist/src/commands/vcs/clone.js.map +1 -0
- package/dist/src/commands/vcs/common.d.ts +21 -0
- package/dist/src/commands/vcs/common.d.ts.map +1 -0
- package/dist/src/commands/vcs/common.js +62 -0
- package/dist/src/commands/vcs/common.js.map +1 -0
- package/dist/src/commands/vcs/drafts.d.ts +13 -0
- package/dist/src/commands/vcs/drafts.d.ts.map +1 -0
- package/dist/src/commands/vcs/drafts.js +45 -0
- package/dist/src/commands/vcs/drafts.js.map +1 -0
- package/dist/src/commands/vcs/get.d.ts +8 -0
- package/dist/src/commands/vcs/get.d.ts.map +1 -0
- package/dist/src/commands/vcs/get.js +61 -0
- package/dist/src/commands/vcs/get.js.map +1 -0
- package/dist/src/commands/vcs/index.d.ts +3 -0
- package/dist/src/commands/vcs/index.d.ts.map +1 -0
- package/dist/src/commands/vcs/index.js +58 -0
- package/dist/src/commands/vcs/index.js.map +1 -0
- package/dist/src/commands/vcs/propose.d.ts +23 -0
- package/dist/src/commands/vcs/propose.d.ts.map +1 -0
- package/dist/src/commands/vcs/propose.js +94 -0
- package/dist/src/commands/vcs/propose.js.map +1 -0
- package/dist/src/commands/vcs/review.d.ts +15 -0
- package/dist/src/commands/vcs/review.d.ts.map +1 -0
- package/dist/src/commands/vcs/review.js +35 -0
- package/dist/src/commands/vcs/review.js.map +1 -0
- package/dist/src/commands/vcs-enrich.d.ts +15 -0
- package/dist/src/commands/vcs-enrich.d.ts.map +1 -0
- package/dist/src/commands/vcs-enrich.js +28 -0
- package/dist/src/commands/vcs-enrich.js.map +1 -0
- package/dist/src/commands/vpn/index.d.ts +24 -0
- package/dist/src/commands/vpn/index.d.ts.map +1 -0
- package/dist/src/commands/vpn/index.js +543 -0
- package/dist/src/commands/vpn/index.js.map +1 -0
- package/dist/src/commands/vpn/tunnel.d.ts +11 -0
- package/dist/src/commands/vpn/tunnel.d.ts.map +1 -0
- package/dist/src/commands/vpn/tunnel.js +188 -0
- package/dist/src/commands/vpn/tunnel.js.map +1 -0
- package/dist/src/commands/workflow.d.ts +24 -0
- package/dist/src/commands/workflow.d.ts.map +1 -0
- package/dist/src/commands/workflow.js +577 -0
- package/dist/src/commands/workflow.js.map +1 -0
- package/dist/src/docs/assemble.d.ts +217 -0
- package/dist/src/docs/assemble.d.ts.map +1 -0
- package/dist/src/docs/assemble.js +739 -0
- package/dist/src/docs/assemble.js.map +1 -0
- package/dist/src/docs/cli-reference.d.ts +93 -0
- package/dist/src/docs/cli-reference.d.ts.map +1 -0
- package/dist/src/docs/cli-reference.js +256 -0
- package/dist/src/docs/cli-reference.js.map +1 -0
- package/dist/src/docs/discover.d.ts +60 -0
- package/dist/src/docs/discover.d.ts.map +1 -0
- package/dist/src/docs/discover.js +233 -0
- package/dist/src/docs/discover.js.map +1 -0
- package/dist/src/docs/portal.d.ts +79 -0
- package/dist/src/docs/portal.d.ts.map +1 -0
- package/dist/src/docs/portal.js +242 -0
- package/dist/src/docs/portal.js.map +1 -0
- package/dist/src/docs/registry-docs.d.ts +43 -0
- package/dist/src/docs/registry-docs.d.ts.map +1 -0
- package/dist/src/docs/registry-docs.js +138 -0
- package/dist/src/docs/registry-docs.js.map +1 -0
- package/dist/src/docs/schema.d.ts +48 -0
- package/dist/src/docs/schema.d.ts.map +1 -0
- package/dist/src/docs/schema.js +96 -0
- package/dist/src/docs/schema.js.map +1 -0
- package/dist/src/docs/serve.d.ts +27 -0
- package/dist/src/docs/serve.d.ts.map +1 -0
- package/dist/src/docs/serve.js +102 -0
- package/dist/src/docs/serve.js.map +1 -0
- package/dist/src/docs/start.d.ts +80 -0
- package/dist/src/docs/start.d.ts.map +1 -0
- package/dist/src/docs/start.js +313 -0
- package/dist/src/docs/start.js.map +1 -0
- package/dist/src/eval/agent-eval.d.mts +14 -0
- package/dist/src/eval/agent-eval.d.mts.map +1 -0
- package/dist/src/eval/agent-eval.mjs +273 -0
- package/dist/src/eval/agent-eval.mjs.map +1 -0
- package/dist/src/index.js +624 -7
- package/dist/src/index.js.map +7 -1
- package/dist/src/program.d.ts +26 -0
- package/dist/src/program.d.ts.map +1 -0
- package/dist/src/program.js +76 -0
- package/dist/src/program.js.map +1 -0
- package/dist/src/utils/auth-preflight.d.ts +159 -0
- package/dist/src/utils/auth-preflight.d.ts.map +1 -0
- package/dist/src/utils/auth-preflight.js +298 -0
- package/dist/src/utils/auth-preflight.js.map +1 -0
- package/dist/src/utils/aws-auth.d.ts +181 -0
- package/dist/src/utils/aws-auth.d.ts.map +1 -0
- package/dist/src/utils/aws-auth.js +527 -0
- package/dist/src/utils/aws-auth.js.map +1 -0
- package/dist/src/utils/bastion.d.ts +1 -1
- package/dist/src/utils/bastion.d.ts.map +1 -1
- package/dist/src/utils/bastion.js +29 -8
- package/dist/src/utils/bastion.js.map +1 -1
- package/dist/src/utils/build-info.d.ts +33 -0
- package/dist/src/utils/build-info.d.ts.map +1 -0
- package/dist/src/utils/build-info.js +102 -0
- package/dist/src/utils/build-info.js.map +1 -0
- package/dist/src/utils/capture-history.d.ts +62 -0
- package/dist/src/utils/capture-history.d.ts.map +1 -0
- package/dist/src/utils/capture-history.js +94 -0
- package/dist/src/utils/capture-history.js.map +1 -0
- package/dist/src/utils/deploy-preflight.d.ts +52 -0
- package/dist/src/utils/deploy-preflight.d.ts.map +1 -0
- package/dist/src/utils/deploy-preflight.js +183 -0
- package/dist/src/utils/deploy-preflight.js.map +1 -0
- package/dist/src/utils/errors.d.ts +75 -0
- package/dist/src/utils/errors.d.ts.map +1 -0
- package/dist/src/utils/errors.js +106 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/dist/src/utils/first-party-contexts.d.ts +40 -0
- package/dist/src/utils/first-party-contexts.d.ts.map +1 -0
- package/dist/src/utils/first-party-contexts.js +53 -0
- package/dist/src/utils/first-party-contexts.js.map +1 -0
- package/dist/src/utils/index.d.ts +2 -0
- package/dist/src/utils/index.d.ts.map +1 -1
- package/dist/src/utils/index.js +2 -0
- package/dist/src/utils/index.js.map +1 -1
- package/dist/src/utils/kubeconfig.d.ts +62 -0
- package/dist/src/utils/kubeconfig.d.ts.map +1 -0
- package/dist/src/utils/kubeconfig.js +118 -0
- package/dist/src/utils/kubeconfig.js.map +1 -0
- package/dist/src/utils/log.d.ts +27 -1
- package/dist/src/utils/log.d.ts.map +1 -1
- package/dist/src/utils/log.js +59 -5
- package/dist/src/utils/log.js.map +1 -1
- package/dist/src/utils/pid.d.ts +47 -0
- package/dist/src/utils/pid.d.ts.map +1 -0
- package/dist/src/utils/pid.js +83 -0
- package/dist/src/utils/pid.js.map +1 -0
- package/dist/src/utils/pulumi-run.d.ts +23 -0
- package/dist/src/utils/pulumi-run.d.ts.map +1 -0
- package/dist/src/utils/pulumi-run.js +112 -0
- package/dist/src/utils/pulumi-run.js.map +1 -0
- package/dist/src/utils/pulumi.d.ts +15 -0
- package/dist/src/utils/pulumi.d.ts.map +1 -1
- package/dist/src/utils/pulumi.js +35 -0
- package/dist/src/utils/pulumi.js.map +1 -1
- package/dist/src/utils/reachability.d.ts +29 -0
- package/dist/src/utils/reachability.d.ts.map +1 -0
- package/dist/src/utils/reachability.js +81 -0
- package/dist/src/utils/reachability.js.map +1 -0
- package/dist/src/utils/recover-conversation.d.ts +154 -0
- package/dist/src/utils/recover-conversation.d.ts.map +1 -0
- package/dist/src/utils/recover-conversation.js +314 -0
- package/dist/src/utils/recover-conversation.js.map +1 -0
- package/dist/src/utils/registry-broker.d.ts +148 -0
- package/dist/src/utils/registry-broker.d.ts.map +1 -0
- package/dist/src/utils/registry-broker.js +180 -0
- package/dist/src/utils/registry-broker.js.map +1 -0
- package/dist/src/utils/socks-forward.d.ts +40 -0
- package/dist/src/utils/socks-forward.d.ts.map +1 -0
- package/dist/src/utils/socks-forward.js +97 -0
- package/dist/src/utils/socks-forward.js.map +1 -0
- package/dist/src/utils/stack-flag.d.ts +23 -0
- package/dist/src/utils/stack-flag.d.ts.map +1 -0
- package/dist/src/utils/stack-flag.js +41 -0
- package/dist/src/utils/stack-flag.js.map +1 -0
- package/dist/src/utils/tailscale-targets.d.ts +52 -0
- package/dist/src/utils/tailscale-targets.d.ts.map +1 -0
- package/dist/src/utils/tailscale-targets.js +93 -0
- package/dist/src/utils/tailscale-targets.js.map +1 -0
- package/dist/src/utils/tailscale.d.ts +277 -0
- package/dist/src/utils/tailscale.d.ts.map +1 -0
- package/dist/src/utils/tailscale.js +1007 -0
- package/dist/src/utils/tailscale.js.map +1 -0
- package/dist/src/utils/temporal-auth.d.ts +43 -0
- package/dist/src/utils/temporal-auth.d.ts.map +1 -0
- package/dist/src/utils/temporal-auth.js +176 -0
- package/dist/src/utils/temporal-auth.js.map +1 -0
- package/dist/src/utils/temporal-codec.d.ts +101 -0
- package/dist/src/utils/temporal-codec.d.ts.map +1 -0
- package/dist/src/utils/temporal-codec.js +319 -0
- package/dist/src/utils/temporal-codec.js.map +1 -0
- package/dist/src/utils/tunnel-ownership.d.ts +130 -0
- package/dist/src/utils/tunnel-ownership.d.ts.map +1 -0
- package/dist/src/utils/tunnel-ownership.js +274 -0
- package/dist/src/utils/tunnel-ownership.js.map +1 -0
- package/dist/src/utils/vpn-join.d.ts +32 -0
- package/dist/src/utils/vpn-join.d.ts.map +1 -0
- package/dist/src/utils/vpn-join.js +62 -0
- package/dist/src/utils/vpn-join.js.map +1 -0
- package/dist/src/utils/vpn.d.ts +62 -0
- package/dist/src/utils/vpn.d.ts.map +1 -0
- package/dist/src/utils/vpn.js +183 -0
- package/dist/src/utils/vpn.js.map +1 -0
- package/dist/src/utils/workflow-fingerprint.d.ts +49 -0
- package/dist/src/utils/workflow-fingerprint.d.ts.map +1 -0
- package/dist/src/utils/workflow-fingerprint.js +135 -0
- package/dist/src/utils/workflow-fingerprint.js.map +1 -0
- package/dist/src/utils/worktree-identity.d.ts +86 -0
- package/dist/src/utils/worktree-identity.d.ts.map +1 -0
- package/dist/src/utils/worktree-identity.js +164 -0
- package/dist/src/utils/worktree-identity.js.map +1 -0
- package/fragments/agents/AGENTS.md.hbs +92 -0
- package/fragments/agents/CLAUDE.md.hbs +8 -0
- package/fragments/base/Pulumi.dev.yaml.hbs +10 -0
- package/fragments/base/Pulumi.yaml.hbs +9 -0
- package/fragments/base/index.ts.hbs +107 -0
- package/fragments/base/package.json.hbs +22 -0
- package/{templates/api-web-db/api → fragments/base}/tsconfig.json +3 -4
- package/fragments/bucket/api/src/storage.ts.hbs +81 -0
- package/fragments/database/api/src/db.ts.hbs +23 -0
- package/fragments/database/prisma/schema.prisma.hbs +18 -0
- package/{templates/api-web-db → fragments/service}/api/.dockerignore +2 -0
- package/fragments/service/api/package.json.hbs +40 -0
- package/fragments/service/api/src/index.ts.hbs +58 -0
- package/{templates/api-web-db → fragments/service/api}/tsconfig.json +5 -3
- package/fragments/temporal/worker/.dockerignore +4 -0
- package/fragments/temporal/worker/Dockerfile +15 -0
- package/{templates/temporal-api-worker → fragments/temporal}/worker/package.json.hbs +3 -2
- package/fragments/temporal/worker/src/activities.ts.hbs +14 -0
- package/fragments/temporal/worker/src/worker.ts.hbs +27 -0
- package/fragments/temporal/worker/src/workflows.ts.hbs +24 -0
- package/fragments/temporal/worker/tsconfig.json +13 -0
- package/package.json +36 -22
- package/skills/core/SKILL.md +365 -0
- package/skills/pull-remote-agent-context/SKILL.md +194 -0
- package/stack/docker-compose.hub.yml +134 -0
- package/stack/docker-compose.yml +447 -0
- package/stack/hub/Dockerfile.api +18 -0
- package/stack/hub/Dockerfile.auth +10 -0
- package/stack/hub/Dockerfile.ui +10 -0
- package/stack/init/01-databases.sql +12 -0
- package/stack/observability/blackbox.yml +20 -0
- package/stack/observability/otel-collector.yaml +73 -0
- package/stack/observability/prometheus.yml +36 -0
- package/stack/observability/tempo.yaml +25 -0
- package/stack/temporal/local.yaml +44 -0
- package/templates/api-auth/AGENTS.md.hbs +16 -0
- package/templates/api-auth/Pulumi.dev.yaml.hbs +8 -0
- package/templates/api-auth/Pulumi.yaml.hbs +8 -0
- package/templates/api-auth/README.md.hbs +67 -0
- package/templates/api-auth/api/package.json.hbs +28 -0
- package/templates/api-auth/api/src/index.ts.hbs +96 -0
- package/templates/api-auth/api/src/schema.ts.hbs +35 -0
- package/templates/api-auth/api/src/seed-authz.ts.hbs +48 -0
- package/templates/api-auth/api/src/spicedb-http-provider.ts.hbs +89 -0
- package/templates/api-auth/index.ts.hbs +65 -0
- package/templates/api-auth/package.json.hbs +20 -0
- package/templates/api-auth/skills/develop/SKILL.md.hbs +32 -0
- package/templates/api-role-gating/AGENTS.md.hbs +20 -0
- package/templates/api-role-gating/Pulumi.dev.yaml.hbs +8 -0
- package/templates/api-role-gating/Pulumi.yaml.hbs +8 -0
- package/templates/api-role-gating/README.md.hbs +64 -0
- package/templates/api-role-gating/api/Dockerfile +17 -0
- package/templates/api-role-gating/api/package.json.hbs +28 -0
- package/templates/api-role-gating/api/src/authn.ts.hbs +29 -0
- package/templates/api-role-gating/api/src/authz.ts.hbs +12 -0
- package/templates/api-role-gating/api/src/main.ts.hbs +58 -0
- package/templates/api-role-gating/api/src/schema.ts.hbs +22 -0
- package/templates/api-role-gating/api/tsconfig.json +13 -0
- package/templates/api-role-gating/index.ts.hbs +107 -0
- package/templates/api-role-gating/package.json.hbs +21 -0
- package/templates/api-role-gating/skills/develop/SKILL.md.hbs +60 -0
- package/templates/api-role-gating/tsconfig.json +10 -0
- package/templates/apps-repo/README.md.hbs +24 -0
- package/templates/apps-repo/gitignore +41 -0
- package/templates/apps-repo/npmrc.hbs +7 -0
- package/templates/apps-repo/package.json.hbs +15 -0
- package/templates/apps-repo/pnpm-workspace.yaml +3 -0
- package/templates/apps-repo/tsconfig.json +18 -0
- package/templates/external-service/AGENTS.md.hbs +20 -0
- package/templates/external-service/Pulumi.dev.yaml.hbs +8 -0
- package/templates/external-service/Pulumi.yaml.hbs +8 -0
- package/templates/external-service/README.md.hbs +56 -0
- package/templates/external-service/api/Dockerfile +15 -0
- package/templates/external-service/api/package.json.hbs +25 -0
- package/templates/external-service/api/src/index.ts.hbs +81 -0
- package/templates/external-service/index.ts.hbs +82 -0
- package/templates/external-service/mocks/vendorpay/package.json.hbs +9 -0
- package/templates/external-service/mocks/vendorpay/server.mjs.hbs +61 -0
- package/templates/external-service/package.json.hbs +34 -0
- package/templates/external-service/skills/develop/SKILL.md.hbs +55 -0
- package/templates/external-service/tsconfig.json +10 -0
- package/templates/platform-repo/AGENTS.md.hbs +18 -0
- package/templates/platform-repo/README.md.hbs +38 -0
- package/templates/platform-repo/core/Pulumi.yaml.hbs +12 -0
- package/templates/platform-repo/core/Pulumi.{{tenant}}-{{env}}.yaml.hbs +57 -0
- package/templates/platform-repo/core/index.ts +62 -0
- package/templates/platform-repo/core/package.json.hbs +22 -0
- package/templates/platform-repo/core/tsconfig.json +8 -0
- package/templates/platform-repo/package.json.hbs +15 -0
- package/templates/platform-repo/platform/Pulumi.yaml.hbs +12 -0
- package/templates/platform-repo/platform/Pulumi.{{tenant}}-{{env}}.yaml.hbs +59 -0
- package/templates/platform-repo/platform/index.ts +95 -0
- package/templates/platform-repo/platform/package.json.hbs +26 -0
- package/templates/platform-repo/platform/tsconfig.json +8 -0
- package/templates/platform-repo/pnpm-workspace.yaml +3 -0
- package/templates/workflow/AGENTS.md.hbs +15 -0
- package/templates/workflow/Pulumi.dev.yaml.hbs +8 -0
- package/templates/workflow/Pulumi.yaml.hbs +8 -0
- package/templates/workflow/README.md.hbs +45 -0
- package/templates/workflow/api/.dockerignore +4 -0
- package/templates/workflow/api/Dockerfile +15 -0
- package/templates/{temporal-api-worker → workflow}/api/package.json.hbs +8 -6
- package/templates/workflow/api/src/index.ts.hbs +130 -0
- package/templates/workflow/api/tsconfig.json +14 -0
- package/templates/workflow/index.ts.hbs +67 -0
- package/templates/workflow/package.json.hbs +20 -0
- package/templates/workflow/skills/develop/SKILL.md.hbs +32 -0
- package/templates/workflow/tsconfig.json +10 -0
- package/templates/workflow/worker/.dockerignore +4 -0
- package/templates/workflow/worker/Dockerfile +13 -0
- package/templates/workflow/worker/package.json.hbs +25 -0
- package/templates/workflow/worker/src/worker.ts.hbs +24 -0
- package/templates/workflow/worker/tsconfig.json +14 -0
- package/dist/src/commands/devbox.d.ts +0 -22
- package/dist/src/commands/devbox.d.ts.map +0 -1
- package/dist/src/commands/devbox.js +0 -463
- package/dist/src/commands/devbox.js.map +0 -1
- package/templates/api-web-db/api/Dockerfile +0 -16
- package/templates/api-web-db/api/package.json.hbs +0 -21
- package/templates/api-web-db/api/src/index.ts.hbs +0 -123
- package/templates/api-web-db/package.json.hbs +0 -27
- package/templates/api-web-db/sst.config.ts.hbs +0 -151
- package/templates/api-web-db/web/.dockerignore +0 -3
- package/templates/api-web-db/web/Dockerfile +0 -17
- package/templates/api-web-db/web/app/lib/db.server.ts +0 -36
- package/templates/api-web-db/web/app/root.tsx +0 -29
- package/templates/api-web-db/web/app/routes/home.tsx.hbs +0 -114
- package/templates/api-web-db/web/app/routes.ts +0 -3
- package/templates/api-web-db/web/package.json.hbs +0 -30
- package/templates/api-web-db/web/prisma/schema.prisma +0 -25
- package/templates/api-web-db/web/prisma.config.ts +0 -18
- package/templates/api-web-db/web/react-router.config.ts +0 -5
- package/templates/api-web-db/web/tsconfig.json +0 -19
- package/templates/api-web-db/web/vite.config.ts +0 -9
- package/templates/temporal-api-worker/README.md.hbs +0 -133
- package/templates/temporal-api-worker/api/src/index.ts.hbs +0 -145
- package/templates/temporal-api-worker/package.json.hbs +0 -29
- package/templates/temporal-api-worker/sst.config.ts.hbs +0 -161
- package/templates/temporal-api-worker/worker/Dockerfile +0 -11
- package/templates/temporal-api-worker/worker/src/bundle-workflows.ts +0 -38
- package/templates/temporal-api-worker/worker/src/worker.ts.hbs +0 -93
- /package/templates/{temporal-api-worker → api-auth}/api/.dockerignore +0 -0
- /package/templates/{temporal-api-worker → api-auth}/api/Dockerfile +0 -0
- /package/templates/{temporal-api-worker → api-auth}/api/tsconfig.json +0 -0
- /package/templates/{temporal-api-worker → api-auth}/tsconfig.json +0 -0
- /package/templates/{temporal-api-worker/worker → api-role-gating/api}/.dockerignore +0 -0
- /package/templates/{temporal-api-worker/worker → external-service/api}/tsconfig.json +0 -0
- /package/templates/{temporal-api-worker → workflow}/api/src/encryption-codec.ts +0 -0
- /package/templates/{temporal-api-worker → workflow}/worker/src/activities.ts.hbs +0 -0
- /package/templates/{temporal-api-worker → workflow}/worker/src/encryption-codec.ts +0 -0
- /package/templates/{temporal-api-worker → workflow}/worker/src/workflows.ts.hbs +0 -0
|
@@ -1,30 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* mesh login — Authenticate with Zitadel via OIDC PKCE flow.
|
|
2
|
+
* mesh login — Authenticate with Zitadel via OIDC PKCE or Device Code flow.
|
|
3
3
|
*
|
|
4
|
-
* Opens browser to Zitadel
|
|
5
|
-
*
|
|
4
|
+
* Opens browser to Zitadel (PKCE) or prints a code to enter in the browser
|
|
5
|
+
* (Device Code). Exchanges for tokens and caches at ~/.config/mesh/credentials.json.
|
|
6
6
|
*
|
|
7
7
|
* Config is read from ~/.config/mesh/config.json, keyed by context
|
|
8
|
-
* (e.g., "mesh.dev").
|
|
8
|
+
* (e.g., "mesh.dev"). After deploying the platform, copy the CLI client ID
|
|
9
|
+
* from the Pulumi stack output into the config file.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
* "mesh.dev": {
|
|
12
|
-
* "issuer": "https://identity.<env>.<your-platform-domain>",
|
|
13
|
-
* "clientId": "<cli-oidc-client-id>"
|
|
14
|
-
* }
|
|
15
|
-
* }
|
|
11
|
+
* In containers/SSH sessions, device code flow is used automatically.
|
|
16
12
|
*
|
|
17
13
|
* Usage:
|
|
18
14
|
* mesh login mesh.dev — authenticate to mesh dev
|
|
19
|
-
* mesh login mesh.dev --
|
|
15
|
+
* mesh login mesh.dev --device — force device code flow
|
|
16
|
+
* mesh login mesh.dev --status — show current auth state (Zitadel session if
|
|
17
|
+
* there is one, otherwise whatever the AWS
|
|
18
|
+
* credential chain resolves — SSO or key pair)
|
|
20
19
|
* mesh logout mesh.dev — clear cached credentials
|
|
21
20
|
*/
|
|
22
21
|
import * as http from "http";
|
|
23
22
|
import * as crypto from "crypto";
|
|
24
23
|
import * as fs from "fs";
|
|
25
24
|
import * as path from "path";
|
|
26
|
-
import {
|
|
25
|
+
import { execFileSync } from "child_process";
|
|
27
26
|
import { logError, logInfo, logSuccess, logWarn } from "../utils/log.js";
|
|
27
|
+
import { assumeRoleCredentials, assumeRoleWithWebIdentity, describeAssumeFailure, probeAwsIdentity, renderCredentialProcessProfile, tokenIssuer, resolveStableMeshBin, selectRoleForCaller, toCredentialProcessJson, upsertManagedAwsConfigSection, } from "../utils/aws-auth.js";
|
|
28
|
+
import { firstPartyDomainFor } from "../utils/first-party-contexts.js";
|
|
29
|
+
import { tailscaleBackendState } from "./vpn/index.js";
|
|
28
30
|
// =============================================================================
|
|
29
31
|
// Paths (XDG Base Directory Specification)
|
|
30
32
|
// =============================================================================
|
|
@@ -44,10 +46,152 @@ function readConfig() {
|
|
|
44
46
|
return {};
|
|
45
47
|
}
|
|
46
48
|
}
|
|
49
|
+
export function writeContextConfig(context, config) {
|
|
50
|
+
const existing = readConfig();
|
|
51
|
+
existing[context] = config;
|
|
52
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
53
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(existing, null, 2));
|
|
54
|
+
}
|
|
47
55
|
function getContextConfig(context) {
|
|
48
56
|
const config = readConfig();
|
|
49
57
|
return config[context] ?? null;
|
|
50
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Parse a mesh context like "mesh.dev" or "acme.uat" into tenant + env.
|
|
61
|
+
* Returns null if the context doesn't match the expected {tenant}.{env} pattern.
|
|
62
|
+
*/
|
|
63
|
+
function parseTenantEnv(context) {
|
|
64
|
+
const parts = context.split(".");
|
|
65
|
+
if (parts.length !== 2 || !parts[0] || !parts[1])
|
|
66
|
+
return null;
|
|
67
|
+
return { tenant: parts[0], env: parts[1] };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Attempt auto-discovery of platform config from SSM Parameter Store.
|
|
71
|
+
*
|
|
72
|
+
* Uses ambient AWS credentials to read the zitadel config export at
|
|
73
|
+
* /mesh-platform/{tenant}/{env}/platform/zitadel. This is the preferred
|
|
74
|
+
* discovery path for users who already have AWS SSO access to the platform.
|
|
75
|
+
*
|
|
76
|
+
* Returns null if AWS credentials are unavailable, SSM read fails, or the
|
|
77
|
+
* parameter doesn't contain the required fields.
|
|
78
|
+
*/
|
|
79
|
+
async function discoverConfigFromSsm(context) {
|
|
80
|
+
const parsed = parseTenantEnv(context);
|
|
81
|
+
if (!parsed)
|
|
82
|
+
return null;
|
|
83
|
+
const { tenant, env } = parsed;
|
|
84
|
+
const ssmPath = `/mesh-platform/${tenant}/${env}/platform/zitadel`;
|
|
85
|
+
logInfo(`Attempting SSM discovery from ${ssmPath}...`);
|
|
86
|
+
try {
|
|
87
|
+
const { SSMClient, GetParameterCommand } = await import("@aws-sdk/client-ssm");
|
|
88
|
+
const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-2";
|
|
89
|
+
const ssm = new SSMClient({ region });
|
|
90
|
+
const resp = await ssm.send(new GetParameterCommand({ Name: ssmPath }));
|
|
91
|
+
const raw = resp.Parameter?.Value;
|
|
92
|
+
if (!raw) {
|
|
93
|
+
logWarn(`SSM parameter ${ssmPath} has no value`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const data = JSON.parse(raw);
|
|
97
|
+
const issuer = data.endpoint;
|
|
98
|
+
const clientId = data.cliClientId;
|
|
99
|
+
if (typeof issuer !== "string" || !issuer) {
|
|
100
|
+
logWarn("SSM zitadel entry missing 'endpoint' field");
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
if (typeof clientId !== "string" || !clientId) {
|
|
104
|
+
logWarn("SSM zitadel entry missing 'cliClientId' field.\n" +
|
|
105
|
+
" The platform needs to be deployed with an updated ZitadelPlatformIdentity\n" +
|
|
106
|
+
" that exports cliClientId. Until then, contact your platform admin.");
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const config = { issuer, clientId };
|
|
110
|
+
if (typeof data.vpn === "string")
|
|
111
|
+
config.vpn = data.vpn;
|
|
112
|
+
if (typeof data.vpnJoinBroker === "string")
|
|
113
|
+
config.vpnJoinBroker = data.vpnJoinBroker;
|
|
114
|
+
if (typeof data.registryBroker === "string")
|
|
115
|
+
config.registryBroker = data.registryBroker;
|
|
116
|
+
writeContextConfig(context, config);
|
|
117
|
+
logSuccess(`Discovered platform configuration for ${context} via SSM`);
|
|
118
|
+
return config;
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
122
|
+
if (message.includes("ExpiredToken") || message.includes("credentials")) {
|
|
123
|
+
logWarn(`SSM discovery failed: AWS credentials expired or unavailable.`);
|
|
124
|
+
// Do NOT send someone to `aws sso login` when an anonymous path is about
|
|
125
|
+
// to be tried — on the install journey that is the exact misdirection
|
|
126
|
+
// this feature removes. Only mention AWS when nothing else can resolve.
|
|
127
|
+
if (firstPartyDomainFor(context) || context.split(".").length - 1 >= 2) {
|
|
128
|
+
logInfo(` Falling back to anonymous HTTPS discovery — no AWS needed.`);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
logInfo(` If you have an AWS account: aws sso login --profile <profile>`);
|
|
132
|
+
logInfo(` Otherwise use the platform's full domain, e.g. mesh login dev.<tenant>.meshtech.io`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else if (message.includes("ParameterNotFound")) {
|
|
136
|
+
logWarn(`SSM parameter not found: ${ssmPath}`);
|
|
137
|
+
logInfo(" This platform context may not be deployed.");
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
logWarn(`SSM discovery failed: ${message}`);
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Attempt auto-discovery of platform config from a well-known endpoint.
|
|
147
|
+
* Returns null if discovery fails for any reason (network, timeout, bad data).
|
|
148
|
+
*
|
|
149
|
+
* `contextKey` is the name the resulting config is CACHED under, which is not
|
|
150
|
+
* always the domain probed: a pinned first-party alias (`mesh.dev`) probes
|
|
151
|
+
* `dev.platform.meshtech.io` but must be cached under `mesh.dev`, or the very
|
|
152
|
+
* next command re-runs discovery and the alias buys nothing.
|
|
153
|
+
*/
|
|
154
|
+
async function discoverConfig(domain, contextKey = domain) {
|
|
155
|
+
const url = `https://cli.${domain}/.well-known/mesh.json`;
|
|
156
|
+
logInfo(`Attempting discovery from ${url}...`);
|
|
157
|
+
try {
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
160
|
+
const resp = await fetch(url, { signal: controller.signal });
|
|
161
|
+
clearTimeout(timeout);
|
|
162
|
+
if (!resp.ok) {
|
|
163
|
+
logWarn(`Discovery endpoint returned ${resp.status}`);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const data = (await resp.json());
|
|
167
|
+
const issuer = data.issuer;
|
|
168
|
+
const clientId = data.clientId;
|
|
169
|
+
if (typeof issuer !== "string" || !issuer || typeof clientId !== "string" || !clientId) {
|
|
170
|
+
logWarn("Discovery endpoint returned invalid config (missing issuer or clientId)");
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const config = { issuer, clientId };
|
|
174
|
+
if (typeof data.vpn === "string")
|
|
175
|
+
config.vpn = data.vpn;
|
|
176
|
+
if (typeof data.vpnJoinBroker === "string")
|
|
177
|
+
config.vpnJoinBroker = data.vpnJoinBroker;
|
|
178
|
+
if (typeof data.registryBroker === "string")
|
|
179
|
+
config.registryBroker = data.registryBroker;
|
|
180
|
+
writeContextConfig(contextKey, config);
|
|
181
|
+
logSuccess(`Discovered platform configuration for ${contextKey}`);
|
|
182
|
+
return config;
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
186
|
+
if (message.includes("abort")) {
|
|
187
|
+
logWarn("Discovery timed out");
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
logWarn(`Discovery failed: ${message}`);
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
51
195
|
function readAllCredentials() {
|
|
52
196
|
if (!fs.existsSync(CREDENTIALS_FILE))
|
|
53
197
|
return {};
|
|
@@ -61,13 +205,43 @@ function readAllCredentials() {
|
|
|
61
205
|
function readCredentials(context) {
|
|
62
206
|
return readAllCredentials()[context] ?? null;
|
|
63
207
|
}
|
|
208
|
+
/** Monotonic counter mixed into temp-file names alongside the pid, so two
|
|
209
|
+
* writes from the same process in the same tick (e.g. rapid retries) never
|
|
210
|
+
* collide on a temp path. */
|
|
211
|
+
let atomicWriteCounter = 0;
|
|
212
|
+
/**
|
|
213
|
+
* Write `data` to `path` atomically: write to a temp file in the SAME
|
|
214
|
+
* directory as `path` (so the subsequent rename is same-filesystem, hence
|
|
215
|
+
* atomic), then `fs.renameSync` it into place. This guarantees concurrent
|
|
216
|
+
* readers (e.g. other `credential_process` invocations racing a refresh)
|
|
217
|
+
* only ever observe a fully-written file — never a truncated one from an
|
|
218
|
+
* interleaved `fs.writeFileSync`.
|
|
219
|
+
*
|
|
220
|
+
* On any error, best-effort removes the temp file before rethrowing.
|
|
221
|
+
*
|
|
222
|
+
* Exported for unit testing.
|
|
223
|
+
*/
|
|
224
|
+
export function atomicWriteFileSync(path, data, mode) {
|
|
225
|
+
const tmpPath = `${path}.${process.pid}.${atomicWriteCounter++}.tmp`;
|
|
226
|
+
try {
|
|
227
|
+
fs.writeFileSync(tmpPath, data, { mode });
|
|
228
|
+
fs.renameSync(tmpPath, path);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
try {
|
|
232
|
+
fs.unlinkSync(tmpPath);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// best-effort cleanup — ignore (e.g. write itself never created the file)
|
|
236
|
+
}
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
64
240
|
function writeCredentials(context, creds) {
|
|
65
241
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
66
242
|
const all = readAllCredentials();
|
|
67
243
|
all[context] = creds;
|
|
68
|
-
|
|
69
|
-
mode: 0o600,
|
|
70
|
-
});
|
|
244
|
+
atomicWriteFileSync(CREDENTIALS_FILE, JSON.stringify(all, null, 2), 0o600);
|
|
71
245
|
}
|
|
72
246
|
function clearCredentials(context) {
|
|
73
247
|
const all = readAllCredentials();
|
|
@@ -143,6 +317,11 @@ function login(context, config) {
|
|
|
143
317
|
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
144
318
|
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
145
319
|
authUrl.searchParams.set("state", state);
|
|
320
|
+
// Tracked teardown — without this `mesh login` hangs after success
|
|
321
|
+
// because (a) the 2-minute timeout below kept the event loop alive,
|
|
322
|
+
// and (b) the browser's keep-alive socket survived `server.close()`
|
|
323
|
+
// (which only stops new connections, not active ones).
|
|
324
|
+
let timeoutId;
|
|
146
325
|
const server = http.createServer(async (req, res) => {
|
|
147
326
|
try {
|
|
148
327
|
const url = new URL(req.url ?? "/", `http://localhost:${REDIRECT_PORT}`);
|
|
@@ -154,9 +333,10 @@ function login(context, config) {
|
|
|
154
333
|
const error = url.searchParams.get("error");
|
|
155
334
|
if (error) {
|
|
156
335
|
const desc = url.searchParams.get("error_description") ?? error;
|
|
336
|
+
const safeDesc = desc.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
157
337
|
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
158
|
-
res.end(`<html><body><h2>Login failed</h2><p>${
|
|
159
|
-
|
|
338
|
+
res.end(`<html><body><h2>Login failed</h2><p>${safeDesc}</p></body></html>`);
|
|
339
|
+
teardown();
|
|
160
340
|
reject(new Error(desc));
|
|
161
341
|
return;
|
|
162
342
|
}
|
|
@@ -164,7 +344,7 @@ function login(context, config) {
|
|
|
164
344
|
if (returnedState !== state) {
|
|
165
345
|
res.writeHead(400);
|
|
166
346
|
res.end("State mismatch");
|
|
167
|
-
|
|
347
|
+
teardown();
|
|
168
348
|
reject(new Error("State mismatch"));
|
|
169
349
|
return;
|
|
170
350
|
}
|
|
@@ -172,7 +352,7 @@ function login(context, config) {
|
|
|
172
352
|
if (!code) {
|
|
173
353
|
res.writeHead(400);
|
|
174
354
|
res.end("No code");
|
|
175
|
-
|
|
355
|
+
teardown();
|
|
176
356
|
reject(new Error("No authorization code received"));
|
|
177
357
|
return;
|
|
178
358
|
}
|
|
@@ -209,23 +389,32 @@ function login(context, config) {
|
|
|
209
389
|
if (tenants.length > 0) {
|
|
210
390
|
logInfo(`Deployable tenants: ${tenants.join(", ")}`);
|
|
211
391
|
}
|
|
212
|
-
|
|
392
|
+
hintVpnIfDisconnected(context);
|
|
393
|
+
teardown();
|
|
213
394
|
resolve();
|
|
214
395
|
}
|
|
215
396
|
catch (err) {
|
|
216
|
-
|
|
397
|
+
teardown();
|
|
217
398
|
reject(err);
|
|
218
399
|
}
|
|
219
400
|
});
|
|
401
|
+
// Defined after `server` so the closure captures it; covers
|
|
402
|
+
// success, error, and timeout paths.
|
|
403
|
+
const teardown = () => {
|
|
404
|
+
if (timeoutId)
|
|
405
|
+
clearTimeout(timeoutId);
|
|
406
|
+
server.closeAllConnections?.();
|
|
407
|
+
server.close();
|
|
408
|
+
};
|
|
220
409
|
server.listen(REDIRECT_PORT, () => {
|
|
221
410
|
logInfo(`Opening browser for authentication (${context})...`);
|
|
222
411
|
const url = authUrl.toString();
|
|
223
412
|
try {
|
|
224
413
|
if (process.platform === "darwin") {
|
|
225
|
-
|
|
414
|
+
execFileSync("open", [url], { stdio: "ignore" });
|
|
226
415
|
}
|
|
227
416
|
else if (process.platform === "linux") {
|
|
228
|
-
|
|
417
|
+
execFileSync("xdg-open", [url], { stdio: "ignore" });
|
|
229
418
|
}
|
|
230
419
|
else {
|
|
231
420
|
logInfo(`Open this URL in your browser:\n${url}`);
|
|
@@ -235,21 +424,272 @@ function login(context, config) {
|
|
|
235
424
|
logInfo(`Open this URL in your browser:\n${url}`);
|
|
236
425
|
}
|
|
237
426
|
});
|
|
238
|
-
// Timeout after 2 minutes
|
|
239
|
-
|
|
240
|
-
|
|
427
|
+
// Timeout after 2 minutes. We keep a handle so success / error
|
|
428
|
+
// paths can clear it via `teardown()` — otherwise the timer keeps
|
|
429
|
+
// the Node event loop alive for 2 minutes after a successful
|
|
430
|
+
// login and the CLI appears to hang.
|
|
431
|
+
timeoutId = setTimeout(() => {
|
|
432
|
+
teardown();
|
|
241
433
|
reject(new Error("Login timed out (2 minutes)"));
|
|
242
434
|
}, 120_000);
|
|
243
435
|
});
|
|
244
436
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
437
|
+
/** Number of fresh device codes to issue before giving up. A code's TTL is
|
|
438
|
+
* short (a couple of minutes), so if the user is slow we quietly issue another
|
|
439
|
+
* rather than failing the whole login (we hit this repeatedly in practice). */
|
|
440
|
+
const MAX_DEVICE_CODES = 3;
|
|
441
|
+
/**
|
|
442
|
+
* Run device-code login, re-issuing a fresh code (up to `maxCodes`) if the user
|
|
443
|
+
* doesn't finish before a code expires. `attempt` performs ONE code's
|
|
444
|
+
* request+poll and resolves `"success"` or `"expired"`; it rejects only on a
|
|
445
|
+
* hard error (denied / network). Extracted + injectable so the reissue loop is
|
|
446
|
+
* unit-testable without real network calls.
|
|
447
|
+
*/
|
|
448
|
+
export async function deviceLoginWithReissue(attempt, maxCodes = MAX_DEVICE_CODES) {
|
|
449
|
+
for (let n = 1; n <= maxCodes; n++) {
|
|
450
|
+
if (n > 1)
|
|
451
|
+
logWarn(`That code expired — issuing a fresh one (${n}/${maxCodes})…`);
|
|
452
|
+
if ((await attempt(n)) === "success")
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
throw new Error(`Device login not completed after ${maxCodes} codes. Re-run the command when you're ready to authorize.`);
|
|
456
|
+
}
|
|
457
|
+
async function deviceCodeLogin(context, config) {
|
|
458
|
+
await deviceLoginWithReissue(() => attemptDeviceCode(context, config));
|
|
459
|
+
}
|
|
460
|
+
async function attemptDeviceCode(context, config) {
|
|
461
|
+
// Step 1: Request device authorization
|
|
462
|
+
const resp = await fetch(`${config.issuer}/oauth/v2/device_authorization`, {
|
|
463
|
+
method: "POST",
|
|
464
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
465
|
+
body: new URLSearchParams({
|
|
466
|
+
client_id: config.clientId,
|
|
467
|
+
scope: SCOPES,
|
|
468
|
+
}).toString(),
|
|
469
|
+
});
|
|
470
|
+
if (!resp.ok) {
|
|
471
|
+
const text = await resp.text();
|
|
472
|
+
throw new Error(`Device authorization request failed (${resp.status}): ${text}`);
|
|
473
|
+
}
|
|
474
|
+
const deviceAuth = await resp.json();
|
|
475
|
+
const openUrl = deviceAuth.verification_uri_complete ?? deviceAuth.verification_uri;
|
|
476
|
+
// Step 2: Display instructions
|
|
477
|
+
console.log();
|
|
478
|
+
logInfo(`Open this URL in your browser:\n`);
|
|
479
|
+
logInfo(` ${openUrl}\n`);
|
|
480
|
+
logInfo(`Code: ${deviceAuth.user_code}`);
|
|
481
|
+
console.log();
|
|
482
|
+
logInfo("Waiting for authorization...");
|
|
483
|
+
// Try to open browser (works on host, harmless failure in container)
|
|
484
|
+
try {
|
|
485
|
+
if (process.platform === "darwin") {
|
|
486
|
+
execFileSync("open", [openUrl], { stdio: "ignore" });
|
|
487
|
+
}
|
|
488
|
+
else if (process.platform === "linux") {
|
|
489
|
+
execFileSync("xdg-open", [openUrl], { stdio: "ignore" });
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
catch { /* ignore — user can open manually */ }
|
|
493
|
+
// Step 3: Poll for token
|
|
494
|
+
const deadline = Date.now() + deviceAuth.expires_in * 1000;
|
|
495
|
+
let interval = deviceAuth.interval * 1000;
|
|
496
|
+
while (Date.now() < deadline) {
|
|
497
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
498
|
+
const tokenResp = await fetch(`${config.issuer}/oauth/v2/token`, {
|
|
499
|
+
method: "POST",
|
|
500
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
501
|
+
body: new URLSearchParams({
|
|
502
|
+
client_id: config.clientId,
|
|
503
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
504
|
+
device_code: deviceAuth.device_code,
|
|
505
|
+
}).toString(),
|
|
506
|
+
});
|
|
507
|
+
if (tokenResp.ok) {
|
|
508
|
+
const tokens = await tokenResp.json();
|
|
509
|
+
const idPayload = decodeJwtPayload(tokens.id_token);
|
|
510
|
+
const email = idPayload.email ?? idPayload.preferred_username ?? "unknown";
|
|
511
|
+
const sub = idPayload.sub;
|
|
512
|
+
let tenants = [];
|
|
513
|
+
try {
|
|
514
|
+
const accessPayload = decodeJwtPayload(tokens.access_token);
|
|
515
|
+
tenants = accessPayload["urn:mesh:tenants"] ?? [];
|
|
516
|
+
}
|
|
517
|
+
catch { /* opaque access token */ }
|
|
518
|
+
writeCredentials(context, {
|
|
519
|
+
idToken: tokens.id_token,
|
|
520
|
+
accessToken: tokens.access_token,
|
|
521
|
+
refreshToken: tokens.refresh_token,
|
|
522
|
+
expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
|
|
523
|
+
email,
|
|
524
|
+
sub,
|
|
525
|
+
});
|
|
526
|
+
logSuccess(`Logged in as ${email} (${context})`);
|
|
527
|
+
if (tenants.length > 0) {
|
|
528
|
+
logInfo(`Deployable tenants: ${tenants.join(", ")}`);
|
|
529
|
+
}
|
|
530
|
+
hintVpnIfDisconnected(context);
|
|
531
|
+
return "success";
|
|
532
|
+
}
|
|
533
|
+
const error = await tokenResp.json();
|
|
534
|
+
switch (error.error) {
|
|
535
|
+
case "authorization_pending":
|
|
536
|
+
continue;
|
|
537
|
+
case "slow_down":
|
|
538
|
+
interval += 1000;
|
|
539
|
+
continue;
|
|
540
|
+
case "expired_token":
|
|
541
|
+
return "expired"; // caller re-issues a fresh code
|
|
542
|
+
case "access_denied":
|
|
543
|
+
throw new Error("Authorization denied by user.");
|
|
544
|
+
default:
|
|
545
|
+
throw new Error(`Token exchange failed: ${error.error} — ${error.error_description ?? ""}`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return "expired"; // deadline reached without authorization — re-issue
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Detect an environment where the PKCE browser-callback flow won't work — a
|
|
552
|
+
* container, a remote/SSH session, or any non-interactive shell (piped output,
|
|
553
|
+
* headless, agent/CI-run). In all of these the callback flow has no browser to
|
|
554
|
+
* open and no interactive terminal, so it can only hang until the 2-minute
|
|
555
|
+
* timeout. Device-code flow (print a URL + code to complete elsewhere) is the
|
|
556
|
+
* correct fallback, so callers use it whenever this returns true.
|
|
557
|
+
*
|
|
558
|
+
* Exported for tests.
|
|
559
|
+
*/
|
|
560
|
+
export function isRemoteEnvironment() {
|
|
561
|
+
// Docker / devcontainer
|
|
562
|
+
if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES)
|
|
563
|
+
return true;
|
|
564
|
+
if (fs.existsSync("/.dockerenv"))
|
|
565
|
+
return true;
|
|
566
|
+
// SSH session
|
|
567
|
+
if (process.env.SSH_CLIENT || process.env.SSH_TTY)
|
|
568
|
+
return true;
|
|
569
|
+
// No interactive terminal on stdin/stdout — `isTTY` is `undefined` (falsy)
|
|
570
|
+
// when the stream is not a TTY. This is the case that bit us: an agent- or
|
|
571
|
+
// script-driven shell over Tailscale has none of the env vars above set, so
|
|
572
|
+
// the old check fell through to the browser flow and timed out. Device flow
|
|
573
|
+
// still works when a browser IS available (just copy the URL+code), so
|
|
574
|
+
// preferring it here is strictly safer than a guaranteed timeout.
|
|
575
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY)
|
|
576
|
+
return true;
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* After a successful login, hint about VPN if Tailscale is not connected.
|
|
581
|
+
* Non-blocking — never fails or delays the login flow.
|
|
582
|
+
*/
|
|
583
|
+
function hintVpnIfDisconnected(context) {
|
|
584
|
+
try {
|
|
585
|
+
const state = tailscaleBackendState();
|
|
586
|
+
if (state !== "Running") {
|
|
587
|
+
console.log("");
|
|
588
|
+
logInfo(`VPN not connected. To access dev services, run:`);
|
|
589
|
+
logInfo(` mesh vpn connect ${context}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
// Tailscale not installed or not queryable — skip hint
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The account id embedded in an IAM role ARN
|
|
598
|
+
* (`arn:aws:iam::123456789012:role/...`), or undefined when there is no ARN
|
|
599
|
+
* to read one from.
|
|
600
|
+
*/
|
|
601
|
+
export function accountIdFromRoleArn(roleArn) {
|
|
602
|
+
return roleArn?.match(/^arn:aws:iam::(\d{12}):/)?.[1];
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Decide everything `--status` says (and its exit code) when there is no
|
|
606
|
+
* usable Zitadel session — pure so the wording and the exit decision are
|
|
607
|
+
* pinned by tests.
|
|
608
|
+
*
|
|
609
|
+
* The probe only proves "some AWS identity works on this machine"; it says
|
|
610
|
+
* nothing about whether that identity belongs to `context`. So the claim is
|
|
611
|
+
* scoped to the credentials — unless the context config carries a role ARN,
|
|
612
|
+
* whose account id makes the context check free: verify when we can, say so
|
|
613
|
+
* when we can't.
|
|
614
|
+
*/
|
|
615
|
+
export function renderAwsFallbackStatus(context, reason, identity, expectedAccountId) {
|
|
616
|
+
if (!identity) {
|
|
617
|
+
return {
|
|
618
|
+
lines: [
|
|
619
|
+
{
|
|
620
|
+
level: "warn",
|
|
621
|
+
text: `${reason}, and no working AWS credentials were found either.`,
|
|
622
|
+
},
|
|
623
|
+
{ level: "info", text: "Authenticate with whichever you use:" },
|
|
624
|
+
{ level: "info", text: ` mesh login ${context} (Zitadel SSO)` },
|
|
625
|
+
{
|
|
626
|
+
level: "info",
|
|
627
|
+
text: " aws sso login --profile … (AWS SSO profile)",
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
level: "info",
|
|
631
|
+
text: " export AWS_PROFILE=… (profile with a key/secret pair)",
|
|
632
|
+
},
|
|
633
|
+
],
|
|
634
|
+
exitCode: 1,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
const lines = [
|
|
638
|
+
{
|
|
639
|
+
level: "success",
|
|
640
|
+
text: `AWS credentials are working (${identity.source})`,
|
|
641
|
+
},
|
|
642
|
+
{ level: "info", text: `Identity: ${identity.arn}` },
|
|
643
|
+
{ level: "info", text: `Account: ${identity.accountId}` },
|
|
644
|
+
];
|
|
645
|
+
if (expectedAccountId && identity.accountId !== expectedAccountId) {
|
|
646
|
+
lines.push({
|
|
647
|
+
level: "warn",
|
|
648
|
+
text: `This account does not match ${context}'s configured role account ` +
|
|
649
|
+
`(${expectedAccountId}) — commands against ${context} will likely ` +
|
|
650
|
+
`fail with AccessDenied. Check AWS_PROFILE / your exported credentials.`,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
else if (expectedAccountId) {
|
|
654
|
+
lines.push({
|
|
655
|
+
level: "info",
|
|
656
|
+
text: `Account matches ${context}'s configured role account.`,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
lines.push({
|
|
661
|
+
level: "info",
|
|
662
|
+
text: `This is a working AWS identity, not a verified connection to ` +
|
|
663
|
+
`${context} — nothing here checked that this account is ${context}'s.`,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
lines.push({
|
|
667
|
+
level: "info",
|
|
668
|
+
text: `${reason} — that only matters for the commands that need a *user* ` +
|
|
669
|
+
`identity (Hub SSO, \`mesh dev\` test users, zero-touch VPN join). ` +
|
|
670
|
+
`Deploys, registry auth and secrets work off these credentials.`,
|
|
671
|
+
});
|
|
672
|
+
return { lines, exitCode: 0 };
|
|
673
|
+
}
|
|
674
|
+
async function showAwsCredentialStatus(context, reason, config) {
|
|
675
|
+
const identity = await probeAwsIdentity();
|
|
676
|
+
const { lines, exitCode } = renderAwsFallbackStatus(context, reason, identity, accountIdFromRoleArn(config.defaultRole ?? config.adminRole));
|
|
677
|
+
for (const line of lines) {
|
|
678
|
+
if (line.level === "success")
|
|
679
|
+
logSuccess(line.text);
|
|
680
|
+
else if (line.level === "warn")
|
|
681
|
+
logWarn(line.text);
|
|
682
|
+
else
|
|
683
|
+
logInfo(line.text);
|
|
684
|
+
}
|
|
685
|
+
if (exitCode !== 0)
|
|
686
|
+
process.exit(exitCode);
|
|
687
|
+
}
|
|
248
688
|
async function showStatus(context, config) {
|
|
249
689
|
const creds = readCredentials(context);
|
|
250
690
|
if (!creds) {
|
|
251
|
-
|
|
252
|
-
|
|
691
|
+
await showAwsCredentialStatus(context, "No cached Zitadel session", config);
|
|
692
|
+
return;
|
|
253
693
|
}
|
|
254
694
|
const expired = new Date(creds.expiresAt) < new Date();
|
|
255
695
|
if (expired && creds.refreshToken) {
|
|
@@ -269,13 +709,13 @@ async function showStatus(context, config) {
|
|
|
269
709
|
return;
|
|
270
710
|
}
|
|
271
711
|
catch {
|
|
272
|
-
|
|
273
|
-
|
|
712
|
+
await showAwsCredentialStatus(context, "Zitadel token expired and refresh failed", config);
|
|
713
|
+
return;
|
|
274
714
|
}
|
|
275
715
|
}
|
|
276
716
|
if (expired) {
|
|
277
|
-
|
|
278
|
-
|
|
717
|
+
await showAwsCredentialStatus(context, `Zitadel token expired at ${creds.expiresAt}`, config);
|
|
718
|
+
return;
|
|
279
719
|
}
|
|
280
720
|
// Parse tenants from access token
|
|
281
721
|
let tenants = [];
|
|
@@ -299,33 +739,88 @@ async function showStatus(context, config) {
|
|
|
299
739
|
export function registerLoginCommand(program) {
|
|
300
740
|
program
|
|
301
741
|
.command("login")
|
|
302
|
-
.description("Authenticate with Zitadel (OIDC PKCE)")
|
|
742
|
+
.description("Authenticate with Zitadel (OIDC PKCE or Device Code)")
|
|
303
743
|
.argument("<context>", 'Platform context (e.g., "mesh.dev")')
|
|
304
|
-
.option("--status", "Show current authentication status")
|
|
744
|
+
.option("--status", "Show current authentication status — the Zitadel session if one is cached, otherwise the AWS identity the credential chain resolves (SSO profile or key pair). Exits non-zero only when neither works.")
|
|
745
|
+
.option("--device", "Force device code flow (no callback server needed)")
|
|
746
|
+
.option("--export", "After login, print a self-refreshing AWS credential_process profile as shell `export` statements (use with `eval`). The resulting shell auto-refreshes credentials via `mesh login`. Requires --role or MESH_AWS_ROLE.")
|
|
747
|
+
.option("--static", "With --export: print raw temporary AWS credentials (a frozen ~1h triple) instead of the default self-refreshing credential_process profile")
|
|
748
|
+
.option("--credential-process", "Print AWS credential_process JSON (used by mesh dev's temp profile); refreshes the Zitadel token as needed. Requires --role or a cached defaultRole.")
|
|
749
|
+
.option("--role <arn>", "IAM role ARN to assume via web identity (used with --export). Saved as the default for this context so future --export runs can omit it. Resolution order: --role > MESH_AWS_ROLE > cached defaultRole.")
|
|
750
|
+
.option("--region <region>", "AWS region to include in the exported AWS_REGION. Defaults to AWS_REGION env or us-east-2.")
|
|
751
|
+
.option("--no-registry", "Skip the CodeArtifact registry-auth step that runs automatically after login for {tenant}.{env} contexts")
|
|
305
752
|
.action(async (context, opts) => {
|
|
306
|
-
|
|
753
|
+
let config = getContextConfig(context);
|
|
307
754
|
if (!config) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
`
|
|
316
|
-
|
|
755
|
+
// One chain, shared with `ensureLogin` — see discoverConfigGuarded.
|
|
756
|
+
// Deliberately NOT re-implemented here: this call site and that one
|
|
757
|
+
// drifted apart once already, and the difference was whether a clean
|
|
758
|
+
// laptop could resolve a short context at all.
|
|
759
|
+
config = await discoverConfigGuarded(context);
|
|
760
|
+
if (!config) {
|
|
761
|
+
const pinned = firstPartyDomainFor(context);
|
|
762
|
+
logError(`No configuration found for "${context}".\n\n` +
|
|
763
|
+
`Tried:\n` +
|
|
764
|
+
` 1. SSM: /mesh-platform/${context.split(".")[0]}/${context.split(".")[1]}/platform/zitadel\n` +
|
|
765
|
+
` (requires AWS SSO login + read access)\n` +
|
|
766
|
+
(pinned
|
|
767
|
+
? ` 2. HTTPS: https://cli.${pinned}/.well-known/mesh.json (pinned first-party domain)\n\n`
|
|
768
|
+
: ` 2. HTTPS: https://cli.${context}/.well-known/mesh.json\n` +
|
|
769
|
+
` (only attempted for a full domain, or a known first-party context)\n\n`) +
|
|
770
|
+
`To fix:\n` +
|
|
771
|
+
` - Use the platform's full domain: mesh login <env>.<tenant>.meshtech.io\n` +
|
|
772
|
+
` - Or, if you have AWS access, ensure SSO is active: aws sso login --profile <profile>\n` +
|
|
773
|
+
` - Or add config manually to ${CONFIG_FILE}:\n\n` +
|
|
774
|
+
` {\n` +
|
|
775
|
+
` "${context}": {\n` +
|
|
776
|
+
` "issuer": "https://identity.<env>.<your-platform-domain>",\n` +
|
|
777
|
+
` "clientId": "<cli-oidc-client-id>"\n` +
|
|
778
|
+
` }\n` +
|
|
779
|
+
` }\n`);
|
|
780
|
+
process.exit(1);
|
|
781
|
+
}
|
|
317
782
|
}
|
|
318
783
|
if (opts.status) {
|
|
319
784
|
await showStatus(context, config);
|
|
320
785
|
return;
|
|
321
786
|
}
|
|
787
|
+
if (opts.export) {
|
|
788
|
+
await exportAwsCredentials(context, config, opts);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (opts.credentialProcess) {
|
|
792
|
+
await credentialProcessAwsCredentials(context, config, opts);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
322
795
|
try {
|
|
323
|
-
|
|
796
|
+
const useDevice = opts.device || isRemoteEnvironment();
|
|
797
|
+
if (useDevice) {
|
|
798
|
+
logInfo("Using device code flow");
|
|
799
|
+
await deviceCodeLogin(context, config);
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
await login(context, config);
|
|
803
|
+
}
|
|
324
804
|
}
|
|
325
805
|
catch (err) {
|
|
326
806
|
logError(`Login failed: ${err.message}`);
|
|
327
807
|
process.exit(1);
|
|
328
808
|
}
|
|
809
|
+
// F2: login absorbs registry auth. CodeArtifact-backed contexts are the
|
|
810
|
+
// {tenant}.{env} ones — the local platform ('local') has no registry.
|
|
811
|
+
// Best-effort: a registry hiccup must not fail an otherwise good login.
|
|
812
|
+
if (opts.registry !== false && parseTenantEnv(context)) {
|
|
813
|
+
try {
|
|
814
|
+
logInfo("Refreshing CodeArtifact registry auth (skip with --no-registry)…");
|
|
815
|
+
const { runRegistryLogin } = await import("./registry.js");
|
|
816
|
+
await runRegistryLogin(context, {});
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
logWarn(`Registry auth failed (login itself succeeded): ${err.message}` +
|
|
820
|
+
` — retry with: mesh registry login ${context}`
|
|
821
|
+
+ ` (add --profile mesh-dev only if you have an AWS account and the broker is unavailable)`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
329
824
|
});
|
|
330
825
|
program
|
|
331
826
|
.command("logout")
|
|
@@ -337,8 +832,441 @@ export function registerLoginCommand(program) {
|
|
|
337
832
|
});
|
|
338
833
|
}
|
|
339
834
|
// =============================================================================
|
|
340
|
-
// Exports (for use by mesh
|
|
835
|
+
// Exports (for use by other mesh commands)
|
|
836
|
+
// =============================================================================
|
|
837
|
+
/**
|
|
838
|
+
* Get a valid access token for the given context, refreshing if needed.
|
|
839
|
+
*
|
|
840
|
+
* Returns the id token (a JWT with `sub` claim) suitable for
|
|
841
|
+
* Authorization: Bearer headers. Returns null if no credentials are
|
|
842
|
+
* cached or refresh fails.
|
|
843
|
+
*/
|
|
844
|
+
/**
|
|
845
|
+
* True while a cached token is still valid, treating anything within `marginMs`
|
|
846
|
+
* of expiry as already stale. The margin lets pollers (e.g. the dev-user
|
|
847
|
+
* token-server) re-mint BEFORE expiry instead of handing out a token that dies
|
|
848
|
+
* before the caller's next poll.
|
|
849
|
+
*/
|
|
850
|
+
export function tokenStillValid(expiresAt, marginMs = 0, now = Date.now()) {
|
|
851
|
+
return new Date(expiresAt).getTime() - marginMs > now;
|
|
852
|
+
}
|
|
853
|
+
async function getValidToken(context, opts = {}) {
|
|
854
|
+
const config = getContextConfig(context);
|
|
855
|
+
if (!config)
|
|
856
|
+
return null;
|
|
857
|
+
const creds = readCredentials(context);
|
|
858
|
+
if (!creds)
|
|
859
|
+
return null;
|
|
860
|
+
if (tokenStillValid(creds.expiresAt, opts.marginMs ?? 0)) {
|
|
861
|
+
return creds.idToken;
|
|
862
|
+
}
|
|
863
|
+
// Attempt refresh
|
|
864
|
+
if (!creds.refreshToken)
|
|
865
|
+
return null;
|
|
866
|
+
try {
|
|
867
|
+
const tokens = await refreshTokens(config.issuer, config.clientId, creds.refreshToken);
|
|
868
|
+
const idPayload = decodeJwtPayload(tokens.id_token);
|
|
869
|
+
writeCredentials(context, {
|
|
870
|
+
...creds,
|
|
871
|
+
idToken: tokens.id_token,
|
|
872
|
+
accessToken: tokens.access_token,
|
|
873
|
+
refreshToken: tokens.refresh_token ?? creds.refreshToken,
|
|
874
|
+
expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
|
|
875
|
+
email: idPayload.email ?? creds.email,
|
|
876
|
+
});
|
|
877
|
+
return tokens.id_token;
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Probe deployer creds for `context`/`roleArn`. Order matters:
|
|
885
|
+
* 1. No cached Zitadel session → `no-session`.
|
|
886
|
+
* 2. Cached session AND ambient `AWS_*` env keys present → `stale-env-override`
|
|
887
|
+
* (the frozen triple would override the self-refreshing profile at
|
|
888
|
+
* runtime — the exact `mesh dev` trap). Guarded on a cached session so
|
|
889
|
+
* legitimate CI env-key credentials, which have no `mesh login` session,
|
|
890
|
+
* are not misreported.
|
|
891
|
+
* 3. Session can't be refreshed → `expired-session`.
|
|
892
|
+
* 4. AssumeRoleWithWebIdentity fails → `assume-denied` (valid identity, wrong
|
|
893
|
+
* IAM/role/policy).
|
|
894
|
+
* 5. Otherwise → `ok` with the STS TTL.
|
|
895
|
+
*/
|
|
896
|
+
export async function probeCredentials(context, roleArn) {
|
|
897
|
+
const cached = readCredentials(context);
|
|
898
|
+
if (!cached)
|
|
899
|
+
return { state: "no-session" };
|
|
900
|
+
if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SESSION_TOKEN) {
|
|
901
|
+
return { state: "stale-env-override" };
|
|
902
|
+
}
|
|
903
|
+
const token = await getValidToken(context);
|
|
904
|
+
if (!token)
|
|
905
|
+
return { state: "expired-session" };
|
|
906
|
+
const sessionName = (cached.email ?? "mesh-cli-doctor")
|
|
907
|
+
.replace(/[^a-zA-Z0-9=,.@-]/g, "_")
|
|
908
|
+
.slice(0, 64);
|
|
909
|
+
const sts = await assumeRoleCredentials(roleArn, token, sessionName);
|
|
910
|
+
if (!sts) {
|
|
911
|
+
return {
|
|
912
|
+
state: "assume-denied",
|
|
913
|
+
detail: describeAssumeFailure({ roleArn, context, issuer: tokenIssuer(token) }, "inline"),
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
const ttlSeconds = Math.max(0, Math.round((Date.parse(sts.Expiration) - Date.now()) / 1000));
|
|
917
|
+
return { state: "ok", ttlSeconds, expiresAt: sts.Expiration, email: cached.email };
|
|
918
|
+
}
|
|
919
|
+
// =============================================================================
|
|
920
|
+
// AWS credential export (--export flag)
|
|
921
|
+
// =============================================================================
|
|
922
|
+
/**
|
|
923
|
+
* Single-quote a value for shell-safe inclusion in an `export` statement.
|
|
924
|
+
* Single-quoted strings in POSIX shells don't expand `$` or other metachars;
|
|
925
|
+
* embedded single-quotes are escaped via the `'\''` trick.
|
|
926
|
+
*/
|
|
927
|
+
function shellSingleQuote(value) {
|
|
928
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Render the `--static` output: raw temporary AWS credentials as shell
|
|
932
|
+
* `export` statements (the old frozen ~1h triple). Pure — no I/O. Includes a
|
|
933
|
+
* trailing newline. Exported for unit testing.
|
|
934
|
+
*/
|
|
935
|
+
export function renderExportStaticLines(args) {
|
|
936
|
+
return (`export AWS_ACCESS_KEY_ID=${shellSingleQuote(args.accessKey)}\n` +
|
|
937
|
+
`export AWS_SECRET_ACCESS_KEY=${shellSingleQuote(args.secretKey)}\n` +
|
|
938
|
+
`export AWS_SESSION_TOKEN=${shellSingleQuote(args.sessionToken)}\n` +
|
|
939
|
+
`export AWS_REGION=${shellSingleQuote(args.region)}\n`);
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Render the default `--export` output: point the shell at a self-refreshing
|
|
943
|
+
* `credential_process` profile. The leading `unset` clears any stale STS
|
|
944
|
+
* triple already in the shell so it can't shadow the profile. Pure — no I/O.
|
|
945
|
+
* Includes a trailing newline. Exported for unit testing.
|
|
946
|
+
*
|
|
947
|
+
* Deliberately does NOT export `AWS_CONFIG_FILE`: the profile lives in the
|
|
948
|
+
* user's REAL AWS config file (managed marker section — see
|
|
949
|
+
* `upsertManagedAwsConfigSection`), so their other profiles (e.g.
|
|
950
|
+
* `AWS_PROFILE=mesh-dev` InfraAdmin) keep working in the same shell.
|
|
951
|
+
* Redirecting AWS_CONFIG_FILE to a mesh-private file used to clobber
|
|
952
|
+
* ~/.aws/config for every subsequent AWS command.
|
|
953
|
+
*/
|
|
954
|
+
export function renderExportProfileLines(args) {
|
|
955
|
+
return (`unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN\n` +
|
|
956
|
+
`export AWS_PROFILE=${shellSingleQuote(args.profileName)}\n` +
|
|
957
|
+
`export AWS_REGION=${shellSingleQuote(args.region)}\n`);
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* The AWS config file the managed profile section is written into: the
|
|
961
|
+
* user's `$AWS_CONFIG_FILE` when THEY have set one, else `~/.aws/config` —
|
|
962
|
+
* the same file `aws configure sso` manages. Exported for unit testing.
|
|
963
|
+
*/
|
|
964
|
+
export function resolveUserAwsConfigPath(env = process.env) {
|
|
965
|
+
return env.AWS_CONFIG_FILE ?? path.join(env.HOME ?? "~", ".aws", "config");
|
|
966
|
+
}
|
|
967
|
+
/** True if `dir` can be created (if needed) and written. Used to detect an
|
|
968
|
+
* unwritable `~/.aws` (e.g. a sandbox with no `~/.aws` grant) without throwing. */
|
|
969
|
+
function canWriteDir(dir) {
|
|
970
|
+
try {
|
|
971
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
972
|
+
fs.accessSync(dir, fs.constants.W_OK);
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Where to write the managed AWS profile, with a safehouse fallback. Normally
|
|
981
|
+
* the user's real `~/.aws/config` (augmented in place, so their other tools see
|
|
982
|
+
* the profile — hence `redirected: false`, we don't touch their `AWS_CONFIG_FILE`).
|
|
983
|
+
* But when `~/.aws` is not writable (a sandbox without a `~/.aws` grant), fall
|
|
984
|
+
* back to a mesh-managed file under `~/.config/mesh` we CAN write, and set
|
|
985
|
+
* `redirected: true` so the caller also exports `AWS_CONFIG_FILE` (otherwise the
|
|
986
|
+
* AWS SDK, looking only at `~/.aws/config`, would never find the profile). An
|
|
987
|
+
* explicit `AWS_CONFIG_FILE` always wins and is never redirected.
|
|
988
|
+
*/
|
|
989
|
+
export function resolveAwsConfigTarget(env = process.env) {
|
|
990
|
+
if (env.AWS_CONFIG_FILE)
|
|
991
|
+
return { configPath: env.AWS_CONFIG_FILE, redirected: false };
|
|
992
|
+
const real = path.join(env.HOME ?? "~", ".aws", "config");
|
|
993
|
+
if (canWriteDir(path.dirname(real)))
|
|
994
|
+
return { configPath: real, redirected: false };
|
|
995
|
+
return { configPath: path.join(CONFIG_DIR, "aws-config"), redirected: true };
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Resolve which IAM role ARN to use, honoring the same precedence in both
|
|
999
|
+
* `--export` and `--credential-process`: explicit `--role` wins, then
|
|
1000
|
+
* `MESH_AWS_ROLE`, then the context's cached `defaultRole`. Pure aside from
|
|
1001
|
+
* reading `process.env.MESH_AWS_ROLE` — returns an explanatory error instead
|
|
1002
|
+
* of throwing so callers can route it to stderr and exit non-zero.
|
|
1003
|
+
*
|
|
1004
|
+
* Exported for unit testing.
|
|
1005
|
+
*/
|
|
1006
|
+
export function resolveRoleOrExplain(opts, config) {
|
|
1007
|
+
const role = opts.role ?? process.env.MESH_AWS_ROLE ?? config.defaultRole;
|
|
1008
|
+
if (!role) {
|
|
1009
|
+
return {
|
|
1010
|
+
error: "No IAM role available: pass --role <arn>, set MESH_AWS_ROLE, or run " +
|
|
1011
|
+
"`mesh login <context> --export --role <arn>` once to cache a default role for this context.",
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
return { role };
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Ensure a valid Zitadel ID token is cached for the given context — refreshing
|
|
1018
|
+
* or running the full login flow as needed. Returns null on failure.
|
|
1019
|
+
*/
|
|
1020
|
+
async function ensureValidToken(context, config, opts) {
|
|
1021
|
+
let token = await getValidToken(context);
|
|
1022
|
+
if (token)
|
|
1023
|
+
return token;
|
|
1024
|
+
logInfo(`No valid Zitadel session for ${context} — running login flow`);
|
|
1025
|
+
try {
|
|
1026
|
+
const useDevice = opts.device || isRemoteEnvironment();
|
|
1027
|
+
if (useDevice) {
|
|
1028
|
+
logInfo("Using device code flow");
|
|
1029
|
+
await deviceCodeLogin(context, config);
|
|
1030
|
+
}
|
|
1031
|
+
else {
|
|
1032
|
+
await login(context, config);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
catch (err) {
|
|
1036
|
+
logError(`Login failed: ${err.message}`);
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
return await getValidToken(context);
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Ensure a valid login session for `context`, running the login flow (device
|
|
1043
|
+
* code in remote/non-TTY, browser callback otherwise) if none is cached. Returns
|
|
1044
|
+
* the cached credentials, or null if no config is available to log in with.
|
|
1045
|
+
*
|
|
1046
|
+
* For use by other commands (e.g. `secrets set`, the AWS-cred resolver) so they
|
|
1047
|
+
* can trigger login on demand instead of just failing with guidance.
|
|
1048
|
+
*/
|
|
1049
|
+
export async function ensureLogin(context, opts = {}) {
|
|
1050
|
+
const existing = readCredentials(context);
|
|
1051
|
+
if (existing && new Date(existing.expiresAt) > new Date())
|
|
1052
|
+
return existing;
|
|
1053
|
+
// interactive: false → cached-session-or-nothing. Callers with their own
|
|
1054
|
+
// fallback chain (e.g. the AWS-cred resolver's SSO path) use this so an
|
|
1055
|
+
// expired cache doesn't stall headless runs in a device-code poll.
|
|
1056
|
+
if (opts.interactive === false)
|
|
1057
|
+
return null;
|
|
1058
|
+
// Same guarded discovery chain as the `login` command: SSM first, and the
|
|
1059
|
+
// well-known HTTPS endpoint only for domain-looking contexts (≥2 dots).
|
|
1060
|
+
// Hitting `https://cli.<short-name>/.well-known/mesh.json` for an uncached
|
|
1061
|
+
// context like "mesh.dev" would let whoever registers that domain seed our
|
|
1062
|
+
// cached issuer/clientId (login phishing).
|
|
1063
|
+
const config = getContextConfig(context) ?? (await discoverConfigGuarded(context));
|
|
1064
|
+
if (!config) {
|
|
1065
|
+
logWarn(`No login config for "${context}". Run: mesh login ${context}`);
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
const token = await ensureValidToken(context, config, opts);
|
|
1069
|
+
if (!token)
|
|
1070
|
+
return null;
|
|
1071
|
+
return readCredentials(context);
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* The one discovery chain, used by both the `login` command and `ensureLogin`:
|
|
1075
|
+
*
|
|
1076
|
+
* 1. SSM, when the context parses as `{tenant}.{env}` — the primary path for
|
|
1077
|
+
* internal users who already have AWS SSO access.
|
|
1078
|
+
* 2. The anonymous HTTPS well-known endpoint at the PINNED domain for a known
|
|
1079
|
+
* first-party short context (`mesh.dev` → `dev.platform.meshtech.io`). This
|
|
1080
|
+
* is what makes `mesh login mesh.dev` work on a laptop with no AWS at all
|
|
1081
|
+
* — the P0 case. The domain comes from a compiled-in table, never from the
|
|
1082
|
+
* context string, so it is not a phishing surface.
|
|
1083
|
+
* 3. The anonymous HTTPS endpoint at the context itself, only when it looks
|
|
1084
|
+
* like a real domain (≥2 dots).
|
|
1085
|
+
*
|
|
1086
|
+
* Never hits `https://cli.<short-name>/…` for an UNKNOWN short context — that
|
|
1087
|
+
* guard is the anti-phishing property and step 2 is deliberately a pinned
|
|
1088
|
+
* allow-list rather than a relaxation of it.
|
|
1089
|
+
*/
|
|
1090
|
+
async function discoverConfigGuarded(context) {
|
|
1091
|
+
let config = null;
|
|
1092
|
+
if (parseTenantEnv(context)) {
|
|
1093
|
+
config = await discoverConfigFromSsm(context);
|
|
1094
|
+
}
|
|
1095
|
+
if (!config) {
|
|
1096
|
+
const pinned = firstPartyDomainFor(context);
|
|
1097
|
+
if (pinned) {
|
|
1098
|
+
logInfo(`"${context}" is a known Mesh platform — resolving via ${pinned} (no AWS needed)`);
|
|
1099
|
+
config = await discoverConfig(pinned, context);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (!config && context.split(".").length - 1 >= 2) {
|
|
1103
|
+
config = await discoverConfig(context);
|
|
1104
|
+
}
|
|
1105
|
+
return config;
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Resolve AWS credentials via Zitadel JWT → AssumeRoleWithWebIdentity, then
|
|
1109
|
+
* print them as `export` statements on stdout. All status/error output goes
|
|
1110
|
+
* to stderr so the caller can safely `eval "$(mesh login ... --export ...)"`.
|
|
1111
|
+
*/
|
|
1112
|
+
async function exportAwsCredentials(context, config, opts) {
|
|
1113
|
+
const token = await ensureValidToken(context, config, opts);
|
|
1114
|
+
if (!token) {
|
|
1115
|
+
process.exit(1);
|
|
1116
|
+
}
|
|
1117
|
+
// Pick a role for the caller. `resolveRoleOrExplain` handles the
|
|
1118
|
+
// --role / MESH_AWS_ROLE / cached-defaultRole precedence (shared with
|
|
1119
|
+
// --credential-process); on top of that, when we fell back to the cached
|
|
1120
|
+
// default we ask `selectRoleForCaller` to upgrade to the context's
|
|
1121
|
+
// `adminRole` when the Zitadel JWT carries an admin claim.
|
|
1122
|
+
const resolvedRole = resolveRoleOrExplain(opts, config);
|
|
1123
|
+
if ("error" in resolvedRole) {
|
|
1124
|
+
logError("--export requires --role <arn> (or MESH_AWS_ROLE env var, or a cached defaultRole).\n" +
|
|
1125
|
+
" Example: mesh login mesh.dev --export --role arn:aws:iam::123456789012:role/mesh-developer\n" +
|
|
1126
|
+
" After the first run, the role is saved to ~/.config/mesh/config.json and --role can be omitted.");
|
|
1127
|
+
process.exit(1);
|
|
1128
|
+
}
|
|
1129
|
+
let roleArn = resolvedRole.role;
|
|
1130
|
+
if (!opts.role && !process.env.MESH_AWS_ROLE && config.defaultRole) {
|
|
1131
|
+
roleArn = selectRoleForCaller(token, {
|
|
1132
|
+
defaultRole: config.defaultRole,
|
|
1133
|
+
adminRole: config.adminRole,
|
|
1134
|
+
adminClaimRoles: config.adminClaimRoles,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
if (config.adminRole && roleArn === config.adminRole && !opts.role) {
|
|
1138
|
+
logInfo(`Caller has admin Zitadel role — assuming ${roleArn.split("/").pop()} (admin variant)`);
|
|
1139
|
+
}
|
|
1140
|
+
// Persist --role so subsequent runs can omit it.
|
|
1141
|
+
if (opts.role && opts.role !== config.defaultRole) {
|
|
1142
|
+
writeContextConfig(context, { ...config, defaultRole: opts.role });
|
|
1143
|
+
logInfo(`Saved default role for ${context}`);
|
|
1144
|
+
}
|
|
1145
|
+
const region = opts.region ??
|
|
1146
|
+
process.env.AWS_REGION ??
|
|
1147
|
+
process.env.AWS_DEFAULT_REGION ??
|
|
1148
|
+
"us-east-2";
|
|
1149
|
+
const roleName = roleArn.split("/").pop() ?? roleArn;
|
|
1150
|
+
// --static: resolve a frozen STS triple now (the old behavior). Consumers
|
|
1151
|
+
// that need raw credentials (no `mesh` binary on refresh) opt into this.
|
|
1152
|
+
if (opts.static) {
|
|
1153
|
+
const creds = readCredentials(context);
|
|
1154
|
+
const sessionName = (creds?.email ?? "mesh-cli-export")
|
|
1155
|
+
.replace(/[^a-zA-Z0-9=,.@-]/g, "_")
|
|
1156
|
+
.slice(0, 64);
|
|
1157
|
+
const env = await assumeRoleWithWebIdentity(roleArn, token, sessionName);
|
|
1158
|
+
if (!env) {
|
|
1159
|
+
logError(describeAssumeFailure({ roleArn, context, issuer: tokenIssuer(token) }));
|
|
1160
|
+
process.exit(1);
|
|
1161
|
+
}
|
|
1162
|
+
const accessKey = env.AWS_ACCESS_KEY_ID;
|
|
1163
|
+
const secretKey = env.AWS_SECRET_ACCESS_KEY;
|
|
1164
|
+
const sessionToken = env.AWS_SESSION_TOKEN;
|
|
1165
|
+
if (!accessKey || !secretKey || !sessionToken) {
|
|
1166
|
+
logError("AWS STS returned incomplete credentials");
|
|
1167
|
+
process.exit(1);
|
|
1168
|
+
}
|
|
1169
|
+
process.stdout.write(renderExportStaticLines({ accessKey, secretKey, sessionToken, region }));
|
|
1170
|
+
logSuccess(`Exported static AWS credentials for ${roleName} in ${region}`);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
// Default: manage a `[profile mesh-<context>]` block inside the user's REAL
|
|
1174
|
+
// AWS config file ($AWS_CONFIG_FILE if they set one, else ~/.aws/config) as
|
|
1175
|
+
// an idempotent marker-delimited section — the same file `aws configure sso`
|
|
1176
|
+
// manages — and export only AWS_PROFILE. The profile's `credential_process`
|
|
1177
|
+
// re-invokes `mesh login … --credential-process`, so `eval`-ed shells
|
|
1178
|
+
// auto-refresh instead of dying ~1h in. We must NOT export AWS_CONFIG_FILE
|
|
1179
|
+
// pointing at a mesh-private file: that hides ~/.aws/config from the whole
|
|
1180
|
+
// shell and breaks the user's other profiles (e.g. AWS_PROFILE=mesh-dev).
|
|
1181
|
+
const meshBin = resolveStableMeshBin(process.argv[1]);
|
|
1182
|
+
const sanitized = context.replace(/[^A-Za-z0-9_-]/g, "-");
|
|
1183
|
+
const profileName = `mesh-${sanitized}`;
|
|
1184
|
+
// Safehouse-friendly: falls back to a mesh-managed config when ~/.aws is not
|
|
1185
|
+
// writable (and then `redirected` tells us to export AWS_CONFIG_FILE too).
|
|
1186
|
+
const { configPath, redirected } = resolveAwsConfigTarget();
|
|
1187
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
1188
|
+
// Read → build → atomic-write back-to-back (all sync) so the lost-update
|
|
1189
|
+
// window against concurrent writers (another `mesh login --export`, the
|
|
1190
|
+
// user's own editor, `aws configure`) is as small as possible; the temp+
|
|
1191
|
+
// rename write means concurrent READERS never observe a truncated file.
|
|
1192
|
+
const existedBefore = fs.existsSync(configPath);
|
|
1193
|
+
const existing = existedBefore ? fs.readFileSync(configPath, "utf-8") : "";
|
|
1194
|
+
// Preserve the user's existing mode (rename replaces the inode, so the
|
|
1195
|
+
// temp file's mode wins) — never chmod their config out from under them.
|
|
1196
|
+
// Restrict to 0600 only when WE create the file.
|
|
1197
|
+
const mode = existedBefore ? fs.statSync(configPath).mode & 0o777 : 0o600;
|
|
1198
|
+
atomicWriteFileSync(configPath, upsertManagedAwsConfigSection(existing, context, renderCredentialProcessProfile({
|
|
1199
|
+
profileName,
|
|
1200
|
+
context,
|
|
1201
|
+
roleArn,
|
|
1202
|
+
region,
|
|
1203
|
+
meshBin,
|
|
1204
|
+
})), mode);
|
|
1205
|
+
process.stdout.write(renderExportProfileLines({ profileName, region }));
|
|
1206
|
+
// When we redirected away from an unwritable ~/.aws, the profile lives in a
|
|
1207
|
+
// mesh-managed file — export AWS_CONFIG_FILE so the AWS SDK finds it.
|
|
1208
|
+
if (redirected) {
|
|
1209
|
+
process.stdout.write(`export AWS_CONFIG_FILE=${shellSingleQuote(configPath)}\n`);
|
|
1210
|
+
}
|
|
1211
|
+
logSuccess(`Exported self-refreshing AWS profile ${profileName} for ${roleName} in ${region} ` +
|
|
1212
|
+
`(managed section in ${configPath})`);
|
|
1213
|
+
}
|
|
1214
|
+
// =============================================================================
|
|
1215
|
+
// AWS credential_process (--credential-process flag)
|
|
341
1216
|
// =============================================================================
|
|
1217
|
+
/**
|
|
1218
|
+
* Resolve AWS credentials via Zitadel JWT → AssumeRoleWithWebIdentity, then
|
|
1219
|
+
* print them as AWS `credential_process` JSON on stdout — nothing else may
|
|
1220
|
+
* reach stdout in this mode, since the AWS SDK parses it directly. All
|
|
1221
|
+
* status/error output goes to stderr.
|
|
1222
|
+
*
|
|
1223
|
+
* Unlike `--export`, this does NOT run an interactive login flow when no
|
|
1224
|
+
* session is cached (there's no terminal/browser to drive it from a child
|
|
1225
|
+
* process) — it only refreshes an existing session via its refresh token
|
|
1226
|
+
* (`getValidToken`). Callers must `mesh login <context>` at least once.
|
|
1227
|
+
*/
|
|
1228
|
+
async function credentialProcessAwsCredentials(context, config, opts) {
|
|
1229
|
+
const token = await getValidToken(context);
|
|
1230
|
+
if (!token) {
|
|
1231
|
+
logError(`No valid Zitadel session for ${context} (missing, expired, or refresh failed). Run: mesh login ${context}`);
|
|
1232
|
+
process.exit(1);
|
|
1233
|
+
}
|
|
1234
|
+
const resolvedRole = resolveRoleOrExplain(opts, config);
|
|
1235
|
+
if ("error" in resolvedRole) {
|
|
1236
|
+
logError(resolvedRole.error);
|
|
1237
|
+
process.exit(1);
|
|
1238
|
+
}
|
|
1239
|
+
const creds = readCredentials(context);
|
|
1240
|
+
const sessionName = (creds?.email ?? "mesh-cli-credential-process")
|
|
1241
|
+
.replace(/[^a-zA-Z0-9=,.@-]/g, "_")
|
|
1242
|
+
.slice(0, 64);
|
|
1243
|
+
const stsCreds = await assumeRoleCredentials(resolvedRole.role, token, sessionName);
|
|
1244
|
+
if (!stsCreds) {
|
|
1245
|
+
logError(describeAssumeFailure({
|
|
1246
|
+
roleArn: resolvedRole.role,
|
|
1247
|
+
context,
|
|
1248
|
+
issuer: tokenIssuer(token),
|
|
1249
|
+
}));
|
|
1250
|
+
process.exit(1);
|
|
1251
|
+
}
|
|
1252
|
+
process.stdout.write(toCredentialProcessJson(stsCreds) + "\n");
|
|
1253
|
+
}
|
|
342
1254
|
export { CONFIG_DIR, CONFIG_FILE, CREDENTIALS_FILE };
|
|
343
|
-
|
|
1255
|
+
/** Every cached context config, keyed by context name. */
|
|
1256
|
+
export function readAllContextConfigs() {
|
|
1257
|
+
return readConfig();
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* The discovery chain on its own, without the browser flow that follows it.
|
|
1261
|
+
*
|
|
1262
|
+
* Exported so the clean-laptop bootstrap — SSM → pinned first-party alias →
|
|
1263
|
+
* full-domain, and the anti-phishing refusal for an unknown short context — can
|
|
1264
|
+
* be exercised end-to-end against the live well-known endpoint. The `login`
|
|
1265
|
+
* command's own path is otherwise only reachable by completing an interactive
|
|
1266
|
+
* sign-in, which is why this leg had no coverage above the unit tier.
|
|
1267
|
+
*/
|
|
1268
|
+
export async function discoverConfigForContext(context) {
|
|
1269
|
+
return getContextConfig(context) ?? (await discoverConfigGuarded(context));
|
|
1270
|
+
}
|
|
1271
|
+
export { readCredentials, readAllCredentials, getContextConfig, decodeJwtPayload, getValidToken };
|
|
344
1272
|
//# sourceMappingURL=login.js.map
|