From 3d92b3cd59701d260bb62f3fc932535ee828ef48 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 26 Jun 2026 15:14:16 -0400 Subject: [PATCH] unify env vars names --- .claude/skills/add-e2e-test/SKILL.md | 6 +-- .claude/skills/add-resource-command/SKILL.md | 4 +- CLAUDE.md | 11 +++-- README.md | 25 +++++----- bin/mb-dev | 2 +- src/commands/auth/login.ts | 10 ++-- src/commands/runtime.test.ts | 11 +++-- src/commands/runtime.ts | 8 ++- src/core/auth/storage.test.ts | 8 +-- src/core/auth/storage.ts | 5 +- src/core/config.test.ts | 49 ++++++++++--------- src/core/config.ts | 16 +++--- src/core/env.test.ts | 51 ++++++++++++++++++++ src/core/env.ts | 44 +++++++++++++++++ src/core/errors.ts | 2 - src/output/error.test.ts | 33 +++++++------ src/output/error.ts | 11 +++-- tests/e2e/auth.e2e.test.ts | 6 +-- tests/e2e/card.e2e.test.ts | 4 +- tests/e2e/collection.e2e.test.ts | 4 +- tests/e2e/dashboard.e2e.test.ts | 4 +- tests/e2e/db.e2e.test.ts | 4 +- tests/e2e/document.e2e.test.ts | 4 +- tests/e2e/eid-translation.e2e.test.ts | 4 +- tests/e2e/field.e2e.test.ts | 4 +- tests/e2e/git-sync.e2e.test.ts | 4 +- tests/e2e/http-errors.e2e.test.ts | 4 +- tests/e2e/measure.e2e.test.ts | 4 +- tests/e2e/profiles.e2e.test.ts | 25 ++++++++-- tests/e2e/query.e2e.test.ts | 4 +- tests/e2e/run-cli.ts | 2 +- tests/e2e/search.e2e.test.ts | 4 +- tests/e2e/segment.e2e.test.ts | 4 +- tests/e2e/setting.e2e.test.ts | 4 +- tests/e2e/setup.e2e.test.ts | 4 +- tests/e2e/setup/oauth-harness.ts | 6 +-- tests/e2e/snippet.e2e.test.ts | 4 +- tests/e2e/table.e2e.test.ts | 4 +- tests/e2e/transform-job.e2e.test.ts | 4 +- tests/e2e/transform.e2e.test.ts | 4 +- tests/e2e/version.e2e.test.ts | 10 ++-- 41 files changed, 275 insertions(+), 146 deletions(-) create mode 100644 src/core/env.test.ts create mode 100644 src/core/env.ts diff --git a/.claude/skills/add-e2e-test/SKILL.md b/.claude/skills/add-e2e-test/SKILL.md index 97abc54..40d8f77 100644 --- a/.claude/skills/add-e2e-test/SKILL.md +++ b/.claude/skills/add-e2e-test/SKILL.md @@ -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. @@ -169,8 +169,8 @@ describe(" e2e", () => { args: ["", "", "--json"], configHome, env: { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }, }); diff --git a/.claude/skills/add-resource-command/SKILL.md b/.claude/skills/add-resource-command/SKILL.md index 4719e62..e79ad77 100644 --- a/.claude/skills/add-resource-command/SKILL.md +++ b/.claude/skills/add-resource-command/SKILL.md @@ -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 @@ -215,7 +215,7 @@ Schemas are imported, never redeclared: - Single item: `` / `Compact` from `src/domain/.ts`. - List envelope: `ListEnvelope` from `src/commands//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 diff --git a/CLAUDE.md b/CLAUDE.md index f4ffc34..31a2f1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` / `Compact` in `src/domain/`, and every `ListEnvelope` in `src/commands//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()`: `.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 `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. @@ -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)`. @@ -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`. 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 @@ -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..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..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`. diff --git a/README.md b/README.md index b3ef28c..80e7e03 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Commands that need more than a baseline OSS server declare it — a higher minim - the server is older than the command's minimum version, or - the command needs a premium feature (e.g. `remote_sync`, `transforms`) that isn't enabled. -Plain OSS commands against a v0.58+ server (the majority) carry no elevated requirement and skip the preflight entirely. When a gated command runs but the server version can't be detected (no cached probe), it proceeds with a warning rather than refusing. To bypass the check for a single run, pass `--skip-preflight`; to bypass it process-wide (e.g. in CI), set `METABASE_CLI_SKIP_PREFLIGHT=1`. Both are footguns — only for servers you know are patched. +Plain OSS commands against a v0.58+ server (the majority) carry no elevated requirement and skip the preflight entirely. When a gated command runs but the server version can't be detected (no cached probe), it proceeds with a warning rather than refusing. To bypass the check for a single run, pass `--skip-preflight`; to bypass it process-wide (e.g. in CI), set `MB_CLI_SKIP_PREFLIGHT=1`. Both are footguns — only for servers you know are patched. ## Install @@ -54,13 +54,13 @@ On success the server is probed once — the rendered output shows the user, rol | Flag | Description | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--url ` | Metabase URL, including any subpath if the instance is hosted under one (`https://my.org.com/metabase`). Falls back to `METABASE_URL`, then prompts. | +| `--url ` | Metabase URL, including any subpath if the instance is hosted under one (`https://my.org.com/metabase`). Falls back to `MB_URL`, then prompts. | | `--api-key ` | API key. Skips the browser flow. Visible in shell history — pipe on stdin instead. | | `--client-id ` | Pre-registered OAuth client id (only needed when dynamic client registration is disabled on the server). | | `--profile `, `-p` | Profile to write to (default: `default`). | | `--skip-verify` | Save without contacting the server (no probe, no cache). | -Non-interactive (non-TTY) login requires an API key; resolution order: `--api-key` → piped stdin → `METABASE_API_KEY` (first non-empty wins). Without one, non-interactive login fails rather than prompting. +Non-interactive (non-TTY) login requires an API key; resolution order: `--api-key` → piped stdin → `MB_API_KEY` (first non-empty wins). Without one, non-interactive login fails rather than prompting. ```sh mb auth login # interactive: browser or API key @@ -1366,14 +1366,17 @@ Exit codes: `0` success, `2` `ConfigError` (missing name, unknown name, `MB_SKIL ## Environment variables -| Variable | Effect | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `METABASE_URL` | Default URL for `auth login` and config resolution. | -| `METABASE_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). | -| `METABASE_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | -| `METABASE_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | -| `METABASE_CLI_SKIP_PREFLIGHT` | When set to `1`, bypasses the per-command server version / token-feature preflight check. Escape hatch for patched Metabase builds; can mask real compatibility problems. | -| `MB_SKILLS_DIR` | Override the directory `mb skills` scans (dev/test only; defaults to the CLI's bundled `skills` + `skill-data` trees). | +| Variable | Effect | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MB_URL` | Default URL for `auth login` and config resolution. | +| `MB_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). | +| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | +| `MB_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | +| `MB_CLI_SKIP_PREFLIGHT` | When set to `1`, bypasses the per-command server version / token-feature preflight check. Escape hatch for patched Metabase builds; can mask real compatibility problems. | +| `MB_CLI_DISABLE_KEYRING` | When set to `1`, skips the OS keychain and stores credentials as plaintext in the profiles file. | +| `MB_SKILLS_DIR` | Override the directory `mb skills` scans (dev/test only; defaults to the CLI's bundled `skills` + `skill-data` trees). | + +The former `METABASE_`-prefixed names (`METABASE_URL`, `METABASE_API_KEY`, `METABASE_PROFILE`, `METABASE_VERBOSE`, `METABASE_CLI_SKIP_PREFLIGHT`, `METABASE_CLI_DISABLE_KEYRING`) are deprecated but still honored; the CLI prints a one-line warning to stderr when it falls back to one. Switch to the `MB_`-prefixed names. ## Agent integration diff --git a/bin/mb-dev b/bin/mb-dev index b727903..dd8004d 100755 --- a/bin/mb-dev +++ b/bin/mb-dev @@ -2,6 +2,6 @@ set -euo pipefail ROOT=$(cd "$(dirname "$0")/.." && pwd) export XDG_CONFIG_HOME="$ROOT/.dev-state" -export METABASE_CLI_DISABLE_KEYRING=1 +export MB_CLI_DISABLE_KEYRING=1 mkdir -p "$XDG_CONFIG_HOME" exec bun "$ROOT/src/cli.ts" "$@" diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index c351bc1..41cc854 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -60,7 +60,7 @@ const loginView: ResourceView = { export default defineMetabaseCommand({ meta: { name: "login", description: "Log in to a Metabase instance for a profile" }, details: - "Interactive login offers browser OAuth (recommended; Metabase v63+) or an API key — older servers fall back to the API key prompt automatically. Browser login opens Metabase, you sign in (password or SSO) and approve, and the CLI stores a refreshing access token. For CI/non-interactive use, supply an API key via --api-key, piped stdin, or $METABASE_API_KEY (first non-empty wins); any of these skips the browser flow, even on a TTY. The URL comes from --url or $METABASE_URL, prompted when stdin is a TTY.", + "Interactive login offers browser OAuth (recommended; Metabase v63+) or an API key — older servers fall back to the API key prompt automatically. Browser login opens Metabase, you sign in (password or SSO) and approve, and the CLI stores a refreshing access token. For CI/non-interactive use, supply an API key via --api-key, piped stdin, or $MB_API_KEY (first non-empty wins); any of these skips the browser flow, even on a TTY. The URL comes from --url or $MB_URL, prompted when stdin is a TTY.", capabilities: { minVersion: 58 }, args: { ...outputFlags, @@ -80,7 +80,7 @@ export default defineMetabaseCommand({ outputSchema: LoginResult, examples: [ "mb auth login --url https://metabase.example.com", - "echo $METABASE_API_KEY | mb auth login --url https://metabase.example.com", + "echo $MB_API_KEY | mb auth login --url https://metabase.example.com", "mb auth login --profile staging --url https://staging.example.com", ], async run({ args, ctx }) { @@ -89,7 +89,7 @@ export default defineMetabaseCommand({ if (args.apiKey) { warn( - "warning: --api-key is visible in shell history and process listings — pipe the key on stdin or set METABASE_API_KEY instead", + "warning: --api-key is visible in shell history and process listings — pipe the key on stdin or set MB_API_KEY instead", ); } @@ -110,7 +110,7 @@ export default defineMetabaseCommand({ if (!process.stdin.isTTY) { throw new ConfigError( - "interactive login requires a TTY; pass --api-key or set METABASE_API_KEY for non-interactive login", + "interactive login requires a TTY; pass --api-key or set MB_API_KEY for non-interactive login", ); } @@ -341,7 +341,7 @@ async function nonInteractiveApiKey( } if (envKey) { if (process.stdin.isTTY) { - warn("using the API key from $METABASE_API_KEY; unset it to choose browser login"); + warn("using the API key from $MB_API_KEY; unset it to choose browser login"); } return envKey; } diff --git a/src/commands/runtime.test.ts b/src/commands/runtime.test.ts index 5d85b12..14528c9 100644 --- a/src/commands/runtime.test.ts +++ b/src/commands/runtime.test.ts @@ -49,9 +49,10 @@ describe("defineMetabaseCommand", () => { beforeEach(() => { hoisted.store.clear(); home = setupTempConfigHome(); - delete process.env["METABASE_URL"]; - delete process.env["METABASE_API_KEY"]; - delete process.env["METABASE_PROFILE"]; + for (const name of ["URL", "API_KEY", "PROFILE"]) { + delete process.env[`MB_${name}`]; + delete process.env[`METABASE_${name}`]; + } delete process.env[SKIP_PREFLIGHT_ENV]; previousExitCode = process.exitCode; process.exitCode = 0; @@ -156,7 +157,7 @@ describe("defineMetabaseCommand", () => { error: { category: "config", message: - 'Not authenticated for profile "default". Run `mb auth login`, set METABASE_URL/METABASE_API_KEY, or pass --url/--api-key.', + 'Not authenticated for profile "default". Run `mb auth login`, set MB_URL/MB_API_KEY, or pass --url/--api-key.', exitCode: 2, }, }); @@ -296,7 +297,7 @@ describe("defineMetabaseCommand", () => { expect(ran).toHaveBeenCalledOnce(); }); - it("bypasses the preflight check when METABASE_CLI_SKIP_PREFLIGHT=1 is set", async () => { + it("bypasses the preflight check when MB_CLI_SKIP_PREFLIGHT=1 is set", async () => { await seedProbedProfile("default", fakeServerInfo(58)); process.env[SKIP_PREFLIGHT_ENV] = "1"; diff --git a/src/commands/runtime.ts b/src/commands/runtime.ts index 2019e80..9c984c2 100644 --- a/src/commands/runtime.ts +++ b/src/commands/runtime.ts @@ -15,6 +15,7 @@ import { type ConfigFlags, type ResolvedConfig, } from "../core/config"; +import { consumeLegacyEnvWarnings } from "../core/env"; import { createClient, type Client } from "../core/http/client"; import { BASELINE_CAPABILITIES, @@ -112,7 +113,7 @@ export function defineMetabaseCommand( getServerInfo, }); } finally { - emitPendingStorageWarnings(); + emitPendingWarnings(); } } catch (error) { reportError(error, reportFormat); @@ -128,7 +129,10 @@ export function defineMetabaseCommand( return cmd; } -function emitPendingStorageWarnings(): void { +function emitPendingWarnings(): void { + for (const message of consumeLegacyEnvWarnings()) { + warn(message); + } const legacy = consumeLegacyStorageWarning(); if (legacy !== null) { warn(legacy); diff --git a/src/core/auth/storage.test.ts b/src/core/auth/storage.test.ts index 864fde8..0173506 100644 --- a/src/core/auth/storage.test.ts +++ b/src/core/auth/storage.test.ts @@ -312,14 +312,14 @@ describe("writeProbeResult and writeProbeFailure", () => { }); }); -describe("METABASE_CLI_DISABLE_KEYRING", () => { +describe("MB_CLI_DISABLE_KEYRING", () => { let home: TempConfigHome; beforeEach(() => { hoisted.store.clear(); hoisted.controls.broken = false; home = setupTempConfigHome(); - process.env["METABASE_CLI_DISABLE_KEYRING"] = "1"; + process.env["MB_CLI_DISABLE_KEYRING"] = "1"; }); afterEach(() => { @@ -340,7 +340,7 @@ describe("METABASE_CLI_DISABLE_KEYRING", () => { }); it("treats values other than '1' as not-disabled", async () => { - process.env["METABASE_CLI_DISABLE_KEYRING"] = "0"; + process.env["MB_CLI_DISABLE_KEYRING"] = "0"; const location = await writeProfile({ url: "https://m.example.com", apiKey: "secret" }); expect(location.backend).toBe("keyring"); }); @@ -355,7 +355,7 @@ describe("keyringFallbackWarning", () => { reason: "disabled", }; expect(keyringFallbackWarning(location)).toBe( - "warning: OS keychain disabled via METABASE_CLI_DISABLE_KEYRING; credentials stored as plaintext at /tmp/profiles.json", + "warning: OS keychain disabled via MB_CLI_DISABLE_KEYRING; credentials stored as plaintext at /tmp/profiles.json", ); }); diff --git a/src/core/auth/storage.ts b/src/core/auth/storage.ts index 75628f8..4182fbc 100644 --- a/src/core/auth/storage.ts +++ b/src/core/auth/storage.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { Entry } from "@napi-rs/keyring"; import { parseJsonResult } from "../../runtime/json"; +import { ENV_DISABLE_KEYRING, readEnv } from "../env"; import { isNotFoundError, ValidationError } from "../errors"; import { configDir } from "../paths"; import type { ServerInfo } from "../version/probe"; @@ -119,7 +120,7 @@ export function consumeKeyringDowngradeWarning(): string | null { } function keyringEnabled(): boolean { - return process.env["METABASE_CLI_DISABLE_KEYRING"] !== "1"; + return readEnv(ENV_DISABLE_KEYRING) !== "1"; } // @napi-rs/keyring surfaces every backend failure (no service, locked vault, @@ -284,7 +285,7 @@ function fileLocation(key: CredentialAccount): FileLocation { export function keyringFallbackWarning(location: FileLocation): string { const cause = location.reason === "disabled" - ? "OS keychain disabled via METABASE_CLI_DISABLE_KEYRING" + ? `OS keychain disabled via ${ENV_DISABLE_KEYRING}` : "OS keychain unavailable"; return `warning: ${cause}; credentials stored as plaintext at ${location.path}`; } diff --git a/src/core/config.test.ts b/src/core/config.test.ts index af8e326..df61d4d 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -69,15 +69,20 @@ const REFRESHED_OAUTH: OAuthCredential = { clientId: "c1", }; +function clearConfigEnv(): void { + for (const name of ["URL", "API_KEY", "PROFILE"]) { + delete process.env[`MB_${name}`]; + delete process.env[`METABASE_${name}`]; + } +} + describe("resolveConfig", () => { let home: TempConfigHome; beforeEach(() => { hoisted.store.clear(); home = setupTempConfigHome(); - delete process.env["METABASE_URL"]; - delete process.env["METABASE_API_KEY"]; - delete process.env["METABASE_PROFILE"]; + clearConfigEnv(); }); afterEach(() => { @@ -85,8 +90,8 @@ describe("resolveConfig", () => { }); it("prefers flags over environment and stored creds", async () => { - process.env["METABASE_URL"] = "http://env"; - process.env["METABASE_API_KEY"] = "env-key"; + process.env["MB_URL"] = "http://env"; + process.env["MB_API_KEY"] = "env-key"; const config = await resolveConfig({ url: "https://flag.example.com", apiKey: "flag-key", @@ -100,8 +105,8 @@ describe("resolveConfig", () => { }); it("falls back to environment", async () => { - process.env["METABASE_URL"] = "https://env.example.com/"; - process.env["METABASE_API_KEY"] = "env-key"; + process.env["MB_URL"] = "https://env.example.com/"; + process.env["MB_API_KEY"] = "env-key"; const config = await resolveConfig({}); expect(config).toEqual({ url: "https://env.example.com", @@ -137,10 +142,10 @@ describe("resolveConfig", () => { }); }); - it("reads the named profile when METABASE_PROFILE is set", async () => { + it("reads the named profile when MB_PROFILE is set", async () => { await writeProfile({ url: "https://default.example.com", apiKey: "default-key" }); await writeProfile({ url: "https://prod.example.com", apiKey: "prod-key" }, "prod"); - process.env["METABASE_PROFILE"] = "prod"; + process.env["MB_PROFILE"] = "prod"; const config = await resolveConfig({}); expect(config).toEqual({ url: "https://prod.example.com", @@ -150,10 +155,10 @@ describe("resolveConfig", () => { }); }); - it("flag profile beats METABASE_PROFILE env var", async () => { + it("flag profile beats MB_PROFILE env var", async () => { await writeProfile({ url: "https://staging.example.com", apiKey: "staging-key" }, "staging"); await writeProfile({ url: "https://prod.example.com", apiKey: "prod-key" }, "prod"); - process.env["METABASE_PROFILE"] = "prod"; + process.env["MB_PROFILE"] = "prod"; const config = await resolveConfig({ profile: "staging" }); expect(config).toEqual({ url: "https://staging.example.com", @@ -176,7 +181,7 @@ describe("resolveConfig", () => { it("composes env-only apiKey with stored url (mixed source)", async () => { await writeProfile({ url: "https://saved.example.com", apiKey: "saved-key" }); - process.env["METABASE_API_KEY"] = "env-key"; + process.env["MB_API_KEY"] = "env-key"; const config = await resolveConfig({}); expect(config).toEqual({ url: "https://saved.example.com", @@ -259,6 +264,7 @@ describe("resolveProfileName", () => { const originalEnv = { ...process.env }; beforeEach(() => { + delete process.env["MB_PROFILE"]; delete process.env["METABASE_PROFILE"]; }); @@ -267,12 +273,12 @@ describe("resolveProfileName", () => { }); it("returns the flag value when provided", () => { - process.env["METABASE_PROFILE"] = "env-profile"; + process.env["MB_PROFILE"] = "env-profile"; expect(resolveProfileName("flag-profile")).toBe("flag-profile"); }); - it("falls back to METABASE_PROFILE when no flag", () => { - process.env["METABASE_PROFILE"] = "env-profile"; + it("falls back to MB_PROFILE when no flag", () => { + process.env["MB_PROFILE"] = "env-profile"; expect(resolveProfileName(undefined)).toBe("env-profile"); }); @@ -285,6 +291,7 @@ describe("explicitProfileName", () => { const originalEnv = { ...process.env }; beforeEach(() => { + delete process.env["MB_PROFILE"]; delete process.env["METABASE_PROFILE"]; }); @@ -293,12 +300,12 @@ describe("explicitProfileName", () => { }); it("returns the flag value when provided", () => { - process.env["METABASE_PROFILE"] = "env-profile"; + process.env["MB_PROFILE"] = "env-profile"; expect(explicitProfileName("flag-profile")).toBe("flag-profile"); }); - it("returns METABASE_PROFILE when no flag", () => { - process.env["METABASE_PROFILE"] = "env-profile"; + it("returns MB_PROFILE when no flag", () => { + process.env["MB_PROFILE"] = "env-profile"; expect(explicitProfileName(undefined)).toBe("env-profile"); }); @@ -314,9 +321,7 @@ describe("resolveConfig OAuth credentials", () => { hoisted.store.clear(); hoisted.refreshUrls = []; home = setupTempConfigHome(); - delete process.env["METABASE_URL"]; - delete process.env["METABASE_API_KEY"]; - delete process.env["METABASE_PROFILE"]; + clearConfigEnv(); }); afterEach(() => { @@ -349,7 +354,7 @@ describe("resolveConfig OAuth credentials", () => { expect(error.message).toBe( 'profile "oauthp" is a browser-login (OAuth) profile bound to https://oauth.example.com, ' + "but the request URL is https://evil.example.com. " + - "Drop --url/METABASE_URL to use the profile's own URL, or run " + + "Drop --url/MB_URL to use the profile's own URL, or run " + "`mb auth login --url https://evil.example.com` to authenticate there.", ); }); diff --git a/src/core/config.ts b/src/core/config.ts index 27b668e..1d3c10e 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -11,18 +11,14 @@ import { readProfileRecord, writeOAuthProfile, } from "./auth/storage"; +import { ENV_API_KEY, ENV_PROFILE, ENV_SKIP_PREFLIGHT, ENV_URL, readEnv } from "./env"; import { ConfigError } from "./errors"; import { normalizeUrl } from "./url"; -const ENV_URL = "METABASE_URL"; -const ENV_API_KEY = "METABASE_API_KEY"; -const ENV_PROFILE = "METABASE_PROFILE"; -const ENV_SKIP_PREFLIGHT = "METABASE_CLI_SKIP_PREFLIGHT"; - export const SKIP_PREFLIGHT_ENV = ENV_SKIP_PREFLIGHT; export function isPreflightSkipped(): boolean { - return process.env[ENV_SKIP_PREFLIGHT] === "1"; + return readEnv(ENV_SKIP_PREFLIGHT) === "1"; } export type ConfigSource = "flag" | "env" | "stored" | "mixed"; @@ -60,13 +56,13 @@ export function resolveProfileName(profileFlag: string | undefined): string { } export function explicitProfileName(profileFlag: string | undefined): string | null { - return profileFlag || process.env[ENV_PROFILE] || null; + return profileFlag || readEnv(ENV_PROFILE) || null; } export function readEnvCredentials(): EnvCredentials { return { - url: process.env[ENV_URL] ?? null, - apiKey: process.env[ENV_API_KEY] ?? null, + url: readEnv(ENV_URL) ?? null, + apiKey: readEnv(ENV_API_KEY) ?? null, }; } @@ -103,7 +99,7 @@ export async function resolveConfig(flags: ConfigFlags): Promise } // A stored OAuth credential's bearer/refresh tokens are bound to the issuer that minted them. -// Refuse to send them to a different host named by --url/METABASE_URL: that would leak the bearer +// Refuse to send them to a different host named by --url/MB_URL: that would leak the bearer // token to the foreign host, and the 401-refresh loop would keep minting fresh tokens for it too. // API-key credentials are unaffected (and only OAuth credentials ever come from a stored profile). function assertOAuthUrlMatchesIssuer( diff --git a/src/core/env.test.ts b/src/core/env.test.ts new file mode 100644 index 0000000..91e3a94 --- /dev/null +++ b/src/core/env.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { consumeLegacyEnvWarnings, ENV_URL, readEnv } from "./env"; + +describe("readEnv", () => { + beforeEach(() => { + delete process.env["MB_URL"]; + delete process.env["METABASE_URL"]; + consumeLegacyEnvWarnings(); + }); + + afterEach(() => { + delete process.env["MB_URL"]; + delete process.env["METABASE_URL"]; + }); + + it("returns undefined and records no warning when neither variant is set", () => { + expect(readEnv(ENV_URL)).toBeUndefined(); + expect(consumeLegacyEnvWarnings()).toEqual([]); + }); + + it("reads the canonical MB_ variant without warning", () => { + process.env["MB_URL"] = "https://canonical.example.com"; + expect(readEnv(ENV_URL)).toBe("https://canonical.example.com"); + expect(consumeLegacyEnvWarnings()).toEqual([]); + }); + + it("falls back to the legacy METABASE_ variant and records a warning", () => { + process.env["METABASE_URL"] = "https://legacy.example.com"; + expect(readEnv(ENV_URL)).toBe("https://legacy.example.com"); + expect(consumeLegacyEnvWarnings()).toEqual([ + "warning: METABASE_URL is deprecated; set MB_URL instead", + ]); + }); + + it("prefers the canonical variant when both are set, without warning", () => { + process.env["MB_URL"] = "https://canonical.example.com"; + process.env["METABASE_URL"] = "https://legacy.example.com"; + expect(readEnv(ENV_URL)).toBe("https://canonical.example.com"); + expect(consumeLegacyEnvWarnings()).toEqual([]); + }); + + it("clears recorded warnings once consumed", () => { + process.env["METABASE_URL"] = "https://legacy.example.com"; + readEnv(ENV_URL); + expect(consumeLegacyEnvWarnings()).toEqual([ + "warning: METABASE_URL is deprecated; set MB_URL instead", + ]); + expect(consumeLegacyEnvWarnings()).toEqual([]); + }); +}); diff --git a/src/core/env.ts b/src/core/env.ts new file mode 100644 index 0000000..db87678 --- /dev/null +++ b/src/core/env.ts @@ -0,0 +1,44 @@ +const CANONICAL_PREFIX = "MB_"; +const LEGACY_PREFIX = "METABASE_"; + +export const ENV_URL = "MB_URL"; +export const ENV_API_KEY = "MB_API_KEY"; +export const ENV_PROFILE = "MB_PROFILE"; +export const ENV_VERBOSE = "MB_VERBOSE"; +export const ENV_SKIP_PREFLIGHT = "MB_CLI_SKIP_PREFLIGHT"; +export const ENV_DISABLE_KEYRING = "MB_CLI_DISABLE_KEYRING"; + +function legacyNameFor(canonical: string): string { + return LEGACY_PREFIX + canonical.slice(CANONICAL_PREFIX.length); +} + +const legacyVarsUsed = new Set(); + +// Read a CLI environment variable by its canonical `MB_` name, falling back to the deprecated +// `METABASE_` alias. A read served from the legacy alias is recorded so the command shell can warn +// the user once per invocation (see `consumeLegacyEnvWarnings`). +export function readEnv(canonical: string): string | undefined { + const direct = process.env[canonical]; + if (direct !== undefined) { + return direct; + } + const legacyValue = process.env[legacyNameFor(canonical)]; + if (legacyValue !== undefined) { + legacyVarsUsed.add(canonical); + return legacyValue; + } + return undefined; +} + +export function consumeLegacyEnvWarnings(): string[] { + if (legacyVarsUsed.size === 0) { + return []; + } + const messages = [...legacyVarsUsed].map(legacyEnvWarning); + legacyVarsUsed.clear(); + return messages; +} + +function legacyEnvWarning(canonical: string): string { + return `warning: ${legacyNameFor(canonical)} is deprecated; set ${canonical} instead`; +} diff --git a/src/core/errors.ts b/src/core/errors.ts index f66ee96..35984ee 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -3,8 +3,6 @@ import { core as zodCore, ZodError } from "zod"; import { escapeJsonPointerSegment } from "./json-pointer"; -export const VERBOSE_ENV = "METABASE_VERBOSE"; - export type ErrorCategory = | "network" | "http" diff --git a/src/output/error.test.ts b/src/output/error.test.ts index 6db10eb..74488f8 100644 --- a/src/output/error.test.ts +++ b/src/output/error.test.ts @@ -16,7 +16,7 @@ interface CapturedStreams { let streams: CapturedStreams; const originalExitCode = process.exitCode; -const originalVerbose = process.env["METABASE_VERBOSE"]; +const originalVerbose = process.env["MB_VERBOSE"]; beforeEach(() => { streams = { stderr: "" }; @@ -25,6 +25,7 @@ beforeEach(() => { return true; }); process.exitCode = 0; + delete process.env["MB_VERBOSE"]; delete process.env["METABASE_VERBOSE"]; }); @@ -32,9 +33,9 @@ afterEach(() => { vi.restoreAllMocks(); process.exitCode = originalExitCode; if (originalVerbose === undefined) { - delete process.env["METABASE_VERBOSE"]; + delete process.env["MB_VERBOSE"]; } else { - process.env["METABASE_VERBOSE"] = originalVerbose; + process.env["MB_VERBOSE"] = originalVerbose; } }); @@ -53,25 +54,25 @@ describe("reportError", () => { it("sets exit code 1 for UnknownError wrapping a generic Error", () => { reportError(new Error("kaboom")); - expect(streams.stderr).toBe("kaboom\n(rerun with METABASE_VERBOSE=1 for details)\n"); + expect(streams.stderr).toBe("kaboom\n(rerun with MB_VERBOSE=1 for details)\n"); expect(process.exitCode).toBe(1); }); - it("appends the verbose breadcrumb (not the detail) when METABASE_VERBOSE is unset and detail exists", () => { + it("appends the verbose breadcrumb (not the detail) when MB_VERBOSE is unset and detail exists", () => { reportError(new UnknownError({ originalMessage: "boom", stack: "trace" })); - expect(streams.stderr).toBe("boom\n(rerun with METABASE_VERBOSE=1 for details)\n"); + expect(streams.stderr).toBe("boom\n(rerun with MB_VERBOSE=1 for details)\n"); }); - it("appends developerDetail JSON when METABASE_VERBOSE=1 and the error carries detail", () => { - process.env["METABASE_VERBOSE"] = "1"; + it("appends developerDetail JSON when MB_VERBOSE=1 and the error carries detail", () => { + process.env["MB_VERBOSE"] = "1"; reportError(new UnknownError({ originalMessage: "boom", stack: "trace" })); expect(streams.stderr).toBe( "boom\n" + JSON.stringify({ originalMessage: "boom", stack: "trace" }, null, 2) + "\n", ); }); - it("does not append developerDetail JSON when METABASE_VERBOSE=1 but detail is null", () => { - process.env["METABASE_VERBOSE"] = "1"; + it("does not append developerDetail JSON when MB_VERBOSE=1 but detail is null", () => { + process.env["MB_VERBOSE"] = "1"; reportError(new ConfigError("nope")); expect(streams.stderr).toBe("nope\n"); expect(process.exitCode).toBe(2); @@ -79,7 +80,7 @@ describe("reportError", () => { it("normalizes a non-MetabaseError value (string) into an UnknownError envelope", () => { reportError("plain string"); - expect(streams.stderr).toBe("plain string\n(rerun with METABASE_VERBOSE=1 for details)\n"); + expect(streams.stderr).toBe("plain string\n(rerun with MB_VERBOSE=1 for details)\n"); expect(process.exitCode).toBe(1); }); @@ -99,7 +100,7 @@ describe("reportError", () => { expect(streams.stderr).toBe( "https://m.example.com/api/collection/8/items: value did not match expected schema\n" + " /total: Invalid input: expected number, received null\n" + - "(rerun with METABASE_VERBOSE=1 for details)\n", + "(rerun with MB_VERBOSE=1 for details)\n", ); expect(process.exitCode).toBe(1); }); @@ -121,7 +122,7 @@ describe("reportError", () => { expect(streams.stderr).toBe( "Metabase returned unexpected response shape:\n" + " version.tag: Invalid input: expected string, received undefined\n" + - "(rerun with METABASE_VERBOSE=1 for details)\n", + "(rerun with MB_VERBOSE=1 for details)\n", ); expect(process.exitCode).toBe(1); }); @@ -138,7 +139,7 @@ describe("reportError", () => { expect(process.exitCode).toBe(2); }); - it("omits detail from the JSON error envelope when METABASE_VERBOSE is unset", () => { + it("omits detail from the JSON error envelope when MB_VERBOSE is unset", () => { reportError(new UnknownError({ originalMessage: "boom", stack: "trace" }), "json"); const expected = JSON.stringify( @@ -150,8 +151,8 @@ describe("reportError", () => { expect(process.exitCode).toBe(1); }); - it("includes detail in the JSON error envelope when METABASE_VERBOSE=1", () => { - process.env["METABASE_VERBOSE"] = "1"; + it("includes detail in the JSON error envelope when MB_VERBOSE=1", () => { + process.env["MB_VERBOSE"] = "1"; reportError(new UnknownError({ originalMessage: "boom", stack: "trace" }), "json"); const expected = JSON.stringify( diff --git a/src/output/error.ts b/src/output/error.ts index 8c966df..3ec1095 100644 --- a/src/output/error.ts +++ b/src/output/error.ts @@ -1,9 +1,11 @@ -import { toMetabaseError, VERBOSE_ENV } from "../core/errors"; +import { consumeLegacyEnvWarnings, ENV_VERBOSE, readEnv } from "../core/env"; +import { toMetabaseError } from "../core/errors"; import type { ErrorCategory, MetabaseError } from "../core/errors"; +import { warn } from "./notice"; import type { Format } from "./types"; -const VERBOSE_BREADCRUMB = "(rerun with METABASE_VERBOSE=1 for details)"; +const VERBOSE_BREADCRUMB = "(rerun with MB_VERBOSE=1 for details)"; interface JsonErrorPayload { category: ErrorCategory; @@ -19,12 +21,15 @@ interface JsonErrorEnvelope { export function reportError(error: unknown, format?: Format): void { const handled = toMetabaseError(error); - const verbose = process.env[VERBOSE_ENV] === "1"; + const verbose = readEnv(ENV_VERBOSE) === "1"; if (format === "json") { writeJsonError(handled, verbose); } else { writeTextError(handled, verbose); } + for (const message of consumeLegacyEnvWarnings()) { + warn(message); + } process.exitCode = handled.exitCode; } diff --git a/tests/e2e/auth.e2e.test.ts b/tests/e2e/auth.e2e.test.ts index a5a8f65..39b1cc3 100644 --- a/tests/e2e/auth.e2e.test.ts +++ b/tests/e2e/auth.e2e.test.ts @@ -365,7 +365,7 @@ describe("auth e2e", () => { }); }); - it("login routes through METABASE_PROFILE when no --profile flag is passed", async () => { + it("login routes through MB_PROFILE when no --profile flag is passed", async () => { const configHome = await makeIsolatedConfigHome(); const login = await runCli({ @@ -379,7 +379,7 @@ describe("auth e2e", () => { "--json", ], configHome, - env: { METABASE_PROFILE: "env_routed" }, + env: { MB_PROFILE: "env_routed" }, }); expect(login.exitCode, login.stderr).toBe(0); @@ -396,7 +396,7 @@ describe("auth e2e", () => { const envStatus = await runCli({ args: ["auth", "status", "--json"], configHome, - env: { METABASE_PROFILE: "env_routed" }, + env: { MB_PROFILE: "env_routed" }, }); expect(envStatus.exitCode, envStatus.stderr).toBe(0); const envPayload = parseJson(envStatus.stdout, AuthStatus); diff --git a/tests/e2e/card.e2e.test.ts b/tests/e2e/card.e2e.test.ts index 4ab591d..794c1d8 100644 --- a/tests/e2e/card.e2e.test.ts +++ b/tests/e2e/card.e2e.test.ts @@ -73,8 +73,8 @@ describe("card e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/collection.e2e.test.ts b/tests/e2e/collection.e2e.test.ts index 3b1f9b8..4b3a73e 100644 --- a/tests/e2e/collection.e2e.test.ts +++ b/tests/e2e/collection.e2e.test.ts @@ -64,8 +64,8 @@ describe("collection e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/dashboard.e2e.test.ts b/tests/e2e/dashboard.e2e.test.ts index 68a7de5..09dacbc 100644 --- a/tests/e2e/dashboard.e2e.test.ts +++ b/tests/e2e/dashboard.e2e.test.ts @@ -79,8 +79,8 @@ describe("dashboard e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/db.e2e.test.ts b/tests/e2e/db.e2e.test.ts index 6822e51..11a086a 100644 --- a/tests/e2e/db.e2e.test.ts +++ b/tests/e2e/db.e2e.test.ts @@ -103,8 +103,8 @@ describe("db e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/document.e2e.test.ts b/tests/e2e/document.e2e.test.ts index 202e3b5..0340da4 100644 --- a/tests/e2e/document.e2e.test.ts +++ b/tests/e2e/document.e2e.test.ts @@ -55,8 +55,8 @@ describe("document e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/eid-translation.e2e.test.ts b/tests/e2e/eid-translation.e2e.test.ts index ac9229e..3ba8c35 100644 --- a/tests/e2e/eid-translation.e2e.test.ts +++ b/tests/e2e/eid-translation.e2e.test.ts @@ -32,8 +32,8 @@ describe("eid e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/field.e2e.test.ts b/tests/e2e/field.e2e.test.ts index bf1925e..9f85430 100644 --- a/tests/e2e/field.e2e.test.ts +++ b/tests/e2e/field.e2e.test.ts @@ -49,8 +49,8 @@ describe("field e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/git-sync.e2e.test.ts b/tests/e2e/git-sync.e2e.test.ts index 07b4278..adf6004 100644 --- a/tests/e2e/git-sync.e2e.test.ts +++ b/tests/e2e/git-sync.e2e.test.ts @@ -140,8 +140,8 @@ describe.skipIf(skipReason !== null)("git-sync e2e against EE git-sync endpoints function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/http-errors.e2e.test.ts b/tests/e2e/http-errors.e2e.test.ts index 8954ae9..9b2cbe9 100644 --- a/tests/e2e/http-errors.e2e.test.ts +++ b/tests/e2e/http-errors.e2e.test.ts @@ -23,8 +23,8 @@ describe("HTTP error messages (end-to-end)", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/measure.e2e.test.ts b/tests/e2e/measure.e2e.test.ts index f84996b..51fb9ad 100644 --- a/tests/e2e/measure.e2e.test.ts +++ b/tests/e2e/measure.e2e.test.ts @@ -54,8 +54,8 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/profiles.e2e.test.ts b/tests/e2e/profiles.e2e.test.ts index 039b781..ca9c678 100644 --- a/tests/e2e/profiles.e2e.test.ts +++ b/tests/e2e/profiles.e2e.test.ts @@ -111,7 +111,23 @@ describe("profiles e2e", () => { expect(parseJson(stagingStatus.stdout, AuthStatus).present).toBe(true); }); - it("METABASE_PROFILE env var selects the active profile when no --profile flag is passed", async () => { + it("MB_PROFILE env var selects the active profile when no --profile flag is passed", async () => { + const configHome = await makeIsolatedConfigHome(); + await loginProfile(configHome, "prod"); + + const status = await runCli({ + args: ["auth", "status", "--json"], + configHome, + env: { MB_PROFILE: "prod" }, + }); + expect(status.exitCode, status.stderr).toBe(0); + const payload = parseJson(status.stdout, AuthStatus); + expect(payload.profile).toBe("prod"); + expect(payload.present).toBe(true); + expect(status.stderr).not.toContain("deprecated"); + }); + + it("honors the deprecated METABASE_PROFILE alias and warns on stderr", async () => { const configHome = await makeIsolatedConfigHome(); await loginProfile(configHome, "prod"); @@ -124,16 +140,19 @@ describe("profiles e2e", () => { const payload = parseJson(status.stdout, AuthStatus); expect(payload.profile).toBe("prod"); expect(payload.present).toBe(true); + expect(status.stderr).toContain( + "warning: METABASE_PROFILE is deprecated; set MB_PROFILE instead", + ); }); - it("--profile flag wins over METABASE_PROFILE env var", async () => { + it("--profile flag wins over MB_PROFILE env var", async () => { const configHome = await makeIsolatedConfigHome(); await loginProfile(configHome, "staging"); const status = await runCli({ args: ["auth", "status", "--profile", "staging", "--json"], configHome, - env: { METABASE_PROFILE: "does-not-exist" }, + env: { MB_PROFILE: "does-not-exist" }, }); expect(status.exitCode, status.stderr).toBe(0); const payload = parseJson(status.stdout, AuthStatus); diff --git a/tests/e2e/query.e2e.test.ts b/tests/e2e/query.e2e.test.ts index a80991c..ce556d0 100644 --- a/tests/e2e/query.e2e.test.ts +++ b/tests/e2e/query.e2e.test.ts @@ -61,8 +61,8 @@ describe("query e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/run-cli.ts b/tests/e2e/run-cli.ts index 4c312ca..c35075f 100644 --- a/tests/e2e/run-cli.ts +++ b/tests/e2e/run-cli.ts @@ -30,7 +30,7 @@ export async function runCli(opts: RunCliOptions): Promise { PATH: process.env["PATH"], HOME: process.env["HOME"], XDG_CONFIG_HOME: configHome, - METABASE_CLI_DISABLE_KEYRING: "1", + MB_CLI_DISABLE_KEYRING: "1", ...opts.env, }; diff --git a/tests/e2e/search.e2e.test.ts b/tests/e2e/search.e2e.test.ts index c80d9d6..ea2649d 100644 --- a/tests/e2e/search.e2e.test.ts +++ b/tests/e2e/search.e2e.test.ts @@ -35,8 +35,8 @@ describe("search e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/segment.e2e.test.ts b/tests/e2e/segment.e2e.test.ts index 7433795..c9fce1f 100644 --- a/tests/e2e/segment.e2e.test.ts +++ b/tests/e2e/segment.e2e.test.ts @@ -51,8 +51,8 @@ describe("segment e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/setting.e2e.test.ts b/tests/e2e/setting.e2e.test.ts index d75bcac..542cb1b 100644 --- a/tests/e2e/setting.e2e.test.ts +++ b/tests/e2e/setting.e2e.test.ts @@ -45,8 +45,8 @@ describe("setting e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/setup.e2e.test.ts b/tests/e2e/setup.e2e.test.ts index 822efc2..5c24a19 100644 --- a/tests/e2e/setup.e2e.test.ts +++ b/tests/e2e/setup.e2e.test.ts @@ -23,8 +23,8 @@ describe("setup e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/setup/oauth-harness.ts b/tests/e2e/setup/oauth-harness.ts index 1212741..6c7f9ae 100644 --- a/tests/e2e/setup/oauth-harness.ts +++ b/tests/e2e/setup/oauth-harness.ts @@ -118,14 +118,14 @@ export async function writeOAuthProfileIntoConfigHome( credential: OAuthCredential, ): Promise { const prevXdg = process.env["XDG_CONFIG_HOME"]; - const prevKeyring = process.env["METABASE_CLI_DISABLE_KEYRING"]; + const prevKeyring = process.env["MB_CLI_DISABLE_KEYRING"]; process.env["XDG_CONFIG_HOME"] = configHome; - process.env["METABASE_CLI_DISABLE_KEYRING"] = "1"; + process.env["MB_CLI_DISABLE_KEYRING"] = "1"; try { await writeOAuthProfile(baseUrl, credential); } finally { restoreEnv("XDG_CONFIG_HOME", prevXdg); - restoreEnv("METABASE_CLI_DISABLE_KEYRING", prevKeyring); + restoreEnv("MB_CLI_DISABLE_KEYRING", prevKeyring); } } diff --git a/tests/e2e/snippet.e2e.test.ts b/tests/e2e/snippet.e2e.test.ts index 773dfd7..a068395 100644 --- a/tests/e2e/snippet.e2e.test.ts +++ b/tests/e2e/snippet.e2e.test.ts @@ -47,8 +47,8 @@ describe("snippet e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/table.e2e.test.ts b/tests/e2e/table.e2e.test.ts index c4f9b38..797a8df 100644 --- a/tests/e2e/table.e2e.test.ts +++ b/tests/e2e/table.e2e.test.ts @@ -114,8 +114,8 @@ describe("table e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/transform-job.e2e.test.ts b/tests/e2e/transform-job.e2e.test.ts index 20450dc..8cd8964 100644 --- a/tests/e2e/transform-job.e2e.test.ts +++ b/tests/e2e/transform-job.e2e.test.ts @@ -105,8 +105,8 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index f2a8156..3c94ddd 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -112,8 +112,8 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { function authEnv(): Record { return { - METABASE_URL: bootstrap.baseUrl, - METABASE_API_KEY: bootstrap.adminApiKey, + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, }; } diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index 25fdfa0..47d35a3 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -19,14 +19,14 @@ function restoreEnv(key: string, value: string | undefined): void { async function withSeedEnv(configHome: string, seed: () => Promise): Promise { const prevXdg = process.env["XDG_CONFIG_HOME"]; - const prevKeyring = process.env["METABASE_CLI_DISABLE_KEYRING"]; + const prevKeyring = process.env["MB_CLI_DISABLE_KEYRING"]; process.env["XDG_CONFIG_HOME"] = configHome; - process.env["METABASE_CLI_DISABLE_KEYRING"] = "1"; + process.env["MB_CLI_DISABLE_KEYRING"] = "1"; try { await seed(); } finally { restoreEnv("XDG_CONFIG_HOME", prevXdg); - restoreEnv("METABASE_CLI_DISABLE_KEYRING", prevKeyring); + restoreEnv("MB_CLI_DISABLE_KEYRING", prevKeyring); } } @@ -204,14 +204,14 @@ describe("version preflight enforcement e2e", () => { ); }); - it("bypasses the refusal via METABASE_CLI_SKIP_PREFLIGHT=1 and reaches the network layer", async () => { + it("bypasses the refusal via MB_CLI_SKIP_PREFLIGHT=1 and reaches the network layer", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 58); const result = await runCli({ args: ["measure", "list"], configHome, - env: { METABASE_CLI_SKIP_PREFLIGHT: "1" }, + env: { MB_CLI_SKIP_PREFLIGHT: "1" }, }); expect(result.exitCode).toBe(1);