Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/skills/add-e2e-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ You must follow all of these. Each rule has bitten the harness before.
import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli";
```

- `runCli({ args, configHome, env, stdin, timeoutMs })` spawns `node dist/cli.mjs` via `execa` with an isolated `XDG_CONFIG_HOME`, `METABASE_CLI_DISABLE_KEYRING=1`, and stripped env (no inherited `METABASE_*`).
- `runCli({ args, configHome, env, stdin, timeoutMs })` spawns `node dist/cli.mjs` via `execa` with an isolated `XDG_CONFIG_HOME`, `MB_CLI_DISABLE_KEYRING=1`, and stripped env (no inherited `MB_*`/`METABASE_*`).
- **Do not** import `execa`, `child_process`, `node:child_process`, or `spawn` directly.
- **Do not** call `fetch` against the Metabase instance. Bootstrap owns network setup; tests drive the CLI.
- **Do not** spread `process.env` into the `env` param. `env: process.env`, `env: { ...process.env, ... }`, and friends defeat the entire isolation guarantee — they let developer-shell `METABASE_*` leak into the test. Pass only the explicit keys you need.
Expand Down Expand Up @@ -169,8 +169,8 @@ describe("<noun> e2e", () => {
args: ["<noun>", "<verb>", "--json"],
configHome,
env: {
METABASE_URL: bootstrap.baseUrl,
METABASE_API_KEY: bootstrap.adminApiKey,
MB_URL: bootstrap.baseUrl,
MB_API_KEY: bootstrap.adminApiKey,
},
});

Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/add-resource-command/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export default defineMetabaseCommand({

**`src/main.ts`** — register the new top-level subcommand alongside the existing entries.

When smoke-testing commands by hand, **never pass an API key on argv** — Metabase keys must come through env (`METABASE_URL`, `METABASE_API_KEY`) or stdin. The runtime hook will block argv-embedded keys.
When smoke-testing commands by hand, **never pass an API key on argv** — Metabase keys must come through env (`MB_URL`, `MB_API_KEY`) or stdin. The runtime hook will block argv-embedded keys.

## Step 3 — Unit tests

Expand Down Expand Up @@ -215,7 +215,7 @@ Schemas are imported, never redeclared:
- Single item: `<Resource>` / `<Resource>Compact` from `src/domain/<r>.ts`.
- List envelope: `<Resource>ListEnvelope` from `src/commands/<r>/list.ts`.

If the command needs auth (the common case), pass `bootstrap.adminApiKey` and `bootstrap.baseUrl` via `runCli({ env: { METABASE_URL, METABASE_API_KEY } })` — never via argv.
If the command needs auth (the common case), pass `bootstrap.adminApiKey` and `bootstrap.baseUrl` via `runCli({ env: { MB_URL, MB_API_KEY } })` — never via argv.

## Step 5 — Manifest parity

Expand Down
11 changes: 6 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Metabase CLI. TypeScript ESM. citty + native `fetch` + Zod + @clack/prompts. oxl
- Type guards must validate what they narrow. `function isFoo(value): value is Foo` must check the property that distinguishes `Foo`, not a weaker shared property. A guard that narrows on `instanceof Error` while claiming `is NodeJS.ErrnoException` is a hidden cast — callers will read `.code` off something that doesn't have it.
- Eloquence: prefer the simplest realistic expression. Don't stack ceremony — repeated `override readonly` modifiers, generic gymnastics, intermediate abstract classes, or option-bag wrappers — when a plain field, an early return, or a non-generic shape is shorter and clearer. If two real-world engineers wouldn't both reach for the pattern, don't write it. Idiomatic > technically-pristine.
- No big inline expressions. `if (a && b && (c.x?.y ?? 0) > Z || isFooBar(d))` is noise. Split into semantically-named locals (`const hasBudget = …; const isFresh = …; if (hasBudget && isFresh) …`). Same for ternary chains — flatten with early returns, guard clauses, or a small lookup. The conditional in an `if`/return should read as one phrase, not a puzzle.
- We do not duplicate auth resolution for SOURCE/TARGET. Use profiles. Multi-instance commands take `--from-profile` / `--to-profile`, each routed through the same `resolveConfig`. There is no `METABASE_SOURCE_*` env-var family, no parallel `getSourceClient`, no shadow flag set. Inline reads of `process.env.METABASE_URL` / `METABASE_API_KEY` belong in `core/config.ts` only.
- We do not duplicate auth resolution for SOURCE/TARGET. Use profiles. Multi-instance commands take `--from-profile` / `--to-profile`, each routed through the same `resolveConfig`. There is no `MB_SOURCE_*` env-var family, no parallel `getSourceClient`, no shadow flag set. Reads of `MB_URL` / `MB_API_KEY` belong in `core/config.ts` only, and go through `core/env.ts` `readEnv` (never a raw `process.env[...]`).
- Tests import production Zod schemas from `src/`; they never redeclare them. `LoginResult` (`src/commands/auth/login.ts`), `AuthStatus` (`src/commands/auth/status.ts`), every `<Resource>` / `<Resource>Compact` in `src/domain/`, and every `<Resource>ListEnvelope` in `src/commands/<noun>/list.ts` is THE contract — copying the shape into a test creates silent drift the type-checker can't catch.
- Compact projections MUST chain `.strip()` after `.pick()`: `<Resource>.pick({...}).strip()`. Zod 4's `.pick()` on a `.loose()` parent inherits the loose catchall — without `.strip()` the projection silently passes every API field through. The bug is invisible until you look at the rendered `--json` output and see fields you never picked. This applies to every `<Resource>Compact` in `src/domain/` and any other `pick()` derived from a `.loose()` schema.
- Tests reuse `src/runtime/` and `src/core/errors` helpers (`parseJson`, `pollUntil`, `isNotFoundError`, `errorMessage`) instead of reimplementing `JSON.parse` + Zod, sleep+deadline loops, or ENOENT shape checks. Tests are code; the layering rules don't bite, but the duplication and drift rules do.
Expand All @@ -31,7 +31,8 @@ Metabase CLI. TypeScript ESM. citty + native `fetch` + Zod + @clack/prompts. oxl
- `src/commands/` — CLI shell only. No HTTP, no parsing, no formatting.
- `src/core/` — pure logic, no CLI deps.
- `auth/` — credential storage + verify, plus the OAuth login flow: `credential.ts` (discriminated `Credential` union), `pkce.ts`, `callback-server.ts` (loopback redirect), `oauth-login.ts` (orchestration), `oauth-session.ts` (refresh/revoke).
- `config.ts` — flag → env → stored resolver. Profile-aware (`resolveProfileName`, `resolveConfig`). All `METABASE_*` env-var reads live here.
- `env.ts` — `readEnv(canonical)` resolves a CLI env var by its canonical `MB_` name, falling back to the deprecated `METABASE_` alias and recording the legacy hit so the command shell warns once per run (`consumeLegacyEnvWarnings`, flushed in `commands/runtime.ts`). Every CLI env-var name (`MB_URL`, `MB_API_KEY`, `MB_PROFILE`, `MB_VERBOSE`, `MB_CLI_SKIP_PREFLIGHT`, `MB_CLI_DISABLE_KEYRING`) is a const here; reads go through `readEnv`, never raw `process.env[...]`.
- `config.ts` — flag → env → stored resolver. Profile-aware (`resolveProfileName`, `resolveConfig`). Credential/profile env reads (`MB_URL`/`MB_API_KEY`/`MB_PROFILE`) live here, via `core/env.ts`.
- `errors.ts` — `isNotFoundError`, `errorMessage` (Node error type guards used outside the HTTP boundary).
- `http/` — the HTTP boundary. `client.ts` wraps native `fetch` with `requestParsed(schema, path, opts)` (the ONLY typed-JSON path), `requestRaw`, `requestStream`. Retries are idempotency-aware: GET/HEAD/OPTIONS retry on retryable status codes by default; POST/PUT/PATCH/DELETE never retry on status (only on network/timeout). Callers may override via `RequestOptions.idempotent`. `errors.ts` owns the discriminated `MetabaseError` taxonomy and `toMetabaseError(unknown)`. `sanitize.ts` runs at `HttpError` construction — secret redaction is not optional. `retry.ts` is the backoff math; it is also the only `core/http/` site allowed to drive a `setTimeout`-based wait loop (via `node:timers/promises`) outside `src/runtime/poll.ts`. `oauth.ts` is the OAuth protocol boundary (RFC 8414 discovery with same-origin endpoint pinning, dynamic client registration, token exchange/refresh/revocation); its schemas are protocol envelopes, not `src/domain/` resources. Nothing outside this directory may import a third-party HTTP library or call `fetch` directly; this is enforced by `tests/structure.test.ts`.
- `url.ts` — `normalizeUrl`, `displayUrl`, `assertEndpointOrigin`. The single permitted home for `new URL(...)` outside `src/core/http/**`; the URL helpers belong here, not at call sites. Base URLs may carry a subpath (`https://my.org.com/metabase`) — never reduce a stored instance URL to its origin, and always join request paths by concatenation, not `new URL(path, base)`.
Expand All @@ -42,12 +43,12 @@ Metabase CLI. TypeScript ESM. citty + native `fetch` + Zod + @clack/prompts. oxl
- `body.ts` — `readBody(sources, schema)` chains `readInput` + `parseJson` + Zod validation for JSON bodies. Rejects multiple explicit body sources (`--body` + `--file` + `--stdin` + positional) with `ConfigError`; only one wins.
- `paginate.ts` — `paginate(client, path, itemSchema, opts)` is the canonical limit/offset iterator over Metabase list endpoints; returns `AsyncIterable<T>`. Honors `commonFlags.limit` via `opts.max`; defaults pageSize to 50 (Metabase server default). `collectPaginated` drains it into an array.
- `tests/` — see **Tests** and **E2E test tier**. Unit tests sit beside source under `src/**/*.test.ts`. The e2e tier lives under `tests/e2e/` with its own runtime contract.
- `bin/mb-dev` — contributor wrapper running the CLI from source against an isolated `XDG_CONFIG_HOME=$ROOT/.dev-state` with `METABASE_CLI_DISABLE_KEYRING=1`. Use this — never the real `~/.config` — when poking at the running e2e Metabase by hand.
- `bin/mb-dev` — contributor wrapper running the CLI from source against an isolated `XDG_CONFIG_HOME=$ROOT/.dev-state` with `MB_CLI_DISABLE_KEYRING=1`. Use this — never the real `~/.config` — when poking at the running e2e Metabase by hand.

## Commands runtime

- `src/commands/runtime.ts` — `defineMetabaseCommand({ meta, args, run })` is the canonical command shell. It merges `commonFlags` into `args` (callers add only their extra flags), parses `args` through `resolveCommonFlags` to build `ctx`, and exposes a lazy `getClient()` that runs `resolveConfig` + `createClient` on first call (cached). Use it instead of `defineCommand` directly. Pass `args: {}` when a command adds no extra flags.
- **Capabilities + preflight.** The minimum supported server is **Metabase v0.58**. Every command declares `capabilities: { minVersion, tokenFeature? }` (`minVersion` is the bare Metabase major integer like `58`, not semver). Baseline is `{ minVersion: 58 }` and is treated as "no gating" (no probe, no enforcement). Commands that never touch a Metabase server (e.g. `uuid`, `upgrade`) declare `capabilities: null` so the manifest reports no version requirement rather than a misleading baseline — don't fake a baseline for a local command. Annotate every command explicitly (a `{...}` or `null`); uniformity keeps the manifest honest. The server version and token-features are probed once on `auth login`/`auth list` and cached in the profile record; For non-baseline commands `getClient()` runs a preflight against that cache and throws `CapabilityError` (exit `2`) on a version/feature mismatch, or warns and proceeds when the version is unknown; baseline and `null` commands never preflight. `--skip-preflight` (per-invocation) or `METABASE_CLI_SKIP_PREFLIGHT=1` (process-wide) bypasses the check. To find the right `minVersion`/feature for a new endpoint, validate against `../metabase` at `origin/release-x.58.x` (route file `src/metabase/api_routes/routes.clj`, EE routes `enterprise/backend/src/metabase_enterprise/api_routes/routes.clj`); token-feature keys are the underscored map keys in `src/metabase/premium_features/settings.clj` (e.g. `remote_sync`, `transforms`).
- **Capabilities + preflight.** The minimum supported server is **Metabase v0.58**. Every command declares `capabilities: { minVersion, tokenFeature? }` (`minVersion` is the bare Metabase major integer like `58`, not semver). Baseline is `{ minVersion: 58 }` and is treated as "no gating" (no probe, no enforcement). Commands that never touch a Metabase server (e.g. `uuid`, `upgrade`) declare `capabilities: null` so the manifest reports no version requirement rather than a misleading baseline — don't fake a baseline for a local command. Annotate every command explicitly (a `{...}` or `null`); uniformity keeps the manifest honest. The server version and token-features are probed once on `auth login`/`auth list` and cached in the profile record; For non-baseline commands `getClient()` runs a preflight against that cache and throws `CapabilityError` (exit `2`) on a version/feature mismatch, or warns and proceeds when the version is unknown; baseline and `null` commands never preflight. `--skip-preflight` (per-invocation) or `MB_CLI_SKIP_PREFLIGHT=1` (process-wide) bypasses the check. To find the right `minVersion`/feature for a new endpoint, validate against `../metabase` at `origin/release-x.58.x` (route file `src/metabase/api_routes/routes.clj`, EE routes `enterprise/backend/src/metabase_enterprise/api_routes/routes.clj`); token-feature keys are the underscored map keys in `src/metabase/premium_features/settings.clj` (e.g. `remote_sync`, `transforms`).
- `src/output/prompt.ts` — `promptText` / `promptPassword` / `promptConfirm` / `promptSelect` wrap `@clack/prompts`. They throw `AbortError` on user cancel and `ConfigError` when stdin is not a TTY. Commands import these instead of `@clack/prompts` directly so the cancel-to-`AbortError` pathway is funneled in one place.

## Domain pattern
Expand Down Expand Up @@ -110,7 +111,7 @@ Local prerequisites for e2e: `bun run e2e:up && bun run e2e:bootstrap` (~1 minut

Lives under `tests/e2e/`. The whole point is to run the **built `dist/cli.mjs`** against a real Metabase via docker compose, with no mocks.

- `tests/e2e/run-cli.ts` — `runCli({ args, configHome, env, stdin, timeoutMs })` is the ONLY way an e2e test invokes the CLI. It spawns `node dist/cli.mjs` via `execa`, with an isolated `XDG_CONFIG_HOME` (per-call temp dir by default), `METABASE_CLI_DISABLE_KEYRING=1`, and stripped env (no inherited `METABASE_*` from the developer's shell). Tests never call `execa`/`child_process` directly; never call `fetch` against Metabase (that's bootstrap's job).
- `tests/e2e/run-cli.ts` — `runCli({ args, configHome, env, stdin, timeoutMs })` is the ONLY way an e2e test invokes the CLI. It spawns `node dist/cli.mjs` via `execa`, with an isolated `XDG_CONFIG_HOME` (per-call temp dir by default), `MB_CLI_DISABLE_KEYRING=1`, and stripped env (no inherited `MB_*`/`METABASE_*` from the developer's shell). Tests never call `execa`/`child_process` directly; never call `fetch` against Metabase (that's bootstrap's job).
- `tests/e2e/bootstrap-data.ts` — sole owner of the `Bootstrap` Zod schema and the stack-scoped `BOOTSTRAP_FILE_PATH` (`.bootstrap.<stack>.json`). The schema carries `seeded` (entity ids the bootstrap **discovers** — warehouse db/collection/card/dashboard/dashcard plus warehouse table & field ids resolved by name, never pinned) and `server` (the probed `{ version, tokenFeatures }`). The writer (`tests/e2e/setup/bootstrap.ts`) imports this schema; do not redeclare it. Tests read admin creds via `readBootstrap()` (async); they read seeded entity ids via the `SEEDED` const in `tests/e2e/seed/seeded.ts` (a sync `seededIds()` read, mirroring Metabase's own `cypress_sample_instance_data` pattern) — never hard-code an entity id, never invoke the setup wizard themselves, never hard-code an API key.
- `tests/e2e/setup/bootstrap.ts` — standalone script invoked by `bun run e2e:bootstrap` and by `tests/e2e/setup/global-setup.ts`. Idempotent: reuses `.bootstrap.<stack>.json` when the stored key still authenticates, otherwise calls `/api/setup` (or logs in directly if already setup), mints a fresh admin API key, discovers seeded ids, and probes the server. The Metabase HTTP responses it parses are setup-only — their schemas live colocated here, not in `src/domain/`.
- `tests/e2e/setup/global-setup.ts` — vitest globalSetup. Verifies `dist/cli.mjs` exists, then spawns `bootstrap.ts` once per `bun run test:e2e`.
Expand Down
Loading
Loading