# CLI ops (`toolctrl ops`) The operator CLI under `packages/cli/` ships three ops sub-commands for day-to-day operational work: - `toolctrl ops doctor` — local environment health check - `toolctrl ops migrate-tenant` — resumable tenant migration between envs - `toolctrl ops impersonate` — audit-logged debug magic-link for a tenant user All three commands accept the `--env` flag to target a named env file under `~/.toolctrl/envs/`. The default is the current working-dir's `.env` (i.e. whatever is already loaded into `process.env`). ## Env-file layout Named envs live under: ``` ~/.toolctrl/envs/.env ``` Example layout: ``` ~/.toolctrl/envs/ ├── staging.env # chmod 600 ├── staging-backup.env └── dev.env ``` - File format: standard `KEY=VALUE` `.env` syntax with `#` comments. No shell interpolation, no multi-line values. - File permissions must be `0600` (owner-only). The CLI emits a warning when world-readable bits are set; rotate the file with `chmod 600 ~/.toolctrl/envs/.env`. - Production env files **must not** live in CI. The `impersonate` command refuses any env named `prod`, `production`, or `live` regardless of file content. ## `toolctrl ops doctor` Runs eight probes against the resolved env and emits an ok/warn/fail table: | Probe | Description | Fail hint | |---|---|---| | `node.version` | Process Node version >=20 | `nvm use` | | `pnpm.version` | `pnpm --version` >=9 | `corepack enable` | | `docker.reachable` | `docker info` succeeds | `docker compose up -d` | | `postgres.reachable` | DATABASE_URL ping (injected) | Check `docker compose ps postgres` | | `redis.reachable` | REDIS_URL ping (injected) | `docker compose up -d redis` | | `sentry.dsn` | HEAD against SENTRY_DSN origin | Check Coolify Sentry container | | `env.required` | Required env keys present | Update your `.env` | | `prisma.migrations` | `prisma migrate status` (injected) | `pnpm db:migrate dev` | Examples: ```bash # Default — read local .env node packages/cli/bin/toolctrl-cli.mjs ops doctor # Read ~/.toolctrl/envs/staging.env node packages/cli/bin/toolctrl-cli.mjs ops doctor --env=staging # JSON output for CI node packages/cli/bin/toolctrl-cli.mjs ops doctor --json ``` **Exit codes:** `0` when every probe is `ok` or `warn`; `1` when any probe reports `fail`. ### Troubleshooting | Symptom | Likely cause | |---|---| | `postgres.reachable: fail (DATABASE_URL not set)` | Forgot to export `.env` before running CLI | | `docker.reachable: warn` | Docker Desktop not running | | `sentry.dsn: warn` | SENTRY_DSN intentionally unset — error reporting disabled | | `env.required: fail Missing required env vars: SESSION_SECRET` | Bootstrap your `.env` from `.env.example` | | `prisma.migrations: warn N pending` | Run `pnpm db:migrate dev` | ## `toolctrl ops migrate-tenant` End-to-end tenant migration between named envs. Reuses the `packages/cli/src/commands/tenant/snapshot.ts` infrastructure from phase 03 (#44). Resumable via a state file under `~/.toolctrl/migrations/-.state.json`. Steps (each is an atomic resume-point): 1. `preflight` — both envs resolve, names validated 2. `snapshot` — `tenant snapshot` exports source tenant to a local archive 3. `restore` — archive is restored into the target env 4. `verify` — row-counts compared source vs target 5. `cleanup` — snapshot kept on disk for audit Examples: ```bash # Plan a migration (no writes) node packages/cli/bin/toolctrl-cli.mjs ops migrate-tenant tenant-abc \ --from=staging --to=staging-backup --dry-run # Real migration node packages/cli/bin/toolctrl-cli.mjs ops migrate-tenant tenant-abc \ --from=staging --to=staging-backup # Resume after a crash node packages/cli/bin/toolctrl-cli.mjs ops migrate-tenant tenant-abc \ --from=staging --to=staging-backup \ --resume ~/.toolctrl/migrations/tenant-abc-2026-05-17T120000000Z.state.json ``` The CLI emits a per-step status line: ``` state-file=/home/op/.toolctrl/migrations/tenant-abc-2026-05-17T120000000Z.state.json preflight: ok — from=staging to=staging-backup snapshot: ok — /home/op/.toolctrl/migrations/tenant-abc-….tar.gz restore: ok — restored 12345 rows verify: ok — row-counts match cleanup: ok — snapshot kept on disk for audit ``` The Prisma + S3 backends are injected at the `bin/toolctrl-cli.mjs` entrypoint. The standalone subcommand prints a warning if invoked without that injection (no destructive operations happen). ## `toolctrl ops impersonate` Generates a 15-minute HMAC-signed magic-link the engineer can paste into a browser to debug a specific tenant user. Every issued link writes an `AuditEvent` so the action is reviewable. ```bash node packages/cli/bin/toolctrl-cli.mjs ops impersonate \ tenant-abc user-123 \ --env=staging \ --reason="support-ticket-456" ``` **Safety guarantees:** - Production env names (`prod`, `production`, `live`) are hard-blocked. No `--force` bypass exists. Use the admin self-service impersonate UI for production. - TTL is fixed at 15 minutes. Tokens cannot be longer-lived. - HMAC signing key must be at least 16 characters (the CLI throws otherwise). - The audit-event captures: - `actorType = admin` - `action = ops.impersonate.issued` - `targetType = user`, `targetId = ` - `regulatoryScope = [soc2]` - `dataSensitivity = pii` - `outcome = success` - `metadata = { envName, tokenId, expiresAt, ttlMinutes, reason, userEmail }` The audit-event is written before the link is returned, so even a crashing operator workflow leaves a trail. ### Backend route The corresponding loader at `apps/web/app/routes/api.auth.impersonate.tsx` (to be wired in a follow-up phase) is responsible for: 1. Verifying the HMAC signature against the same secret 2. Looking up the audit-event by `tokenId` and refusing if absent 3. Refusing if the audit-event's `outcome ≠ success` 4. Setting a session cookie scoped to the impersonated user with a short lifetime ## Where things live ``` packages/cli/src/commands/ops/ ├── _env.ts # env-file resolver (local vs named) ├── _env.test.ts ├── doctor.ts # doctor command + 8 probes ├── doctor.test.ts ├── migrate-tenant.ts # 5-step resumable migration ├── migrate-tenant.test.ts ├── impersonate.ts # HMAC magic-link + audit-event ├── impersonate.test.ts └── index.ts # citty subcommand group ``` The bin entrypoint in `packages/cli/bin/toolctrl-cli.mjs` is responsible for injecting the live Prisma client + S3 client + URL base into the commands. The standalone files keep zero dependencies on `@prisma/client` or AWS SDK so the package builds without optional peers. ## Security considerations - **Never commit `~/.toolctrl/envs/*.env`.** The path is outside the repo on purpose. - The `migrate-tenant` state files contain tenant IDs but no secrets. They are safe to retain for audit. - Snapshot archives may contain PII. Apply the standard PII-retention policy (chmod 600, delete after migration audit window). - The `impersonate` audit-event is admin-visible and forms part of the soc2 audit trail. Do not redact it after the fact.