Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"plugins": [
{
"name": "metabase-cli",
"description": "Drive a Metabase instance from the terminal via the `mb` CLI: auth, list/get/create/update/delete on every resource, run queries and transforms, git-sync content to and from a remote, manage Enterprise workspaces. Bundles workspace, transform, and git-sync references as on-demand skills served by `mb skills get`.",
"description": "Drive a Metabase instance from the terminal via the `mb` CLI: auth, list/get/create/update/delete on every resource, run queries and transforms, git-sync content to and from a remote. Bundles transform and git-sync references as on-demand skills served by `mb skills get`.",
"source": "./",
"strict": false,
"skills": ["./skills/metabase-cli"],
Expand Down
10 changes: 5 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` / `METABASE_LICENSE_TOKEN` 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 `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.
- 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,7 @@ 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/` — storage + verify.
- `config.ts` — flag → env → stored resolver. Profile-aware (`resolveProfileName`, `resolveConfig`, `resolveLicenseToken`). All `METABASE_*` env-var reads live here.
- `config.ts` — flag → env → stored resolver. Profile-aware (`resolveProfileName`, `resolveConfig`). All `METABASE_*` env-var reads live here.
- `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`. 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` and `originOnly`. The single permitted home for `new URL(...)` outside `src/core/http/**`; the URL helpers belong here, not at call sites.
Expand All @@ -47,7 +47,7 @@ Metabase CLI. TypeScript ESM. citty + native `fetch` + Zod + @clack/prompts. oxl
## 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`, `workspaces`).
- **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`).
- `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 @@ -115,7 +115,7 @@ Lives under `tests/e2e/`. The whole point is to run the **built `dist/cli.mjs`**
- `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`.
- `tests/e2e/defaults.ts` — sole owner of `DEFAULT_E2E_BASE_URL`/`resolveE2EBaseUrl()` (reads `METABASE_CLI_E2E_URL`), `DEFAULT_E2E_STACK`/`resolveStackId()` (reads `METABASE_CLI_E2E_STACK`, default `default`), and `resolveSnapshotName()` (`cli_<stack>`). Anything needing a base URL, stack id, or snapshot name imports from here.
- `tests/e2e/server-gate.ts` — `requireServer({ minVersion?, tokenFeature? })` returns a skip reason (or `null`) by feeding the persisted `server` block through the production `checkCapabilities`. Suites whose commands declare non-baseline capabilities self-skip via `describe.skipIf(requireServer(...) !== null)` (measure, transform, transform-job → v59; git-sync → v60 + remote_sync; workspace → v62 + workspaces). This is how a lane "passes or skips" rather than failing on a server that can't satisfy the command.
- `tests/e2e/server-gate.ts` — `requireServer({ minVersion?, tokenFeature? })` returns a skip reason (or `null`) by feeding the persisted `server` block through the production `checkCapabilities`. Suites whose commands declare non-baseline capabilities self-skip via `describe.skipIf(requireServer(...) !== null)` (measure, transform, transform-job → v59; git-sync → v60 + remote_sync). This is how a lane "passes or skips" rather than failing on a server that can't satisfy the command.
- `tests/e2e/docker-compose.yml` — Postgres warehouse + Metabase (image via `METABASE_E2E_IMAGE`, host port via `METABASE_E2E_PORT`, project/volume namespaced by the runner's `-p mb-e2e-<stack>`). Token override via `MB_PREMIUM_EMBEDDING_TOKEN` env passes through; absence is fine — EE boots without a token, and token-gated suites skip themselves.
- `scripts/e2e-matrix.ts` (`bun run e2e:matrix`) — runs the suite against the version/edition matrix (oss/ee × 58–61 + oss/ee head), each in an isolated stack (own project, port, app-db volume, `.bootstrap.<stack>.json`, `cli_<stack>.sql`). `--stack=<id>` runs one; no flag runs all sequentially; `--parallel[=N]` runs N at a time. CI (`.github/workflows/e2e.yml`) runs one matrix job per stack with `fail-fast: false`.

Expand Down Expand Up @@ -143,7 +143,7 @@ Running e2e — the suite is slow (~3–5 minutes for a full run, ~hundreds of m
- Add a third-party HTTP library (`axios`, `got`, `node-fetch`, `undici`, etc.). `src/core/http/` is the HTTP boundary; it wraps native `fetch` (Node ≥ 20.6) with our project-specific contract (`requestParsed(schema)`, `HttpError`-with-sanitization-at-construction, idempotency-aware retries, `expectContentType` enforcement). Extend that module instead of importing a library — every off-the-shelf client would need to be wrapped to satisfy our contract anyway, and the wrapping is more code than the current implementation.
- Write a dotenv parser. Use Node's native `--env-file` (Node ≥ 20.6).
- Add deps for one-off helpers — inline.
- Read or print the EE license token. The dev token is supplied to the e2e stack via `MB_PREMIUM_EMBEDDING_TOKEN` (Metabase's own env var name), and the dev token-check URL via `METASTORE_DEV_SERVER_URL` (also Metabase's name; honored only when `MB_RUN_MODE=dev`, which the compose file already sets). Both flow shell → docker compose → JVM and shell → vitest → `runCli({ stdin })`. Never `cat`/`Read` `.env`/`.envrc`/shell rcs that may contain them, never `echo $MB_PREMIUM_EMBEDDING_TOKEN`, never `console.log` or `expect(...).toContain(token)`. To check whether a license test will run, inspect `process.env["MB_PREMIUM_EMBEDDING_TOKEN"] === undefined` — never the value. Use `mb_dev_…` dummy tokens for storage-roundtrip tests; only the EE-integration suite (gated on the token + dev URL being set) ever threads the real value, and only as opaque stdin to the CLI subprocess.
- Read or print the EE license token. The dev token is supplied to the e2e stack via `MB_PREMIUM_EMBEDDING_TOKEN` (Metabase's own env var name), and the dev token-check URL via `METASTORE_DEV_SERVER_URL` (also Metabase's name; honored only when `MB_RUN_MODE=dev`, which the compose file already sets). Both flow shell → docker compose → JVM to enable EE features (transforms, measures, remote-sync) on the booted server. Never `cat`/`Read` `.env`/`.envrc`/shell rcs that may contain them, never `echo $MB_PREMIUM_EMBEDDING_TOKEN`, never `console.log` or `expect(...).toContain(token)`. To check whether a token-gated suite will run, inspect `process.env["MB_PREMIUM_EMBEDDING_TOKEN"] === undefined` — never the value.

## Commands

Expand Down
Loading
Loading