git:20261014.c98df9c to git:20261014.80042ba

14 added, 0 removed. Audit A to A.

# Agent Instructions — `apps/cli`
Scope pointers first — this file's own body covers **telemetry only**:
- The agent-facing command contract (JSON envelope, posture, claim, doctor
repair) is [`SKILL.md`](skills/zitadel-cli/SKILL.md) — keep it aligned with the command
surface on every CLI behavior change (root `AGENTS.md` Generated Files rule).
- Scaffold posture derivation (standalone page vs widget in a pre-existing
app) is [ADR 044](../../docs/adrs/044-scaffold-embedding-posture-defaults.md);
the implementation lives in `src/lib/orca/patchers/posture.ts` and the
manifest in `src/lib/scaffold-manifest.ts` (drift rules:
[ADR 042](../../docs/adrs/042-scaffolded-file-ownership-and-drift-detection.md)).
- The claim lifecycle is
[ADR 046](../../docs/adrs/046-claim-lifecycle-v2.md) (`src/commands/claim.ts`,
`src/lib/claim-state.ts`, `src/commands/doctor/checks/claim.ts`).
- The journey e2e contract is
[`apps/cli-journey-e2e/AGENTS.md`](../cli-journey-e2e/AGENTS.md).
- The test contract — the unit/spec split and one spec per command — is
[`tests/AGENTS.md`](tests/AGENTS.md). Read it before adding or moving any
test.
- Warnings go through `reportWarning` in `src/lib/warnings.ts`, or the
`warnings` field of a command's result. Never call `consola.warn` directly:
`--json` silences consola, so the warning would never reach an agent.
`reportWarning` shows it in the terminal at once and adds it to the JSON
envelope's `warnings`, and `emit` prints a result's `warnings` after its
`pretty` text. A unit test fails on any other `consola.warn` call.
+ - Input goes through one function, `input` in `src/lib/input.ts` — a command
+ calls it as `this.input(...)`. It takes the value from `given` (the flag),
+ else piped stdin (`stdin: true`), else a prompt, else throws one uniform
+ `E_VALIDATION` ("No <name> supplied", with a hint from `flag`, `stdin` and
+ `retry`), and runs the question's `parse` on whichever source answered.
+ `retry` is the command an agent can run once the input is to hand: it becomes
+ the error's `nextCommands`. `optional` returns `undefined` instead of
+ prompting or refusing when nothing was given or piped (a bad non-empty pipe
+ still fails). A `confirm` is answered by the command's `--force`, and
+ refuses without it when no one can be asked, so a command whose `--force`
+ means something else (`setup`) must use its own asker with `force: false`.
+ Never import clack's `text`, `password`, `select`, `multiselect`, `confirm`,
+ `isCancel` or `cancel` anywhere else, and never hand-write the
+ flag-or-stdin-or-prompt rule: a unit test fails on the import.
- The flag surface deliberately tracks the [WebCLI spec](https://webcli.com/) so
an agent can drive the CLI from conventions it already knows. That is the
rationale; the canonical flag list, output-channel rules and the two
intentional deviations are the invocation rules in [`SKILL.md`](skills/zitadel-cli/SKILL.md).
Keep that alignment in mind when adding a global flag, and record it there —
not in a second copy here.
# Analytics Tracking — Mixpanel
This package (`@zitadel/cli`) uses **Mixpanel** for anonymous product analytics.
Mixpanel is the single source of truth for usage events. Do not introduce any
other analytics tool, SDK, or tracking library without explicit instruction.
---
## Before You Add or Modify Any Tracking
⛔ **Read this file before writing any Mixpanel tracking code.**
This is a **CLI**, not a web app, so the usual identity model does not apply:
there is no logged-in user, no `identify()`/`reset()`, and no PII. Events are
attributed to an anonymous, randomly-generated install id. Treat that as a hard
invariant — never attach a user, email, project id, URL, or file path to an
event.
### Mandatory checklist
- [ ] The Node server-side SDK (`mixpanel`) is the correct SDK — do not add the browser SDK.
- [ ] No CDP is involved; events go straight to Mixpanel.
- [ ] Consent is opt-out and resolved centrally in `src/lib/telemetry/consent.ts` — never fire an event that bypasses it.
- [ ] The new event only carries allow-listed dimensions (enums, booleans, counts, versions).
---
## Tech Stack
| Detail | Value |
|---|---|
| **Platform** | Node.js CLI (oclif), ESM, Node ≥ 24 |
| **Mixpanel SDK** | `mixpanel` (server-side) |
| **SDK version** | `^0.18.1` |
| **Tracking method** | server-side, fire-and-forget |
| **CDP (if any)** | none |
| **Consent required** | yes — opt-out model (`DO_NOT_TRACK`, `ZITADEL_TELEMETRY=0`, `--no-telemetry`) |
| **Project token location** | `src/lib/telemetry/config.ts` (write-only dev + prod tokens baked in) |
| **Data region** | `ZITADEL_TELEMETRY_REGION` = `eu` (default, Zitadel projects' region) \| `us` |
The ingestion token is **write-only** (cannot read data back), so embedding it
in the published CLI is safe and intentional — this is how dev-tool telemetry
works. Dev and prod are separate Mixpanel projects. The CLI ships as a **single
generic build** with no channel stamp, so events **default to the production
project** and the published CLI needs no per-user env. Development, CI, and test
runs stay out of prod by opting out entirely (`DO_NOT_TRACK` /
`ZITADEL_TELEMETRY=0` — CI sets `DO_NOT_TRACK=1` for every job) or by selecting
the dev project with `ZITADEL_TELEMETRY_ENV=development`. `ZITADEL_TELEMETRY_TOKEN`
overrides the token outright. Ambient `NODE_ENV` is intentionally not consulted.
---
## Initialization
Telemetry is constructed **once per command invocation** by `BaseCommand`
(`src/lib/oclif/base.ts`) via `Telemetry.create({ env, flag, debug })` in
`toMeta()`. The generic client lives in `src/lib/telemetry`; all CLI-specific
property building (event names, `server_kind`, base/profile dimensions) lives in
`src/lib/oclif/command-telemetry.ts`. The
Mixpanel client is built lazily — only when consent is granted *and* a token is
configured. Do not construct a `Mixpanel` client anywhere else.
**File:** `src/lib/telemetry/index.ts`
**Do not:**
- Initialize Mixpanel outside `BaseCommand`.
- Write to **stdout** from telemetry — the JSON envelope is the machine
contract; the one-time first-run notice goes to stderr and only in
interactive mode.
- Let telemetry throw or block — every path fails open; the network is bounded
by `Telemetry.shutdown()`'s timeout in `BaseCommand.finally()`.
---
## Identity
There is **no user identity**. Each install gets a stable, anonymous
`distinct_id` (a random UUID) persisted under the platform config dir
(`src/lib/telemetry/identity.ts`). It is tied to no account and contains no
fingerprint. On first run, `isFirstRun` drives the one-time consent notice.
---
## Tracking Plan
Three **lifecycle events** cover every command (one instrumentation point in
`BaseCommand`, so new commands are covered automatically):
| Event | Trigger | Key Properties | File |
|---|---|---|---|
| `cli_command_started` | a command begins (`toMeta`) | base dimensions | `src/lib/oclif/base.ts` |
| `cli_command_completed` | a command succeeds or skips (`emit`) | `status`, `duration_ms`, command extras | `src/lib/oclif/base.ts` |
| `cli_command_failed` | a command throws (`catch`) | `reason`, `exit_code`, `step` | `src/lib/oclif/base.ts` |
**Value Moment:** `cli_command_completed` for `command=setup` — Zitadel auth is
wired into the user's app.
### Base dimensions (every event)
`command`, `cli_version`, `os`, `arch`, `node_version`, `invocation_id`,
`non_interactive`, `is_tty`, `is_ci`, `ci_provider`, `host_agent`,
`invocation_channel`, `dry_run`, `force`, `server_kind` (bucketed
`cloud`/`local`/`self_hosted` — **never the URL**), plus the reserved
event properties `$os`/`mp_country_code` (country derived from the timezone, not
the IP — `ip: 0` keeps IP geolocation off). First-run user profiles use the
People API's `$country_code` profile property for the same derived country. Built
in `src/lib/oclif/command-telemetry.ts` (using the generic env/geo helpers in
`src/lib/telemetry/`).
`ci_provider` and `host_agent` also leave the machine outside Mixpanel, as the
`ci/` and `host/` tokens of the HTTP `User-Agent` (`src/lib/user-agent.ts`). They
share the telemetry opt-out: `--no-telemetry`, `DO_NOT_TRACK` and
`ZITADEL_TELEMETRY=0` drop them.
### Event shape
Each event is one Mixpanel `track` call. We set the properties below;
Mixpanel injects the transport fields (`time`, `$lib_version`,
`$mp_api_endpoint`, `$user_id`, `mp_lib`, …) on ingestion — we do not send those.
```json
{
"event": "cli_command_completed",
"properties": {
"distinct_id": "09233195-cd34-468c-bb7b-151554e19fbc",
"$insert_id": "2398bc39-3cb6-4b05-835f-bb03908794b8",
"ip": 0,
"invocation_id": "6d919718-6168-42b4-9482-e656776445f0",
"command": "status",
"cli_version": "0.1.0-alpha.11",
"$os": "Mac OS X",
"mp_country_code": "AU",
"os": "darwin",
"arch": "arm64",
"node_version": "24.12.0",
"non_interactive": true,
"is_tty": false,
"is_ci": false,
"host_agent": "unknown",
"invocation_channel": "unknown",
"dry_run": false,
"force": false,
"server_kind": "cloud",
"status": "ok",
"duration_ms": 25
}
}
```
### Command-specific dimensions
Commands add dimensions via `this.recordTelemetry({ … })` (merged immutably onto
each lifecycle event emitted *after* recording — typically `completed`/`failed`,
since `started` fires before the command body runs):
- **setup** — `framework`, `renderer`, `package_manager`, `scaffolded_skeleton`, `skip_install`, `dev_port_explicit`, `preset`, `use_case`, `sso` (an `IDP_PROVIDERS` slug, `custom` for a developer-described connection — never its free-text slug —, `none`, or `multiple` when setup enabled several), `design` (a `BRANDING_DESIGNS` value or `built-in`), `files_written_count`, `step` (`framework_resolved` → `project_created` → `files_patched` → `dependencies_installed`).
- **plan / apply** — `creates`, `updates`, `deletes`, `revisions`, `total` (diff *counts* only).
- **doctor** — `runtime`, `checks_total`, `checks_failed`, `checks_warn`, `failed_checks` (failing check **names**, never messages).
- **start** — `runtime` (`binary` / `docker`).
- **variables** — `is_secret`, `variable_count`. Deliberately *not* recorded: the variable's name or value — a name is free text, and a value is the credential itself. There is no owner dimension: the project level is the only owner the commands can address.
- **claim** — `claim_outcome` (`completed` / `already_claimed` / `expired` / `window_expired` / `timeout` / `dry_run`), `poll_count`, `browser_opened`. Deliberately *not* recorded: `challenge_id`, `team_id`, `claim_url`, `dashboard_url` — every one of them is an id or a URL.
### Naming conventions
- Event names: `snake_case`, past tense (`cli_command_completed`).
- Property names: `snake_case`; booleans use `is_`/`has_`/`dry_`/`non_` style flags.
- Omit empty properties — `compact()` drops `null`/`undefined`/`""`; never send them.
---
## How to Add a New Event or Property
1. Prefer enriching `this.telemetryProps` in the command over adding a new
event — the lifecycle events already cover command outcomes.
2. Only add properties available at the moment the event fires; never fetch data
just for telemetry.
3. Keep it allow-listed: enums, booleans, counts, versions. **No** URLs, ids,
paths, emails, secrets, or free-text.
4. Add the property to the table above and cover it in
`tests/unit/lib/telemetry/`.
5. Verify it lands in Mixpanel **Live View** (set `ZITADEL_TELEMETRY_REGION=eu`
first if the project is in the EU region — events to the wrong host are
silently dropped).
---
## What Not to Do
- **Do not** introduce another analytics tool.
- **Do not** track PII — no emails, names, IPs, URLs, project ids, file paths, or secrets.
- **Do not** write to stdout from telemetry.
- **Do not** let a telemetry failure surface to the user — fail open, always.
- **Do not** fire events that bypass `resolveConsent`.