UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

411 lines 53.2 kB
---
name: core
description: >
  The `mesh` developer CLI (@mesh-tech/mesh-cli) — Zitadel/OIDC login,
  CodeArtifact registry auth, Pulumi deploy/stack passthrough, app scaffolding
  (create-app), local dev orchestration (dev), SSM tunnels (tunnel), DB access
  (db), Secrets Manager (secrets), Headscale VPN (vpn), Temporal inspection
  (temporal), workflow IR extraction (workflow), agent artifact export
  (artifacts), reading deployed-agent conversations (conversations), and both
  app API docs and the generated developer portal / CLI reference (docs).
  Activate when running any `mesh ...`
  command, scaffolding a tenant app, opening a bastion/DB tunnel, deploying a
  Pulumi stack via the deployer role, wiring local dev, or building the docs
  portal.
metadata:
  version: '0.1'
  category: 'tooling'
  type: core
---

# `@mesh-tech/mesh-cli`

Developer CLI for Mesh Platform. Binary name is **`mesh`**. Install it explicitly —
`@mesh-tech/app-kit` ships a `mesh` bin shim onto it but declares it an **optional
peer**, so it is never installed transitively.

## Running it

**In this monorepo the CLI runs from TypeScript source — there is no build step and
no `dist/` to go stale.** The `bin` is a launcher (`bin/mesh.mjs`) that runs
`bin/mesh.ts` under `tsx`, so you always get the local source of whatever
worktree/app you're in. (At publish time `build-for-publish` rewrites the `bin` to
the built `./dist/bin/mesh.js` for consumers.)

- **Any cwd:** `pnpm exec mesh <cmd>` — works in the repo root, any worktree, or an
  app subdir, even in a never-built worktree (no `pnpm build` needed).
- **Repo/worktree root:** `pnpm mesh <cmd>` (the root `"mesh"` script).
- **Bare `mesh <cmd>`:** run `pnpm exec mesh install-shim` **once** — it writes a tiny
  cwd-aware resolver to `~/.local/bin/mesh` (build-free; survives worktree switches
  and pulls). After that, `mesh dev` etc. run the local source directly. The shim is
  **not** version-pinned: it walks up from your cwd to the nearest
  `node_modules/.bin/mesh`, so it always runs the mesh-cli the **current workspace**
  resolves — the worktree's live source in-repo, or the repo's pinned published
  version in a standalone consumer repo. One global shim, correct local version.
- **NEVER** run `node …/dist/bin/mesh.js` directly — that path doesn't exist in-repo
  (the CLI isn't built here) and, if it does from a stray publish, it's stale. Use one
  of the forms above.
- **Suspect you're running a stale copy?** `mesh --version` prints provenance:
  `source @ <commit> <date>` means this checkout's TypeScript source;
  `published build <date>, commit <commit>` means a packaged install. If the commit
  isn't your checkout's HEAD (or it says `published build` inside a worktree), your
  resolution is stale — remedy: re-run the scoped install so `.bin/mesh` exists here
  (`pnpm install --frozen-lockfile --filter @mesh-tech/mesh-cli...`), then invoke via
  `pnpm exec mesh` (or re-run `pnpm exec mesh install-shim` once if the shim itself
  was overwritten).

```bash
# Standalone consumer repo (published package, runs built dist):
npm i -D @mesh-tech/mesh-cli   # or pnpm add -D
pnpm exec mesh --help
```

Built on `commander`. Most commands auto-detect tenant/env/stack from Pulumi config
in the cwd or its parents; AWS-touching commands resolve credentials in this order:
ambient env creds (CI) → cached Zitadel JWT from `mesh login` (`AssumeRoleWithWebIdentity`) → AWS SSO profile (`AssumeRole`).

> **`mesh registry login` is the exception, and tries a step *before* all three:**
> the Zitadel-gated **registry-auth broker**. It exchanges the id token `mesh login`
> already cached for a CodeArtifact read token minted with the broker's own AWS
> identity, so installing `@mesh-tech/*` needs **no AWS account, no AWS CLI and no
> `~/.aws/config`** — only a Mesh account holding the `registry:read` role. The three
> AWS paths remain the fallback (CI, client-owned tenant accounts, publishing).
> See [`docs/guides/new-developer-setup.md`](../../../../docs/guides/new-developer-setup.md).

> **`mesh` is the front door — don't run raw `pulumi` or `aws`.** Every Pulumi op
> goes through `mesh deploy <op>` (up/preview/destroy/refresh/import/state/cancel/
> stack output) and stack lifecycle through `mesh stack init`/`rm` — all assume the
> stack's deployer role with an up-front **login preflight** (a clear "run `mesh login
> --device`" instead of a raw AWS error). This works in a sandbox with **no `~/.aws`
> access**: creds are passed as env vars (deploy/db/secrets), `mesh dev` writes its
> self-refreshing profile under the session scratch dir, and `mesh login --export`
> falls back to a mesh-managed AWS config when `~/.aws` isn't writable. The one
> genuine bootstrap that still needs raw pulumi: setting the initial
> `mesh:deployerRole` on a brand-new stack.

## Command reference

Top-level commands (each `register*` is wired in `src/program.ts`, which `bin/mesh.ts` runs):

| Command | Subcommands | Does | Key flags / args |
|---|---|---|---|
| `mesh login <context>` | (also `mesh logout <context>`) | Zitadel OIDC PKCE or device-code auth; caches creds in `~/.config/mesh/`. Context like `mesh.dev`. Auto-discovers config via SSM then HTTPS `.well-known/mesh.json`. | `--status` (reports the cached Zitadel session, or — when there isn't one — the AWS identity the credential chain resolves, so an SSO profile or a key/secret pair passes too; non-zero only when neither works), `--device`, `--export` (print AWS `export`s for `eval`), `--role <arn>`, `--region <r>` |
| `mesh registry` | `login [context]`, `logout`, `status`, `publish` | CodeArtifact auth → refreshes `~/.npmrc` token (12h) + adds `@mesh-tech` scope to project `.npmrc` + **strips an unscoped CodeArtifact `registry=` line from `~/.npmrc`** (after writing `~/.npmrc.bak-<ts>`) — that line is what a raw CodeArtifact sign-in *without* `--namespace @mesh-tech` leaves behind, and it makes CodeArtifact your DEFAULT registry so every *public* install 401s at the 12h expiry. Skipped under `--ci`, where the unscoped login is deliberate. `status` probes the registry rather than grepping the file: expired/missing token or a hijack exits 1, an unreachable registry warns and exits 0. **Step 0 is the Zitadel-gated broker:** the registry is ONE global identity (`utils/registry-identity.ts`, seeded from the compiled-in first-party alias table, session cached under the reserved key `registry`), so no context is needed — `mesh registry login` signs in to the registry's issuer if nothing is cached (browser; `--device` when headless; reuses a `mesh.dev` session on the same issuer) and POSTs the id token to the platform's `registry-auth` service, which returns a CodeArtifact read token minted with its OWN AWS identity — so this path needs no AWS account, CLI or profile at all. It writes the same two `~/.npmrc` lines the AWS path does. Falls back to the AWS chain when the platform publishes no broker, when the broker is unreachable, or with `--no-broker`; a 403 (a valid Mesh account without the `registry:read` role) is fatal and does **not** fall through. `--publish` and `--profile` deliberately skip the broker — both are AWS-identity paths. `--profile mesh-dev` auto-runs the AWS SSO login when the session is stale. `mesh login` never touches the registry (it prints a one-line pointer to `mesh registry login` when the machine has no registry access); `logout` clears the registry session only. A `[context]` on the read path is ignored with a notice; it still selects the SSM export for `--publish`. `login --publish` assumes the registry's publisher role (from `--role` or the registry SSM export); `publish` pushes package snapshots. | `login`: `--device`, `--no-broker`, `--profile <sso>`, `--role <arn>`, `--ci`, `--skip-npmrc`, `--publish`; `publish`: `--snapshot` |
| `mesh db` | `connect`, `credentials`(alias `creds`), `env`, `psql`, `exec <command...>` | Access tenant DBs through an SSM bastion tunnel. `env` prints `export`s for `eval`; `exec` runs a command with `DATABASE_URL` injected. | `-t/--tenant`, `-e/--env`, `-a/--app`, `--app-tenant`, `--app-stage`; `exec` also: `--secret <arn>`, `--ssl <require\|no-verify\|disable>`, `--port <n>` |
| `mesh deploy ...` | passthrough | Runs `pulumi` with the stack's `mesh:deployerRole` assumed. Unknown opts/args pass through; defaults to `up` if no subcommand. | `-s/--stack <name>` (stripped before pulumi `up`); everything else → pulumi (`preview`, `up --yes`, `destroy`, `stack output …`) |
| `mesh dev` | `logs <service>`, `restart <service>`, `list`, `test-user [name]` | Two modes. **Tethered** (deployed stack): reads the Pulumi stack dev output, opens tunnels, injects Secrets Manager secrets; needs VPN/AWS creds. **Local** (`--local`, auto when the app has no `Pulumi.yaml`): synthesizes services from the app layout (subdirs with a `dev` script, plus a synthesized `dg dev` webserver when the app root is a Dagster workspace — `dg.toml`, or `[tool.dg]` in `pyproject.toml`; such an app runs with zero package.json services — see `docs/guides/local-dagster.md`) and wires them to the `mesh start` platform (Temporal `local-dev`, app DB, Zitadel, ministack via `AWS_ENDPOINT_URL`) — zero AWS/VPN/Pulumi. `--externals` (local mode; `--mock` is an alias) also realizes the app's declared external services (package.json → `mesh.externals`; legacy `mesh.mocks` still read). Each declaration runs in one of three MODES — `mock` (the service is emulated: `openapi` → a Prism mock server from a spec, or `src` → a mock process), `local` (a local version of the real service: a `compose` file + published `port`, e.g. a vendor-shaped DB — up `--wait` before the app's services; a realization another checkout already started is **adopted** instead of failing — declared port already served **by a container this compose file defines** → adopt as-is (a publisher the file doesn't define — a native install or unrelated container — is a hard **conflict**, never adopted, since credentials would seed against the wrong service); a stopped foreign container holding the compose `container_name` → `docker start` + adopt — so startup order across checkouts doesn't matter; adopted externals stay out of session state and `--kill` tears down only externals this session created, volumes kept), `remote` (connect to the ACTUAL service: inline vendor `credentials`, or the credentials the app's tenant environment is configured with via `remote.env`). A declaration may carry SEVERAL realizations; `mode` picks the default and `--externals name=mode` overrides it per run (`--externals plaid-db=remote,plaid`). Remote externals get a blackbox uptime probe against their real endpoint. Failing to realize a remote external is fatal only when you named it; a bare `--externals` warns and keeps going, so the local loop still works with no AWS identity. Every realization seeds its ExternalService credential secret (`{{url}}`/`{{host}}`/`{{port}}` from the stand-in's endpoint, `{{env:VAR}}` from the shell) so `resolveCredentials()` runs the deployed code path against the stand-in. Runners: tmux (default) or `--runner docker` (CI/headless — services run as a docker compose project with host networking; parity `--status`/`--kill`/`logs`/`restart`). | root: `--local`, `--externals [name[=mode],…]` (alias `--mock`), `--runner <tmux\|docker>`, `--app <path>`, `--stage`, `--headless`, `--kill`, `--status`, `--json`, `--session <name>`, `--profile <sso>`; `logs`: `--tail <n>`; `test-user`: `--tenant/--env/--region` |
| `mesh start` / `mesh stop` / `mesh status` | — | Full-local Mesh platform via docker compose (project `mesh-local`, assets ship in the CLI package): Postgres, Temporal (+UI), Zitadel, SpiceDB, ministack (local AWS fabric: SSM/Secrets/S3 registry; also seeds a general-purpose `mesh-local-data` bucket with sample data — see `docs/guides/local-dagster.md`), memcached, OpenSearch, a **local mailbox** (Mailpit — SMTP :1025 wired into Zitadel, inbox at http://localhost:8025, so user-activation and password-reset mail is testable locally instead of vanishing), **and the hosted observability backends** (OTel collector + Loki + Tempo + Prometheus — the Hub's logs/traces/metrics views run unchanged). Seeds tenant `local`/env `dev` on first boot and reconciles every Zitadel-provisioned app tenant into the registry. **No AWS creds, VPN, or Pulumi state needed — Docker only.** The **Hub is included by default** — three overlay services, `hub-api` + `hub-ui` + `hub-auth` (an oauth2-proxy; it is what binds the front-door port and proxies to `hub-ui`, so front-door 401s/redirect loops are in ITS logs, not `hub-api`'s) — the front door at http://localhost:9000 — `MESH_HUB_PORT=<port>` publishes it elsewhere when 9000 is taken; export it for `mesh status` too (images build once from the published @mesh-tech/hub tarball; needs `mesh registry login` the first time; `--no-hub` opts out). Auth contract: `--with-hub` refreshes to the latest published hub after preflighting the CodeArtifact token — with an expired/missing token it falls back to the already-built local images (warn + fixing command), or fails fast naming `mesh registry login --profile mesh-dev` when none exist; the default (no-flag) path with an expired/missing token starts hub-less and names the same fix. `--hub-from-source` sidesteps the registry entirely — it builds the Hub images from THIS checkout's `apps/hub`, which is the only way to run an unpublished Hub change in the containerized stack. Non-TTY runs (CI, agents, piped output) get an `[HH:MM:SS]` prefix on every step line plus a heartbeat during long fetches/builds — `MESH_LOG_TIMESTAMPS=1/0` forces the prefix on/off (default: on when stderr is not a TTY). | `start`: `--no-seed`, `--no-hub`, `--with-hub`, `--hub-from-source`, `--takeover`, `--skip-port-check`; `stop`: `--destroy` (drops volumes/seeds), `--force`; `status`: `--json` |
| `mesh hub` | `dev` | One-command local Hub over a running dev-local stack. Launches the CURRENT-CODE Hub (apps/hub api+ui from a mesh-platform checkout) pointed at an existing `mesh dev` session — auto-assembles `HUB_TENANT`/`HUB_SCOPE_ENV`/`HUB_SCOPE_TENANTS` (session platform context), `TEMPORAL_ADDRESS` (session tunnel), `AWS_*` (session scratch profile), `DEV_USER_*`+`DEV_USER_TOKEN_URL` (session dev identity; starts its own token-server if the session predates it, so tokens stay fresh past ~1h). Refuses to launch with an empty tenant scope (which the Hub renders as silently blank). Runs in tmux session `mesh-hub-dev`. | `dev`: `--session <name>`, `--tenants <csv>`, `--port` (or `MESH_HUB_DEV_PORT`, default 9000; the containerized `mesh start` Hub uses the separate `MESH_HUB_PORT`), `--api-port <3002>`, `--platform-dir <dir>` (or `MESH_PLATFORM_DIR`), `--print-env`, `--kill` |
| `mesh create-app` | — | Scaffold a tenant app. Composable mode (primitives) or legacy template mode. Interactive when TTY + missing flags. Run at the root of an **empty** `{tenant}-mesh-apps` git clone (a `.git` with no `package.json`/`apps/`/`tenants/` and no enclosing pnpm workspace), it first generates the repo-level shape — `pnpm-workspace.yaml` over `apps/*`, private root `package.json`, scoped-registry `.npmrc`, base `tsconfig.json`, `.gitignore`, `README.md`, `apps/` — then scaffolds the app. Never overwrites an existing file. | `--tenant`, `--name`, `--primitives <csv>` (`service,database,temporal,bucket`), `--template <workflow\|api-auth\|api-role-gating\|external-service>`, `--test` (writes to `tests/tenants/`) |
| `mesh app` | `check [apps...]` | Checks tenant apps against the **Mesh app contract** (`@mesh-tech/app-kit#apps` → `references/app-contract.md`): a UI is its own Service (`UI_IN_API`, `NO_UI_SERVICE`), the app registers with the Hub (`NO_REGISTER`), sign-in goes through the platform proxy (`CUSTOM_SESSION_AUTH`), logs go through `@mesh-tech/logger` (`NON_PLATFORM_LOGGER`), and people/roles/keys are surfaced through the Hub (`NO_AUTHZ_POINTER`, `METADATA_UNPUBLISHED`, `IAC_GRANTS`, `PASSWORD_STORE`, `EMAIL_ALLOWLIST`, `USERS_TABLE`), and an API surface's docs site sits behind the sign-in proxy with its readers stated (`DOCS_SITE_UNAUTHED`, `DOCS_AUDIENCE_IMPLICIT`); a tenant-local auth lib is advisory (`LOCAL_AUTH_LIB`), as is a pointer that names its own Zitadel project instead of binding the env with `zitadelAppProjectId` (`POINTER_PROJECT_OVERRIDE`). One check per code on the `mesh dev doctor` engine; exit 1 on any BLOCK. The SAME gate `mesh create-app` runs after scaffolding, tenant CI runs beside `mesh skills sync --check`, and the platform reviewer runs on a PR. Default: every `apps/*` with a Pulumi program. | `[apps...]` (repo-relative dirs), `--root <dir>`, `--json`, `--verbose` |
| `mesh init` | (bare = wizard), `app-tenant`, `platform <tenant>` | **Bare `mesh init` is the guided setup wizard**: tenant (pre-filled from `mesh.json` / Pulumi `mesh:tenant`), local-only vs deployed platform, registry access (in-process `mesh registry login`; a 403 marks the step ✘ and the wizard continues), platform sign-in on the deployed branch only, repo bootstrap (empty clone, or an empty non-git folder the wizard offers to `git init`) or shape check, skills sync; writes `mesh.json` (`{tenant, platform}`) so `create-app` / `mesh dev` default `--tenant`; flags `--tenant --local\|--platform <env> --device --profile --skip-repo --yes --json`; exit 1 if any step failed, 130 on Ctrl-C. `app-tenant` is the repo doctor (A1–A3): read-only check pipeline — CLI auth, registry access, public npm not hijacked, platform reachable (Hub API), app-tenant registration, deployer role, repo shape, agent skills — each failure names the exact `mesh` fix. **Registry access** is a live probe of the CodeArtifact token, not a grep of `~/.npmrc`: an expired token `fail`s (it used to `pass`), an unreachable registry `warn`s (a network fault is not an auth fault). **Public npm not hijacked** fails when `~/.npmrc` carries an unscoped `registry=<codeartifact>` line, which makes CodeArtifact your default registry — one `mesh registry login` fixes both. Exit 0 iff all pass; re-run any time. `--fix` applies developer-scope fixes (registry token, skills sync); operator actions stay remediations — and `--profile <p>` is the SSO profile `--fix` logs into CodeArtifact with, echoed back in the `fix:` command each check prints so the advertised command is the one that runs. | `app-tenant`: `--tenant <name>`, `--context <ctx>` (default `local`), `--hub-url <url>`, `--fix`, `--profile <sso>`, `--json` |
| `mesh skills` | `sync` | Agent-skill distribution (D1/D2): installs the base building-with-Mesh skills into the repo's `.claude/skills/mesh-*/` (managed-marker files) and wires TanStack-Intent discovery for the pattern skills shipping inside `@mesh-tech/*` packages (`.intent/hooks/` gate + `.claude/settings.json` SessionStart hook + AGENTS.md fence — the PR #2356 mechanism, vendored). Idempotent; runs automatically after `mesh create-app`. | `sync`: `--check` (CI/doctor: exit 1 when missing/stale), `--root <path>` |
| `mesh docs` | `build`, `portal`, `cli-reference` | `build` builds/validates an **app's** OpenAPI specs from `docs/docs.config.json` (file copy or SymXchange generation). `start`/`stop`/`list` are the simple front door: `start` serves the docs on loopback — DETACHED in a tmux session named `mesh-docs` by default (prints the bare URL once live; `stop` kills the session; `--foreground` or any non-TTY runs in the foreground) — serving the working tree in a mesh-platform checkout, or the published `@mesh-tech/docs` artifact fetched from the role-gated CodeArtifact registry anywhere else (docs version == the @mesh-tech/* baseline it describes; `-v` pins, default latest; cached under `~/.cache/mesh/docs/`). `list` shows the published docs versions. `portal` assembles the **Mesh developer portal** (docs.meshtech.io) from every `docs.json`-opted-in doc root in the repo — a directory publishes its markdown iff it holds a `docs.json`; route/title/order/nav derive from the tree and each file, so adding a doc is adding one file — then hands the assembled tree to Zudoku (`apps/docs/`): default runs `zudoku build`, `--serve` runs `zudoku dev`, `--assemble-only`/`-o` stop after assembly. Links between published files are rewritten to routes; links to unpublished repo files become blob links. Reserved directory names (`plans/`, `designs/`, `incidents/`, … the frozen list in `src/docs/schema.ts`) are excluded at any depth. `cli-reference` regenerates `docs/portal/generated/cli-reference.md` from THIS command tree, so it can't document a flag the binary lacks. `portal --check` is the CI gate (`pnpm check:docs-portal`): docs.json schema validation, reserved-segment fail-close, broken links, and CLI-reference staleness; `--diff-base <ref>` prints the publish-set diff for PR job summaries. Hidden commands and `--version` are excluded from the reference. | `build`: `-c/--config <path>` (default `docs/docs.config.json`); `portal`: `-o/--out <dir>`, `--assemble-only`, `--serve`, `-p/--port <n>` (default `3000`), `--check`, `--print-manifest`, `--diff-base <ref>`, `--manifest-out <path>`; `cli-reference`: `-o/--out <path>`, `--check`; `start`: `-v/--version <v>` (default: latest), `-p/--port <n>` (default `4400`; `0` is foreground-only), `--dev` (foreground HMR), `--foreground`; `stop`: no flags; `list`: no flags |
| `mesh stack` | `init`, `rm <name>` | `init` creates a personal dev stack `dev-{github-user}`, copying base-stack config, setting `mesh:deploy: false`, inheriting KMS secrets provider; `--worktree` appends the git-worktree token (`dev-{user}-{token}`) so concurrent worktrees deploy to **distinct** stacks (SSM paths, Temporal namespace, Nexus endpoint all derive from the stack name → one discriminator isolates all three; no-op on the primary checkout). `rm <name>` removes a personal stack (backend state + local config), credentialed. Both assume the deployer role — no raw `pulumi` / manual creds needed. | `init`: `--from <stack>`, `--name <stack>`, `--worktree`; `rm`: `--yes` |
| `mesh tenant` | `add <name>`, `list` | Register/inspect app tenants on a platform stack. Run from the **platform layer** of a tenant platform repo (e.g. `mesh-sandbox/platform`). `add` inserts the tenant under `mesh:tenants` in `Pulumi.<stack>.yaml` via a comment-preserving YAML round-trip (untouched lines don't reformat), refuses a tenant that is already declared (an existing entry may carry fields a re-add would drop), derives the `{name}-{env}` subdomain from the stack name (echoed in the success line), and guards against running on the core layer or an app dir; it then prints the deploy + verify sequence — `mesh deploy up` is what makes the registration live. `list` prints what the stack config declares, so "is it registered?" is answerable without opening YAML (warns when the project doesn't look like a platform layer, so an empty result isn't mistaken for "no tenants"). | `add`: `--display-name <name>`, `--subdomain <sub>`, `--zitadel-org-id <id>`, `--stack <stack>`, `--json`; `list`: `--stack <stack>`, `--json` |
| `mesh tunnel [group]` | `external <name>` | SSM port-forward to platform services. Groups: `dev` (temporal+db), `temporal`. **`external <name>`** tunnels to a *registered* `ExternalService`: host/port are read from its credential secret (`mesh/{app-tenant}/{app-stage}/external/{name}`) — the same values deployed apps resolve at runtime — so anything the bastion can reach is tunnelable with no per-service CLI entry and no bastion services-map change. `-t/-e` are **optional everywhere** — they fall back to the same Pulumi/SST context detection `mesh dev` uses, and `AWS_REGION`/`AWS_PROFILE` default from the nearest Pulumi config + the `{tenant}-{env}` profile convention (region falls back to the effective profile's own `region` — the `[default]` profile when none was resolved — before the platform default us-east-2). | root: `-t/--tenant`, `-e/--env`, `-l/--list`, `-s/--services <csv\|group>`, `--db-port`, `--temporal-ui-port`, `--temporal-frontend-port`; `external`: `-p/--port` (default: the remote port), `-k/--key <key>` (multi-instance `credentials.keyedBy` externals), `--app-tenant`/`--app-stage` (credential axis; default to `-t`/`-e`) |
| `mesh secrets` | `exec <command...>`, `set [service]`, `reindex [service]`, `migrate-config` | Manage external-service creds in Secrets Manager. `exec` injects secrets as env; `set` reads field schema from SSM and prompts (or `--json`) — with `--key` it also records the instance in the service's instance index (`{prefix}/.index`, what `listInstances()` reads). `reindex` rebuilds that index from the per-key secrets actually in Secrets Manager (operator-credential `ListSecrets`) — the repair for an index that drifted (out-of-band secret create/delete, concurrent writers, malformed value) and the populate step when rolling the index out to an estate: **deploy the declaring stack first** (creates the managed, empty index), then `reindex`. | `set`: `--key <id>` (multi-instance), `--all`, `--json <str>`, `--stack`, `--region`; `reindex`: `--dry-run`, `--stack`, `--region`; `migrate-config`: `--force`, `--dry-run`, `--stack`, `--region` |
| `mesh artifacts` | `get <ref>` | Download workflow artifact files from an AI-agent conversation. Ref `<conversationId>:<artifactId>` (artifactId defaults to `workflow-artifact`). | `-o/--output <dir>`, `--target <name>`, `--api-url <url>`, `--context <ctx>` (default `mesh.dev`) |
| `mesh conversations` (alias `conv`) | `list`, `show <id>`, `artifacts <id>` | Read a deployed Mesh agent's conversations: list the caller's conversations, render a transcript, or list a conversation's artifacts. Resolves against the same `agent-targets` registry the MCP `agent_*` tools use. | all three: `--target <name>`, `--api-url <url>` (overrides `--target`), `--context <ctx>` (used with `--api-url`, default `mesh.dev`), `--json` (default when stdout isn't a TTY) |
| `mesh vpn` | `connect <context>`, `disconnect`, `status`, `tunnel <up\|down\|status>`, `api-key`, `pre-auth-key`, `users` | Headscale VPN mgmt via Tailscale + kubectl. **`connect` now defaults to the userspace `tailscaled`** (brew formula, NOT the GUI Tailscale.app) — the same daemon `mesh dev --transport tailscale` / `mesh vpn tunnel` use, so it **runs headless / under the sandbox** and exposes a local SOCKS5 proxy (route a tool via `ALL_PROXY=socks5://127.0.0.1:<port>`). It relays a one-time browser-register URL for Zitadel auth. `--system` opts into the whole-machine GUI Tailscale.app (system TUN) — needs the app installed and **cannot run headless/sandboxed** (Sparkle.framework abort). For auto-forwarded VPC services (Temporal/RDS) prefer **`mesh vpn tunnel up`** or `mesh dev`. `tunnel status` reports runner **ownership** (`Runner: owned (verified)` vs `NOT OWNED (<reason>)` — a port answering a dial is not proof it's mesh's); `--json` adds `owned` (bool) + `ownership` (reason string). Admin subcommands `exec` into the `headscale-0` pod. | root: `-t/--tenant` (default `mesh`), `-e/--env` (default `dev`), `-n/--namespace`; `connect`/`disconnect`: `--system`; `tunnel up`: `--context <ctx>`; `tunnel down`: `--stop` (stop daemon, keep login), `--logout`; `tunnel status`: `--json`; `pre-auth-key`: `-u/--user` (**required**), `--expiration <24h>`, `--reusable`, `--ephemeral` |
| `mesh workflow` | `extract-ir <path>`, `inventory <path>`, `lint-process <path>` | `extract-ir`: parse Temporal workflow TS source → WorkflowIR graph (nodes+edges); prints JSON or uploads to S3. `inventory`: the as-built `{workflowType, commands, queries, activities}` the process lint diffs against. `lint-process`: parse + conformance-lint a process artifact against that inventory; exits 1 on any error-severity finding. | `extract-ir`: `--app <name>`, `--upload <bucket>` (both required together for S3); `extract-ir`/`lint-process`: `--process <path>` (else a sibling `process/*.process.json` is discovered); `lint-process`: `--predicates <csv>`, `--selectors <csv>` — the CLI cannot execute the worker's registries, so without a flag those findings are skipped (a note is printed) while every other rule still runs |
| `mesh site` | `publish <name> <dir>`, `versions <name>`, `rollback <name> <version>` | Publish a built directory as an OAuth-protected static site served by Studio, content-addressed and incremental: `publish` walks and hashes the tree, uploads only the blobs the site does not already hold, then commits a version and points the site at it; `versions` lists what has been published, newest first; `rollback` points the site back at an earlier version. Three stateless calls against the agent-api, so a run that dies partway leaves nothing to clean up. | all: `--target <name>`, `--api-url <url>`, `--context <ctx>`; `publish`: `--version-id <id>`, `--no-activate` (publish without pointing the site at it) |
| `mesh vcs` | `clone <repo> [dest]`, `get <repo> <path>`, `propose`, `rm <repo> <paths...>`, `proposals`/`show`/`diff`/`approve`/`reject`/`comment`/`request-changes`, `drafts <list\|show\|create\|submit\|discard>` | Versioned content repos (`mesh.vcs`). Repos are real git repos over smart HTTP but **read-only** — every change goes through a proposal. `clone` wires the bearer auth header into the clone; `propose` submits the working tree (binary content is base64-encoded from the bytes, not the extension, so images survive intact); **`rm` proposes deletions of files or folders with no clone at all**, and `--dry-run` lists what would go without proposing anything. | all: `--url <base>`, `--token <t>`, `--context <ctx>`; `propose`: `-m/--message`, `--revise <id>`, `--merge-parent <sha>`; `rm`: `-m/--message`, `--dry-run` |
| `mesh temporal` | `describe <wfId> [runId]`, `history <wfId> [runId]`, `recover-conversation <wfId> [runId]`, `capture-history <wfId> [runId]` | Inspect Temporal workflows for the current app/stack; reconstruct an agent conversation transcript from durable history when the worker can't replay it; capture a full history to a local replay fixture. | shared: `--stack`, `--address`, `--namespace`; `history`: `-n/--limit <200>`, `-f/--follow`, `--no-compact`, `-p/--show-payloads` (decrypts via `TEMPORAL_ENCODING_KEY` from the K8s secret); `recover-conversation`: `--out <path>`, `--json`, `--snapshot` (structured `{conversationId,messages,artifacts,focus}` blob); `capture-history`: `--out <path>` (default `~/.mesh/replay-histories/<wfId>.json`, do not commit) | <!-- skill-lint-disable-next-line — row documents the mesh-owned kubectl wrappers; the backticked `aws eks get-token` describes what the CLI runs internally, not an operator step -->
| `mesh kubectl` / `mesh logs` / `mesh exec` | `kubectl [args…]`, `logs [service\|deployment/x\|k=v]`, `exec <service> -- <cmd>` | Cluster access for a **deployed** app — debug CrashLoops/logs without broader creds. Assumes the app's `mesh:deployerRole`, builds a session kubeconfig from the platform `eks` SSM export (`aws eks get-token` — no `eks:DescribeCluster` / `~/.kube/config` needed), shells out to kubectl. Defaults the namespace to the app's own (deployer RBAC is tenant-namespace-scoped). **Use `--stack <name>` to target the deployed stack (e.g. `dev`, not `dev-local`).** | `--stack <name>`; `logs`: `-f/--follow`, `--tail <200>`, `-c/--container`, `--previous`; `exec`: `-c/--container` |

## Core workflows

### 1. Onboard: auth + install dependencies

```bash
mesh registry login          # ONE command: signs in (browser) + registry token via the Zitadel broker + project .npmrc scope — no AWS, no platform context
mesh registry login --device # same, device-code sign-in (browser callback times out over SSH/tailscale)
mesh init                    # guided: tenant, local-only vs deployed, registry access, repo bootstrap/doctor, skills → mesh.json
pnpm install                 # now resolves @mesh-tech/* from CodeArtifact
# mesh login <tenant>.<env> is for a DEPLOYED platform only (mesh deploy / Hub / VPN) — never needed for packages
```

### 1b. Run the platform fully locally (no cloud)

```bash
mesh start                   # docker compose up + health waits + first-boot seed
                             # (seeds: tenant registry, hub DB, Temporal ns local-dev,
                             #  Zitadel org/CLI app/test users, artifacts bucket;
                             #  reconciles Zitadel app tenants into the Hub registry)
# → open http://localhost:9000 — the Hub, your local operations dashboard
#   (sign in via the local Zitadel: admin@local.mesh or dev@local.mesh / LocalDev1!)
mesh login local             # PKCE against the seeded Zitadel (dev@local.mesh / LocalDev1!)
mesh status                  # per-component health, endpoints, ports (--json for agents)
cd <your app> && mesh dev    # local mode auto-wires services to this stack:
                             # per-app Temporal namespace {tenant}-dev-{app}, auth
                             # auto-provisioning, self-registration in the Hub, and
                             # service logs shipped to Loki (Hub logs view)
mesh stop --destroy          # tear down incl. volumes (resets seeds)
```

**The local platform is a machine-wide singleton** — one compose project
(`mesh-local`), one set of host ports — shared by every checkout and project
on the machine. App/tenant work composes safely on top of it: `mesh dev
--local` provisions only tenant-scoped state (Zitadel org, Temporal
namespace, registry/secret prefixes, per-app mock compose projects), so
concurrent projects coexist on one running stack. Only `mesh start`/`mesh
stop` mutate the shared containers, and both refuse to act on a stack a
DIFFERENT checkout started (compose config drift would recreate shared
containers out from under the other project) — `mesh start --takeover` /
`mesh stop --force` override deliberately.
Device flow prints a URL + short code (e.g. `identity.dev.mesh-platform.trabian.com/device?user_code=XXXX-XXXX`);
open it, enter the code, wait for `✓ Logged in as …`. Over SSH always use `--device` — the
browser-callback flow times out. After login, `mesh dev`/`mesh deploy` resolve AWS creds via a
self-refreshing credential_process (the deployer role), so no separate `--export` eval is needed for those.

### 2. Scaffold and run an app locally

Run this from the root of your tenant apps repo (`{tenant}-mesh-apps`) — apps
land in `apps/<name>/`:

```bash
mesh create-app --tenant acme --name billing --primitives service,database,temporal
cd apps/billing && pnpm install
mesh skills sync             # picks up the platform skills now that deps are installed
mesh app check apps/billing  # the Mesh app contract — create-app already ran it; re-run before every PR
mesh start                   # the local Mesh platform (once; Docker only — no AWS, no VPN)
mesh dev                     # run the app against it
mesh dev logs api            # tail one service;  mesh dev --kill to stop
```

**This is the whole loop for a local-only tenant** — a `mesh.json` with
`platform: local`, or no platform recorded yet. `mesh start` brings up the
platform the app needs (Zitadel, Temporal, Postgres, the local AWS fabric, the
Hub) and prints a `★ Start here` block with the seeded sign-in and the local
mailbox; `mesh status` reprints it. No stack, no deployer role, no VPN.
`create-app` prints this same order at the end of a scaffold, chosen from the
tenant's `mesh.json`.

**When the tenant deploys to a Mesh platform**, the stack and the deployer
role replace `mesh start` — they need a platform to exist, so they come after
it, not before:

```bash
mesh stack init              # personal dev-{github-user} stack (deploy:false)
mesh deploy up --yes         # pulumi up via mesh:deployerRole
mesh dev                     # run locally against the platform (needs VPN for VPC access)
```

> **Where the app lands.** `create-app` writes to the first of `tenants/<tenant>/apps/`,
> `../<tenant>/apps/`, `apps/` that exists — so a tenant apps repo gets `apps/<name>/`.
> The `tenants/<tenant>/apps/…` and `tests/tenants/…` paths you see elsewhere in this
> file are the **mesh-platform monorepo's own** layouts (the second is test-only, from
> `--test`); no tenant repo has them.

### 2b. Watch your dev-local stack in the Hub (`mesh hub dev`)

```bash
cd <your app repo>           # a repo with a running `mesh dev` session
mesh hub dev                 # → local current-code Hub at http://localhost:9000
                             #   scoped to this session's tenant: workflow types with
                             #   live run counts, execution timelines incl. child slices
mesh hub dev --print-env     # inspect the assembled env without launching
mesh hub dev --kill          # tear down (tmux session mesh-hub-dev)
```

Zero hand-set env vars: the command reads the `mesh dev` session's state file
and tmux environment — Temporal tunnel address, AWS scratch profile, dev-user
identity, per-session token-server — and derives the Hub scope
(`HUB_TENANT`/`HUB_SCOPE_ENV`/`HUB_SCOPE_TENANTS`) from the session's platform
context. Needs a mesh-platform checkout for the Hub's source (auto-detected
when you're inside one; otherwise `--platform-dir` / `MESH_PLATFORM_DIR`).
An empty tenant scope is a loud error, never a blank Hub. A full-local platform
session (`mesh start`) is supported too — the CLI wires that stack's own Zitadel,
ops Postgres, SpiceDB, Temporal and observability backends into the Hub, so prefer
it over the containerized Hub when you are iterating on Hub code and want the UI to
reload on save. Both default to :9000, so run them together only with `--port` /
`MESH_HUB_DEV_PORT` set on this one.

### 3. Concurrent `mesh dev` across git worktrees and sibling apps (auto-isolation)

Run the **same plain `mesh dev`** from each worktree's app dir — there is nothing special to
configure. `mesh dev` derives a deterministic token from the worktree path and threads it through
the tmux session name, the tmpdir session-state + env-file dir and the Temporal task-queue suffix, so
two worktrees don't kill each other's session — as long as their directory basenames differ, since the
session name carries the worktree's sanitized basename, not the hash. The reserved **service-port
block is derived from (worktree, app)**, not the worktree alone — a repo can hold several apps whose
sessions run concurrently (e.g. an apps repo's `worker-a` + `worker-b`), and one
block per worktree handed both the same ports.

```bash
# worktree A
cd .worktrees/feature-a/tests/tenants/acme/apps/demo-agent   # monorepo test tenant
mesh dev --dry-run          # preview: session name, port block, state/env paths, task queue
mesh dev                    # launch (attaches tmux; --headless to skip)

# worktree B — SAME command, different shell; auto-isolated
cd .worktrees/feature-b/tests/tenants/acme/apps/demo-agent
mesh dev

# sibling app in worktree A — same worktree, still its own port block
cd .worktrees/feature-a/tests/tenants/acme/apps/silverlake-demo
mesh dev
```

- The **primary checkout** keeps its historical identity (`${project}-dev` session, the app's
  preferred ports). Only **linked worktrees** get an offset session name + a port block in the 40000+
  range. `mesh dev --dry-run` labels which you are (`primary checkout` vs `linked worktree (block N)`).
- **Port blocks are disjoint by default, not by construction.** The block is a hash bucketed into 63
  slots, so two `(worktree, app)` pairs *can* land on the same block; the in-block free-port probe and
  the ephemeral-port fallback are the backstop when they do — but that backstop is the same
  check-then-bind probe that raced two still-booting sessions onto one port before #2889, so treat a
  shared block as a live hazard, not a handled case. Two consequences worth knowing before you
  debug a collision as a regression:
  - Sibling apps in the **primary checkout** are deliberately excluded — they keep preferred-port
    semantics (block 0) and rely on that probe, so run them from linked worktrees if you need them
    isolated. This is the layout most tenant app repos use day to day.
  - App-scoping raises the number of hashed entities from *#worktrees* to *#worktrees × #apps*, so
    bucket collisions get likelier as you add apps.

  A deterministic, collision-free allocator that also folds primary checkouts back in is planned.
- **Do NOT force a shared `--session`** across worktrees — that defeats the auto-isolation and
  reintroduces the collision. Omit `--session` and let it derive per worktree.
- `mesh dev --kill` / `--status` act only on the current worktree's session.
- If the stack was last deployed from a *different* worktree, `mesh dev` **refuses** to launch a service
  whose source resolves outside the current worktree (guards against silently running stale code) — re-run
  `mesh deploy up` in this worktree.
- **Deploying isolated stacks per worktree?** The `mesh dev` isolation above is local-only. To
  `mesh deploy up` a `deploy:false` personal stack from several worktrees at once, give each worktree
  its own stack with `mesh stack init --worktree` — otherwise they collide on SSM export paths
  (`ParameterAlreadyExists`) and the Temporal namespace (both derive from the stack name).
  `mesh deploy up`/`destroy` **warns** when it sees a shared (non-worktree) stack name in a linked worktree.

### 4. Deploy (pulumi passthrough)

```bash
mesh deploy preview
mesh deploy up --yes
mesh deploy stack output dev --json
mesh deploy destroy
# Requires `mesh:deployerRole` in Pulumi.<stack>.yaml:
#   pulumi config set mesh:deployerRole arn:aws:iam::<acct>:role/<tenant>-<env>-apps-deployer
```

`<env>` is the **platform environment** (`dev`, `prod`), which is not always your
Pulumi stack name — a stack called `dev-jane` still deploys against the `dev`
platform and so assumes `…-dev-apps-deployer`. The distinction is load-bearing
beyond the role name: the deployer's KMS-alias grant is scoped to
`alias/{tenant}-{env}-*`, so an app that creates an alias has to prefix it the
same way. See [Naming resources so the deployer can create them](https://github.com/mesh-tech/mesh-platform/blob/main/libs/infra-components/docs/concepts/deploying.md#naming-resources-so-the-deployer-can-create-them).

### 5. Database access

```bash
mesh db exec --ssl=require -- npx prisma migrate dev --name init
mesh db psql                       # interactive session through the bastion
eval "$(mesh db env)"              # load DATABASE_URL / DB_* into the shell
mesh db exec --secret <arn> -- node seed.js   # direct mode, skips SSM discovery
```

### 6. External secrets

```bash
mesh secrets set external/symitar                         # interactive (schema from SSM)
mesh secrets set external/symitar --json '{"baseUrl":"…"}'
mesh secrets set external/symitar --key 12345 --json '{…}'  # multi-instance (key = FI id)
mesh secrets reindex external/symitar --dry-run           # show instance-index drift, write nothing
mesh secrets reindex external/symitar                     # rebuild {prefix}/.index from real secrets
mesh secrets exec -- node ./script.js                     # secrets injected as env
```

### 7. Read a remote agent's conversation context

```bash
mesh conversations list --target fub          # discover conversation ids
mesh conversations show <id> --target fub     # transcript
mesh conversations artifacts <id> --target fub
mesh artifacts get <id>:<artifactId> --target fub
```

`--target` resolves the same `~/.config/mesh/agent-targets.json` registry the
MCP `agent_*` tools use; see the dedicated
`libs/mesh-cli/skills/pull-remote-agent-context` skill for the full recipe
(registry format, auth model, the `conversation_unavailable` → `mesh temporal
recover-conversation` break-glass path, and the MCP-vs-CLI relationship).

### 7b. Workspace environments are driven from mesh-studio

A Studio workspace's environment (live container, grace, build queue) is read and signalled
by `studio workspace status|restart|reseed|rebuild` from mesh-studio's `@mesh-tech/studio-cli`,
which builds on this package's `@mesh-tech/mesh-cli/temporal` (`connect`) and
`@mesh-tech/mesh-cli/temporal-codec` exports. This CLI has no `workspace` command.

### 8. Relaunch safety — don't strand live conversations

For a **code-only** change, prefer a scoped `mesh dev restart <service>` over a full
`mesh dev` relaunch — it's faster and touches only that service. Restart the
**worker** only when you actually changed worker/workflow code.

Both a relaunch and a `restart <worker>` recreate the Temporal worker onto current
code. If the **workflow** source (`agent-sdk` / `workflow-interpreter` /
`agent-contracts`) changed since the session started, in-flight conversations replay
against the new code — a replay-incompatible change **strands** them. `mesh dev` now
fingerprints that source at launch and **warns + refuses (use `--force`)** when it
changed on a relaunch/worker-restart. The fix is not `--force`: keep the change
replay-compatible — gate it with `wf.patched()` and regenerate the replay goldens
(see the `temporal-workflow-safety` skill + the CI replay gate). Restarting unchanged
code, or a non-worker service (ui/api), never warns.

For **config/deploy** changes (not code): `mesh dev restart <svc> --refresh-env`
re-reads stack/SSM output; if the *topology* changed, run `mesh deploy up` first
(`mesh dev doctor` flags config staleness).

## Config / environment requirements

| Need | Source |
|---|---|
| Tenant / env / stage | flags (`-t/-e`, `--stage`) → `MESH_TENANT`/`MESH_STAGE`/`SST_STAGE` env → Pulumi config in cwd/parents → defaults (`mesh`/`dev`) |
| AWS creds | ambient env → cached Zitadel JWT (`mesh login`) → AWS SSO profile; region from `AWS_REGION`/`AWS_DEFAULT_REGION` else `us-east-2` |
| Login config | `~/.config/mesh/config.json`; creds cache `~/.config/mesh/credentials.json` |
| Deploy role | `mesh:deployerRole` (and optional `mesh:adminDeployerRole`) in `Pulumi.<stack>.yaml` |
| Login role for `--export` | `--role` → `MESH_AWS_ROLE` → cached `defaultRole` for the context |
| Registry | `@mesh-tech` scope; CodeArtifact token written to `~/.npmrc` (12h TTL) |
| `vpn`, `temporal --show-payloads` | a working `kubectl` kubeconfig for the target EKS cluster |
| Temporal client / S3 upload | optional deps `@temporalio/client`, `@aws-sdk/client-s3` (lazy-loaded) |

## Failure modes / gotchas

| Symptom | Cause | Fix |
|---|---|---|
| `No Pulumi.yaml found` | run outside a Pulumi app dir | cd into the app (deploy/stack/temporal use `findAppRoot`) |
| `No mesh:deployerRole found` | stack config missing the role | `mesh deploy config set mesh:deployerRole <arn>` |
| `Could not determine stack` (deploy) | no selected stack & no `--stack` | `mesh deploy stack select <name>` or pass `--stack` |
| `artifacts get` → ECONNREFUSED / 404 | agent API down or convo expired | start API (`mesh dev`) or pass `--api-url`; restart API after code changes |
| `login` config not found | context not discoverable | a known first-party short context (`mesh.dev`) resolves anonymously — no AWS needed. Otherwise use the platform's full domain (`mesh login dev.<tenant>.meshtech.io`). Only if neither works: ensure AWS SSO is active + the platform is deployed (SSM `cliClientId`), or add config to `~/.config/mesh/config.json` |
| `vpn`/`temporal --show-payloads` fail | kubeconfig not set / expired creds | refresh the tenant cluster kubeconfig (`mesh vpn connect <ctx>` wires it; kubeconfig absorption into `mesh deploy` is planned) then retry | <!-- skill-lint-disable-next-line — next row's backticked `kubectl …` is a quoted ERROR SYMPTOM, not an operator command; the row's fix column already routes through mesh dev -->|
| `kubectl could not read a kubeconfig … macOS blocks it` (from `mesh kubectl`/`logs`/`exec`/`temporal` in a bare shell) | macOS Full-Disk-Access blocks reading `~/.kube` from a shell outside the `mesh dev` session | run the command **inside** the `mesh dev` tmux window, or `export KUBECONFIG=$TMPDIR/mesh-dev-sessions/<session>.kubeconfig` first |
| `mesh temporal …` won't authenticate against a **deployed** Temporal | passing `--address` (or `TEMPORAL_ADDRESS`) skips the app/stack resolution that mints a Zitadel **Bearer** — auth falls back to ambient env | omit `--address` and run from the app dir so `mesh temporal` resolves the authenticated frontend over the tunnel; use `--address` only for a local/unauthenticated Temporal |
| `mesh dev` chose "VPN direct" then api/worker spin on `ECONNREFUSED …:27233` | tailnet-blind VPN *presence* check picked direct routing the mesh VPC doesn't actually serve | **no manual fallback needed** — current `mesh dev` TCP-probes the frontend and auto-falls back to SSM tunnels; if still stuck, force `--transport ssm` (or `--no-vpn`) |
| `mesh dev` / `mesh vpn tunnel up` → `Tunnel port(s) for tenant '<t>' are bound by a process mesh does not own` | a foreign or orphaned listener holds a forwarder port; mesh refuses to adopt it rather than route traffic to the wrong upstream | `mesh vpn -t <t> tunnel down --stop` to reap mesh-owned runners; if the port is still held, `lsof -nP -iTCP:<port> -sTCP:LISTEN` and stop that process (if it names another mesh runner, it belongs to a different tenant — tear that one down). `mesh vpn -t <t> tunnel status` shows `Runner: owned (verified)` when healthy |
| `mesh vpn tunnel down`/`up` or `mesh dev` → `Refusing to signal <the tunnel supervisor pid \| the tunnel runner process group \| the VPN daemon pid> recorded for tenant '<t>' — …`, or `Refusing to signal a tunnel runner pid discovered from 'ps' for tenant '<t>' — …` | the pid is corrupt or stale — from `tunnel-state.json` / `runner.json` / `daemon.json` (truncated write, hand edit, older build) for the first three, or from the live `ps` argv scan for the fourth. POSIX `kill(2)` overloads the pid argument and teardown negates before signalling, so a `1` there would SIGTERM every process you can signal — mesh refuses it instead | usually **nothing** — it is a warning, the rest of the teardown still runs and the state file is cleared, so re-running the command is clean. If it recurs: **when the message names a file**, `rm` that exact path; **the `discovered from 'ps'` variant names none** — there is no state file to clear, so just re-run. Either way finish with `mesh vpn -t <t> tunnel status` to confirm |
| `mesh kubectl`/`logs`/`exec` → `Could not resolve the EKS cluster … Parameter tried: /mesh-platform/<name>/<env>/core/eks`; `mesh dev` → `EKS cluster resolve failed (/mesh-platform/<name>/<env>/core/eks): …` | the platform segment of the SSM path is wrong (or the platform core layer isn't deployed) — cluster data lives under the app's HUB platform name, and an `AccessDeniedException` here usually means the wrong path, not missing SSM permissions | check `mesh:platform` in the app's `Pulumi.<stack>.yaml` (its `name` is the hub, e.g. `trabian`) and log into that platform's context (`mesh login <platform>.<env>`) |
| 401/403 installing `@mesh-tech/*` | stale CodeArtifact token | `mesh registry login` (or `--ci` in CI) |
| 401 installing a **public** package (`npm view lodash version` fails) | an unscoped `registry=<codeartifact>` line in `~/.npmrc` makes CodeArtifact the DEFAULT registry, so public packages resolve through it and 401 at the 12h token expiry | `mesh registry login` — it removes the line after backing `~/.npmrc` up; confirm with `npm config get registry` (expect `https://registry.npmjs.org/`) |
| `--export` prints nothing | no role resolved | pass `--role <arn>` or set `MESH_AWS_ROLE` |
| `--export` / `EPERM … ~/.aws` in a sandbox | `~/.aws` not writable | it now auto-falls back to a mesh-managed config (`~/.config/mesh/aws-config`) and also exports `AWS_CONFIG_FILE`; or prefer `mesh deploy <op>` (no shell profile needed) |
| `workflow extract-ir` no S3 upload | only one of `--app`/`--upload` given | pass both, or omit both to print JSON |

## Programmatic API

`@mesh-tech/mesh-cli` re-exports its utils (`src/utils/index.ts`):

```ts
import { detectContext, getPlatformBastionInfo } from "@mesh-tech/mesh-cli";
const ctx = detectContext();                                  // { tenant, stage, ... }
const bastion = await getPlatformBastionInfo(ctx.tenant, ctx.platformEnv);
```

Exports include `detectContext`, `getPlatformBastionInfo` (bastion), credentials,
pulumi, vpn, and log helpers.

## See also

- `@mesh-tech/app-kit#core` — app-kit's `mesh` bin is a shim onto this CLI, which is an **optional peer**: declare `@mesh-tech/mesh-cli` in your repo root's devDependencies. Consumers then invoke it as `pnpm mesh <cmd>`
- `@mesh-tech/credentials#core` — credential resolution the auth/deploy flows build on