From 792643029592d34b9f10d65afa2d85d755cd6db3 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 16 Sep 2026 16:24:03 -0400 Subject: [PATCH 01/22] handle versions better --- .claude/skills/add-e2e-test/SKILL.md | 24 +- .claude/skills/add-resource-command/SKILL.md | 97 +++- .claude/skills/review/SKILL.md | 16 +- CLAUDE.md | 22 +- README.md | 26 +- docs/architecture.md | 28 +- packages/cli/skill-data/core/SKILL.md | 16 +- .../references/building-clean-tables.md | 8 +- .../references/reusable-definitions.md | 14 +- packages/cli/skill-data/document/SKILL.md | 5 +- packages/cli/skill-data/git-sync/SKILL.md | 5 + packages/cli/skill-data/mbql/SKILL.md | 2 +- .../skill-data/mbql/references/operators.md | 35 +- packages/cli/skill-data/metadata/SKILL.md | 2 +- .../metadata/references/semantic-types.md | 8 +- .../native-sql/references/template-tags.md | 8 +- packages/cli/skill-data/notification/SKILL.md | 8 +- packages/cli/skill-data/transform/SKILL.md | 12 +- .../cli/skill-data/visualization/SKILL.md | 14 +- .../visualization/references/settings.md | 10 +- packages/cli/skills/metabase-cli/SKILL.md | 2 +- packages/cli/src/commands/alert/archive.ts | 2 +- packages/cli/src/commands/alert/create.ts | 2 +- packages/cli/src/commands/alert/get.ts | 2 +- packages/cli/src/commands/alert/list.ts | 2 +- packages/cli/src/commands/alert/send.ts | 2 +- packages/cli/src/commands/alert/update.ts | 2 +- packages/cli/src/commands/auth/list.test.ts | 20 +- packages/cli/src/commands/auth/list.ts | 23 +- packages/cli/src/commands/auth/login.ts | 13 +- packages/cli/src/commands/auth/logout.ts | 2 +- packages/cli/src/commands/auth/render.ts | 13 + packages/cli/src/commands/auth/status.test.ts | 60 +- packages/cli/src/commands/auth/status.ts | 16 +- packages/cli/src/commands/card/alerts.ts | 2 +- packages/cli/src/commands/card/archive.ts | 2 +- packages/cli/src/commands/card/create.ts | 2 +- packages/cli/src/commands/card/get.ts | 2 +- packages/cli/src/commands/card/list.ts | 2 +- packages/cli/src/commands/card/query.ts | 2 +- packages/cli/src/commands/card/update.ts | 2 +- .../cli/src/commands/collection/archive.ts | 2 +- .../cli/src/commands/collection/create.ts | 2 +- packages/cli/src/commands/collection/get.ts | 2 +- packages/cli/src/commands/collection/items.ts | 2 +- packages/cli/src/commands/collection/list.ts | 2 +- packages/cli/src/commands/collection/tree.ts | 2 +- .../commands/content-translation/download.ts | 2 +- .../commands/content-translation/upload.ts | 2 +- .../cli/src/commands/dashboard/archive.ts | 2 +- packages/cli/src/commands/dashboard/cards.ts | 2 +- packages/cli/src/commands/dashboard/create.ts | 2 +- packages/cli/src/commands/dashboard/get.ts | 2 +- packages/cli/src/commands/dashboard/list.ts | 2 +- .../commands/dashboard/parameter-values.ts | 2 +- .../src/commands/dashboard/subscriptions.ts | 2 +- .../src/commands/dashboard/update-dashcard.ts | 2 +- packages/cli/src/commands/dashboard/update.ts | 2 +- packages/cli/src/commands/db/get.ts | 2 +- packages/cli/src/commands/db/list.ts | 2 +- packages/cli/src/commands/db/rescan-values.ts | 2 +- packages/cli/src/commands/db/schema-tables.ts | 2 +- packages/cli/src/commands/db/schemas.ts | 2 +- packages/cli/src/commands/db/sync-schema.ts | 2 +- packages/cli/src/commands/document/archive.ts | 2 +- packages/cli/src/commands/document/create.ts | 2 +- packages/cli/src/commands/document/get.ts | 2 +- packages/cli/src/commands/document/list.ts | 2 +- packages/cli/src/commands/document/update.ts | 2 +- packages/cli/src/commands/eid.ts | 2 +- packages/cli/src/commands/field/get.ts | 2 +- packages/cli/src/commands/field/summary.ts | 2 +- packages/cli/src/commands/field/update.ts | 2 +- packages/cli/src/commands/field/values.ts | 2 +- .../src/commands/git-sync/add-collection.ts | 2 +- .../cli/src/commands/git-sync/branches.ts | 2 +- .../cli/src/commands/git-sync/cancel-task.ts | 2 +- .../src/commands/git-sync/create-branch.ts | 2 +- .../cli/src/commands/git-sync/current-task.ts | 2 +- packages/cli/src/commands/git-sync/dirty.ts | 2 +- packages/cli/src/commands/git-sync/export.ts | 2 +- .../commands/git-sync/has-remote-changes.ts | 2 +- packages/cli/src/commands/git-sync/import.ts | 2 +- .../cli/src/commands/git-sync/is-dirty.ts | 2 +- .../commands/git-sync/remove-collection.ts | 2 +- packages/cli/src/commands/git-sync/stash.ts | 2 +- packages/cli/src/commands/git-sync/status.ts | 7 +- packages/cli/src/commands/git-sync/wait.ts | 2 +- packages/cli/src/commands/group.ts | 1 + packages/cli/src/commands/library/create.ts | 2 +- packages/cli/src/commands/library/get.ts | 2 +- packages/cli/src/commands/library/publish.ts | 2 +- .../cli/src/commands/library/unpublish.ts | 2 +- packages/cli/src/commands/measure/archive.ts | 2 +- packages/cli/src/commands/measure/create.ts | 2 +- packages/cli/src/commands/measure/get.ts | 2 +- packages/cli/src/commands/measure/list.ts | 2 +- packages/cli/src/commands/measure/update.ts | 2 +- packages/cli/src/commands/query.ts | 2 +- .../cli/src/commands/requires-guard.test.ts | 102 ++++ packages/cli/src/commands/runtime.test.ts | 546 ++++++++++++++++-- packages/cli/src/commands/runtime.ts | 181 ++++-- packages/cli/src/commands/search.ts | 2 +- packages/cli/src/commands/segment/archive.ts | 2 +- packages/cli/src/commands/segment/create.ts | 2 +- packages/cli/src/commands/segment/get.ts | 2 +- packages/cli/src/commands/segment/list.ts | 2 +- packages/cli/src/commands/segment/update.ts | 2 +- packages/cli/src/commands/setting/get.ts | 2 +- packages/cli/src/commands/setting/list.ts | 2 +- packages/cli/src/commands/setting/set.ts | 2 +- packages/cli/src/commands/setup.ts | 2 +- packages/cli/src/commands/skills/get.test.ts | 219 ++++++- packages/cli/src/commands/skills/get.ts | 65 ++- packages/cli/src/commands/skills/list.test.ts | 170 +++++- packages/cli/src/commands/skills/list.ts | 45 +- packages/cli/src/commands/skills/path.ts | 2 +- packages/cli/src/commands/snippet/archive.ts | 2 +- packages/cli/src/commands/snippet/create.ts | 2 +- packages/cli/src/commands/snippet/get.ts | 2 +- packages/cli/src/commands/snippet/list.ts | 2 +- packages/cli/src/commands/snippet/update.ts | 2 +- .../cli/src/commands/subscription/archive.ts | 2 +- .../cli/src/commands/subscription/create.ts | 2 +- packages/cli/src/commands/subscription/get.ts | 2 +- .../cli/src/commands/subscription/list.ts | 2 +- .../cli/src/commands/subscription/update.ts | 2 +- packages/cli/src/commands/table/fields.ts | 2 +- packages/cli/src/commands/table/get.ts | 2 +- packages/cli/src/commands/table/list.ts | 2 +- packages/cli/src/commands/table/update.ts | 2 +- .../src/commands/timeline-event/archive.ts | 2 +- .../cli/src/commands/timeline-event/create.ts | 2 +- .../cli/src/commands/timeline-event/delete.ts | 2 +- .../cli/src/commands/timeline-event/get.ts | 2 +- .../cli/src/commands/timeline-event/update.ts | 2 +- packages/cli/src/commands/timeline/archive.ts | 2 +- packages/cli/src/commands/timeline/create.ts | 2 +- packages/cli/src/commands/timeline/delete.ts | 2 +- packages/cli/src/commands/timeline/events.ts | 2 +- packages/cli/src/commands/timeline/get.ts | 2 +- packages/cli/src/commands/timeline/list.ts | 2 +- packages/cli/src/commands/timeline/update.ts | 2 +- .../cli/src/commands/transform-job/create.ts | 2 +- .../cli/src/commands/transform-job/delete.ts | 2 +- .../cli/src/commands/transform-job/get.ts | 2 +- .../cli/src/commands/transform-job/list.ts | 2 +- .../cli/src/commands/transform-job/run.ts | 21 +- .../src/commands/transform-job/set-active.ts | 2 +- .../src/commands/transform-job/transforms.ts | 2 +- .../cli/src/commands/transform-job/update.ts | 2 +- .../cli/src/commands/transform-tag/create.ts | 2 +- .../cli/src/commands/transform-tag/delete.ts | 2 +- .../cli/src/commands/transform-tag/list.ts | 2 +- .../cli/src/commands/transform-tag/update.ts | 2 +- packages/cli/src/commands/transform/cancel.ts | 2 +- packages/cli/src/commands/transform/create.ts | 2 +- .../src/commands/transform/delete-table.ts | 2 +- packages/cli/src/commands/transform/delete.ts | 2 +- .../src/commands/transform/dependencies.ts | 2 +- .../cli/src/commands/transform/get-run.ts | 2 +- packages/cli/src/commands/transform/get.ts | 2 +- packages/cli/src/commands/transform/list.ts | 2 +- packages/cli/src/commands/transform/run.ts | 2 +- packages/cli/src/commands/transform/runs.ts | 2 +- packages/cli/src/commands/transform/update.ts | 2 +- packages/cli/src/commands/upgrade.ts | 2 +- packages/cli/src/commands/upload/append.ts | 2 +- packages/cli/src/commands/upload/csv.ts | 2 +- packages/cli/src/commands/upload/replace.ts | 2 +- packages/cli/src/commands/uuid.ts | 2 +- .../cli/src/core/auth/cached-server.test.ts | 53 ++ packages/cli/src/core/auth/cached-server.ts | 14 + packages/cli/src/core/auth/profile-record.ts | 5 + .../cli/src/core/auth/server-summary.test.ts | 101 ++++ packages/cli/src/core/auth/server-summary.ts | 111 ++++ packages/cli/src/core/auth/storage.test.ts | 6 + packages/cli/src/core/auth/storage.ts | 2 + .../cli/src/core/auth/temp-config-home.ts | 32 + packages/cli/src/core/auth/verify.test.ts | 8 +- packages/cli/src/core/config.test.ts | 2 + packages/cli/src/core/skills.test.ts | 399 ++++++++++++- packages/cli/src/core/skills.ts | 165 +++++- packages/cli/src/core/temp-skills-dir.ts | 24 +- packages/cli/src/main.ts | 1 + packages/cli/src/output/error.test.ts | 37 +- packages/cli/src/output/error.ts | 4 +- packages/cli/src/output/help.test.ts | 38 +- packages/cli/src/output/render.test.ts | 55 +- packages/cli/src/output/render.ts | 25 +- packages/cli/src/output/skill-list.test.ts | 38 +- packages/cli/src/output/skill-list.ts | 50 ++ packages/cli/src/output/types.test.ts | 22 +- packages/cli/src/output/types.ts | 18 +- .../cli/src/runtime/command-augment.test.ts | 23 +- packages/cli/src/runtime/command-augment.ts | 12 +- packages/cli/src/runtime/command-help.test.ts | 148 +++-- packages/cli/src/runtime/command-help.ts | 21 +- packages/client/README.md | 52 +- packages/client/package.json | 6 +- packages/client/src/client.ts | 1 + packages/client/src/domain/collection.ts | 3 +- packages/client/src/domain/library.test.ts | 139 +++++ packages/client/src/domain/library.ts | 120 +++- .../client/src/domain/transform-job.test.ts | 39 ++ packages/client/src/domain/transform-job.ts | 36 +- packages/client/src/domain/transform.test.ts | 55 +- packages/client/src/domain/transform.ts | 41 +- packages/client/src/errors.test.ts | 1 + packages/client/src/errors.ts | 21 +- packages/client/src/http/errors.test.ts | 8 +- packages/client/src/http/errors.ts | 7 +- packages/client/src/http/response-shape.ts | 3 + packages/client/src/http/transport.test.ts | 234 ++++++++ packages/client/src/http/transport.ts | 64 +- packages/client/src/index.test.ts | 2 + packages/client/src/index.ts | 40 +- packages/client/src/json.test.ts | 14 + packages/client/src/json.ts | 14 +- packages/client/src/resources/card.ts | 7 + packages/client/src/resources/collection.ts | 12 +- .../src/resources/content-translation.test.ts | 11 + .../src/resources/content-translation.ts | 2 + packages/client/src/resources/dashboard.ts | 9 + packages/client/src/resources/database.ts | 6 + packages/client/src/resources/dataset.ts | 1 + packages/client/src/resources/document.ts | 5 + .../client/src/resources/eid-translation.ts | 1 + packages/client/src/resources/field.ts | 4 + .../client/src/resources/git-sync.test.ts | 11 + packages/client/src/resources/git-sync.ts | 15 + packages/client/src/resources/library.test.ts | 151 +++-- packages/client/src/resources/library.ts | 57 +- packages/client/src/resources/measure.test.ts | 11 + packages/client/src/resources/measure.ts | 5 + packages/client/src/resources/notification.ts | 6 + .../client/src/resources/optional-parsed.ts | 7 +- packages/client/src/resources/pulse.ts | 5 + packages/client/src/resources/search.ts | 1 + packages/client/src/resources/segment.ts | 5 + packages/client/src/resources/setting.test.ts | 51 ++ packages/client/src/resources/setting.ts | 5 +- packages/client/src/resources/setup.ts | 1 + packages/client/src/resources/snippet.ts | 5 + packages/client/src/resources/table.ts | 6 + .../client/src/resources/timeline-event.ts | 5 + packages/client/src/resources/timeline.ts | 7 + .../src/resources/transform-job.test.ts | 68 ++- .../client/src/resources/transform-job.ts | 32 +- .../src/resources/transform-tag.test.ts | 11 + .../client/src/resources/transform-tag.ts | 4 + .../client/src/resources/transform.test.ts | 85 ++- packages/client/src/resources/transform.ts | 83 +-- packages/client/src/resources/upload.ts | 1 + packages/client/src/resources/user.ts | 1 + .../client/src/testing/fake-client.test.ts | 32 + packages/client/src/testing/fake-client.ts | 26 +- .../client/src/version/capabilities.test.ts | 105 ---- packages/client/src/version/capabilities.ts | 62 -- .../src/version/capability-summary.test.ts | 33 ++ .../client/src/version/capability-summary.ts | 29 + packages/client/src/version/features.test.ts | 107 ++++ packages/client/src/version/features.ts | 94 +++ .../version/major-comparison-guard.test.ts | 74 +++ .../client/src/version/preflight-error.ts | 34 +- packages/client/src/version/probe.test.ts | 8 +- packages/client/src/version/probe.ts | 12 +- packages/client/src/version/profile.test.ts | 127 ++++ packages/client/src/version/profile.ts | 86 +++ .../src/version/requirement-check.test.ts | 120 ++++ .../client/src/version/requirement-check.ts | 58 ++ .../client/src/version/requirements.test.ts | 341 +++++++++++ packages/client/src/version/requirements.ts | 158 +++++ packages/client/src/version/tag.test.ts | 21 +- packages/client/src/version/tag.ts | 26 +- tests/e2e/auth.e2e.test.ts | 24 +- tests/e2e/bootstrap-data.ts | 2 + tests/e2e/collection.e2e.test.ts | 10 +- tests/e2e/content-translation.e2e.test.ts | 5 +- tests/e2e/eid-translation.e2e.test.ts | 2 +- tests/e2e/git-sync.e2e.test.ts | 10 +- tests/e2e/help-json.e2e.test.ts | 55 ++ tests/e2e/library.e2e.test.ts | 54 +- tests/e2e/measure.e2e.test.ts | 2 +- tests/e2e/run-cli.e2e.test.ts | 4 +- tests/e2e/seed-profile.ts | 66 +++ tests/e2e/seed/ids.ts | 6 + tests/e2e/server-gate.ts | 71 +-- tests/e2e/setting.e2e.test.ts | 43 +- tests/e2e/setup/bootstrap.ts | 14 +- tests/e2e/skills.e2e.test.ts | 161 ++++++ tests/e2e/transform-job.e2e.test.ts | 135 +++-- tests/e2e/transform-tag.e2e.test.ts | 2 +- tests/e2e/transform.e2e.test.ts | 96 ++- tests/e2e/version.e2e.test.ts | 177 ++++-- 295 files changed, 6427 insertions(+), 1208 deletions(-) create mode 100644 packages/cli/src/commands/requires-guard.test.ts create mode 100644 packages/cli/src/core/auth/cached-server.test.ts create mode 100644 packages/cli/src/core/auth/cached-server.ts create mode 100644 packages/cli/src/core/auth/server-summary.test.ts create mode 100644 packages/cli/src/core/auth/server-summary.ts create mode 100644 packages/client/src/domain/library.test.ts create mode 100644 packages/client/src/domain/transform-job.test.ts delete mode 100644 packages/client/src/version/capabilities.test.ts delete mode 100644 packages/client/src/version/capabilities.ts create mode 100644 packages/client/src/version/capability-summary.test.ts create mode 100644 packages/client/src/version/capability-summary.ts create mode 100644 packages/client/src/version/features.test.ts create mode 100644 packages/client/src/version/features.ts create mode 100644 packages/client/src/version/major-comparison-guard.test.ts create mode 100644 packages/client/src/version/profile.test.ts create mode 100644 packages/client/src/version/profile.ts create mode 100644 packages/client/src/version/requirement-check.test.ts create mode 100644 packages/client/src/version/requirement-check.ts create mode 100644 packages/client/src/version/requirements.test.ts create mode 100644 packages/client/src/version/requirements.ts create mode 100644 tests/e2e/seed-profile.ts diff --git a/.claude/skills/add-e2e-test/SKILL.md b/.claude/skills/add-e2e-test/SKILL.md index f30038f7..49b4319f 100644 --- a/.claude/skills/add-e2e-test/SKILL.md +++ b/.claude/skills/add-e2e-test/SKILL.md @@ -20,7 +20,7 @@ Before generating anything, anchor to the existing harness: - `tests/e2e/bootstrap-data.ts` — the `Bootstrap` schema you must NOT redeclare. - `tests/e2e/cli-error.ts` — `cliErrorMessage` / `cliErrorCategory`, how you assert on stderr. - `tests/e2e/defaults.ts` — base URL, stack id, snapshot name. - - `tests/e2e/server-gate.ts` — `requireServer` / `serverVersionBelow`, how a suite skips. + - `tests/e2e/server-gate.ts` — `requireServer` (how a suite skips) and `serverHas` (how a suite picks its generation's branch). - `tests/e2e/seed/seeded.ts` and `tests/e2e/seed/ids.ts` — discovered and fixed entity ids. Skip this and you will reinvent harness pieces that already exist, or redeclare the bootstrap schema and silently drift from the writer. @@ -93,15 +93,29 @@ const tableId = SEEDED.tables.orders; **2b. Gate the suite on server capability.** ```ts -import { requireServer } from "./server-gate"; +import { requireServer, serverHas } from "./server-gate"; -const skipReason = requireServer("transform › transform e2e", { minVersion: 59 }); +const skipReason = requireServer("transform › transform e2e", ["transforms"]); describe.skipIf(skipReason !== null)("transform e2e", () => { ``` -- If the command under test declares `capabilities` above the baseline `{ minVersion: 58 }` — a higher major, or a `tokenFeature` like `remote_sync` or `library` — the suite must gate itself. `requireServer` feeds the persisted server probe through the production `checkCapabilities` and returns a skip reason (or `null`). Its first argument is the lane label naming the describe or test the gate guards; an unmet gate appends it to `.gate-skips..json`, which the closing block prints so a skipped lane is reported rather than counted as a passing one. +- If any method the command under test calls needs a feature — look the command's `requires` up in `METHOD_REQUIREMENTS` (`packages/client/src/version/requirements.ts`), or read `help --json`'s `requires.features` — the suite must gate itself on those feature names. `requireServer(lane, features)` feeds the persisted server probe through `createServerProfile` and the production `checkFeatures` — the same check the CLI's preflight and the client's `require()` run — and returns the skip reason (or `null`). Its first argument is the lane label naming the describe or test the gate guards; an unmet gate appends it to `.gate-skips..json`, which the closing block prints so a skipped lane is reported rather than counted as a passing one. - The point is that a lane **passes or skips**, never fails, on a server that cannot satisfy the command. A suite that hits HTTP 402 or 404 because nobody gated it is a broken lane, not a real failure. +- Where every stack answers but the answer differs by generation — a drift site whose canonical JSON carries `null` on the servers that cannot report a field, a status code that changed, a run id that is numeric on newer servers — do not gate. Branch with `serverHas("")` and assert the exact outcome each generation produces, so the lane runs everywhere and pins both shapes: + + ```ts + const reportsRunId = serverHas("transformJobRunIdIsNumeric"); + expect(parseJson(result.stdout, TransformJobRunResult)).toEqual({ + message: "Job run started", + started: true, + run_id: reportsRunId ? 1 : null, + }); + ``` + + A feature the suite needs that no rule names yet is added to `FEATURE_RULES` in `packages/client/src/version/features.ts`, named after the behaviour and verified against the Metabase release branches — never as a version comparison in the test. `serverRejectedMessage()` and `invalidDatabaseRejection(...)` in `server-gate.ts` are the shared branches for two such facts. + +- A suite that needs the CLI to see a particular server version — the skew notices, the re-probe — seeds a profile record with `seedProbedProfile` / `seedProbedProfileAt` from `tests/e2e/seed-profile.ts` rather than talking to a different server. **3. Each test gets its own config home.** @@ -314,7 +328,7 @@ If you did **not** run the e2e suite (it requires `bun run e2e:up && bun run e2e - [ ] Imports `runCli`, `mkTempConfigHome`, `cleanupConfigHome` from `./run-cli`. No `execa`/`child_process`/`fetch` import. - [ ] Reads creds via `readBootstrap()`. No hard-coded API keys, no `Bootstrap` schema redeclaration, no setup-wizard call. - [ ] Seeded entity ids come from `SEEDED` (`./seed/seeded`) or `./seed/ids`; no literal id anywhere. -- [ ] If the command's `capabilities` are above `{ minVersion: 58 }`, the suite gates on `requireServer("", {...})` via `describe.skipIf`, with a lane label naming what the gate guards. +- [ ] If any method the command calls needs a feature, the suite gates on `requireServer("", [""])` via `describe.skipIf`, with a lane label naming what the gate guards; where generations answer differently, it branches with `serverHas("")` and asserts each outcome exactly. - [ ] No `vi.mock` / `vi.spyOn` / `vi.hoisted` / `vi.fn` anywhere in the file. - [ ] No `process.env` spread into `runCli({ env: ... })`. Only the explicit keys the test needs. - [ ] Per-test `makeIsolatedConfigHome()` pattern with `tempDirs` + `afterEach` cleanup. diff --git a/.claude/skills/add-resource-command/SKILL.md b/.claude/skills/add-resource-command/SKILL.md index b0089a11..1eebd7dd 100644 --- a/.claude/skills/add-resource-command/SKILL.md +++ b/.claude/skills/add-resource-command/SKILL.md @@ -16,9 +16,9 @@ A resource straddles both workspace packages. The schema and the endpoint knowle Anchor to the existing house style. **Skip this and you will produce drift the type-checker won't catch.** 1. `ls packages/client/src/domain/` and read **one** existing resource schema file end-to-end (e.g. `packages/client/src/domain/segment.ts`). -2. Read the matching `packages/client/src/resources/segment.ts` and its wire test `packages/client/src/resources/segment.test.ts`, then `packages/client/src/client.ts` for how a namespace is composed onto the client. +2. Read the matching `packages/client/src/resources/segment.ts` and its wire test `packages/client/src/resources/segment.test.ts`, then `packages/client/src/client.ts` for how a namespace is composed onto the client, and `packages/client/src/version/requirements.ts` for how every method is keyed there. If the resource's routes or shapes differ between supported Metabase majors, also read `packages/client/src/version/features.ts` and the drift site in `packages/client/src/domain/transform.ts` + `packages/client/src/resources/transform.ts`. 3. Read `packages/cli/src/output/view.ts` (the CLI-side `ColumnDef` / `ResourceView` contract) and one existing `packages/cli/src/output/views/.ts` presentation binding. -4. Read **one** existing list command and **one** existing get command (look under `packages/cli/src/commands//`). Note how `ListEnvelope`, `outputSchema`, `capabilities`, `parseId`, `renderList`, `renderItem`, and `defineMetabaseCommand` compose. +4. Read **one** existing list command and **one** existing get command (look under `packages/cli/src/commands//`). Note how `ListEnvelope`, `outputSchema`, `requires`, `parseId`, `renderList`, `renderItem`, and `defineMetabaseCommand` compose. 5. Read **one** existing e2e test (`tests/e2e/.e2e.test.ts`) and the harness (`tests/e2e/run-cli.ts`, `tests/e2e/bootstrap-data.ts`). The `add-e2e-test` skill's runtime contract is binding here — re-read it before writing the e2e file. 6. Read `packages/cli/src/output/types.ts` for `listEnvelopeSchema`, the `ListEnvelope` interface, and `ListRange`; then `packages/cli/src/output/window.ts` for the three window helpers (`windowList`, `windowServerPage`, `collectForOutput`) and the `PageSource` / `PageRequest` contract. @@ -28,17 +28,18 @@ For a typical list/get pair on a new resource: 1. `packages/client/src/domain/.ts` — the schema pair (`` + `Compact`). No fixture, no schema-parse unit test. 2. Updated `packages/client/src/index.ts` — re-export every value the domain file exports from the public barrel. -3. `packages/client/src/resources/.ts` — `Resource(transport)`, one method per endpoint. -4. Updated `packages/client/src/client.ts` — compose the namespace onto the client as `: Resource(transport)`. -5. `packages/client/src/resources/.test.ts` — the wire test: one `it` per method, asserting the exact URL, method, headers and body through `captureFetch`. -6. `packages/cli/src/output/views/.ts` — the CLI-side `View` presentation binding. -7. `packages/cli/src/commands//index.ts` — citty group with subcommands. -8. `packages/cli/src/commands//list.ts` — exports `ListEnvelope`, uses `renderList`. -9. `packages/cli/src/commands//get.ts` — uses `parseId` for the positional id, uses `renderItem`. -10. (optional) `packages/cli/src/commands//.test.ts` — unit test **only where there is non-orchestration logic to test**. -11. `tests/e2e/.e2e.test.ts` — comprehensive e2e suite. -12. Updated `packages/cli/src/main.ts` — register the new top-level subcommand. -13. Updated `packages/cli/src/runtime/command-help.test.ts` — add the new leaf paths to the literal `ALL_COMMANDS` list. +3. `packages/client/src/resources/.ts` — `Resource(transport)`, one method per endpoint, each opening with `await transport.require(".")`. +4. Updated `packages/client/src/version/requirements.ts` — one `"."` entry per method in `METHOD_REQUIREMENTS`, and a rule in `packages/client/src/version/features.ts` if the route is gated by a major or a token feature. +5. Updated `packages/client/src/client.ts` — compose the namespace onto the client as `: Resource(transport)`. +6. `packages/client/src/resources/.test.ts` — the wire test: one `it` per method, asserting the exact URL, method, headers and body through `captureFetch`. +7. `packages/cli/src/output/views/.ts` — the CLI-side `View` presentation binding. +8. `packages/cli/src/commands//index.ts` — citty group with subcommands. +9. `packages/cli/src/commands//list.ts` — exports `ListEnvelope`, uses `renderList`. +10. `packages/cli/src/commands//get.ts` — uses `parseId` for the positional id, uses `renderItem`. +11. (optional) `packages/cli/src/commands//.test.ts` — unit test **only where there is non-orchestration logic to test**. +12. `tests/e2e/.e2e.test.ts` — comprehensive e2e suite. +13. Updated `packages/cli/src/main.ts` — register the new top-level subcommand. +14. Updated `packages/cli/src/runtime/command-help.test.ts` — add the new leaf paths to the literal `ALL_COMMANDS` list. If the resource genuinely has more verbs (e.g. a ` values` for fetching distinct values), add them under the same group; the rules below scale per-verb. @@ -46,7 +47,7 @@ If the resource genuinely has more verbs (e.g. a ` values` for fetching disti The domain file is client surface, so it lives in `@metabase/client`, not the CLI. Two constraints follow from that: -- **A domain file imports `zod` and sibling `domain/*` files, nothing else.** Not the rest of the client (`../json`, `../poll`), not `node:` builtins, and never `packages/cli` — a client file that reaches into the CLI inverts the dependency between the two packages. The wider `zod` + `semver` + `node:` budget is the client package's ceiling; `domain/` sits well inside it. +- **A domain file imports `zod`, sibling `domain/*` files and — only for a reader that selects a wire shape by generation — `import type { Features } from "../version/features"`, nothing else.** Not the rest of the client (`../json`, `../poll`), not `node:` builtins, and never `packages/cli` — a client file that reaches into the CLI inverts the dependency between the two packages. The wider `zod` + `semver` + `node:` budget is the client package's ceiling; `domain/` sits well inside it. - **Nothing CLI-shaped leaks in.** No `@clack/prompts`, no `process.stdout.write`, no `process.exit` — the client never owns presentation or process control. A single file in `packages/client/src/domain/` may host multiple resources (e.g. `domain/user.ts` exports `CurrentUser` / `CurrentUserCompact`). The pair holds **per resource**, not per file. `` is PascalCase. @@ -74,7 +75,7 @@ Rules: - **`.strip()` after `.pick()` is mandatory on the Compact**, not optional. `.pick({...})` on a `.loose()` parent inherits the loose catchall, and the picked schema then _passes every API field through unchanged at parse time_ — your "compact" projection silently leaks the full payload into list output and default (compact) JSON. The bug is invisible until you eyeball the rendered output. Always end with `.strip()`. - The compact projection is the **agent-facing contract** — it shows up in list output and default (compact) JSON. Pick the smallest set of fields that uniquely identifies + describes the resource for an LLM caller. - Type aliases via `z.infer`. Never hand-write a parallel `interface` — it will drift silently. -- **Optional vs. nullable.** Metabase returns `null` for absent values; it rarely omits the key. Default to `z.().nullable()` and reach for `.optional()` only when you have observed the key actually missing in a real response. Wrong here causes silent parse failures on real payloads. +- **Optional vs. nullable.** Metabase returns `null` for absent values; it rarely omits the key. Default to `z.().nullable()` and reach for `.optional()` only when you have observed the key actually missing in a real response **on every supported server**. `.optional()` means "any server may omit this"; a key that one generation sends and another does not is version drift and takes the wire-variant path below, never an optional. Wrong here causes silent parse failures on real payloads. - **Schema scope is principal-engineer judgment, not "mirror the frontend type."** Pick the fields the agent needs to do its job (write queries, choose content). Drop sync flags, fingerprints, JSON-unfolding metadata, audit timestamps, and other internal plumbing. `.loose()` keeps the door open for fields the agent doesn't need declared. The schema's job is to declare what's required and what's typed — not to recapitulate the API. - **Request-body schemas belong here too.** A create or update verb takes a `CreateInput` / `UpdateInput` declared alongside the resource, and both the CLI's `readBody` and the resource method's parameter type read it from this one place. - **Re-export every value the domain file exports from `packages/client/src/index.ts`**, in the alphabetical `./domain/` block. A `domain/` value the barrel does not name is not public client surface — a domain file alone does not make one. @@ -89,6 +90,44 @@ Forbidden: - Editing an existing command to wire the schema in. Every layer here is purely additive; the command in Step 3 is the one that consumes it. - Adding a `packages/client/tests/fixtures//sample.json` + colocated parse-test pair. `Schema.parse(fixture).toEqual(fixture)` is a tautology against Zod itself, with zero signal about whether the schema matches a real response. The schema is contract-tested by the e2e tier in Step 5. +### Step 1a — When a shape differs between supported Metabase majors + +Establish the fact first: read the endpoint on each release branch of the reference Metabase checkout (`origin/release-x.58.x` … the newest, and `master`), or `curl` the matrix stacks, and write down which majors answer which shape. Then: + +1. Add a rule to `FEATURE_RULES` in `packages/client/src/version/features.ts`, named after the behaviour (`transformTargetTableId`, `libraryChildrenCarryType`) and never after a version. `{ since }` for a shape that arrived, `{ since, until }` for one only older servers have, `tokenFeature` when a premium feature gates it. `features.test.ts` fails a rule that is constant across `KNOWN_RANGE`, and `major-comparison-guard.test.ts` fails a `.major` comparison anywhere else — the rule is the only place the major is read. +2. The canonical `` is the **newest** supported server's shape. A field an older server cannot report is `.nullable()` on the canonical, `null` meaning "this server cannot say". +3. Each older generation gets a module-private `WireV` schema (N is the first major answering that shape) that parses exactly what that server sends, and a converter to the canonical shape. A converter may add fields but never maps two distinguishable wire states onto one canonical state, and it strips the wire fields it owns so the canonical JSON is the same on every server. +4. Export a reader, `Schema(features: Features): z.ZodType`, that returns the canonical schema on the newest generation and `wire.transform(converter)` on the others. The resource hands it to `transport.requestParsed`, so a mismatch is the transport's `ResponseShapeError` naming the server tag. + +```ts +import type { Features } from "../version/features"; + +const TransformBase = z.object({ id: z.number().int(), name: z.string() /* … */ }).loose(); + +export const Transform = TransformBase.extend({ target_table_id: z.number().int().nullable() }); +export type Transform = z.infer; + +const TransformWireV59Detail = TransformBase.extend({ + table: z.object({ id: z.number().int() }).loose().nullable(), +}); + +function fromHydratedTable(wire: z.infer): Transform { + const { table, ...rest } = wire; + return { ...rest, target_table_id: table === null ? null : table.id }; +} + +/** The shape `GET /api/transform/{id}` answers on a server with `features`, read as `Transform`. */ +export function transformDetailSchema(features: Features): z.ZodType { + return features.transformTargetTableId + ? Transform + : TransformWireV59Detail.transform(fromHydratedTable); +} +``` + +When the converter needs a second request (the canonical field is on no generation's wire), the reader returns a tagged wire (`{ childrenCarryType: true, root }`) and a separate exported converter takes the extra input — `libraryWireSchema(features)` + `toLibrary(wire, listing)` in `packages/client/src/domain/library.ts` is the model. Unit-test the converters in `packages/client/src/domain/.test.ts` over `evaluateFeatures(, )` with exact `toEqual` per generation, and the refusal direction (an older wire on a newer profile) in the resource wire test with the exact `ResponseShapeError` message. + +Two things that look like drift are not: a setting value's encoding and a 404's content type are dispatched on runtime type and handler, identical on every supported server. Verify against the server's middleware before adding a switch. + ## Step 2 — Resource methods (`packages/client/src/resources/.ts`) One file per Metabase resource, exporting `Resource(transport)` — a factory returning the object of methods reached as `mb..(…)`. This is the only layer that names an `/api/` path or calls the transport. @@ -120,6 +159,7 @@ export function cardResource(transport: Transport) { params: CardListParams = {}, options: RequestOptions = {}, ): Promise> { + await transport.require("card.list"); const data = await transport.requestParsed(CardApiList, "/api/card", { ...options, query: { f: params.f, model_id: params.model_id }, @@ -129,11 +169,13 @@ export function cardResource(transport: Transport) { /** Get one card by id. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("card.get"); return transport.requestParsed(Card, `/api/card/${id}`, { ...options }); } /** Create a card — a question, a model, or a metric — from a full card body. */ async function create(params: CardCreateInput, options: RequestOptions = {}): Promise { + await transport.require("card.create"); return transport.requestParsed(Card, "/api/card", { ...options, method: "POST", body: params }); } @@ -143,6 +185,7 @@ export function cardResource(transport: Transport) { params: CardUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("card.update"); return transport.requestParsed(Card, `/api/card/${id}`, { ...options, method: "PUT", @@ -152,6 +195,7 @@ export function cardResource(transport: Transport) { /** Archive (soft-delete) a card by id. Metabase models this as an update, not its own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { + await transport.require("card.archive"); return update(id, { archived: true }, options); } @@ -159,7 +203,7 @@ export function cardResource(transport: Transport) { } ``` -The eight conventions every method follows: +The nine conventions every method follows: 1. `client..(...)`, namespace named after the API resource (`mb.card`, singular). The resource is what Metabase calls the thing, not what it calls the route: the file and the @@ -175,6 +219,7 @@ The eight conventions every method follows: `ListResult` = `{ data, total }`. 7. A string path parameter always goes through `encodeURIComponent`. 8. Every method carries the endpoint's description as a doc comment. +9. Every method opens with `await transport.require(".")`, keyed exactly as the method is reached on the client, and that key has an entry in `METHOD_REQUIREMENTS` (`packages/client/src/version/requirements.ts`): `[]` when every supported server answers the route, otherwise the feature names it needs, strictest first. `requirements.test.ts` fails a key the table lacks, a table key no method requires, and a method requiring a key from another resource's namespace. A method whose wire shape is selected by generation then reads `const { features } = await transport.server()` and parses with the reader from Step 1a. Convention 7 has a companion habit: when every path parameter on a resource is a numeric id, say so in a one-line comment at the top of the factory, so a reader knows the omission was decided rather than forgotten. @@ -266,7 +311,7 @@ export const ListEnvelope = listEnvelopeSchema(Compact); export default defineMetabaseCommand({ meta: { name: "list", description: "List " }, - capabilities: { minVersion: 58 }, + requires: [".list"], args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags /* + filter flags */ }, outputSchema: ListEnvelope, examples: ["mb list", "mb list --json"], @@ -306,7 +351,7 @@ renderList(envelope, collectionItemView, ctx); `collectForOutput` pulls only as far as the output byte budget can display, so an unbounded listing over a large collection costs a page or two rather than a full drain the cap then discards. It sizes each request through the `PageRequest` it hands the source — forward `max` and `pageSize` verbatim, or the budget cannot bound the walk. -Every command declares `capabilities`: `{ minVersion: 58 }` is the supported baseline (no probe, no enforcement), a higher `minVersion` or a `tokenFeature` gates the command behind the preflight check, and `null` marks a command that never touches a Metabase server. Validate the right `minVersion` against the Metabase route files before picking one. +Every command declares `requires`: the list of `"."` keys its `run` body reaches (plus the methods of any helper it hands the client to), or `null` for a command that never touches a Metabase server. `packages/cli/src/commands/requires-guard.test.ts` reads the body and fails a declaration that differs from the calls in it, so the list is transcribed, not chosen. The preflight derives the features from `METHOD_REQUIREMENTS`, runs once per command against the profile's cached probe (or one live probe), and refuses with the client's own message and exit code 2; a command whose methods need nothing never preflights. `help --json` reports the derived `requires: { methods, features }` and a `capabilities` summary. Nothing on the command names a version. The `ListEnvelope` export is **mandatory**. It is consumed by JSON help (`--help --json`, via `outputSchema`) and by the matching e2e test (which imports it back to parse `--json` output). Do **not** redeclare the envelope shape inline anywhere. It is the _CLI's_ envelope — `{ data, returned, offset, limit?, total?, has_more, next_offset?, truncated? }`, declared in `packages/cli/src/output/types.ts` — and has nothing to do with the server's wire envelope, which stayed module-private in Step 2. @@ -323,7 +368,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a by id" }, - capabilities: { minVersion: 58 }, + requires: [".get"], args: { ...outputFlags, ...profileFlag, @@ -375,6 +420,7 @@ A comprehensive suite for a typical list/get pair covers, at minimum: 3. **Get, success** — `exitCode === 0`, parsed via `` (with `--full`) or `Compact` (default). Assert the parsed object with one `toEqual({ ... })` over the full expected payload, **never** a sequence of `expect(parsed.id).toBe(...)`/`expect(parsed.name).toBe(...)` field pokes. Use a seeded id from `tests/e2e/seed/seeded.ts` (`SEEDED`) or a pinned constant from `tests/e2e/seed/ids.ts`, otherwise look up the id dynamically by listing first and filtering by a known name. 4. **Get, invalid positional** (`abc`, empty, negative, zero) — `exitCode === 2` (`ConfigError`), `stderr.toContain('invalid id: "" (expected integer)')` (the literal message from `packages/cli/src/commands/parse-integer.ts`, which `parseId` delegates to), stdout empty. 5. **Get, valid format but missing on server** (e.g. `9999999`) — `exitCode === 1` (`HttpError`), `stderr.toContain("Not found.")` (the literal `userMessage` from Metabase's 404 envelope; see `packages/client/src/http/errors.ts` for the taxonomy). +6. **Gating and generations.** A suite whose methods need a feature gates itself with `requireServer(" › ", [""])` from `tests/e2e/server-gate.ts` via `describe.skipIf`, so a stack that lacks the route reports a skip rather than a failure. Where every stack answers but the answers differ (a drift site from Step 1a), branch with `serverHas("")` and assert the exact outcome for each generation — the canonical JSON is the same shape on every server, so the assertion usually differs only in a value (`expect.any(Number)` vs `null`). Assertions are exact at every level — these are not stylistic preferences, they are hard rules from CLAUDE.md and the `add-e2e-test` skill: @@ -415,11 +461,15 @@ rg -n "/api/" packages/client/src/domain/.ts && echo FAIL || echo OK # endp rg -n "\.request(Parsed|Raw|Stream)\(" packages/client/src/resources/.ts || echo "FAIL: no transport call — is this file doing anything?" rg -n "\$\{[a-zA-Z_$][\w$]*\}" packages/client/src/resources/.ts # every string interpolation: numeric id, or encodeURIComponent? rg -n "Resource" packages/client/src/client.ts || echo "FAIL: namespace not composed onto the client" +rg -n "transport\.require\(\"\." packages/client/src/resources/.ts || echo "FAIL: no require() — every method opens with one" +rg -n "\"\." packages/client/src/version/requirements.ts || echo "FAIL: no METHOD_REQUIREMENTS entries for " +rg -n "\.major\s*(<|>|=)" packages/client/src/domain/.ts packages/client/src/resources/.ts && echo FAIL || echo OK # only version/features.ts compares a major # Command files (CLI surface — flags in, client call, render out): rg -n '["`]/api/' packages/cli/src/commands// && echo FAIL || echo OK # the API-path rule rg -n "\.request(Parsed|Raw|Stream)\(|paginatePages\(" packages/cli/src/commands// && echo FAIL || echo OK # the transport rule rg -n "from \"\.\./\.\./domain/" packages/cli/src/commands// && echo FAIL || echo OK # use @metabase/client/domain/ +rg -n "minVersion|capabilities:" packages/cli/src/commands// && echo FAIL || echo OK # commands declare `requires`, never a version # E2E test: rg -n 'from\s+"execa"|from\s+"node:child_process"|from\s+"child_process"' tests/e2e/.e2e.test.ts && echo FAIL || echo OK @@ -458,13 +508,14 @@ If either skill surfaces a structural issue (missing `.strip()`, a request built ## Sanity checks before declaring done - [ ] Step 0 actually performed (read existing domain file, resource file + wire test, `client.ts`, list command, get command, e2e test, `packages/cli/src/output/types.ts`, `packages/cli/src/output/window.ts`). -- [ ] Domain file landed in `packages/client/src/domain/`, importing only `zod` and sibling `domain/*` files. +- [ ] Domain file landed in `packages/client/src/domain/`, importing only `zod`, sibling `domain/*` files and (for a generation reader) `import type { Features }`. - [ ] **Domain pair**: `` with `.loose()`, `Compact` with `.pick({...}).strip()`. No hand-written parallel interface. - [ ] Every value the domain file exports is re-exported from `packages/client/src/index.ts`. - [ ] Closed enums pinned via `z.enum([...])` where the backend defines a closed set. - [ ] Schema scope is query/agent-relevant fields only — no sync flags, fingerprints, audit timestamps, or other internal plumbing unless they drive an actual decision. - [ ] No fixture or schema-parse unit test added. -- [ ] **Resource file** `packages/client/src/resources/.ts` exports `Resource(transport)`, holds every `/api/` path and every transport call, and follows all eight conventions — positional path params then params then options, Metabase's own field names, transport concerns in `options`, wire envelopes module-private, domain values returned, `ListResult` for a non-paginated list, `encodeURIComponent` on every string path param, a doc comment per method. +- [ ] **Resource file** `packages/client/src/resources/.ts` exports `Resource(transport)`, holds every `/api/` path and every transport call, and follows all nine conventions — positional path params then params then options, Metabase's own field names, transport concerns in `options`, wire envelopes module-private, domain values returned, `ListResult` for a non-paginated list, `encodeURIComponent` on every string path param, a doc comment per method, `await transport.require(".")` first in every method. +- [ ] **Requirements** — every method has its `"."` entry in `METHOD_REQUIREMENTS`; a gated route has a behaviour-named rule in `FEATURE_RULES` verified against the Metabase release branches; a shape that differs by generation has its `WireV` + converter + exported reader, with converter unit tests per generation. - [ ] **Namespace composed** onto `packages/client/src/client.ts` as `: Resource(transport)`. - [ ] **Wire test** `packages/client/src/resources/.test.ts` asserts URL, method, headers and body for every method through `captureFetch`. - [ ] **View binding** `View` landed in `packages/cli/src/output/views/.ts` (not the domain file, not the resource file), with any `format:` cell helpers colocated there. @@ -472,10 +523,10 @@ If either skill surfaces a structural issue (missing `.strip()`, a request built - [ ] **List command** exports `ListEnvelope = listEnvelopeSchema(Compact)` and uses it as `outputSchema`. - [ ] **List command** spreads `...listFlags` and builds its envelope with the window helper that matches who applied the window (`windowList` / `windowServerPage` / `collectForOutput`) — never a hand-rolled object. - [ ] **Get command** uses `parseId` for the positional integer id. -- [ ] Every leaf command declares `capabilities` explicitly (`{ minVersion: … }` or `null`). +- [ ] Every leaf command declares `requires` (the method keys its body reaches, or `null`) and names no version. - [ ] `packages/cli/src/main.ts` registers the new subcommand. - [ ] Command unit test added only where non-orchestration logic exists (or none added, with explicit rationale — "command body is pure orchestration" is acceptable). -- [ ] **E2E test** at `tests/e2e/.e2e.test.ts` covering at minimum: list default, list filtered (if applicable), get success, get invalid id, get missing id. +- [ ] **E2E test** at `tests/e2e/.e2e.test.ts` covering at minimum: list default, list filtered (if applicable), get success, get invalid id, get missing id; gated with `requireServer` where a stack may lack the route, branched with `serverHas` where generations differ. - [ ] E2E test imports schemas from `@metabase/client/domain/` and `../../packages/cli/src/commands//list`; no `z.object({...})` redeclaration of any output shape. - [ ] `packages/cli/src/runtime/command-help.test.ts` `ALL_COMMANDS` list updated with the new leaves. - [ ] Self-grep step (Step 7) ran clean. diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index 7039790b..8c72e1e5 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -35,7 +35,9 @@ packages/client/src/ response-shape.ts, sanitize.ts, retry.ts, oauth.ts testing/ the shipped test doubles — fake-client.ts, fetch-capture.ts auth/ OAuth login flow: credential.ts, pkce.ts, callback-server.ts, oauth-*.ts - version/ tag.ts, probe.ts, capabilities.ts, preflight-error.ts + version/ tag.ts, probe.ts, profile.ts (KNOWN_RANGE, ServerProfile), features.ts (FEATURE_RULES — + the only file comparing a Metabase major), requirements.ts (METHOD_REQUIREMENTS), + requirement-check.ts, capability-summary.ts, preflight-error.ts domain/ Metabase API resource Zod schemas + inferred types errors.ts MetabaseError taxonomy, toMetabaseError, isFileNotFoundError, errorMessage list.ts ListResult = { data, total } — what a one-response list method returns @@ -63,7 +65,7 @@ Allowed import direction — the package boundary first, then within each packag - **Nothing in the client may import from the CLI** — not by an `@metabase/cli` specifier, not by a relative path that escapes the client's own src tree. The reverse is expected: the CLI consumes the client as `@metabase/client/`. - The client's dependency budget is `zod` + `semver` + `node:` builtins. Root devDependencies hoist, so the resolver will not reject a third dependency — a production client file importing anything else is FAIL even though it type-checks. -- `domain/*` imports only `zod` and sibling `domain/*` files. Nothing from elsewhere in either package. +- `domain/*` imports only `zod`, sibling `domain/*` files and — for a reader that selects a wire schema by generation (`Schema(features)`) — `import type { Features } from "../version/features"`. A value import from `version/`, or anything else from elsewhere in either package, is FAIL. - `resources/*` is imported by `client.ts` and by sibling `resources/*` files, and by nothing else. A `domain/`, `http/`, `auth/` or `version/` file importing a resource is FAIL — the direction runs one way, and the permitted importers are an allowlist, so a directory added later is not exempt for having arrived after the rule. - `commands/*` may import `core/*`, `output/*`, `runtime/*`, and any `@metabase/client/*` path. - `core/*` may import `runtime/*` and `@metabase/client/*`. Never `commands/`, never `output/`. @@ -104,6 +106,14 @@ A method violating any of the eight is FAIL — quote the signature or the path A new or changed method comes with its wire test in `packages/client/src/resources/.test.ts` — one `it` per method, asserting URL, verb, headers and body in a single `toEqual` over `capture.calls` from `@metabase/client/testing/fetch-capture`. That test is what pins the path and query vocabulary; a method landed without one is FAIL. +## Version contract + +`packages/client/src/version/features.ts` is the only production file that compares a Metabase major (`major-comparison-guard.test.ts` scans both packages); `profile.ts` places a version and `tag.ts` parses one. A `.major` comparison, a literal major used as a decision, or a `minVersion` anywhere else is FAIL. Feature names describe a behaviour (`transformTargetTableId`), never a version (`transformV61Shape` is FAIL), and every version-gated rule must flip somewhere inside `KNOWN_RANGE` — `features.test.ts` enforces it, so a diff that raises `KNOWN_RANGE.min` without deleting the rules that went constant is FAIL. + +Every method in `resources/*.ts` opens with `await transport.require(".")` under its own namespace and has a matching `METHOD_REQUIREMENTS` entry, strictest feature first (`requirements.test.ts`). A method without one, or a command under `packages/cli/src/commands/` declaring `requires` that differs from the `client..(` calls in its body (`requires-guard.test.ts`), is FAIL. A `defineMetabaseCommand` declaring `capabilities`, a `minVersion` or a `tokenFeature` is FAIL — the summary is derived from `requires`, never declared. + +Version-driven drift is a `WireV` schema plus a converter, selected by a feature and handed to `requestParsed` through an exported `Schema(features): z.ZodType` reader. The canonical schema is the newest server's shape; a field an older server cannot report is `.nullable()`. FAIL: `.optional()` meaning "an older server omits it"; a converter mapping two distinguishable wire states onto one canonical state; a `z.union` of generation shapes parsed by trial (runtime shape detection); a converter that leaves a wire-only field on the canonical value; a discriminated union of generations as a method's public return type; version prose in `packages/cli/skill-data/` where a `requires:` frontmatter key or a `` … `` fence belongs (markers on their own lines, never inside a table). + ## Forbidden patterns and their permitted homes CLAUDE.md declares these as a **separate policy per package**, applied to production source only (`*.test.ts` files are exempt). A pattern's permitted home in one package says nothing about the other: `JSON.parse` has a home in the client and none in the CLI, `fetch` has a home in each but at different paths. The e2e-side restrictions below are part of this review too. @@ -191,7 +201,7 @@ Two rules bind both packages but come from elsewhere, so judge them repo-wide ra - The `Bootstrap` Zod schema and `BOOTSTRAP_FILE_PATH` constant live only in `tests/e2e/bootstrap-data.ts`. Re-declaring either in `tests/e2e/setup/bootstrap.ts` (or anywhere else) is FAIL — the writer imports from the reader to prevent shape drift. - `--json` output assertions parse through the schema imported from the package that owns it: `@metabase/client/domain/` for a resource, `../../packages/cli/src/commands//` for a command-shaped output (e.g. `LoginResult`, `AuthStatus`, `ListEnvelope`). Re-declaring a `z.object({...})` in a test that mirrors either export is FAIL. - Each test or test-group gets its own `XDG_CONFIG_HOME` via `mkTempConfigHome()`. Sharing config home across tests that mutate credentials is FAIL. -- A suite whose command declares non-baseline `capabilities` gates itself with `requireServer("", {...})` from `tests/e2e/server-gate.ts`, passing a lane label naming the describe or test the gate guards so an unmet gate is reported in the closing coverage block. A suite that fails instead of skipping on a server that cannot satisfy the command is FAIL. +- A suite whose command's methods need a feature gates itself with `requireServer("", [""])` from `tests/e2e/server-gate.ts`, passing a lane label naming the describe or test the gate guards so an unmet gate is reported in the closing coverage block. A suite that fails instead of skipping on a server that cannot satisfy the command is FAIL. A suite that compares a version (`.major`, a literal major) instead of asking `serverHas("")` is FAIL — the feature rule is the one place a major is read. ## Axes (output one row per axis) diff --git a/CLAUDE.md b/CLAUDE.md index 7ce8370f..903e477f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,11 +29,11 @@ This file is rules only. Architecture and rationale live in `docs/architecture.m ## Layout -| Path | What | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `packages/client` | Private Metabase API client: Zod `domain/` schemas, `resources/` methods, `http/` boundary, OAuth, version/capabilities | -| `packages/cli` | Publishable CLI: `commands/` (shell only), `core/` (pure logic), `output/` (presentation), `runtime/` (platform glue) | -| `tests/e2e` | Built-binary tier against a live Metabase | +| Path | What | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `packages/client` | Private Metabase API client: Zod `domain/` schemas, `resources/` methods, `http/` boundary, OAuth, `version/` (server profile, feature rules, method requirements) | +| `packages/cli` | Publishable CLI: `commands/` (shell only), `core/` (pure logic), `output/` (presentation), `runtime/` (platform glue) | +| `tests/e2e` | Built-binary tier against a live Metabase | - Nothing in `packages/client` may import from `packages/cli`, touch `process`, or mutate process-global state. Its dependency budget is `zod` (peer) + `semver` + `node:` builtins. - Within `packages/client`, only `client.ts` and `resources/` may import from `resources/`. The CLI may `import type` from the resource subpaths the `exports` map publishes. @@ -47,9 +47,9 @@ This file is rules only. Architecture and rationale live in `docs/architecture.m ## Commands -- Use `defineMetabaseCommand({ meta, args, capabilities, run })` from `commands/runtime.ts`, never citty's `defineCommand`. Spread the shared flag sets from `commands/flags.ts` (`outputFlags`, `profileFlag`, `connectionFlags`, plus `listFlags` for a list command). A new global flag must also be added to `GLOBAL_FLAG_ARGS` in `commands/global-flags.ts` or it will not survive hoisting. +- Use `defineMetabaseCommand({ meta, args, requires, run })` from `commands/runtime.ts`, never citty's `defineCommand`. Spread the shared flag sets from `commands/flags.ts` (`outputFlags`, `profileFlag`, `connectionFlags`, plus `listFlags` for a list command). A new global flag must also be added to `GLOBAL_FLAG_ARGS` in `commands/global-flags.ts` or it will not survive hoisting. - Commands call the client, never the wire. An `/api/` path literal or a `requestParsed` / `requestRaw` / `requestStream` / `paginatePages` call inside `src/commands/` belongs in `resources/`. Both rules are absolute and carry no allowlist. The one sanctioned request a command issues by name is `tryDiscoverMetadata` in `auth/login.ts`, which runs before there is a transport to hang it on. -- Every command declares `capabilities` explicitly: `{ minVersion }` (bare Metabase major, e.g. `58`) and/or `{ tokenFeature }`, `{}` for the v58 baseline, or `null` for a command that never reaches a server. The field is required, so an omission is a compile error. Baseline and `null` commands never preflight. +- Every command declares `requires`: the `MethodKey` list of every `client..(` its body reaches (plus the methods of any helper it hands the client to), or `null` for a command that never reaches a server. The field is required, so an omission is a compile error. `commands/requires-guard.test.ts` asserts the declaration equals the calls in the body, so a list is never guessed. The preflight derives the features from `METHOD_REQUIREMENTS`, checks them through the client's own `checkFeatures` and throws its `RequirementFailure`; a command whose methods need nothing, and a `null` command, never preflight. - Import prompts from `output/prompt.ts`, never `@clack/prompts` directly — that is where cancel becomes `AbortError`. - Anything that can block takes `interruptSignal` explicitly: client construction, every `WaitSchedule`, any long-running fetch. @@ -62,11 +62,19 @@ Every resource lives in `packages/client/src/domain/.ts` and - Trim to what an agent needs: ids, names, FK targets, base/semantic types, descriptions. Drop sync flags, fingerprints, timestamps, internal plumbing. - Pin closed enums with `z.enum([...])` where the backend enumerates the values, so a new server value fails loudly instead of passing as an untyped string. +- `.optional()` on a domain field means every supported server may omit it. Absence or a different shape on some majors is never an optional: it is a module-private `WireV` schema for that generation plus a converter to the canonical shape, selected by a feature and handed to the transport as a reader (`Schema(features): z.ZodType`), and the canonical field is `nullable`, where `null` means "this server cannot say". +- Canonical is the newest supported server's shape. A converter may add fields but never collapses two distinguishable wire states into one canonical state, and it strips the wire fields it owns so the canonical JSON is identical on every server. +- Only `version/features.ts` compares a Metabase major (`major-comparison-guard.test.ts` enforces it; `profile.ts` and `tag.ts` place and parse). Everything else branches on a named feature from `FEATURE_RULES`. A feature name describes a behaviour (`transformTargetTableId`), never a version, and every version-gated rule must be non-constant across `KNOWN_RANGE` (`features.test.ts`), so a rule that stops flipping is deleted with its wire variant and converter. +- Every resource method declares its features in `version/requirements.ts` (strictest first) and opens with `await transport.require(".")`, which refuses with `CapabilityError` before any request; `requirements.test.ts` asserts the literal set equals the table. A method that reads a version-selected shape then reads `(await transport.server()).features` and parses with the reader. - Presentation is the CLI's: the terminal binding is `View` in `packages/cli/src/output/views/.ts`. Never inline a column list in a command; never put a `ColumnDef` on the client surface. - Resource methods (`packages/client/src/resources/.ts`) are the only layer naming an `/api/` path. Path params positional, then params, then options; params use Metabase's own field names verbatim with no mapping layer; transport concerns (`signal`, `timeoutMs`, `retries`) only in the trailing options; wire envelopes stay module-private; methods return domain values, and a non-paginated list returns `ListResult`; every string path param goes through `encodeURIComponent`; every method carries the endpoint's description as a doc comment. - List commands wrap items in `ListEnvelope` via one of `windowList` / `windowServerPage` / `collectForOutput` (`packages/cli/src/output/window.ts`), and export the envelope schema as a named const used both as the command's `outputSchema` and by its e2e test. `has_more` reports what the walk observed — only a source that ran dry may report `false`, and a server count never overrides rows already in hand. `has_more: true` carries a `next_offset` greater than `offset`, or `null` when `--max-bytes` left no room for a single row — an empty window has nowhere to resume from, so it reports the rows that remain and offers no offset to repeat. - The client is embeddable: no message it produces may name the CLI, an `mb` command, or a CLI flag. +## Skills + +- A shipped skill under `packages/cli/skill-data/` describes the newest Metabase plainly, with no version prose. What it relies on is declared: `requires: [, …]` in frontmatter for the whole skill, and `` … `` around a passage, each marker on its own line and never inside a Markdown table. `core/skills.ts` validates both on every read; `core/skills.test.ts` pins the shipped skill-level map, so a new feature-bound skill is added there. + ## Tests - Test code is code: the type rules above apply in full. diff --git a/README.md b/README.md index 04aba583..51d48106 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,16 @@ Command-line client for Metabase. Logs in to an instance in your browser (OAuth, ## Supported Metabase versions -The minimum supported server is **Metabase v0.58** (major `58`). Anything older is unsupported. +The CLI is built against Metabase majors **58 through 63** (the client's `KNOWN_RANGE`), the latest patch of each; a newer server, or a head build whose version tag does not parse, runs as the newest known major with one stderr notice per run, and an older one is refused command by command with the version it needs. -Commands that need more than a baseline OSS server declare it — a higher minimum major version or a premium token feature. The server version and token features are detected and cached when you run `mb auth login` (or `mb auth list`). For those commands, a preflight check runs before the first request and refuses with an actionable message (exit code `2`) when: +Every command declares the client methods it calls, and each method names the server features it needs — a feature is a minimum major version, a premium token feature, or both. The server version and token features are detected and cached when you run `mb auth login` (or `mb auth list`). For a command whose methods need a feature, a preflight check runs before the first request and refuses with an actionable message (exit code `2`) when: - the server is older than the command's minimum version, or - the command needs a premium feature (e.g. `remote_sync`, `content_translation`, `library`) 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 `MB_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 without a cached probe, the CLI asks the server for its version once and decides on the answer; a server that cannot be reached fails the command with that network error. 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 switch off the client's own check too, so every request goes to the wire and the server answers for itself — footguns, only for servers you know are patched. + +`mb auth status --json` reports the window as `knownRange` and where the server sits as `skew`. A server above the window is treated as the newest known major — its additions pass through, and one stderr notice per run points at `mb upgrade`; a server whose version tag does not parse (head builds) is treated the same way with its own notice. A response the CLI cannot parse under a cached profile triggers one fresh probe: if the server's version or premium features changed since the cache was written, the profile is refreshed and the error says so — retry the command. ## Install @@ -102,7 +104,7 @@ Log in to a Metabase instance and save the credential to a profile. Interactive Against a server older than v63 the CLI detects the missing OAuth support and falls back to the API key prompt automatically. Supplying an API key (flag, env, or stdin) always skips the browser flow, so CI and scripts behave exactly as before. -On success the server is probed once — the rendered output shows the user, role (`Admin`/`User`), and Metabase version, and the same values are cached in `/profiles.json` so later commands skip re-probing. Failure of either the auth probe (`/api/user/current`) or the server probe (`/api/session/properties`) rejects the login; an existing profile keeps its last-known-good credential and gains a `lastFailure` entry. +On success the server is probed once — the rendered output shows the user, role (`Admin`/`User`), Metabase version and skew (`--json` adds `edition`, `knownRange` and `features`), and the probe is cached in `/profiles.json` so later commands skip re-probing. Failure of either the auth probe (`/api/user/current`) or the server probe (`/api/session/properties`) rejects the login; an existing profile keeps its last-known-good credential and gains a `lastFailure` entry. | Flag | Description | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | @@ -122,7 +124,7 @@ mb auth login --url https://m.example.com < key.txt ### `mb auth status` -Show whether a profile is authenticated. The output includes the auth method (`OAuth` or `API key`) alongside the cached user, role, and server version. +Show whether a profile is authenticated. The output includes the auth method (`OAuth` or `API key`) alongside the cached user, role, server version and skew (`supported`, `newer than this CLI knows (vN max)`, or `unknown version`). `--json` adds what the CLI derives from the cached probe: `edition`, `skew`, `knownRange` and the `features` map the preflight checks. ```sh mb auth status @@ -139,7 +141,7 @@ mb auth status --profile staging List configured authentication profiles. All profile metadata (URL, auth method, last successful probe, last failure) lives in `/profiles.json` at mode `0600`; the secrets (API key, or OAuth access/refresh tokens) sit in the OS keychain when available, or inline in the same file when the keychain is unavailable. -`auth list` re-probes every profile, one at a time — a probe can refresh and rewrite an expired OAuth token, so probes are serialized to avoid racing on the shared `profiles.json`. On success it refreshes `lastProbe` (Metabase version, token features, user identity) and clears `lastFailure`; on failure it updates `lastFailure` and leaves the prior `lastProbe`/`url`/credential untouched. Rendered columns: `Profile | URL | Auth | Status | Role | Version | Last probed`. Failed rows append a one-line footer pointing at `mb auth login --profile `. +`auth list` re-probes every profile, one at a time — a probe can refresh and rewrite an expired OAuth token, so probes are serialized to avoid racing on the shared `profiles.json`. On success it refreshes `lastProbe` (Metabase version, token features, user identity) and clears `lastFailure`; on failure it updates `lastFailure` and leaves the prior `lastProbe`/`url`/credential untouched. Rendered columns: `Profile | URL | Auth | Status | Role | Version | Skew | Last probed`; `--json` rows carry the same derived `edition`, `skew`, `knownRange` and `features` as `auth status`. Failed rows append a one-line footer pointing at `mb auth login --profile `. ```sh mb auth list @@ -1915,10 +1917,12 @@ Exit codes: `0` success (including up-to-date / printed-instructions), `1` regis The CLI ships with bundled agent skills (Claude Code / `npx skills add` compatible) that document `mb` itself. Content is served at runtime from the installed CLI version, so the instructions an agent fetches always match the binary it's about to run — no drift between a separately-installed skill copy and the CLI. ```sh -mb skills list # discover bundled skills (table or JSON) -mb skills get core # print the top-level guide +mb skills list # bundled skills the profile's server can use (table or JSON) +mb skills list --all # every bundled skill, whatever the server +mb skills get core # print the top-level guide, sections the server cannot use left out mb skills get core --full # include references and templates mb skills get git-sync,transform # comma-separated, multi-skill fetch +mb skills get transform --all # print a skill as written, even one the server cannot use mb skills get --all --json --max-bytes 0 # every non-hidden skill, structured (default cap truncates) mb skills path # absolute paths for direct Read mb skills path core # one path @@ -1926,6 +1930,8 @@ mb skills path core # one path `mb skills get` honors the shared `--max-bytes` list cap. With the default 24 576 cap, `--all` will return only the first skill and emit a truncation notice — pass `--max-bytes 0` to dump every skill in one envelope. +Skills describe the newest Metabase plainly and declare what they rely on: a skill's frontmatter carries `requires: [, …]` (names from the client's feature table, e.g. `transforms`, `remoteSync`), and a passage inside a skill or one of its references sits between `` and `` markers, each on its own line. `skills list` and `skills get` read the profile's cached server probe (`--profile` respected; no request is made) and resolve both against it: a skill whose features the server lacks is left out and reported in the JSON envelope's `unavailable` array as `{ name, failure }`, where `failure` is the same `{ reason, detail, feature, since, tokenFeature, serverVersion }` a refused command carries; a met section keeps its text and loses its markers; an unmet one is removed. Text mode reports each skipped skill on stderr. Without a cached probe nothing is filtered, `unavailable` is `null`, the markers are printed as written, and text mode says so on stderr. `--all` bypasses the filter on both commands (with names on `get`, it prints those skills as written). An unknown feature name or an unbalanced marker pair is a `ConfigError` on every read, so a typo fails the gate rather than hiding a skill. + Bundled skills: | Name | Use | @@ -1947,7 +1953,7 @@ Discovery surfaces: - **Claude Code plugin marketplace**: `.claude-plugin/marketplace.json` declares a `metabase-cli` plugin pointing at the in-repo discovery stub. Users install with `/plugin marketplace add metabase/mb-cli` then `/plugin install metabase-cli@metabase`. The manifest lives at the repo root and is served from GitHub, not from the npm tarball: its `source: "./packages/cli"` is resolved relative to the repo checkout, so a copy inside the published package would point at nothing. `files` in `packages/cli/package.json` therefore omits `.claude-plugin` by design. - **`npx skills add`**: the same stub at `packages/cli/skills/metabase-cli/SKILL.md` is picked up by `npx skills add metabase/mb-cli`. The stub is intentionally minimal — it redirects the agent at `mb skills get core` so the real workflow content always comes from the installed CLI version. -Exit codes: `0` success, `2` `ConfigError` (missing name, unknown name, `MB_SKILLS_DIR` not a directory), `1` unexpected I/O. +Exit codes: `0` success (a skill the server cannot use is reported, not refused), `2` `ConfigError` (missing name, unknown name, `MB_SKILLS_DIR` not a directory, an unknown feature in `requires`, an unbalanced section marker), `1` unexpected I/O. ## Environment variables @@ -1969,7 +1975,7 @@ The former `METABASE_`-prefixed names (`METABASE_URL`, `METABASE_API_KEY`, `META Every node of the command tree answers `--help --json` with machine-readable help, mirroring what text help shows at that level: -- A leaf command emits its full entry — name, description, `details`, examples, citty args with types/defaults/enums, `capabilities` (min server version / token feature), and the input and output Zod schemas rendered as JSON Schema (`inputSchema` is the exact validator `readBody` enforces on the JSON body, `null` for commands that take none). +- A leaf command emits its full entry — name, description, `details`, examples, citty args with types/defaults/enums, `requires` (the client methods the command calls and the server features they need), `capabilities` (the same requirements summarized as a minimum server version and, where one is needed, a token feature), and the input and output Zod schemas rendered as JSON Schema (`inputSchema` is the exact validator `readBody` enforces on the JSON body, `null` for commands that take none). - A command group (and the root) emits `{ description, skills, commands }` — its own sentence (`null` when it declares none), its own agent-skill pointers, and a flat `commands: [{ command, description }]` index of every leaf in its subtree, with full-path names. ```sh diff --git a/docs/architecture.md b/docs/architecture.md index 1bb65f1d..9f9e2f65 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ Why the repo is shaped the way it is. The binding rules are in `CLAUDE.md`; this ## Two packages -A bun workspace. `packages/client` (`@metabase/client`) is the Metabase API client — Zod domain schemas, the HTTP boundary, the OAuth protocol and login flow, the version/capability model. `packages/cli` (`@metabase/cli`) is the publishable CLI built on it. +A bun workspace. `packages/client` (`@metabase/client`) is the Metabase API client — Zod domain schemas, the HTTP boundary, the OAuth protocol and login flow, the server version model. `packages/cli` (`@metabase/cli`) is the publishable CLI built on it. The CLI consumes the client as `@metabase/client/` and inlines it at build time through tsdown's `noExternal`, so nothing under `dist/` names the private package. That is why the CLI's manifest never lists it as a dependency. @@ -52,13 +52,29 @@ At the CLI layer the same principle governs `has_more`. Over-reporting costs one Truncation by the output byte cap recomputes `has_more` / `next_offset`, making a capped result a resumption point. An envelope pointing the caller back at the offset it just used would livelock an agent, so a window that returned no rows reports no continuation. -## Capabilities and preflight +## Server versions -The minimum supported server is Metabase v0.58, which is why `{ minVersion: 58 }` is the baseline and is treated as "no gating" — no probe, no enforcement. +The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packages/client/src/version/profile.ts`, and only the latest patch of each major: patch releases carry fixes, not API-contract changes, so the e2e matrix boots the latest patch per major and that is the whole contract. Inside the window every difference the client cares about is a named feature; outside it the client degrades in one stated way rather than guessing. -The server version and token features are probed once on `auth login` / `auth list` and cached in the profile record. A non-baseline command preflights against that cache and throws `CapabilityError` (exit 2) on a mismatch, or warns and proceeds when the version is unknown. `--skip-preflight` or `MB_CLI_SKIP_PREFLIGHT=1` bypasses it. +**One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (parsed tag, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's semver major — `v0.` is OSS, `v1.` is EE — and, for a tag that does not parse, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`; a tag that does not parse — head images report `vUNKNOWN` — lands in the same slot as `unknown`. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. -To find the right `minVersion` for a new endpoint, validate against `../metabase` at `origin/release-x.58.x` — route files `src/metabase/api_routes/routes.clj` and `enterprise/backend/src/metabase_enterprise/api_routes/routes.clj`. Token-feature keys are the underscored map keys in `src/metabase/premium_features/settings.clj`. +**`features.ts` is the only file that compares a major.** `FEATURE_RULES` maps a behaviour name to `{ since, until?, tokenFeature? }`; `evaluateFeatures` turns the placed major and the token map into `Features`, a record of booleans every consumer branches on. Two tests keep the table honest: `major-comparison-guard.test.ts` scans both packages' source for a `.major` comparison or a `minVersion` outside the version layer, and `features.test.ts` asserts every version-gated rule is non-constant across `KNOWN_RANGE.min … max + 1` and that no name carries a version. When a major leaves the window the rules that stopped flipping fail the second test and are deleted with their wire variants — that is what keeps the switch count bounded. Rules exist for three kinds of consumer: resource methods and readers (`transforms`, `transformTargetTableId`, `libraryChildrenCarryType`), e2e suites that pin per-generation server behaviour (`invalidMbqlIsBadRequest`, `collectionItemsTotalOnEmptyPage`), and skill prose (`boxplotDisplay`, `smartLinkMeasureModel`); a rule no client code reads is still a rule, its consumer is the gate or the skill. + +**Every method declares what it needs and refuses before the wire.** `version/requirements.ts` keys `METHOD_REQUIREMENTS` by `.` exactly as the method is reached on the client, listing the features it needs strictest first (the first unmet one is what the refusal names, and naming the lowest floor sends a user through one upgrade only to be refused again). Each resource method opens with `await transport.require(key)`; a method whose list is empty returns at once and never probes, so a baseline command with no cached probe stays socket-free. Otherwise the transport reads the profile and throws `CapabilityError` carrying a `RequirementFailure` — `reason`, `detail`, `feature`, `since`, `tokenFeature`, `serverVersion` — before any request. `requirements.test.ts` asserts the literal `require("…")` set in `resources/` equals the table. `ClientOptions.enforceRequirements: false` is the escape hatch; the CLI wires `--skip-preflight` / `MB_CLI_SKIP_PREFLIGHT=1` to it, and a version-selected reader then still costs one lazy probe because the wire shape is chosen from the profile. The module imports only `FeatureName`, so the CLI's preflight and `--help` reach it without dragging `domain/` onto the startup chunk. + +**The CLI's preflight asks the client.** A command declares `requires: readonly MethodKey[] | null`, the methods its body reaches; `commands/requires-guard.test.ts` reads every command file and asserts the declaration equals the `client..(` calls in it plus the methods of the helpers it hands the client to — verified, not derived at runtime, because runtime derivation would import the resource layer before `--help`. `defineMetabaseCommand` unions the methods' features, and `createPreflightEnforcer` runs once per command: `client.server()` (cached or one live probe), `checkFeatures`, and the same `CapabilityError` (exit 2) the client would raise moments later. A probe that fails ends the command with that network error rather than warning and proceeding, which would only buy a second connection attempt and the same error. `help --json` reports `requires: { methods, features }` beside `capabilities` — `summarizeCapabilities` collapses the features into `{ minVersion, tokenFeature? }`, the one place a floor is built, for consumers that want a single number. + +**Canonical schema, exact wire variants, lossless converters.** The exported domain schema of a resource is the shape the newest supported server returns. Where an older generation answers differently it gets a module-private `WireV` schema (N is the first major answering that shape) and a converter to the canonical shape; the feature switch selects deterministically, the transport parses that one schema, and a mismatch is its `ResponseShapeError` naming the server tag and the expected fields. There is no guessing between shapes. The canonical field is `nullable`, `null` meaning "this server cannot say"; a converter may add fields but never maps two distinguishable wire states onto one (`job_run_id` — an opaque stub on released servers, a number or `null` on head — becomes `{ started, run_id }`, not `run_id` alone); and a converter strips the wire fields it owns so the JSON the CLI prints is identical on every server. Readers are schema factories the resource hands to `requestParsed` — `transformDetailSchema(features)` / `transformRowSchema(features)` in `domain/transform.ts`, `transformJobRunResultSchema(features)` in `domain/transform-job.ts` — so the exact-schema failure is the transport's and no raw `ZodError` escapes. When the converter needs a second request (`domain/library.ts`: `type` rides the wire from one generation on, `is_remote_synced` is on no generation's wire and comes from the collection listing), the reader tags its generation (`libraryWireSchema(features)`) and a separate exported converter (`toLibrary(wire, listing)`) finishes the job. The pattern to copy is in `domain/transform.ts` and `resources/transform.ts`: `TransformBase` for the fields every generation shares, `Transform` = base plus the newest link, `TransformWireV59Detail` for the hydrated older detail, and the resource doing `await transport.require(key)`, `const { features } = await transport.server()`, `transport.requestParsed(reader(features), path, opts)`. + +Two things that look like version drift are not, and must not become switches: a setting's value encoding (a string-typed setting answers bare `text/plain`, everything else `application/json`, and a keyword's bare name under that label is the one body that lies) is dispatched on the value's runtime type by middlewares unchanged since v58, so `parseJsonOrPlain` is a correct sniff; and a resource-missing 404 is `text/plain` `Not found.` on every generation while a route-missing 404 is a JSON string literal, dispatched on which handler produced it, so `isRouteMissingResponse` is a correct sniff too. + +**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run pointing at `mb upgrade`; an unparseable tag gets its own notice. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. A `ResponseShapeError` raised under a cached profile earns one re-probe (`refreshProfileOnShapeError` in `commands/runtime.ts`): a server whose version tag or granted token features changed since the cache was written has its record rewritten and the error says so; the command is not retried, because its request may have been a write. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). + +**Skills carry `requires`.** Skill prose describes the newest behaviour with no version branches — logic executed by a model is untestable and drifts. A skill declares `requires: [, …]` in its frontmatter and fences a passage between `` and ``; `mb skills list` / `mb skills get` resolve both against the profile's cached probe (`core/auth/cached-server.ts`, no request), leave out a skill the server cannot use, strip an unmet passage, and report the skipped skills as `unavailable: [{ name, failure }]` with the same `RequirementFailure` a refused command carries. A marker must sit on its own line and never inside a Markdown table, because the formatter splits a table around an HTML comment. + +**Adding a version-conditional feature** is: a rule in `FEATURE_RULES` named after the behaviour, verified against the reference Metabase checkout's release branches (route files `src/metabase/api_routes/routes.clj` and `enterprise/backend/src/metabase_enterprise/api_routes/routes.clj`; token-feature keys in `src/metabase/premium_features/settings.clj`; trust the source over a branch's committed `openapi.json`, which can lag its routes); the method's entry in `METHOD_REQUIREMENTS`; `requires` on the command; and, where a shape differs per generation, the wire variant and converter. The e2e suite gates with `requireServer(lane, [])` where a server may lack the route and branches with `serverHas(feature)` where every stack answers and the answers differ. + +**When a new Metabase major ships:** raise `KNOWN_RANGE.max`, add the `oss-` / `ee-` stacks to `scripts/e2e-matrix.ts` and run them, and when a major leaves the window at the other end raise `min` and run the unit tier — `features.test.ts` flags every rule that is now constant, and each one is deleted together with its wire variant, converter, `METHOD_REQUIREMENTS` entries, skill markers and e2e branches. Update the range the root `README.md` states. Nothing else names a major. ## The e2e stack @@ -66,7 +82,7 @@ The suite drives the built binary against a real Metabase in docker compose, wit The bootstrap is idempotent and reuses its artifact while the stored key still authenticates, so iteration cost is test execution rather than setup. It refuses to seed a server that already holds seed content, because seeding twice duplicates every entity. The artifact and snapshot are working-tree state while the app-db is a docker volume, so a fresh worktree or a `git clean -x` puts them out of step; `bun run e2e:down && bun run e2e:up && bun run e2e:bootstrap` is the way back. -A green summary reports the lanes that ran. The closing coverage block reports the ones that did not and why — locally, absent an EE license token, that is every `remote_sync`- and `library`-gated lane. When pasting a run as evidence, paste that block too. +A green summary reports the lanes that ran. The closing coverage block reports the ones that did not and why — locally, absent an EE license token, that is every `remote_sync`-, `library`- and `content_translation`-gated lane. A gate is `requireServer(lane, [])`, the same `checkFeatures` the CLI's preflight runs over the bootstrap's probe, so a lane skips exactly where the command would refuse; `serverHas(feature)` is for a suite that runs on every stack and asserts the outcome its generation produces. When pasting a run as evidence, paste that block too. ## Generated and vendored files diff --git a/packages/cli/skill-data/core/SKILL.md b/packages/cli/skill-data/core/SKILL.md index 49e0ebff..9e9002d5 100644 --- a/packages/cli/skill-data/core/SKILL.md +++ b/packages/cli/skill-data/core/SKILL.md @@ -129,7 +129,9 @@ Routine verb shapes (list / get / create / update), every flag, and output schem - **table fields.** `table get` never returns fields on its own — pass `--include fields` (compact; the underlying query_metadata response also carries FK targets and dimensions, visible under `--full`) or use `table fields ` (list envelope). `table update` patches table-level metadata only; physical columns aren't editable. - **field has no `list`.** Fields are per-table — get them via `table get --include fields`. Never enumerate fields across a whole db (context blow-up). `field summary` is live cardinality `{field_id, count, distincts}`; `field values` is the cached distinct set (`has_more_values: true` ⇒ truncated cache). `field update` patches metadata only (`base_type` isn't editable) — this is where you set a column's `semantic_type` or foreign-key target. - **upload (CSV → tables).** `upload csv --file ` creates a new table + model (prints `{model_id, table_id}`); `upload append ` / `upload replace --file ` add to / overwrite a table **previously created by upload** (columns must match). The destination db+schema is admin-configured, not per-call — check with `mb setting get uploads-settings --json` (`db_id: null` ⇒ uploads off/unconfigured; needs admin to read). `--collection ` only sets the model's collection. Max 50 MB. Errors: **"The uploads database is not configured."** = no db has uploads enabled; **"Uploads are not enabled."** = the append/replace target isn't an uploaded table. -- **content-translation.** EE-only (`content_translation` premium feature), admin-only, and separate from Remote Sync. `content-translation download > translations.csv` streams the complete active dictionary; `content-translation upload --file translations.csv` replaces every active translation with the file's contents. Always upload the canonical complete CSV, never a partial patch. An empty dictionary downloads as Metabase's four-row sample dictionary — don't re-upload it as real translations. Metabase limits dictionaries to 1.5 MiB. + +- **content-translation.** Admin-only, and separate from Remote Sync. `content-translation download > translations.csv` streams the complete active dictionary; `content-translation upload --file translations.csv` replaces every active translation with the file's contents. Always upload the canonical complete CSV, never a partial patch. An empty dictionary downloads as Metabase's four-row sample dictionary — don't re-upload it as real translations. Metabase limits dictionaries to 1.5 MiB. + - **card.** `dataset_query` is the **flat** `mbql/query` value, not a legacy `{type:"query",query:…}` envelope (→ `mbql`). `--export-format csv|xlsx` streams the raw export (pipe to a file), bypassing the JSON envelope. `archive` is the only delete; unarchive with `update --body '{"archived":false}'`. `visualization_settings` keys are scoped by `display` and aren't pre-flighted — see `visualization`. - **dashboard.** Dashcards round-trip through `PUT /api/dashboard/:id` (no per-dashcard endpoint): `update-dashcard ` patches one safely; `update --body '{"dashcards":[…]}'` replaces the whole set (omitted ids are deleted server-side; negative ids for new cards). Every dashcard must include `card_id`, including existing rows; use `card_id:null` plus a `visualization_settings.virtual_card` block (`{display:"text"|"heading"|"link"|…}`) for non-question cards. `create` accepts the **same** `dashcards` array in its initial body, so lay out the whole dashboard in one call. `create`/`update` pre-flight every positive `card_id` and exit **2** with `{ok:false,errors:[…]}` on a bad ref (non-bypassable). `dashboard get ` (or `--full`) hydrates dashcards/tabs; `list` omits them. **The grid is 24 columns wide:** each dashcard's `{col, row, size_x, size_y}` is in grid units — **full-width is `size_x: 24`** (`size_x: 12` is half a row, the usual cause of a card filling only half the width). Keep `col + size_x ≤ 24`, start a full-width stack's `col` at 0, and don't overlap (the server stores collisions as sent — no auto-fix). Layout patterns and per-chart default sizes → the `dashboard` skill; load it before composing any `dashcards` array. - **dashboard parameters (filters).** A dashboard's `parameters` array holds its filter widgets; they're part of the dashboard record, so read them with `dashboard get --fields parameters --json` (no separate verb). **Editing replaces the _whole_ array** (like dashcards), so it's a read-modify-write loop and omitting a parameter deletes it. A parameter only filters a card once it is **mapped** onto that dashcard's `parameter_mappings` — an unmapped parameter is an inert widget. `type` is a **closed enum**; an unlisted value is a hard parse error that echoes the full allowed set back to you. `dashboard parameter-values [--query ]` fetches a widget's selectable values (`{values, has_more_values}`; `--query` is a case-insensitive substring search). Parameter types, ids, mapping targets, and value sources → the `dashboard` skill; load it before authoring a `parameters` array. @@ -140,10 +142,14 @@ Routine verb shapes (list / get / create / update), every flag, and output schem - **collection ``** accepts four forms only — positive int, `root`, `trash`, or a 21-char entity_id; anything else is a client-side `ConfigError`. `collection items` pages the server endpoint, pulling only as far as the output cap can show — read `has_more`/`next_offset` to continue. `collection tree` is **JSON-only** (`--format text` is rejected). A transform collection needs `collection create --namespace transforms`. - **setting set** parses the value as **strict JSON**: a string is `'"value"'` (inner quotes), booleans `true`/`false`, numbers bare. Wrong quoting silently errors — confirm with `setting get ` after. `setting get --json` works on every value type (wrapping bare-text responses into `{key, value}`). - **search vs. list.** For plain enumeration of cards/dashboards/collections use the dedicated `… list` verbs; reach for `search --models ` only for ranking against a query string or a cross-resource lookup. + - **transform.** Iterate with `transform update `, never `delete` + `create` (keeps the row, `entity_id`, materialized table, and YAML filename — avoids `_2` suffixes and noisy git history). `transform run` needs `--wait` (or `--sync`, which also waits for the output table to register and returns `target_table_id`) or you get only `{run_id, final:null}`. (→ `transform`.) + - **setup is one-shot.** `mb setup` walks `/api/setup` for a **fresh** instance only — errors against an already-configured one. Mostly for bootstrapping local / e2e instances. - **eid** translates a string entity id → numeric id: `mb eid --model --json`. Entity ids are NanoIDs that can start with `-`, which the positional form misreads as a flag (shell quotes don't help) — for those, use `--body '{"entity_ids":{"card":["-…"]}}'` (the id is a JSON string value, immune to flag parsing). -- **library.** EE-only (`library` premium feature, v59+). The Library is a curated subtree (`library-data` "Data" + `library-metrics` "Metrics" under a `library` root): tables published to **Data** appear first in data pickers and rank up in search; metrics saved to **Metrics** are prioritized in nav, search, and the query builder — it's how you tell people (and agents) "start from these, they're trusted." `library get` shows the Library and its Data/Metrics collection ids; `library create` provisions it (idempotent). `library publish --table-ids/--db-ids/--schemas` publishes tables into Data — it **resolves the Data collection itself and creates the Library if absent** (no collection id to find); each `--schemas` entry is `:` (e.g. `1:public`), not a bare name. `publish` cascades to upstream FK dependencies, `unpublish` to downstream dependents; both need **admin or data-analyst** (Curate alone won't publish) and exit **403** without write **and** query permission on every affected table. Publish status shows on the table: `table get`/`table list` carry `is_published` (`collection_id` under `--full`). Good candidates are finished, analysis-ready tables — clean/combine via transforms first, then publish the polished result. Publishing does not put the Data collection in the git-sync scope: on a remote-sync instance, `mb git-sync add-collection ` is what makes exports carry the published tables' metadata (see the `git-sync` skill). + +- **library.** The Library is a curated subtree (`library-data` "Data" + `library-metrics` "Metrics" under a `library` root): tables published to **Data** appear first in data pickers and rank up in search; metrics saved to **Metrics** are prioritized in nav, search, and the query builder — it's how you tell people (and agents) "start from these, they're trusted." `library get` shows the Library and its Data/Metrics collection ids; `library create` provisions it (idempotent). `library publish --table-ids/--db-ids/--schemas` publishes tables into Data — it **resolves the Data collection itself and creates the Library if absent** (no collection id to find); each `--schemas` entry is `:` (e.g. `1:public`), not a bare name. `publish` cascades to upstream FK dependencies, `unpublish` to downstream dependents; both need **admin or data-analyst** (Curate alone won't publish) and exit **403** without write **and** query permission on every affected table. Publish status shows on the table: `table get`/`table list` carry `is_published` (`collection_id` under `--full`). Good candidates are finished, analysis-ready tables — clean/combine via transforms first, then publish the polished result. Publishing does not put the Data collection in the git-sync scope: on a remote-sync instance, `mb git-sync add-collection ` is what makes exports carry the published tables' metadata (see the `git-sync` skill). + - **query / uuid.** `mb query` is the ad-hoc MBQL surface (`--print-schema` → `--dry-run` → run); `mb uuid --count ` mints the `lib/uuid` values MBQL clauses need. Both live in `mbql`. ## Specialized skills (load on demand) @@ -156,12 +162,16 @@ This file is enough for any single-command task. For anything deeper, load the r - **`dashboard`** — building interactive dashboards: wiring filters (parameters + mappings), linked/cascading filters, cross-filtering, click behavior, series, and tabs. Load beyond a plain card-layout task. - **`metadata`** — setting field/table metadata: semantic types, foreign-key targets, dropdown/scan behavior, and column visibility, and the downstream features each unlocks. Load when editing what a column _means_, not its data. - **`notification`** — scheduled delivery: question alerts (`mb alert`) and dashboard subscriptions (`mb subscription`). Choosing between them, the two schedule/recipient contracts, channel prerequisites, testing a send. + - **`transform`** — transform body JSON, create + run-with-wait, run inspection, tags, jobs. + - **`document`** — Metabase documents (TipTap body, embedding cards). + - **`git-sync`** — round-tripping content to/from a git remote. + - **`data-workflow`** — the guided, end-to-end data workflow: investigate raw data, build clean analysis-ready tables, define reusable segments/measures/metrics, answer questions, build dashboards. **Start here when the user states a goal rather than a single verb** — "make sense of my data", "build a data model", "go from raw data to a dashboard", "be my data analyst", "set up analytics for X". It detects where the data is and routes to the right stage. -If a task spans more than one, load each. `mb skills list` enumerates everything on the installed version. +If a task spans more than one, load each. `mb skills list` enumerates the skills the profile's server can use, and `mb skills get` prints a skill with the sections that server cannot use left out; `--all` shows everything. ## Don't diff --git a/packages/cli/skill-data/data-workflow/references/building-clean-tables.md b/packages/cli/skill-data/data-workflow/references/building-clean-tables.md index 5ad20cd1..eace056d 100644 --- a/packages/cli/skill-data/data-workflow/references/building-clean-tables.md +++ b/packages/cli/skill-data/data-workflow/references/building-clean-tables.md @@ -131,7 +131,7 @@ Then make the links real, not just implied: - **Wire foreign keys between your tables.** Mark each linking id as a foreign key pointing at the id it references — set the column's type to foreign-key and its target so Metabase itself knows the tables connect and can traverse them. - **Graft onto existing clean data** the user approved (step 3 / Phase 1): point the linking id at the existing table's id the same way. Link, don't duplicate. -**Set the metadata — a transform's output starts blank, and these tables are Library-bound.** A fresh transform table has no descriptions, raw column names, and untyped columns. You worked it out while investigating; don't leave that knowledge stranded in this chat. Set it on the table so the data explains itself inside Metabase (search, the Question editor, Metabot) and is fit to publish. The mechanics — `mb field update` for semantic types / FK targets / display names, `mb table update` for table descriptions, and the feature each edit unlocks — are in the `metadata` skill; the calls about _what_ to set: +**Set the metadata — a transform's output starts blank, and these tables are the ones people will start from.** A fresh transform table has no descriptions, raw column names, and untyped columns. You worked it out while investigating; don't leave that knowledge stranded in this chat. Set it on the table so the data explains itself inside Metabase (search, the Question editor, Metabot) and is fit to publish. The mechanics — `mb field update` for semantic types / FK targets / display names, `mb table update` for table descriptions, and the feature each edit unlocks — are in the `metadata` skill; the calls about _what_ to set: - **Semantic types — the highest-value piece.** A column's semantic type is what makes Metabase treat it right: `type/Email`, `type/Currency`/`type/Price`, `type/Category` (turns into a filter dropdown), `type/City`/`type/State`/`type/Country`, `type/CreationTimestamp`, `type/Description`. Set it on every column whose meaning you decoded. A typed column shows money as money, offers a filter dropdown, and lands on the right chart axis for everyone downstream; an untyped one is a guess. - **Descriptions.** A one-line description on each table and every non-obvious column. @@ -165,7 +165,11 @@ Then report plainly: End on that connection map: it's what the user reads to trust the result, and what lets whatever they build next join on the right ids instead of guessing. -These clean tables are exactly what belongs in the **Library** — published tables appear first when anyone picks a data source, so people start from your curated set, not the raw source. If the user wants that, publish the polished tables to the Library (`mb library publish` / `mb library create` mechanics, premium feature, and permissions are in `core`). Defining reusable segments / measures / metrics on top is the **reusable-definitions** stage (`references/reusable-definitions.md` in this skill). + + +These clean tables are exactly what belongs in the **Library** — published tables appear first when anyone picks a data source, so people start from your curated set, not the raw source. If the user wants that, publish the polished tables to the Library (`mb library publish` / `mb library create` mechanics and permissions are in `core`). Defining reusable segments / measures / metrics on top is the **reusable-definitions** stage (`references/reusable-definitions.md` in this skill). + + --- diff --git a/packages/cli/skill-data/data-workflow/references/reusable-definitions.md b/packages/cli/skill-data/data-workflow/references/reusable-definitions.md index 93e53fc9..43792d26 100644 --- a/packages/cli/skill-data/data-workflow/references/reusable-definitions.md +++ b/packages/cli/skill-data/data-workflow/references/reusable-definitions.md @@ -50,7 +50,9 @@ Two things never bend in any mode: when genuinely unsure, **ask** (the Shared Co - "Let me add up revenue the same way everywhere, on this table" → a **measure** on the table. - "Revenue is an _official company number_ people pull onto dashboards" → a **metric** in a collection, with a default month-by-month view so it charts cleanly. Lean: make it a metric when it's a headline figure the org reuses across many questions/dashboards; keep it a measure when it's a table-local convenience. - **Where the metric lives.** Metrics sit in a collection (folder). Lean: put the org's blessed ones in the shared **Library** so they surface prominently; keep experimental ones in a working collection until trusted. -- **Publish the official tables to the Library.** The clean, analysis-ready tables your definitions sit on are the org's official starting points — the **Library** is how you mark them as such. Tables published to the Library's **Data** section appear _first_ when anyone picks a data source, nudging people toward your curated tables instead of raw warehouse ones. Lean: publish the wide clean tables you built the semantic layer on; hold back raw or half-built ones. Surface which tables you'd publish and confirm. (Library is a Pro/Enterprise feature; only admins and data analysts can publish — mechanics in `core`.) + +- **Publish the official tables to the Library.** The clean, analysis-ready tables your definitions sit on are the org's official starting points — the **Library** is how you mark them as such. Tables published to the Library's **Data** section appear _first_ when anyone picks a data source, nudging people toward your curated tables instead of raw warehouse ones. Lean: publish the wide clean tables you built the semantic layer on; hold back raw or half-built ones. Surface which tables you'd publish and confirm. (Only admins and data analysts can publish — mechanics in `core`.) + - **Default time dimension for a metric.** A monthly default makes it chart nicely on a dashboard, but doesn't lock anyone out of other groupings. Lean: set a sensible default (usually month) for anything headline; leave it off for raw counts that aren't inherently time-series. - **How strict a segment is.** "Active" = last 30 vs 90 days is a real business call with no right answer from the data alone. Lean: surface the few reasonable thresholds with how many rows each catches, let the user pick. @@ -101,7 +103,9 @@ Build each agreed definition. The verb mechanics (create/update flags, the `revi - **Segment** → `mb segment create`. A flat MBQL filter clause on a table. - **Measure** → `mb measure create`. **Exactly one** aggregation on a table. - **Metric** → `mb card create` with the metric shape (`type: "metric"`) — it lives in a **collection**, carries the aggregation plus an optional default time dimension. Put org-blessed ones in the Library collection. + - **Publish the official tables** → `mb library create` then `mb library publish` (mechanics in `core`) to move the clean tables your definitions sit on into the Library's **Data** section, so people start from your curated set, not raw warehouse tables. + Then **verify what the user can't see**, before you hand back: @@ -122,10 +126,14 @@ Then **stop. Hard gate — every mode, no exceptions.** Recap in plain language > > **Metric** (in your **Library**, charts by month): > • **Monthly recurring revenue** -> + + + > **Published to the Library** (these now show up first when anyone picks a data source): > • **Customers**, **Orders** -> + + + > Open any of those tables' Filter or Summarize block in Metabase to see them in place and try one — give it a look before you start building dashboards on top. End on that plain-language map. It's what the user reads to trust the result — and it's what stops a wrong definition from quietly propagating into everything built next. diff --git a/packages/cli/skill-data/document/SKILL.md b/packages/cli/skill-data/document/SKILL.md index db1a8277..eebc08c9 100644 --- a/packages/cli/skill-data/document/SKILL.md +++ b/packages/cli/skill-data/document/SKILL.md @@ -81,7 +81,10 @@ Every node is `{ "type": string, "attrs"?: object, "content"?: [nodes], "text"?: - **`resizeNode`** — wraps a single `cardEmbed` or `flexContainer` to make it resizable (no `_id`). `attrs: { "height": , "minHeight": }`, `content` is exactly one `cardEmbed`/`flexContainer`. - **`flexContainer`** — a horizontal row of 1–3 `cardEmbed` / `supportingText` cells side by side (no `_id`). `attrs.columnWidths` is an array of width percentages. - **`supportingText`** — a text column that sits next to a card inside a `flexContainer` (id-bearing); `content` is the usual block nodes (`paragraph`, `heading`, lists, …). -- **`smartLink`** — an inline reference to a Metabase entity (renders as a live chip). Inline, atomic, no `_id`. `attrs: { "entityId": , "model": , "label": , "href": }`. `model` ∈ `card`, `dataset`, `metric`, `dashboard`, `collection`, `table`, `database`, `document`, `transform`, `segment`, `user`, `action`, `indexed-entity` (plus `measure` on v60+). +- **`smartLink`** — an inline reference to a Metabase entity (renders as a live chip). Inline, atomic, no `_id`. `attrs: { "entityId": , "model": , "label": , "href": }`. `model` ∈ `card`, `dataset`, `metric`, `dashboard`, `collection`, `table`, `database`, `document`, `transform`, `segment`, `user`, `action`, `indexed-entity`. + + - `measure` is a valid `model` too. + - **`metabot`** — an inline Metabot prompt block. ## Embedding an existing card diff --git a/packages/cli/skill-data/git-sync/SKILL.md b/packages/cli/skill-data/git-sync/SKILL.md index 2aa5927c..e5d4da63 100644 --- a/packages/cli/skill-data/git-sync/SKILL.md +++ b/packages/cli/skill-data/git-sync/SKILL.md @@ -2,6 +2,7 @@ name: git-sync description: Round-trip Metabase content (cards, dashboards, transforms, snippets, collections, Library-published table/field metadata) between an instance and a git remote via `mb git-sync …` — status, dirty / has-remote-changes checks, import, export (with branch guard), branches, stash, add/remove a collection from sync. Load when the user wants to "import the latest changes", "export to git", "push my changes to the repo", "open a PR with my Metabase changes", "git sync", "dirty check", "stash before pulling", "add a collection to sync", or anything `mb git-sync …`. allowed-tools: Read, Write, Edit, Bash, AskUserQuestion +requires: [remoteSync] --- # git-sync (representations ↔ instance) @@ -130,6 +131,8 @@ mb setting set remote-sync-type '"read-write"' --profile **Verifying the result.** `mb git-sync status --profile --json` lists the flagged collections under `synced_collections`, and `mb collection get --json` shows the per-collection `is_remote_synced` flag. + + ## Published table metadata (Library) and sync scope Table and field metadata — table/field descriptions, semantic types (`type/PK`, `type/FK`), FK targets, plus segments and measures on the table — serializes for **Library-published tables only**, under `databases//schemas//tables//…` in the repo. Eligibility is two-gated: the table must be published (`mb library publish`), **and** the Library collection holding it must itself carry `is_remote_synced: true`. An ordinary warehouse table, or a transform's target table that isn't published, never serializes — a transform's YAML carries only the transform definition (query, target, description), not the output table's field metadata. @@ -144,6 +147,8 @@ mb git-sync stash --new-branch -m "..." --profile # or create-b Flagging the collection records it for the next export, which serializes its current content — including already-published tables and their field metadata. `mb library publish` prints a reminder when the target collection is outside the sync scope on an instance with a configured remote. + + ## Don't (git-sync-specific) - Don't turn instance-side changes into hand-written repo files. When the changes were made against the instance, export them (`stash` / `create-branch` + `export`) and PR the exported branch; reconstructing them as YAML by hand — or pushing files in paths/formats the serializer doesn't own — produces content that never applies on import, and pushing behind Metabase's back races its own sync tasks. Hand-editing YAML belongs to the repo-first workflow, in the serialized layout the repo already uses. diff --git a/packages/cli/skill-data/mbql/SKILL.md b/packages/cli/skill-data/mbql/SKILL.md index 81d70614..58c3567d 100644 --- a/packages/cli/skill-data/mbql/SKILL.md +++ b/packages/cli/skill-data/mbql/SKILL.md @@ -122,7 +122,7 @@ No `type:"query"` wrapper, no `query:` nesting. If you wrap the query inside a l ## Legacy formats you may encounter -Older Metabase servers used a different query envelope (sometimes called MBQL 4 / "legacy MBQL"); the `mbql/query` shape above is what recent servers store and return. You won't author the legacy shapes, but you may see them in queries created long ago. Anything not `lib/type: "mbql/query"` is sent as-is and normalized server-side — you lose validation, so don't author these: +Queries created long ago may carry a different envelope (sometimes called MBQL 4 / "legacy MBQL"); the `mbql/query` shape above is what the server stores and returns. You won't author the legacy shapes, but you may see them in old content. Anything not `lib/type: "mbql/query"` is sent as-is and normalized server-side — you lose validation, so don't author these: - **Legacy structured** — `{ "type": "query", "database": N, "query": { "source-table": T, … } }` - **Flat native** — `{ "type": "native", "database": N, "native": { "query": "SELECT …" } }` — the server accepts it, but author the native stage instead (`native-sql`). diff --git a/packages/cli/skill-data/mbql/references/operators.md b/packages/cli/skill-data/mbql/references/operators.md index d06684d9..e3eb5502 100644 --- a/packages/cli/skill-data/mbql/references/operators.md +++ b/packages/cli/skill-data/mbql/references/operators.md @@ -106,21 +106,26 @@ Units (truncation only): `millisecond`, `second`, `minute`, `hour`, `day`, `week A stage's `aggregation` is a list of aggregation clauses. -| Op | Args | Notes | -| ----------------------- | -------------------------- | -------------------------------- | -| `count` | none, or 1 expression | with arg: count non-NULL | -| `sum` `avg` `min` `max` | 1 numeric/orderable | | -| `distinct` | 1 expression | count of distinct values | -| `cum-count` | none or 1 expression | running count | -| `cum-sum` | 1 numeric | running sum | -| `stddev` `var` `median` | 1 numeric | | -| `percentile` | numeric, p (0.0–1.0) | | -| `count-where` | 1 boolean clause | | -| `sum-where` | numeric, boolean clause | | -| `distinct-where` | expression, boolean clause | | -| `share` | 1 boolean clause | proportion 0–1 | -| `metric` | metric id | reference a saved metric card | -| `measure` | measure id | reference a saved measure (v59+) | +| Op | Args | Notes | +| ----------------------- | -------------------------- | ----------------------------- | +| `count` | none, or 1 expression | with arg: count non-NULL | +| `sum` `avg` `min` `max` | 1 numeric/orderable | | +| `distinct` | 1 expression | count of distinct values | +| `cum-count` | none or 1 expression | running count | +| `cum-sum` | 1 numeric | running sum | +| `stddev` `var` `median` | 1 numeric | | +| `percentile` | numeric, p (0.0–1.0) | | +| `count-where` | 1 boolean clause | | +| `sum-where` | numeric, boolean clause | | +| `distinct-where` | expression, boolean clause | | +| `share` | 1 boolean clause | proportion 0–1 | +| `metric` | metric id | reference a saved metric card | + + + +`measure` takes a measure id and references a saved measure. + + ```json ["count", {…}] diff --git a/packages/cli/skill-data/metadata/SKILL.md b/packages/cli/skill-data/metadata/SKILL.md index dfc42274..92196ef8 100644 --- a/packages/cli/skill-data/metadata/SKILL.md +++ b/packages/cli/skill-data/metadata/SKILL.md @@ -43,7 +43,7 @@ mb field update 1711 --body '{"semantic_type":"type/FK","fk_target_field_id":168 - Queries can pull columns from the related table with no explicit join — `["field", {"source-field": 1711}, 1682]` in MBQL (see `mbql`). - Dashboard **linked filters** become possible (a State filter narrowing a City filter). **Linked filters read only these table-metadata FKs** — never a join you wrote inside a saved question — which is why a linked filter that "shows values it shouldn't" almost always means the FK isn't set in metadata. (See `dashboard`.) -Removing the `type/FK` semantic type auto-clears `fk_target_field_id`. Point a FK only at a field in the **same database** — v60+ rejects a cross-database target; v58–v59 accept it silently and leave a broken relationship. +Removing the `type/FK` semantic type auto-clears `fk_target_field_id`. Point a FK only at a field in the **same database** — a cross-database target is invalid and never resolves to a relationship. ## Semantic types are labels, not casts diff --git a/packages/cli/skill-data/metadata/references/semantic-types.md b/packages/cli/skill-data/metadata/references/semantic-types.md index 4ef0d306..4113540e 100644 --- a/packages/cli/skill-data/metadata/references/semantic-types.md +++ b/packages/cli/skill-data/metadata/references/semantic-types.md @@ -71,7 +71,13 @@ Everything else on a field/table (physical `name`, `base_type`, `effective_type` `display_name` · `description` · `caveats` · `points_of_interest` · `semantic_type` · `coercion_strategy` · `fk_target_field_id` · `visibility_type` · `has_field_values` · `settings` · `nfc_path` · `json_unfolding` **`PUT /api/table/:id` (`mb table update`)** -`display_name` · `description` · `visibility_type` · `field_order` (`database` / `alphabetical` / `custom` / `smart`) · `entity_type` · `caveats` · `points_of_interest` · `show_in_getting_started` · `owner_user_id` / `owner_email` (ownership) · `data_layer` / `data_authority` / `data_source` (data-governance tiers) · `collection_id` (v62+) +`display_name` · `description` · `visibility_type` · `field_order` (`database` / `alphabetical` / `custom` / `smart`) · `entity_type` · `caveats` · `points_of_interest` · `show_in_getting_started` · `owner_user_id` / `owner_email` (ownership) · `data_layer` / `data_authority` / `data_source` (data-governance tiers) + + + +· `collection_id` — the Library **Data** collection a published table sits in (`mb library publish` sets it; see `core`) + + ## Coercion strategies (common) diff --git a/packages/cli/skill-data/native-sql/references/template-tags.md b/packages/cli/skill-data/native-sql/references/template-tags.md index c25d4856..74992aa6 100644 --- a/packages/cli/skill-data/native-sql/references/template-tags.md +++ b/packages/cli/skill-data/native-sql/references/template-tags.md @@ -86,9 +86,13 @@ SQL: `{{snippet: Active Rows}}`. Create/manage the fragment with `mb snippet` (` SQL: `{{#42}}` or `{{#42-slug}}`, used where a table/subquery goes (`FROM {{#42}}`, `WITH x AS {{#42}}`). Runs with the referenced card's own defaults; no user value. -### Source table — `table` (v59+) + -A niche v59+ type that references a warehouse table by id (`{type: "table", table-id: }`, optional `source-filters`) where a table/subquery goes — analogous to a card reference but pointing at a raw table. Absent on v0.58. Reach for a card reference (`card`) unless you specifically need a bare-table source tag. +### Source table — `table` + +A niche type that references a warehouse table by id (`{type: "table", table-id: }`, optional `source-filters`) where a table/subquery goes — analogous to a card reference but pointing at a raw table. Reach for a card reference (`card`) unless you specifically need a bare-table source tag. + + ### Temporal unit — `temporal-unit` diff --git a/packages/cli/skill-data/notification/SKILL.md b/packages/cli/skill-data/notification/SKILL.md index 61eb11fc..1655a6f8 100644 --- a/packages/cli/skill-data/notification/SKILL.md +++ b/packages/cli/skill-data/notification/SKILL.md @@ -110,7 +110,13 @@ An alert body has exactly three parts: Recipients are `{"email": "a@b.com"}` for an external address or `{"id": }` for a Metabase user. A Slack channel is **not** a recipient — it goes in `details`: `{"channel_type": "slack", "details": {"channel": "#general"}, …}`. -**`skip_if_empty: true`** suppresses the send when the dashboard's questions return no rows — the way to build a "only tell me when something happened" dashboard report. **`parameters`** sets filter values for this subscription only, so one dashboard can feed several audiences a different slice (Pro/Enterprise). Its entries are dashboard parameter values — the parameter `id`s come from `mb dashboard get --fields parameters --json` (see the `dashboard` skill). +**`skip_if_empty: true`** suppresses the send when the dashboard's questions return no rows — the way to build a "only tell me when something happened" dashboard report. + + + +**`parameters`** sets filter values for this subscription only, so one dashboard can feed several audiences a different slice. Its entries are dashboard parameter values — the parameter `id`s come from `mb dashboard get --fields parameters --json` (see the `dashboard` skill). + + `mb subscription list --archived` is a **swap, not a union** — it returns archived subscriptions _instead of_ active ones, never both. `mb dashboard subscriptions ` is the list scoped to one dashboard. `mb subscription archive ` also disables every channel on it, so restoring takes two steps: `update --body '{"archived":false}'`, then re-send the `channels` array with `enabled: true`. diff --git a/packages/cli/skill-data/transform/SKILL.md b/packages/cli/skill-data/transform/SKILL.md index b7fd015c..f746e216 100644 --- a/packages/cli/skill-data/transform/SKILL.md +++ b/packages/cli/skill-data/transform/SKILL.md @@ -2,6 +2,7 @@ name: transform description: Author and run Metabase transforms via `mb` — body shape (native SQL or structured MBQL), create + run-with-wait, run inspection, dependencies, cancel, the `update`-vs-recreate iteration rule, the writable-keys-only PATCH contract, plus transform tags and tag-driven transform-job schedules. Load when the user touches transforms — "create a transform", "run a transform", "fix a failing transform", "list transform runs", "cancel a running transform", "manage transform tags", "run a transform job", or anything `mb transform …` / `mb transform-job …` / `mb transform-tag …`. allowed-tools: Read, Write, Edit, Bash, AskUserQuestion +requires: [transforms] --- # Transforms @@ -67,7 +68,7 @@ TABLE_ID=$(mb transform run --sync --profile --json | jq -r '.target mb table get "$TABLE_ID" --include fields --profile --json # field ids for MBQL ``` -On `target_table_id: null` (still syncing when the poll timed out; exit 0) re-poll `mb transform get --full --json` until the `target_table_id` / `table` linkage lands. +On `target_table_id: null` (still syncing when the poll timed out; exit 0) re-poll `mb transform get --json` until `target_table_id` is a number. Columns and types are inferred from the result set; change the SELECT shape and the next run fails on a column mismatch — drop the table first (`transform delete-table `). A changed shape also needs a re-run with `--sync` before MBQL sees the new/renamed columns. @@ -198,7 +199,14 @@ Key verbs (`mb transform-job --help` for the full list): mb transform-job transforms --profile --json # preview which transforms this job resolves to (by tag) mb transform-job run --profile --json # trigger a job now; runs all its tagged transforms mb transform-job run --force-refresh --profile --json # also re-run dependencies that are already fresh +``` + + + +```bash mb transform-job set-active false --profile --json # disable every job at once (true re-enables); admin only ``` -`transform-job run` is fire-and-forget — the server returns `{message, job_run_id}` immediately, with no per-job-run polling (no `--wait`). Most ad-hoc agent work is one-off `transform run`, not job authoring. + + +`transform-job run` is fire-and-forget — it returns `{message, started, run_id}` immediately (`run_id` is `null` when the server does not number job runs), with no per-job-run polling (no `--wait`). Most ad-hoc agent work is one-off `transform run`, not job authoring. diff --git a/packages/cli/skill-data/visualization/SKILL.md b/packages/cli/skill-data/visualization/SKILL.md index 379c8a46..64027991 100644 --- a/packages/cli/skill-data/visualization/SKILL.md +++ b/packages/cli/skill-data/visualization/SKILL.md @@ -27,7 +27,10 @@ Decide which relationship in the data matters most, then pick the chart. The sha - **Compare a measure across categories** → `bar` (vertical). Use `row` (horizontal bar) when labels are long or there are many categories. Sort by value unless the dimension has a natural order. - **Change over time / trend** → `line` for a continuous series; `bar`/`area` for a few discrete periods. Two measures on unlike scales → `combo` (line + bar, dual-axis) — only when the metrics are genuinely related. - **Part-to-whole, one snapshot** → `pie`, but only for a meaningful whole with **≤5 slices**; beyond that use a sorted `bar`/`row`. Composition over time → stacked `area`/`bar`. -- **Distribution / spread / outliers** → a `bar` histogram (bin the measure — see `mbql` binning); on **v59+** servers `boxplot` compares several groups' spread directly. +- **Distribution / spread / outliers** → a `bar` histogram (bin the measure — see `mbql` binning). + +- **Spread across several groups** → `boxplot` compares the groups' distributions directly. + - **Correlation between two measures** → `scatter` (a third measure → bubble size). - **Sequential additive contributions** (start → +/− steps → total) → `waterfall`. - **Stage drop-off in an ordered, cumulative funnel** → `funnel`. @@ -35,7 +38,7 @@ Decide which relationship in the data matters most, then pick the chart. The sha - **Geographic** → `map`: region/choropleth (a region dimension + a measure), pin (lat + long), or grid/heat (coordinates + measure). - **Precise values, many columns, mixed types, or no chart fits** → `table`; `pivot` for a cross-tab of two dimensions; `object` for a single record's detail. -Valid `display` values — the registered visualizations: `table`, `bar`, `line`, `area`, `row`, `pie`, `scalar`, `smartscalar`, `combo`, `pivot`, `funnel`, `map`, `scatter`, `waterfall`, `progress`, `gauge`, `object`, `sankey`. The API types `display` as a plain string and accepts any value — it renders an unknown one as nothing. (`boxplot` is registered only on **v59+** servers — older ones render it blank; use a `bar` histogram for distributions instead. `scalar` **is** the "Number" viz — `display: number` is a legacy serialization alias, not a registered visualization; use `scalar`. `list` exists but is hidden — don't pick it. `heading`/`text`/`link`/`iframe`/`action` are dashcard virtuals, not standalone cards — see references.) A typo like `bargraph`/`linechart` is accepted and renders blank — the most common "why is my chart blank" cause. +Valid `display` values — the registered visualizations: `table`, `bar`, `line`, `area`, `row`, `pie`, `scalar`, `smartscalar`, `combo`, `pivot`, `funnel`, `map`, `scatter`, `waterfall`, `progress`, `gauge`, `object`, `sankey`. The API types `display` as a plain string and accepts any value — it renders an unknown one as nothing. (`scalar` **is** the "Number" viz — `display: number` is a legacy serialization alias, not a registered visualization; use `scalar`. `list` exists but is hidden — don't pick it. `heading`/`text`/`link`/`iframe`/`action` are dashcard virtuals, not standalone cards — see references.) A typo like `bargraph`/`linechart` is accepted and renders blank — the most common "why is my chart blank" cause. ## Step 2 — bind data columns and set options @@ -53,7 +56,6 @@ Valid `display` values — the registered visualizations: `table`, `bar`, `line` | `row` | as bar; prefer for long/many category labels | `graph.dimensions`, `graph.metrics` | | `scatter` | two numeric measures (correlation) | `graph.dimensions`, `graph.metrics` (`scatter.bubble` opt) | | `waterfall` | exactly 1 dimension + ≥1 measure; sequential | `graph.dimensions` (1), `graph.metrics` (1) | -| `boxplot` _(v59+)_ | ≥3 cols, ≥2 dimensions, ≥1 measure | `graph.dimensions`, `graph.metrics` | | `pie` | ≥2 rows, ≥2 cols, ≥1 dimension + ≥1 measure; ≤~5 slices | `pie.dimension`, `pie.metric` | | `funnel` | 2 columns (stage + value); ordered stages | `funnel.dimension`, `funnel.metric` | | `map` (region) | a string/region dimension + a measure | `map.region`, `map.dimension`, `map.metric` | @@ -62,6 +64,12 @@ Valid `display` values — the registered visualizations: `table`, `bar`, `line` | `pivot` | ≥2 cols, all aggregated/breakout | — (`pivot_table.column_split` auto) | | `table` `object` | anything (table is the universal fallback) | — (always renders) | + + +`boxplot` is registered too: ≥3 cols, ≥2 dimensions, ≥1 measure, on **unaggregated** rows; required keys `graph.dimensions`, `graph.metrics`. + + + ### The rule that trips everyone: settings name **output columns**, by name `graph.dimensions`, `graph.metrics`, `pie.dimension`, `pie.metric`, `scalar.field`, `funnel.metric`, `map.latitude_column`, `sankey.source`, … all take **output column-name strings** — the names the query _produces_, not field ids. A `count` aggregation outputs the column `count`; a breakout on a field outputs that field's name; a named aggregation outputs its `name`. These strings are **identical in the API form and the portable (git-sync) form** — no numeric-vs-name footgun here. diff --git a/packages/cli/skill-data/visualization/references/settings.md b/packages/cli/skill-data/visualization/references/settings.md index 2661eb89..3da8d719 100644 --- a/packages/cli/skill-data/visualization/references/settings.md +++ b/packages/cli/skill-data/visualization/references/settings.md @@ -2,7 +2,7 @@ ## Contents -- [Cartesian — `bar`, `line`, `area`, `combo`, `scatter`, `waterfall`, `row`, `boxplot`](#cartesian--bar-line-area-combo-scatter-waterfall-row-boxplot) — shared keys (binding, stacking, goal/trend, data labels, axes, tooltip) plus `scatter`, `waterfall`, `row`, `boxplot` extras +- [Cartesian — `bar`, `line`, `area`, `combo`, `scatter`, `waterfall`, `row`](#cartesian--bar-line-area-combo-scatter-waterfall-row) — shared keys (binding, stacking, goal/trend, data labels, axes, tooltip) plus `scatter`, `waterfall`, `row` extras - [Part-to-whole & single value — `pie`, `funnel`, `gauge`, `progress`, `scalar`, `smartscalar`](#part-to-whole--single-value--pie-funnel-gauge-progress-scalar-smartscalar) - [Tabular, geographic & flow — `table`, `pivot`, `object`, `map`, `sankey`](#tabular-geographic--flow--table-pivot-object-map-sankey) — includes `table.column_formatting` conditional formatting - [`column_settings` — per-column formatting](#column_settings--per-column-formatting) — number/date/currency, `view_as`, alignment, mini bars @@ -18,7 +18,7 @@ All column-naming keys (`graph.dimensions`, `pie.dimension`, `table.columns[].na --- -# Cartesian — `bar`, `line`, `area`, `combo`, `scatter`, `waterfall`, `row`, `boxplot` +# Cartesian — `bar`, `line`, `area`, `combo`, `scatter`, `waterfall`, `row` `bar`/`line`/`area`/`combo` share an identical key set (combo just defaults the first series to a line, the rest to bars). `scatter`/`waterfall`/`row`/`boxplot` use a subset plus their own extras. Most allow up to 2 dimensions and unlimited metrics; **waterfall is 1 dimension + 1 metric**, boxplot allows 2 dimensions. @@ -102,9 +102,9 @@ Use for sequential additive contributions (start → +/− steps → total). **U Horizontal bars — use when category labels are long or numerous. Here `graph.dimensions` is the y-axis (categories) and `graph.metrics` the x-axis (values). X-scale is `"ordinal"` only; no trend line, tooltip columns, or split panels. **Required:** `graph.dimensions`, `graph.metrics`. -## boxplot + -_Registered only on **v59+** servers — older ones render `display: boxplot` blank; use a `bar` histogram there._ +## boxplot Use for distribution/spread/outliers, especially across several groups. Needs **unaggregated** rows. **Use for:** ≥3 columns, ≥2 dimensions, ≥1 measure. **Required:** `graph.dimensions`, `graph.metrics`. X-scale is `"ordinal"`. @@ -115,6 +115,8 @@ Use for distribution/spread/outliers, especially across several groups. Needs ** | `boxplot.show_mean` | boolean | | `true` | | `boxplot.show_values_mode` | string | `"median"`, `"all"` (when `graph.show_values`) | `"median"` | + + --- # Part-to-whole & single value — `pie`, `funnel`, `gauge`, `progress`, `scalar`, `smartscalar` diff --git a/packages/cli/skills/metabase-cli/SKILL.md b/packages/cli/skills/metabase-cli/SKILL.md index 82262307..32a904d6 100644 --- a/packages/cli/skills/metabase-cli/SKILL.md +++ b/packages/cli/skills/metabase-cli/SKILL.md @@ -17,7 +17,7 @@ Before running any `mb` command, load the workflow content from the CLI: ```bash mb skills get core # auth, flag conventions, every command group -mb skills list # everything available on the installed version +mb skills list # every bundled skill the profile's server can use (--all for all of them) ``` **Doing a whole job, not one command?** If the user wants an outcome — "make sense of my data", "build a data model", "go from raw data to a dashboard", "answer questions about my data", "be my data analyst", "set up analytics for X" — load the guided end-to-end skill instead and let it drive: diff --git a/packages/cli/src/commands/alert/archive.ts b/packages/cli/src/commands/alert/archive.ts index fcf05b71..23380ac2 100644 --- a/packages/cli/src/commands/alert/archive.ts +++ b/packages/cli/src/commands/alert/archive.ts @@ -14,7 +14,7 @@ export default defineMetabaseCommand({ }, details: "Deactivates the alert and drops its scheduled trigger. `mb alert list --include-inactive` still shows it, and `mb alert update --body '{\"active\":true}'` brings it back.", - capabilities: { minVersion: 58 }, + requires: ["notification.archive"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/alert/create.ts b/packages/cli/src/commands/alert/create.ts index 3d732982..45c8c16e 100644 --- a/packages/cli/src/commands/alert/create.ts +++ b/packages/cli/src/commands/alert/create.ts @@ -17,7 +17,7 @@ export default defineMetabaseCommand({ "`handlers` is a list of `{channel_type: channel/email|channel/slack|channel/http, recipients}`;", 'each recipient is `{type: "notification-recipient/user", user_id}` or `{type: "notification-recipient/raw-value", details: {value: "a@b.com"}}`.', ].join(" "), - capabilities: { minVersion: 58 }, + requires: ["notification.create"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/alert/get.ts b/packages/cli/src/commands/alert/get.ts index 0cdb2503..c7347f47 100644 --- a/packages/cli/src/commands/alert/get.ts +++ b/packages/cli/src/commands/alert/get.ts @@ -9,7 +9,7 @@ export default defineMetabaseCommand({ meta: { name: "get", description: "Get a question alert by id" }, details: "`--full` includes the hydrated card the alert watches, alongside its schedules and handlers.", - capabilities: { minVersion: 58 }, + requires: ["notification.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/alert/list.ts b/packages/cli/src/commands/alert/list.ts index d9152e31..9c74651e 100644 --- a/packages/cli/src/commands/alert/list.ts +++ b/packages/cli/src/commands/alert/list.ts @@ -13,7 +13,7 @@ export default defineMetabaseCommand({ meta: { name: "list", description: "List question alerts" }, details: "Archived (inactive) alerts are hidden unless you pass --include-inactive. System-event notifications, which share the same API, are never listed here.", - capabilities: { minVersion: 58 }, + requires: ["notification.list"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/alert/send.ts b/packages/cli/src/commands/alert/send.ts index c31354fa..1a85d382 100644 --- a/packages/cli/src/commands/alert/send.ts +++ b/packages/cli/src/commands/alert/send.ts @@ -24,7 +24,7 @@ export default defineMetabaseCommand({ meta: { name: "send", description: "Send a question alert now, off-schedule" }, details: "Delivers to every handler on the alert, ignoring its send condition and schedule. Requires the channel to be configured on the server (email needs SMTP set up).", - capabilities: { minVersion: 58 }, + requires: ["notification.send"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/alert/update.ts b/packages/cli/src/commands/alert/update.ts index ed36c1ef..ee4ab7af 100644 --- a/packages/cli/src/commands/alert/update.ts +++ b/packages/cli/src/commands/alert/update.ts @@ -17,7 +17,7 @@ export default defineMetabaseCommand({ "`subscriptions` and `handlers` replace the whole list, so send every schedule and recipient you want to keep —", "`mb alert get ` prints the current ones. An alert's card cannot be moved to a different card.", ].join(" "), - capabilities: { minVersion: 58 }, + requires: ["notification.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/auth/list.test.ts b/packages/cli/src/commands/auth/list.test.ts index 93971cfd..9a2f06a2 100644 --- a/packages/cli/src/commands/auth/list.test.ts +++ b/packages/cli/src/commands/auth/list.test.ts @@ -4,6 +4,8 @@ import type { ZodType } from "zod"; import type { Credential } from "@metabase/client/auth/credential"; import { parseJson } from "@metabase/client/json"; +import type { ServerInfo } from "@metabase/client/version/probe"; +import { createServerProfile, KNOWN_RANGE } from "@metabase/client/version/profile"; import type { Verification } from "../../core/auth/verify"; const hoisted = vi.hoisted(() => { @@ -66,14 +68,20 @@ function captureStderr(): string[] { return captured; } +function successServer(): ServerInfo { + return { + version: { tag: "v0.58.7", major: 58, patch: 7 }, + date: null, + hash: null, + tokenFeatures: null, + }; +} + function successVerify(): Verification { return { ok: true, user: { id: 1, name: "Tester", isAdmin: true }, - server: { - version: { tag: "v0.58.7", major: 58, patch: 7 }, - tokenFeatures: null, - }, + server: successServer(), }; } @@ -213,7 +221,11 @@ describe("auth list command", () => { status: "ok", user: { id: 1, name: "Tester", isAdmin: true }, version: { tag: "v0.58.7", major: 58, patch: 7 }, + edition: "oss", + skew: "supported", + knownRange: KNOWN_RANGE, tokenFeatures: null, + features: createServerProfile(successServer()).features, lastProbedAt: envelope.data[0]?.lastProbedAt, lastFailure: null, }, diff --git a/packages/cli/src/commands/auth/list.ts b/packages/cli/src/commands/auth/list.ts index 84f55a34..f1952fc8 100644 --- a/packages/cli/src/commands/auth/list.ts +++ b/packages/cli/src/commands/auth/list.ts @@ -1,9 +1,7 @@ import { z } from "zod"; -import { TokenFeatures } from "@metabase/client/domain/session-properties"; import { displayUrl } from "@metabase/client/url"; import type { ServerInfo } from "@metabase/client/version/probe"; -import { ParsedVersion } from "@metabase/client/version/tag"; import { listProfileRecords, @@ -11,6 +9,7 @@ import { writeProbeFailure, writeProbeResult, } from "../../core/auth/storage"; +import { ServerSummary, summarizeServer } from "../../core/auth/server-summary"; import { verifyAndProbe, type VerifyFailure } from "../../core/auth/verify"; import { createCredentialRefresher } from "../../core/config"; import { @@ -28,7 +27,13 @@ import type { ResourceView } from "../../output/view"; import { windowList } from "../../output/window"; import { interruptSignal } from "../../runtime/interrupt"; import { listFlags, outputFlags } from "../flags"; -import { renderAuthMethod, renderTimestamp, renderUserRole, renderVersionTag } from "./render"; +import { + renderAuthMethod, + renderSkew, + renderTimestamp, + renderUserRole, + renderVersionTag, +} from "./render"; import { defineMetabaseCommand } from "../runtime"; const AuthProfileStatus = z.enum([ @@ -47,8 +52,7 @@ const AuthProfile = z.object({ authenticated: z.boolean(), status: AuthProfileStatus, user: ProbedUser.nullable(), - version: ParsedVersion.nullable(), - tokenFeatures: TokenFeatures.nullable(), + ...ServerSummary.shape, lastProbedAt: z.iso.datetime().nullable(), lastFailure: ProfileLastFailure.nullable(), }); @@ -73,6 +77,7 @@ const authProfileView: ResourceView = { { key: "status", label: "Status", format: (value) => renderStatus(value) }, { key: "user", label: "Role", format: (value) => renderUserRole(value) }, { key: "version", label: "Version", format: (value) => renderVersionTag(value) }, + { key: "skew", label: "Skew", format: (value) => renderSkew(value) }, { key: "lastProbedAt", label: "Last probed", format: (value) => renderTimestamp(value) }, ], }; @@ -84,7 +89,7 @@ function renderStatus(value: unknown): string { export default defineMetabaseCommand({ meta: { name: "list", description: "List configured authentication profiles" }, - capabilities: { minVersion: 58 }, + requires: ["user.current"], args: { ...outputFlags, ...listFlags }, outputSchema: AuthProfileListEnvelope, examples: ["mb auth list", "mb auth list --json"], @@ -194,8 +199,7 @@ function projectSuccess( authenticated: true, status: "ok", user: probe.user, - version: probe.version, - tokenFeatures: probe.tokenFeatures, + ...summarizeServer(probe), lastProbedAt: probe.at, lastFailure: null, }; @@ -210,8 +214,7 @@ function toJson(record: ProfileRecord, status: AuthProfileStatusValue): AuthProf authenticated: status === "ok", status, user: probe?.user ?? null, - version: probe?.version ?? null, - tokenFeatures: probe?.tokenFeatures ?? null, + ...summarizeServer(probe), lastProbedAt: probe?.at ?? null, lastFailure: record.lastFailure, }; diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 1ad852b3..5a32a23a 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -6,7 +6,6 @@ import { revokeOAuthCredential } from "@metabase/client/auth/oauth-session"; import { ConfigError, errorMessage } from "@metabase/client/errors"; import { tryDiscoverMetadata, type OAuthServerMetadata } from "@metabase/client/http/oauth"; import { normalizeUrl } from "@metabase/client/url"; -import { ParsedVersion } from "@metabase/client/version/tag"; import { consumeKeychainResidualWarning, @@ -22,6 +21,7 @@ import { import { verifyAndProbe, type VerifyFailure, type VerifyWhich } from "../../core/auth/verify"; import { explicitProfileName, readEnvCredentials } from "../../core/config"; import { ProbedUser } from "../../core/auth/profile-record"; +import { ServerSummary, summarizeServer } from "../../core/auth/server-summary"; import { OAUTH_CLIENT_NAME, USER_AGENT } from "../../core/user-agent"; import { warn } from "../../output/notice"; import { promptPassword, promptSelect, promptText } from "../../output/prompt"; @@ -34,14 +34,14 @@ import { readInput } from "../../runtime/input"; import type { CommonContext } from "../context"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { renderUserName, renderUserRole, renderVersionTag } from "./render"; +import { renderSkew, renderUserName, renderUserRole, renderVersionTag } from "./render"; export const LoginResult = z.object({ profile: z.string(), url: z.string(), authenticated: z.boolean(), user: ProbedUser.nullable(), - version: ParsedVersion.nullable(), + ...ServerSummary.shape, }); type LoginResultJson = z.infer; @@ -64,6 +64,7 @@ const loginView: ResourceView = { { key: "user", label: "Logged in as", format: (value) => renderUserName(value) }, { key: "user", label: "Role", format: (value) => renderUserRole(value) }, { key: "version", label: "Version", format: (value) => renderVersionTag(value) }, + { key: "skew", label: "Skew", format: (value) => renderSkew(value) }, ], }; @@ -71,7 +72,7 @@ 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 $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 }, + requires: ["user.current"], args: { ...outputFlags, ...profileFlag, @@ -237,7 +238,7 @@ async function completeLogin( if (skipVerify) { await persistWithWarning(persist); renderSummary( - { profile: profileName, url, authenticated: false, user: null, version: null }, + { profile: profileName, url, authenticated: false, user: null, ...summarizeServer(null) }, loginView, `Saved credentials for profile "${profileName}" (${url}) without verifying.`, ctx, @@ -265,7 +266,7 @@ async function completeLogin( url, authenticated: true, user: result.user, - version: result.server.version, + ...summarizeServer(result.server), }, loginView, `Logged in to ${url} as ${who} (${role}). Saved to profile "${profileName}".${serverClause}`, diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts index 913c7d8d..61a2c23c 100644 --- a/packages/cli/src/commands/auth/logout.ts +++ b/packages/cli/src/commands/auth/logout.ts @@ -35,7 +35,7 @@ const logoutView: ResourceView = { export default defineMetabaseCommand({ meta: { name: "logout", description: "Clear stored credentials for a profile" }, - capabilities: { minVersion: 58 }, + requires: [], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/auth/render.ts b/packages/cli/src/commands/auth/render.ts index 9428d577..65452730 100644 --- a/packages/cli/src/commands/auth/render.ts +++ b/packages/cli/src/commands/auth/render.ts @@ -1,5 +1,13 @@ +import { KNOWN_RANGE, Skew } from "@metabase/client/version/profile"; + import { EMPTY_CELL } from "../../output/table"; +const SKEW_LABEL: Readonly> = Object.freeze({ + supported: "supported", + "newer-than-known": `newer than this CLI knows (v${KNOWN_RANGE.max} max)`, + unknown: "unknown version", +}); + function pickProperty(value: unknown, key: string): unknown { if (typeof value !== "object" || value === null || !(key in value)) { return undefined; @@ -47,3 +55,8 @@ export function renderVersionTag(value: unknown): string { export function renderTimestamp(value: unknown): string { return typeof value === "string" ? value : EMPTY_CELL; } + +export function renderSkew(value: unknown): string { + const parsed = Skew.safeParse(value); + return parsed.success ? SKEW_LABEL[parsed.data] : EMPTY_CELL; +} diff --git a/packages/cli/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts index 043c7bf3..b37ea688 100644 --- a/packages/cli/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ZodType } from "zod"; import { parseJson } from "@metabase/client/json"; +import type { ServerInfo } from "@metabase/client/version/probe"; +import { createServerProfile, KNOWN_RANGE } from "@metabase/client/version/profile"; const hoisted = vi.hoisted(() => ({ store: new Map(), @@ -62,7 +64,11 @@ describe("auth status command", () => { method: null, user: null, version: null, + edition: null, + skew: null, + knownRange: KNOWN_RANGE, tokenFeatures: null, + features: null, lastProbedAt: null, lastFailure: null, }); @@ -79,7 +85,11 @@ describe("auth status command", () => { method: "apiKey", user: null, version: null, + edition: null, + skew: null, + knownRange: KNOWN_RANGE, tokenFeatures: null, + features: null, lastProbedAt: null, lastFailure: null, }); @@ -98,14 +108,17 @@ describe("auth status command", () => { expect(capture.parse(AuthStatus).method).toBe("oauth"); }); - it("surfaces the cached probe (user, version, lastProbedAt) when one exists", async () => { + it("surfaces the cached probe and what this CLI derives from it when one exists", async () => { await writeProfile({ url: "https://m.example.com", apiKey: "secret" }); + const server: ServerInfo = { + version: { tag: "v0.58.7", major: 58, patch: 7 }, + date: null, + hash: null, + tokenFeatures: null, + }; const probe = await writeProbeResult("default", { user: { id: 42, name: "Alice", isAdmin: true }, - server: { - version: { tag: "v0.58.7", major: 58, patch: 7 }, - tokenFeatures: null, - }, + server, }); const capture = captureStdout(); @@ -117,7 +130,44 @@ describe("auth status command", () => { method: "apiKey", user: { id: 42, name: "Alice", isAdmin: true }, version: { tag: "v0.58.7", major: 58, patch: 7 }, + edition: "oss", + skew: "supported", + knownRange: KNOWN_RANGE, tokenFeatures: null, + features: createServerProfile(server).features, + lastProbedAt: probe?.at ?? null, + lastFailure: null, + }); + }); + + it("reports a cached probe above the known range as newer-than-known with an EE edition read off the tag", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret" }); + const beyond = KNOWN_RANGE.max + 5; + const server: ServerInfo = { + version: { tag: `v1.${beyond}.0`, major: beyond, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { library: true }, + }; + const probe = await writeProbeResult("default", { + user: { id: 42, name: "Alice", isAdmin: true }, + server, + }); + + const capture = captureStdout(); + await runCommand(authStatusCommand, { rawArgs: ["--profile", "default", "--json"] }); + expect(capture.parse(AuthStatus)).toEqual({ + profile: "default", + present: true, + url: "https://m.example.com", + method: "apiKey", + user: { id: 42, name: "Alice", isAdmin: true }, + version: { tag: `v1.${beyond}.0`, major: beyond, patch: 0 }, + edition: "ee", + skew: "newer-than-known", + knownRange: KNOWN_RANGE, + tokenFeatures: { library: true }, + features: createServerProfile(server).features, lastProbedAt: probe?.at ?? null, lastFailure: null, }); diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index bff0b765..a2845575 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -1,10 +1,9 @@ import { z } from "zod"; -import { TokenFeatures } from "@metabase/client/domain/session-properties"; import { displayUrl } from "@metabase/client/url"; -import { ParsedVersion } from "@metabase/client/version/tag"; import { readProfileRecord } from "../../core/auth/storage"; +import { ServerSummary, summarizeServer } from "../../core/auth/server-summary"; import { resolveProfileName } from "../../core/config"; import { ProbedUser, @@ -18,6 +17,7 @@ import { outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; import { renderAuthMethod, + renderSkew, renderTimestamp, renderUserName, renderUserRole, @@ -30,8 +30,7 @@ export const AuthStatus = z.object({ url: z.string().nullable(), method: ProfileAuthMethod.nullable(), user: ProbedUser.nullable(), - version: ParsedVersion.nullable(), - tokenFeatures: TokenFeatures.nullable(), + ...ServerSummary.shape, lastProbedAt: z.iso.datetime().nullable(), lastFailure: ProfileLastFailure.nullable(), }); @@ -47,13 +46,14 @@ const authStatusView: ResourceView = { { key: "user", label: "Logged in as", format: (value) => renderUserName(value) }, { key: "user", label: "Role", format: (value) => renderUserRole(value) }, { key: "version", label: "Version", format: (value) => renderVersionTag(value) }, + { key: "skew", label: "Skew", format: (value) => renderSkew(value) }, { key: "lastProbedAt", label: "Last probed", format: (value) => renderTimestamp(value) }, ], }; export default defineMetabaseCommand({ meta: { name: "status", description: "Show authentication status for a profile" }, - capabilities: null, + requires: null, args: { ...outputFlags, ...profileFlag }, outputSchema: AuthStatus, examples: ["mb auth status --json", "mb auth status --profile staging"], @@ -69,8 +69,7 @@ export default defineMetabaseCommand({ url: null, method: null, user: null, - version: null, - tokenFeatures: null, + ...summarizeServer(null), lastProbedAt: null, lastFailure: null, }, @@ -88,8 +87,7 @@ export default defineMetabaseCommand({ url: displayUrl(record.url), method: profileAuthMethod(record), user: probe?.user ?? null, - version: probe?.version ?? null, - tokenFeatures: probe?.tokenFeatures ?? null, + ...summarizeServer(probe), lastProbedAt: probe?.at ?? null, lastFailure: record.lastFailure, }, diff --git a/packages/cli/src/commands/card/alerts.ts b/packages/cli/src/commands/card/alerts.ts index e8b2f49e..88baf675 100644 --- a/packages/cli/src/commands/card/alerts.ts +++ b/packages/cli/src/commands/card/alerts.ts @@ -10,7 +10,7 @@ export default defineMetabaseCommand({ meta: { name: "alerts", description: "List alerts watching a card" }, details: "Manage them with `mb alert create|update|send|archive`, which take the alert id printed here.", - capabilities: { minVersion: 58 }, + requires: ["notification.list"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/card/archive.ts b/packages/cli/src/commands/card/archive.ts index 3b781606..65850eb8 100644 --- a/packages/cli/src/commands/card/archive.ts +++ b/packages/cli/src/commands/card/archive.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a card by id" }, - capabilities: { minVersion: 58 }, + requires: ["card.archive"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/card/create.ts b/packages/cli/src/commands/card/create.ts index 3bef7c85..36610933 100644 --- a/packages/cli/src/commands/card/create.ts +++ b/packages/cli/src/commands/card/create.ts @@ -23,7 +23,7 @@ export default defineMetabaseCommand({ { skill: "native-sql", purpose: "author a native SQL dataset_query with parameters" }, { skill: "visualization", purpose: "choose display and visualization_settings" }, ], - capabilities: { minVersion: 58 }, + requires: ["card.create"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/card/get.ts b/packages/cli/src/commands/card/get.ts index b2a10bc9..069c4ae4 100644 --- a/packages/cli/src/commands/card/get.ts +++ b/packages/cli/src/commands/card/get.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a card by id" }, - capabilities: { minVersion: 58 }, + requires: ["card.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/card/list.ts b/packages/cli/src/commands/card/list.ts index 3c6f3cea..565d065c 100644 --- a/packages/cli/src/commands/card/list.ts +++ b/packages/cli/src/commands/card/list.ts @@ -11,7 +11,7 @@ export const CardListEnvelope = listEnvelopeSchema(CardCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List cards (questions, models, metrics)" }, - capabilities: { minVersion: 58 }, + requires: ["card.list"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/card/query.ts b/packages/cli/src/commands/card/query.ts index 0f46557e..0e594a3f 100644 --- a/packages/cli/src/commands/card/query.ts +++ b/packages/cli/src/commands/card/query.ts @@ -21,7 +21,7 @@ export default defineMetabaseCommand({ description: "Run a saved card and return results (json envelope, or stream CSV/JSON/XLSX via --export-format)", }, - capabilities: { minVersion: 58 }, + requires: ["card.exportQuery", "card.query"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/card/update.ts b/packages/cli/src/commands/card/update.ts index 0f885bd5..d02be12c 100644 --- a/packages/cli/src/commands/card/update.ts +++ b/packages/cli/src/commands/card/update.ts @@ -23,7 +23,7 @@ export default defineMetabaseCommand({ { skill: "mbql", purpose: "author the dataset_query" }, { skill: "visualization", purpose: "choose display and visualization_settings" }, ], - capabilities: { minVersion: 58 }, + requires: ["card.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/collection/archive.ts b/packages/cli/src/commands/collection/archive.ts index 06157876..e2375b2a 100644 --- a/packages/cli/src/commands/collection/archive.ts +++ b/packages/cli/src/commands/collection/archive.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a collection by id" }, - capabilities: { minVersion: 58 }, + requires: ["collection.archive"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/collection/create.ts b/packages/cli/src/commands/collection/create.ts index 6e23e458..86ead8d7 100644 --- a/packages/cli/src/commands/collection/create.ts +++ b/packages/cli/src/commands/collection/create.ts @@ -15,7 +15,7 @@ export default defineMetabaseCommand({ meta: { name: "create", description: "Create a collection from a JSON spec" }, details: 'Body keys: `name` (required), `description`, `parent_id`, `authority_level`, `namespace`. Most collections use the default namespace (omit it). Pass `namespace: "transforms"` (or `--namespace transforms`) to create the kind of collection a transform\'s `collection_id` can point at — a regular collection is rejected there.', - capabilities: { minVersion: 58 }, + requires: ["collection.create"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/collection/get.ts b/packages/cli/src/commands/collection/get.ts index 8804b1e8..cd8b3f65 100644 --- a/packages/cli/src/commands/collection/get.ts +++ b/packages/cli/src/commands/collection/get.ts @@ -11,7 +11,7 @@ export default defineMetabaseCommand({ name: "get", description: 'Get a collection by id, 21-char entity id, or "root"/"trash"', }, - capabilities: { minVersion: 58 }, + requires: ["collection.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/collection/items.ts b/packages/cli/src/commands/collection/items.ts index 4f961f64..c936c3d2 100644 --- a/packages/cli/src/commands/collection/items.ts +++ b/packages/cli/src/commands/collection/items.ts @@ -19,7 +19,7 @@ export const CollectionItemListEnvelope = listEnvelopeSchema(CollectionItemCompa export default defineMetabaseCommand({ meta: { name: "items", description: "List items inside a collection" }, - capabilities: { minVersion: 58 }, + requires: ["collection.itemPages"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/collection/list.ts b/packages/cli/src/commands/collection/list.ts index c17628f5..faf54008 100644 --- a/packages/cli/src/commands/collection/list.ts +++ b/packages/cli/src/commands/collection/list.ts @@ -12,7 +12,7 @@ export const CollectionListEnvelope = listEnvelopeSchema(CollectionCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List collections" }, - capabilities: { minVersion: 58 }, + requires: ["collection.list"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/collection/tree.ts b/packages/cli/src/commands/collection/tree.ts index d4987311..9ace9be9 100644 --- a/packages/cli/src/commands/collection/tree.ts +++ b/packages/cli/src/commands/collection/tree.ts @@ -13,7 +13,7 @@ export default defineMetabaseCommand({ name: "tree", description: "Fetch the collection hierarchy as a nested tree (JSON only)", }, - capabilities: { minVersion: 58 }, + requires: ["collection.tree"], args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: CollectionTreeResponse, examples: ["mb collection tree", "mb collection tree --json"], diff --git a/packages/cli/src/commands/content-translation/download.ts b/packages/cli/src/commands/content-translation/download.ts index fb3fd7b3..fa2b822d 100644 --- a/packages/cli/src/commands/content-translation/download.ts +++ b/packages/cli/src/commands/content-translation/download.ts @@ -13,7 +13,7 @@ export default defineMetabaseCommand({ }, details: "Streams the active dictionary to stdout with the server's CSV formatting. Redirect stdout to preserve it as a file that can be reviewed, versioned, or uploaded later. An empty dictionary downloads as Metabase's four-row sample dictionary, not a header-only file.", - capabilities: { minVersion: 58, tokenFeature: "content_translation" }, + requires: ["contentTranslation.download"], args: { ...profileFlag, ...connectionFlags, diff --git a/packages/cli/src/commands/content-translation/upload.ts b/packages/cli/src/commands/content-translation/upload.ts index e4c99aad..410148ef 100644 --- a/packages/cli/src/commands/content-translation/upload.ts +++ b/packages/cli/src/commands/content-translation/upload.ts @@ -13,7 +13,7 @@ export default defineMetabaseCommand({ }, details: "Uploads one complete dictionary and replaces every active content translation on the server. Keep the canonical full CSV in version control and download the current dictionary before replacing it. Metabase accepts dictionaries up to 1.5 MiB.", - capabilities: { minVersion: 58, tokenFeature: "content_translation" }, + requires: ["contentTranslation.upload"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/dashboard/archive.ts b/packages/cli/src/commands/dashboard/archive.ts index c3e83cee..1be4197f 100644 --- a/packages/cli/src/commands/dashboard/archive.ts +++ b/packages/cli/src/commands/dashboard/archive.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a dashboard by id" }, - capabilities: { minVersion: 58 }, + requires: ["dashboard.archive"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/dashboard/cards.ts b/packages/cli/src/commands/dashboard/cards.ts index 41dc0b67..d1f881e6 100644 --- a/packages/cli/src/commands/dashboard/cards.ts +++ b/packages/cli/src/commands/dashboard/cards.ts @@ -11,7 +11,7 @@ export const DashcardListEnvelope = listEnvelopeSchema(DashcardCompact); export default defineMetabaseCommand({ meta: { name: "cards", description: "List dashcards on a dashboard" }, - capabilities: { minVersion: 58 }, + requires: ["dashboard.get"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/dashboard/create.ts b/packages/cli/src/commands/dashboard/create.ts index 025eb55b..e435046c 100644 --- a/packages/cli/src/commands/dashboard/create.ts +++ b/packages/cli/src/commands/dashboard/create.ts @@ -19,7 +19,7 @@ export default defineMetabaseCommand({ { skill: "dashboard", purpose: "wiring filters, cross-filtering, click behavior, tabs" }, { skill: "visualization", purpose: "dashcard visualization_settings and the 24-column grid" }, ], - capabilities: { minVersion: 58 }, + requires: ["dashboard.create", "dashboard.update", "dashboard.checkCardReferences"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/dashboard/get.ts b/packages/cli/src/commands/dashboard/get.ts index 1da2ddf5..bf1d48c4 100644 --- a/packages/cli/src/commands/dashboard/get.ts +++ b/packages/cli/src/commands/dashboard/get.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a dashboard by id" }, - capabilities: { minVersion: 58 }, + requires: ["dashboard.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts index 39de7694..102d2fd8 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -11,7 +11,7 @@ export const DashboardListEnvelope = listEnvelopeSchema(DashboardCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List dashboards" }, - capabilities: { minVersion: 58 }, + requires: ["dashboard.list"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/dashboard/parameter-values.ts b/packages/cli/src/commands/dashboard/parameter-values.ts index 8c4c866e..06d32a44 100644 --- a/packages/cli/src/commands/dashboard/parameter-values.ts +++ b/packages/cli/src/commands/dashboard/parameter-values.ts @@ -14,7 +14,7 @@ export default defineMetabaseCommand({ details: "Reads the chain-filter value endpoint. With --query the server returns only values containing the substring (first 1000 matches).", skills: [{ skill: "dashboard", purpose: "dashboard parameters, mapping, value sources" }], - capabilities: { minVersion: 58 }, + requires: ["dashboard.parameterValues", "dashboard.searchParameterValues"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/dashboard/subscriptions.ts b/packages/cli/src/commands/dashboard/subscriptions.ts index 21cdf9ef..805a40d7 100644 --- a/packages/cli/src/commands/dashboard/subscriptions.ts +++ b/packages/cli/src/commands/dashboard/subscriptions.ts @@ -10,7 +10,7 @@ export default defineMetabaseCommand({ meta: { name: "subscriptions", description: "List subscriptions on a dashboard" }, details: "Manage them with `mb subscription create|update|archive`, which take the subscription id printed here.", - capabilities: { minVersion: 58 }, + requires: ["pulse.list"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/dashboard/update-dashcard.ts b/packages/cli/src/commands/dashboard/update-dashcard.ts index 52aaeeeb..17693322 100644 --- a/packages/cli/src/commands/dashboard/update-dashcard.ts +++ b/packages/cli/src/commands/dashboard/update-dashcard.ts @@ -12,7 +12,7 @@ export default defineMetabaseCommand({ name: "update-dashcard", description: "Patch a single dashcard's layout or settings on a dashboard", }, - capabilities: { minVersion: 58 }, + requires: ["dashboard.updateDashcard"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/dashboard/update.ts b/packages/cli/src/commands/dashboard/update.ts index 1e475fbf..cc71266a 100644 --- a/packages/cli/src/commands/dashboard/update.ts +++ b/packages/cli/src/commands/dashboard/update.ts @@ -20,7 +20,7 @@ export default defineMetabaseCommand({ { skill: "dashboard", purpose: "wiring filters, cross-filtering, click behavior, tabs" }, { skill: "visualization", purpose: "dashcard visualization_settings and the 24-column grid" }, ], - capabilities: { minVersion: 58 }, + requires: ["dashboard.update", "dashboard.checkCardReferences"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/db/get.ts b/packages/cli/src/commands/db/get.ts index 9579c4ad..601b07d4 100644 --- a/packages/cli/src/commands/db/get.ts +++ b/packages/cli/src/commands/db/get.ts @@ -25,7 +25,7 @@ export default defineMetabaseCommand({ meta: { name: "get", description: "Get a database by id" }, details: "`--include tables` returns the table map (id, name, schema, description per table) — one call that fits most databases. `--include tables.fields` is the full rollup; on all but small databases prefer the map plus `mb table fields ` per table of interest.", - capabilities: { minVersion: 58 }, + requires: ["database.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/db/list.ts b/packages/cli/src/commands/db/list.ts index 9bf5a4a7..c4d5b36e 100644 --- a/packages/cli/src/commands/db/list.ts +++ b/packages/cli/src/commands/db/list.ts @@ -11,7 +11,7 @@ export const DatabaseListEnvelope = listEnvelopeSchema(DatabaseCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List databases" }, - capabilities: { minVersion: 58 }, + requires: ["database.list"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/db/rescan-values.ts b/packages/cli/src/commands/db/rescan-values.ts index 1cd2c72f..bc98d583 100644 --- a/packages/cli/src/commands/db/rescan-values.ts +++ b/packages/cli/src/commands/db/rescan-values.ts @@ -10,7 +10,7 @@ export default defineMetabaseCommand({ name: "rescan-values", description: "Trigger a rescan of cached field values for a database", }, - capabilities: { minVersion: 58 }, + requires: ["database.rescanValues"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/db/schema-tables.ts b/packages/cli/src/commands/db/schema-tables.ts index 50bf1be1..e3587326 100644 --- a/packages/cli/src/commands/db/schema-tables.ts +++ b/packages/cli/src/commands/db/schema-tables.ts @@ -14,7 +14,7 @@ export default defineMetabaseCommand({ name: "schema-tables", description: "List tables in a database schema", }, - capabilities: { minVersion: 58 }, + requires: ["database.schemaTables"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/db/schemas.ts b/packages/cli/src/commands/db/schemas.ts index 4c0fd4a5..16b51ccf 100644 --- a/packages/cli/src/commands/db/schemas.ts +++ b/packages/cli/src/commands/db/schemas.ts @@ -20,7 +20,7 @@ export const DatabaseSchemaListEnvelope = listEnvelopeSchema(SchemaName); export default defineMetabaseCommand({ meta: { name: "schemas", description: "List schemas in a database" }, - capabilities: { minVersion: 58 }, + requires: ["database.schemas"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/db/sync-schema.ts b/packages/cli/src/commands/db/sync-schema.ts index 01c07275..33e089ec 100644 --- a/packages/cli/src/commands/db/sync-schema.ts +++ b/packages/cli/src/commands/db/sync-schema.ts @@ -14,7 +14,7 @@ export default defineMetabaseCommand({ }, details: "Queues an async schema sync and returns immediately. Pass --wait to poll the database until its initial_sync_status reports `complete` (a database that has already finished its initial sync returns at once). To wait for a specific newly-materialized transform table to register, prefer `mb transform run --sync`.", - capabilities: { minVersion: 58 }, + requires: ["database.syncSchema"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/document/archive.ts b/packages/cli/src/commands/document/archive.ts index b2d0c318..bc96e299 100644 --- a/packages/cli/src/commands/document/archive.ts +++ b/packages/cli/src/commands/document/archive.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a document by id" }, - capabilities: { minVersion: 58 }, + requires: ["document.archive"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/document/create.ts b/packages/cli/src/commands/document/create.ts index 46bce565..9ca07d0e 100644 --- a/packages/cli/src/commands/document/create.ts +++ b/packages/cli/src/commands/document/create.ts @@ -8,7 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "create", description: "Create a document" }, - capabilities: { minVersion: 58 }, + requires: ["document.create"], args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, inputSchema: DocumentCreateInput, outputSchema: Document, diff --git a/packages/cli/src/commands/document/get.ts b/packages/cli/src/commands/document/get.ts index 1a29dee3..bd69d0c9 100644 --- a/packages/cli/src/commands/document/get.ts +++ b/packages/cli/src/commands/document/get.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a document by id" }, - capabilities: { minVersion: 58 }, + requires: ["document.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/document/list.ts b/packages/cli/src/commands/document/list.ts index 1dc819cb..a0685f3f 100644 --- a/packages/cli/src/commands/document/list.ts +++ b/packages/cli/src/commands/document/list.ts @@ -10,7 +10,7 @@ export const DocumentListEnvelope = listEnvelopeSchema(DocumentCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List documents" }, - capabilities: { minVersion: 58 }, + requires: ["document.list"], args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: DocumentListEnvelope, examples: ["mb document list", "mb document list --json"], diff --git a/packages/cli/src/commands/document/update.ts b/packages/cli/src/commands/document/update.ts index 12d4f9af..6767302f 100644 --- a/packages/cli/src/commands/document/update.ts +++ b/packages/cli/src/commands/document/update.ts @@ -9,7 +9,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "update", description: "Update a document by id" }, - capabilities: { minVersion: 58 }, + requires: ["document.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/eid.ts b/packages/cli/src/commands/eid.ts index 89193d77..04303588 100644 --- a/packages/cli/src/commands/eid.ts +++ b/packages/cli/src/commands/eid.ts @@ -23,7 +23,7 @@ export default defineMetabaseCommand({ details: "Entity ids are NanoIDs that can start with `-`; pass those via `--body` so the positional parser doesn't read them as a flag.", skills: [{ skill: "core", purpose: "entity-id translation and body input" }], - capabilities: { minVersion: 58 }, + requires: ["eidTranslation.translate"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/field/get.ts b/packages/cli/src/commands/field/get.ts index 9df3d6a3..97323823 100644 --- a/packages/cli/src/commands/field/get.ts +++ b/packages/cli/src/commands/field/get.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a field by id" }, - capabilities: { minVersion: 58 }, + requires: ["field.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/field/summary.ts b/packages/cli/src/commands/field/summary.ts index 20ba3a6c..4b2f2e8a 100644 --- a/packages/cli/src/commands/field/summary.ts +++ b/packages/cli/src/commands/field/summary.ts @@ -10,7 +10,7 @@ export default defineMetabaseCommand({ name: "summary", description: "Get the row count and distinct count for a field", }, - capabilities: { minVersion: 58 }, + requires: ["field.summary"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/field/update.ts b/packages/cli/src/commands/field/update.ts index 8c004fd8..47798207 100644 --- a/packages/cli/src/commands/field/update.ts +++ b/packages/cli/src/commands/field/update.ts @@ -12,7 +12,7 @@ export default defineMetabaseCommand({ name: "update", description: "Update a field (description, semantic_type, FK target, visibility, etc.)", }, - capabilities: { minVersion: 58 }, + requires: ["field.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/field/values.ts b/packages/cli/src/commands/field/values.ts index b3cbe50b..90584a26 100644 --- a/packages/cli/src/commands/field/values.ts +++ b/packages/cli/src/commands/field/values.ts @@ -10,7 +10,7 @@ export default defineMetabaseCommand({ name: "values", description: "Fetch the cached distinct values for a field (FieldValues list)", }, - capabilities: { minVersion: 58 }, + requires: ["field.values"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/git-sync/add-collection.ts b/packages/cli/src/commands/git-sync/add-collection.ts index 677e11b9..8150d959 100644 --- a/packages/cli/src/commands/git-sync/add-collection.ts +++ b/packages/cli/src/commands/git-sync/add-collection.ts @@ -13,7 +13,7 @@ export default defineMetabaseCommand({ name: "add-collection", description: "Mark a collection as git-synced; cascades to descendants by location prefix", }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.setCollectionSynced"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/git-sync/branches.ts b/packages/cli/src/commands/git-sync/branches.ts index f270dce1..0eba5020 100644 --- a/packages/cli/src/commands/git-sync/branches.ts +++ b/packages/cli/src/commands/git-sync/branches.ts @@ -19,7 +19,7 @@ const SyncBranchListEnvelope = listEnvelopeSchema(BranchRow); export default defineMetabaseCommand({ meta: { name: "branches", description: "List branches on the configured git remote" }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.branches"], args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncBranchListEnvelope, examples: ["mb git-sync branches", "mb git-sync branches --json"], diff --git a/packages/cli/src/commands/git-sync/cancel-task.ts b/packages/cli/src/commands/git-sync/cancel-task.ts index 6894f8bc..ecaab222 100644 --- a/packages/cli/src/commands/git-sync/cancel-task.ts +++ b/packages/cli/src/commands/git-sync/cancel-task.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "cancel-task", description: "Cancel the running git-sync task" }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.cancelTask"], args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncTask, examples: ["mb git-sync cancel-task", "mb git-sync cancel-task --json"], diff --git a/packages/cli/src/commands/git-sync/create-branch.ts b/packages/cli/src/commands/git-sync/create-branch.ts index 78a24350..26520a7f 100644 --- a/packages/cli/src/commands/git-sync/create-branch.ts +++ b/packages/cli/src/commands/git-sync/create-branch.ts @@ -11,7 +11,7 @@ export default defineMetabaseCommand({ name: "create-branch", description: "Create a new branch on the git remote and switch git-sync to it", }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.createBranch"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/git-sync/current-task.ts b/packages/cli/src/commands/git-sync/current-task.ts index d25996b2..202ac018 100644 --- a/packages/cli/src/commands/git-sync/current-task.ts +++ b/packages/cli/src/commands/git-sync/current-task.ts @@ -12,7 +12,7 @@ export default defineMetabaseCommand({ name: "current-task", description: "Get the most recent git-sync task (or idle if none)", }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.currentTask"], args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: CurrentTaskResult, examples: ["mb git-sync current-task", "mb git-sync current-task --json"], diff --git a/packages/cli/src/commands/git-sync/dirty.ts b/packages/cli/src/commands/git-sync/dirty.ts index f408eca3..a54ddd8b 100644 --- a/packages/cli/src/commands/git-sync/dirty.ts +++ b/packages/cli/src/commands/git-sync/dirty.ts @@ -11,7 +11,7 @@ export const SyncDirtyListEnvelope = listEnvelopeSchema(SyncDirtyItemCompact); export default defineMetabaseCommand({ meta: { name: "dirty", description: "List objects with unsynced local changes" }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.dirty"], args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncDirtyListEnvelope, examples: ["mb git-sync dirty", "mb git-sync dirty --json"], diff --git a/packages/cli/src/commands/git-sync/export.ts b/packages/cli/src/commands/git-sync/export.ts index e3a39529..f9e0f712 100644 --- a/packages/cli/src/commands/git-sync/export.ts +++ b/packages/cli/src/commands/git-sync/export.ts @@ -16,7 +16,7 @@ export default defineMetabaseCommand({ name: "export", description: "Export Metabase changes back to the configured git remote", }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.export"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/git-sync/has-remote-changes.ts b/packages/cli/src/commands/git-sync/has-remote-changes.ts index 2a4b0d86..8f794b5c 100644 --- a/packages/cli/src/commands/git-sync/has-remote-changes.ts +++ b/packages/cli/src/commands/git-sync/has-remote-changes.ts @@ -10,7 +10,7 @@ export default defineMetabaseCommand({ name: "has-remote-changes", description: "Check whether the remote branch has unimported changes", }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.hasRemoteChanges"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/git-sync/import.ts b/packages/cli/src/commands/git-sync/import.ts index 763e827f..0c802abb 100644 --- a/packages/cli/src/commands/git-sync/import.ts +++ b/packages/cli/src/commands/git-sync/import.ts @@ -14,7 +14,7 @@ export default defineMetabaseCommand({ name: "import", description: "Import content from the configured git remote into Metabase", }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.import"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/git-sync/is-dirty.ts b/packages/cli/src/commands/git-sync/is-dirty.ts index cf920862..89d73105 100644 --- a/packages/cli/src/commands/git-sync/is-dirty.ts +++ b/packages/cli/src/commands/git-sync/is-dirty.ts @@ -20,7 +20,7 @@ export default defineMetabaseCommand({ name: "is-dirty", description: "Check whether Metabase has unsynced local changes", }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.isDirty"], args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: IsDirtyResult, examples: ["mb git-sync is-dirty", "mb git-sync is-dirty --json"], diff --git a/packages/cli/src/commands/git-sync/remove-collection.ts b/packages/cli/src/commands/git-sync/remove-collection.ts index d77d1cf1..fd6bc6b5 100644 --- a/packages/cli/src/commands/git-sync/remove-collection.ts +++ b/packages/cli/src/commands/git-sync/remove-collection.ts @@ -11,7 +11,7 @@ export default defineMetabaseCommand({ name: "remove-collection", description: "Unmark a collection as git-synced; cascades to descendants by location prefix", }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.setCollectionSynced"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/git-sync/stash.ts b/packages/cli/src/commands/git-sync/stash.ts index 5107f21b..2551aa90 100644 --- a/packages/cli/src/commands/git-sync/stash.ts +++ b/packages/cli/src/commands/git-sync/stash.ts @@ -17,7 +17,7 @@ export default defineMetabaseCommand({ name: "stash", description: "Export current Metabase state to a new branch on the git remote", }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.stash"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/git-sync/status.ts b/packages/cli/src/commands/git-sync/status.ts index 88a5bda0..08fa1644 100644 --- a/packages/cli/src/commands/git-sync/status.ts +++ b/packages/cli/src/commands/git-sync/status.ts @@ -36,7 +36,12 @@ export default defineMetabaseCommand({ name: "status", description: "Show current git-sync state (branch, dirty, current task)", }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: [ + "gitSync.branch", + "gitSync.isDirty", + "gitSync.currentTask", + "gitSync.syncedCollections", + ], args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncStatus, examples: ["mb git-sync status", "mb git-sync status --json"], diff --git a/packages/cli/src/commands/git-sync/wait.ts b/packages/cli/src/commands/git-sync/wait.ts index abece2ee..7c8e77a5 100644 --- a/packages/cli/src/commands/git-sync/wait.ts +++ b/packages/cli/src/commands/git-sync/wait.ts @@ -22,7 +22,7 @@ export default defineMetabaseCommand({ name: "wait", description: "Poll the current git-sync task until it reaches a terminal status", }, - capabilities: { minVersion: 60, tokenFeature: "remote_sync" }, + requires: ["gitSync.waitForTask"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/group.ts b/packages/cli/src/commands/group.ts index adade155..0587d222 100644 --- a/packages/cli/src/commands/group.ts +++ b/packages/cli/src/commands/group.ts @@ -28,6 +28,7 @@ export function defineCommandGroup(def: CommandGroupDef): CommandDef { skills: def.skills ?? [], inputSchema: null, outputSchema: null, + requires: null, capabilities: null, }); return cmd; diff --git a/packages/cli/src/commands/library/create.ts b/packages/cli/src/commands/library/create.ts index 45a2432c..720a2b2b 100644 --- a/packages/cli/src/commands/library/create.ts +++ b/packages/cli/src/commands/library/create.ts @@ -11,7 +11,7 @@ export default defineMetabaseCommand({ }, details: "Creates the Library subtree if it doesn't exist and returns it; a no-op that returns the existing Library when it's already there. Only admins and data analysts can curate the Library.", - capabilities: { minVersion: 59, tokenFeature: "library" }, + requires: ["library.create"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/library/get.ts b/packages/cli/src/commands/library/get.ts index 4f2b8318..40549fe6 100644 --- a/packages/cli/src/commands/library/get.ts +++ b/packages/cli/src/commands/library/get.ts @@ -11,7 +11,7 @@ export default defineMetabaseCommand({ }, details: "Reads the Library root and its child collections. Use the `library-data` child's id as the target for publishing tables (or just run `mb library publish`, which resolves it for you).", - capabilities: { minVersion: 59, tokenFeature: "library" }, + requires: ["library.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/library/publish.ts b/packages/cli/src/commands/library/publish.ts index e7a709ba..dc3d92df 100644 --- a/packages/cli/src/commands/library/publish.ts +++ b/packages/cli/src/commands/library/publish.ts @@ -30,7 +30,7 @@ export default defineMetabaseCommand({ }, details: "Sets each selected table and every upstream table it depends on into the Library Data collection, so they appear first in data pickers and rank up in search. The Library Data collection is resolved automatically (and the Library is created if it doesn't exist yet). Select with --table-ids, --db-ids, or --schemas (each schema id is \":\", e.g. 1:public); the filters are combined. Publishing does not add the Library Data collection to the git-sync scope — on an instance with remote sync configured, run `mb git-sync add-collection ` so exports carry the published tables' metadata.", - capabilities: { minVersion: 59, tokenFeature: "library" }, + requires: ["library.ensureDataCollectionId", "library.publishTables", "gitSync.remoteUrl"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/library/unpublish.ts b/packages/cli/src/commands/library/unpublish.ts index 46d027e8..1b04b2de 100644 --- a/packages/cli/src/commands/library/unpublish.ts +++ b/packages/cli/src/commands/library/unpublish.ts @@ -32,7 +32,7 @@ export default defineMetabaseCommand({ }, details: 'Clears the Library collection for each selected table and recursively for every downstream table that depends on it. Select with --table-ids, --db-ids, or --schemas (each schema id is ":", e.g. 1:public); the filters are combined.', - capabilities: { minVersion: 59, tokenFeature: "library" }, + requires: ["library.unpublishTables"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/measure/archive.ts b/packages/cli/src/commands/measure/archive.ts index 0045be39..95156d9e 100644 --- a/packages/cli/src/commands/measure/archive.ts +++ b/packages/cli/src/commands/measure/archive.ts @@ -8,7 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a measure by id" }, - capabilities: { minVersion: 59 }, + requires: ["measure.archive"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/measure/create.ts b/packages/cli/src/commands/measure/create.ts index 4f28aef2..9b92c619 100644 --- a/packages/cli/src/commands/measure/create.ts +++ b/packages/cli/src/commands/measure/create.ts @@ -19,7 +19,7 @@ export default defineMetabaseCommand({ details: "A measure is a reusable, saved aggregation tied to a table. The JSON body needs `name`, `table_id`, and a `definition` (an MBQL query holding exactly one aggregation). An MBQL 5 `definition` is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass.", skills: [{ skill: "mbql", purpose: "the definition aggregation" }], - capabilities: { minVersion: 59 }, + requires: ["measure.create"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/measure/get.ts b/packages/cli/src/commands/measure/get.ts index 403ba035..f73d5c7e 100644 --- a/packages/cli/src/commands/measure/get.ts +++ b/packages/cli/src/commands/measure/get.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a measure by id" }, - capabilities: { minVersion: 59 }, + requires: ["measure.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/measure/list.ts b/packages/cli/src/commands/measure/list.ts index a8cb6351..7775dbef 100644 --- a/packages/cli/src/commands/measure/list.ts +++ b/packages/cli/src/commands/measure/list.ts @@ -10,7 +10,7 @@ export const MeasureListEnvelope = listEnvelopeSchema(MeasureCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List measures" }, - capabilities: { minVersion: 59 }, + requires: ["measure.list"], args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: MeasureListEnvelope, examples: ["mb measure list", "mb measure list --json"], diff --git a/packages/cli/src/commands/measure/update.ts b/packages/cli/src/commands/measure/update.ts index 526b868f..d32efea5 100644 --- a/packages/cli/src/commands/measure/update.ts +++ b/packages/cli/src/commands/measure/update.ts @@ -20,7 +20,7 @@ export default defineMetabaseCommand({ details: "Patches only the fields you send and must include `revision_message` (recorded in the audit log). When `definition` is an MBQL 5 query it is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass.", skills: [{ skill: "mbql", purpose: "the definition aggregation" }], - capabilities: { minVersion: 59 }, + requires: ["measure.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/query.ts b/packages/cli/src/commands/query.ts index 17b4adbc..e56c092c 100644 --- a/packages/cli/src/commands/query.ts +++ b/packages/cli/src/commands/query.ts @@ -32,7 +32,7 @@ export default defineMetabaseCommand({ details: 'Reads a JSON query body from --body, --file, or stdin and runs it. MBQL 5 is Metabase\'s structured query format, shaped {"lib/type":"mbql/query", "database": , "stages": [...]}; it is checked against a bundled JSON Schema before sending — --print-schema prints that schema, and --dry-run reports any errors as {ok, errors:[{path, message}]} and exits 2 without sending. Legacy MBQL 4 and native-SQL bodies are not checked and run as-is.', skills: [{ skill: "mbql", purpose: "body shape, clause rules, and the dry-run loop" }], - capabilities: { minVersion: 58 }, + requires: ["dataset.query"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/requires-guard.test.ts b/packages/cli/src/commands/requires-guard.test.ts new file mode 100644 index 00000000..25ca328a --- /dev/null +++ b/packages/cli/src/commands/requires-guard.test.ts @@ -0,0 +1,102 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { isMethodKey, type MethodKey } from "@metabase/client/version/requirements"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_SRC = resolve(HERE, ".."); + +// A call on a client namespace, wherever the client came from: `client.card.list(`, +// `mb.gitSync.branch(`. Filtering the match through the requirements table drops everything that +// merely looks like one. +const METHOD_CALL = /\b[A-Za-z_$][\w$]*\.([A-Za-z]+)\.([A-Za-z]+)\(/g; +const REQUIRES_DECLARATION = /^\s*requires: (null|\[[^\]]*\]),?$/m; +const STRING_LITERAL = /"([^"]+)"/g; + +// Helpers a command hands its client to. The methods they call count as the command's, so a +// command declares what reaches the wire on its behalf rather than what its own body spells out. +const CLIENT_HELPERS: Readonly> = { + preflightDashcardCardReferences: "commands/dashboard/preflight.ts", + warnIfOutsideSyncScope: "commands/git-sync/sync-scope.ts", + verifyAndProbe: "core/auth/verify.ts", +}; + +function commandFiles(): string[] { + return readdirSync(HERE, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".ts")) + .filter((entry) => !entry.name.endsWith(".test.ts")) + .map((entry) => relative(CLI_SRC, resolve(entry.parentPath, entry.name)).split(sep).join("/")) + .filter((file) => readSource(file).includes("defineMetabaseCommand({")); +} + +function readSource(file: string): string { + return readFileSync(resolve(CLI_SRC, file), "utf8"); +} + +function methodsCalledIn(source: string): MethodKey[] { + return [...source.matchAll(METHOD_CALL)] + .map((match) => `${match[1]}.${match[2]}`) + .filter(isMethodKey); +} + +function methodsReachedBy(file: string): MethodKey[] { + const source = readSource(file); + const viaHelpers = Object.entries(CLIENT_HELPERS) + .filter(([helper]) => new RegExp(`\\b${helper}\\(`).test(source)) + .flatMap(([, helperFile]) => methodsCalledIn(readSource(helperFile))); + return [...new Set([...methodsCalledIn(source), ...viaHelpers])].toSorted(); +} + +interface Declaration { + readonly file: string; + readonly requires: readonly string[] | null; +} + +function declarationOf(file: string): Declaration { + const match = REQUIRES_DECLARATION.exec(readSource(file)); + if (match === null || match[1] === undefined) { + throw new Error(`${file}: no \`requires\` declaration found`); + } + if (match[1] === "null") { + return { file, requires: null }; + } + const requires = [...match[1].matchAll(STRING_LITERAL)].flatMap((literal) => + literal[1] === undefined ? [] : [literal[1]], + ); + return { file, requires: requires.toSorted() }; +} + +describe("every command declares exactly the client methods it reaches", () => { + const declarations = commandFiles().map(declarationOf); + + it("declares the methods called in its body and in the helpers it hands the client to", () => { + const online = declarations.filter((declaration) => declaration.requires !== null); + const declared = Object.fromEntries(online.map((entry) => [entry.file, entry.requires])); + const reached = Object.fromEntries( + online.map((entry) => [entry.file, methodsReachedBy(entry.file)]), + ); + expect(declared).toEqual(reached); + }); + + it("reaches no client method at all when it declares it never reaches a server", () => { + const offline = declarations.filter((declaration) => declaration.requires === null); + const reached = Object.fromEntries( + offline.map((entry) => [entry.file, methodsReachedBy(entry.file)]), + ); + expect(reached).toEqual(Object.fromEntries(offline.map((entry) => [entry.file, []]))); + }); + + it("names only helpers that still exist and still call a client method", () => { + const reached = Object.fromEntries( + Object.values(CLIENT_HELPERS).map((file) => [file, methodsCalledIn(readSource(file))]), + ); + expect(reached).toEqual({ + "commands/dashboard/preflight.ts": ["dashboard.checkCardReferences"], + "commands/git-sync/sync-scope.ts": ["gitSync.remoteUrl"], + "core/auth/verify.ts": ["user.current"], + }); + }); +}); diff --git a/packages/cli/src/commands/runtime.test.ts b/packages/cli/src/commands/runtime.test.ts index fa64ef69..ee96c032 100644 --- a/packages/cli/src/commands/runtime.test.ts +++ b/packages/cli/src/commands/runtime.test.ts @@ -1,7 +1,9 @@ import { runCommand } from "citty"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { captureFetch, jsonResponse } from "@metabase/client/testing/fetch-capture"; import type { ServerInfo } from "@metabase/client/version/probe"; +import { createServerProfile, KNOWN_RANGE } from "@metabase/client/version/profile"; import { setupTempConfigHome, type TempConfigHome } from "../core/auth/temp-config-home"; @@ -17,7 +19,21 @@ vi.mock("@napi-rs/keyring", async () => { const { defineMetabaseCommand, SKIP_PREFLIGHT_ENV } = await import("./runtime"); const { connectionFlags, listFlags, outputFlags, profileFlag } = await import("./flags"); -const { writeProbeResult, writeProfile } = await import("../core/auth/storage"); +const { readProfileRecord, writeProbeResult, writeProfile } = await import("../core/auth/storage"); + +const BEYOND_KNOWN = KNOWN_RANGE.max + 5; + +const NEWER_NOTICE = `Metabase v0.${BEYOND_KNOWN}.0 is newer than this CLI supports (up to v${KNOWN_RANGE.max}); commands run as if it were v${KNOWN_RANGE.max}. Run \`mb upgrade\` for a newer CLI.\n`; +const UNKNOWN_NOTICE = `Could not parse the Metabase version; assuming the newest supported (v${KNOWN_RANGE.max}).\n`; + +function shapeErrorEnvelope(message: string): unknown { + return { ok: false, error: { category: "response-shape", message, exitCode: 1 } }; +} + +function errorEnvelopeOf(stderr: string[]): unknown { + const parsed: unknown = JSON.parse(stderr.join("")); + return parsed; +} async function seedProbedProfile(name: string, info: ServerInfo): Promise { await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }, name); @@ -30,6 +46,8 @@ async function seedProbedProfile(name: string, info: ServerInfo): Promise function fakeServerInfo(major: number): ServerInfo { return { version: { tag: `v0.${major}.0`, major, patch: 0 }, + date: null, + hash: null, tokenFeatures: null, }; } @@ -61,6 +79,7 @@ describe("defineMetabaseCommand", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); home.cleanup(); delete process.env[SKIP_PREFLIGHT_ENV]; process.exitCode = previousExitCode; @@ -71,7 +90,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo" }, - capabilities: {}, + requires: [], args: { ...outputFlags, custom: { type: "string", description: "custom flag" } }, run({ args, ctx }) { observed(ctx.format, args.custom); @@ -86,7 +105,7 @@ describe("defineMetabaseCommand", () => { const observed = vi.fn<(profile: string | undefined, url: string | undefined) => void>(); const cmd = defineMetabaseCommand({ meta: { name: "bare", description: "no opt-ins" }, - capabilities: {}, + requires: [], args: {}, run({ ctx }) { observed(ctx.profile, ctx.url); @@ -103,7 +122,7 @@ describe("defineMetabaseCommand", () => { const observed = vi.fn<(client: unknown) => void>(); const cmd = defineMetabaseCommand({ meta: { name: "uses-client", description: "uses the client" }, - capabilities: {}, + requires: [], args: { ...profileFlag }, async run({ getClient }) { const client = await getClient(); @@ -118,7 +137,7 @@ describe("defineMetabaseCommand", () => { it("does not call resolveConfig when the run handler never calls getClient", async () => { const cmd = defineMetabaseCommand({ meta: { name: "no-client", description: "does not need the client" }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -134,7 +153,7 @@ describe("defineMetabaseCommand", () => { let second: unknown; const cmd = defineMetabaseCommand({ meta: { name: "cached", description: "client is cached" }, - capabilities: {}, + requires: [], args: {}, async run({ getClient }) { first = await getClient(); @@ -148,7 +167,7 @@ describe("defineMetabaseCommand", () => { it("reports ConfigError as a JSON error envelope to stderr (non-TTY format) and sets exitCode 2", async () => { const cmd = defineMetabaseCommand({ meta: { name: "needs-creds", description: "needs creds" }, - capabilities: {}, + requires: [], args: {}, async run({ getClient }) { await getClient(); @@ -175,7 +194,7 @@ describe("defineMetabaseCommand", () => { const ran = vi.fn(); const cmd = defineMetabaseCommand({ meta: { name: "ranged", description: "takes a range" }, - capabilities: {}, + requires: [], args: { ...outputFlags, ...listFlags }, run() { ran(); @@ -201,7 +220,7 @@ describe("defineMetabaseCommand", () => { it("reports a rejected --max-bytes as a JSON error envelope", async () => { const cmd = defineMetabaseCommand({ meta: { name: "capped", description: "takes a cap" }, - capabilities: {}, + requires: [], args: { ...outputFlags }, run() { return; @@ -226,7 +245,7 @@ describe("defineMetabaseCommand", () => { it("reports an unresolvable --format as plain text, there being no format to serialize into", async () => { const cmd = defineMetabaseCommand({ meta: { name: "misformatted", description: "bad format" }, - capabilities: {}, + requires: [], args: { ...outputFlags }, run() { return; @@ -240,14 +259,14 @@ describe("defineMetabaseCommand", () => { expect(process.exitCode).toBe(2); }); - it("refuses with CapabilityError exit code 2 when the cached server major is below required minVersion", async () => { + it("refuses with CapabilityError exit code 2 before run does any work when the cached server lacks a required feature", async () => { await seedProbedProfile("default", fakeServerInfo(58)); const ran = vi.fn(); const cmd = defineMetabaseCommand({ - meta: { name: "needs-v60", description: "wants v60" }, + meta: { name: "needs-activation", description: "wants job activation" }, args: {}, - capabilities: { minVersion: 60 }, + requires: ["transformJob.setActive"], async run({ getClient }) { await getClient(); ran(); @@ -258,20 +277,44 @@ describe("defineMetabaseCommand", () => { await runCommand(cmd, { rawArgs: [] }); expect(stderr.join("")).toContain( - "This operation requires Metabase v60+ (this server is v0.58.0). Upgrade Metabase to use it.", + "This operation requires Metabase v61+ (this server is v0.58.0). Upgrade Metabase to use it.", ); expect(process.exitCode).toBe(2); expect(ran).not.toHaveBeenCalled(); }); it("refuses with CapabilityError exit code 2 when the required premium token-feature is absent", async () => { - await seedProbedProfile("default", fakeServerInfo(58)); + await seedProbedProfile("default", fakeServerInfo(61)); + + const ran = vi.fn(); + const cmd = defineMetabaseCommand({ + meta: { name: "needs-library", description: "wants the library" }, + args: {}, + requires: ["library.get"], + async run({ getClient }) { + await getClient(); + ran(); + }, + }); + const stderr = captureStderr(); + + await runCommand(cmd, { rawArgs: [] }); + + expect(stderr.join("")).toContain( + "This operation requires the 'library' premium feature (not enabled on this server).", + ); + expect(process.exitCode).toBe(2); + expect(ran).not.toHaveBeenCalled(); + }); + + it("checks every declared method, so a second method's feature refuses when the first is satisfied", async () => { + await seedProbedProfile("default", fakeServerInfo(59)); const ran = vi.fn(); const cmd = defineMetabaseCommand({ - meta: { name: "needs-transforms", description: "wants transforms" }, + meta: { name: "two-methods", description: "calls two gated methods" }, args: {}, - capabilities: { tokenFeature: "transforms" }, + requires: ["measure.list", "transformJob.setActive"], async run({ getClient }) { await getClient(); ran(); @@ -282,19 +325,21 @@ describe("defineMetabaseCommand", () => { await runCommand(cmd, { rawArgs: [] }); expect(stderr.join("")).toContain( - "This operation requires the 'transforms' premium feature (not enabled on this server).", + "This operation requires Metabase v61+ (this server is v0.59.0). Upgrade Metabase to use it.", ); expect(process.exitCode).toBe(2); expect(ran).not.toHaveBeenCalled(); }); - it("runs a baseline-capabilities command without consulting the cached probe", async () => { + it("never asks the client for a profile when every declared method is baseline", async () => { await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + const capture = captureFetch([]); + vi.stubGlobal("fetch", capture.fetch); const ran = vi.fn(); const cmd = defineMetabaseCommand({ - meta: { name: "no-caps", description: "no caps" }, - capabilities: {}, + meta: { name: "baseline-only", description: "baseline methods only" }, + requires: ["card.list", "user.current"], args: {}, async run({ getClient }) { await getClient(); @@ -303,17 +348,90 @@ describe("defineMetabaseCommand", () => { }); await runCommand(cmd, { rawArgs: [] }); + expect(ran).toHaveBeenCalledOnce(); + expect(capture.calls).toEqual([]); + expect(process.exitCode).toBe(0); }); - it("warns to stderr but proceeds when the profile has no cached probe", async () => { + it("never asks the client for a profile when the command declares no methods at all", async () => { await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + const capture = captureFetch([]); + vi.stubGlobal("fetch", capture.fetch); const ran = vi.fn(); const cmd = defineMetabaseCommand({ - meta: { name: "needs-v60-warn", description: "wants v60" }, + meta: { name: "no-methods", description: "declares nothing" }, + requires: [], args: {}, - capabilities: { minVersion: 60 }, + async run({ getClient }) { + await getClient(); + ran(); + }, + }); + + await runCommand(cmd, { rawArgs: [] }); + + expect(ran).toHaveBeenCalledOnce(); + expect(capture.calls).toEqual([]); + expect(process.exitCode).toBe(0); + }); + + it("never asks the client for a profile when the command declares it reaches no server", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + const capture = captureFetch([]); + vi.stubGlobal("fetch", capture.fetch); + + const ran = vi.fn(); + const cmd = defineMetabaseCommand({ + meta: { name: "offline", description: "reaches no server" }, + requires: null, + args: {}, + async run({ getClient }) { + await getClient(); + ran(); + }, + }); + + await runCommand(cmd, { rawArgs: [] }); + + expect(ran).toHaveBeenCalledOnce(); + expect(capture.calls).toEqual([]); + expect(process.exitCode).toBe(0); + }); + + it("hands the client the profile derived from the cached probe, so it never probes for itself", async () => { + const info = fakeServerInfo(61); + await seedProbedProfile("default", info); + + const seen = vi.fn(); + const cmd = defineMetabaseCommand({ + meta: { name: "reads-profile", description: "reads the server profile" }, + args: {}, + requires: [], + async run({ getClient }) { + const client = await getClient(); + seen(await client.server()); + }, + }); + + await runCommand(cmd, { rawArgs: [] }); + + expect(seen.mock.calls).toEqual([[createServerProfile(info)]]); + }); + + it("probes the server once when the profile has no cached probe and refuses on what it learns", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + const capture = captureFetch([ + jsonResponse({ version: { tag: "v0.58.0" }, "token-features": {} }), + ]); + vi.stubGlobal("fetch", capture.fetch); + + const ran = vi.fn(); + const cmd = defineMetabaseCommand({ + meta: { name: "probes-lazily", description: "wants measures" }, + args: {}, + requires: ["measure.list"], async run({ getClient }) { await getClient(); ran(); @@ -323,25 +441,64 @@ describe("defineMetabaseCommand", () => { await runCommand(cmd, { rawArgs: [] }); - const joined = stderr.join(""); - expect(joined).toContain( - "Could not detect Metabase server version. Proceeding without preflight check; failures may produce confusing errors.", + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://m.example.com/api/session/properties", + ]); + expect(stderr.join("")).toContain( + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", ); - expect(joined).toContain( - "Run `mb auth list` (or `mb auth login`) to populate the version cache.", + expect(process.exitCode).toBe(2); + expect(ran).not.toHaveBeenCalled(); + }); + + it("ends the command with the probe's own error when there is no cached probe and the server cannot be reached", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + const capture = captureFetch([new TypeError("fetch failed")]); + vi.stubGlobal("fetch", capture.fetch); + + const ran = vi.fn(); + const cmd = defineMetabaseCommand({ + meta: { name: "probe-fails", description: "wants measures" }, + args: {}, + requires: ["measure.list"], + async run({ getClient }) { + await getClient(); + ran(); + }, + }); + const stderr = captureStderr(); + + await runCommand(cmd, { rawArgs: [] }); + + expect(stderr.join("")).toBe( + JSON.stringify({ + ok: false, + error: { + category: "network", + message: "Could not reach Metabase: fetch failed", + exitCode: 1, + }, + }) + "\n", ); - expect(ran).toHaveBeenCalledOnce(); - expect(process.exitCode).toBe(0); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://m.example.com/api/session/properties", + ]); + expect(ran).not.toHaveBeenCalled(); }); - it("proceeds without any version warning when the cached probe has a non-numeric version", async () => { - await seedProbedProfile("default", { version: null, tokenFeatures: null }); + it("proceeds when the cached probe carries a version the client could not parse, reading it as the newest known and saying so once", async () => { + await seedProbedProfile("default", { + version: null, + date: null, + hash: null, + tokenFeatures: null, + }); const ran = vi.fn(); const cmd = defineMetabaseCommand({ - meta: { name: "needs-v60-unknown", description: "wants v60" }, + meta: { name: "needs-activation-unknown", description: "wants job activation" }, args: {}, - capabilities: { minVersion: 60 }, + requires: ["transformJob.setActive"], async run({ getClient }) { await getClient(); ran(); @@ -351,11 +508,277 @@ describe("defineMetabaseCommand", () => { await runCommand(cmd, { rawArgs: [] }); - expect(stderr.join("")).not.toContain("Could not detect Metabase server version"); + expect(stderr.join("")).toBe(UNKNOWN_NOTICE); expect(ran).toHaveBeenCalledOnce(); expect(process.exitCode).toBe(0); }); + it("prints one newer-server notice when the cached probe is above the known range, even for a baseline command", async () => { + await seedProbedProfile("default", fakeServerInfo(BEYOND_KNOWN)); + + const cmd = defineMetabaseCommand({ + meta: { name: "baseline-on-newer", description: "baseline on a newer server" }, + args: {}, + requires: ["card.list"], + async run({ getClient }) { + await getClient(); + await getClient(); + }, + }); + const stderr = captureStderr(); + + await runCommand(cmd, { rawArgs: [] }); + + expect(stderr.join("")).toBe(NEWER_NOTICE); + expect(process.exitCode).toBe(0); + }); + + it("prints no notice when the cached probe is inside the known range", async () => { + await seedProbedProfile("default", fakeServerInfo(KNOWN_RANGE.max)); + + const cmd = defineMetabaseCommand({ + meta: { name: "supported", description: "supported server" }, + args: {}, + requires: ["measure.list"], + async run({ getClient }) { + await getClient(); + }, + }); + const stderr = captureStderr(); + + await runCommand(cmd, { rawArgs: [] }); + + expect(stderr.join("")).toBe(""); + }); + + it("prints the newer-server notice off the live probe when a gated command runs without a cached probe", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + const capture = captureFetch([ + jsonResponse({ version: { tag: `v0.${BEYOND_KNOWN}.0` }, "token-features": {} }), + jsonResponse([]), + ]); + vi.stubGlobal("fetch", capture.fetch); + + const cmd = defineMetabaseCommand({ + meta: { name: "probes-newer", description: "wants measures on a newer server" }, + args: {}, + requires: ["measure.list"], + async run({ getClient }) { + const client = await getClient(); + await client.measure.list(); + }, + }); + const stderr = captureStderr(); + + await runCommand(cmd, { rawArgs: [] }); + + expect(stderr.join("")).toBe(NEWER_NOTICE); + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://m.example.com/api/session/properties", + "https://m.example.com/api/measure", + ]); + expect(process.exitCode).toBe(0); + }); + + it("ignores the cached probe when --url points the profile at another server", async () => { + await seedProbedProfile("default", fakeServerInfo(58)); + const capture = captureFetch([ + jsonResponse({ version: { tag: "v0.61.0" }, "token-features": {} }), + jsonResponse([]), + ]); + vi.stubGlobal("fetch", capture.fetch); + + const seen = vi.fn(); + const cmd = defineMetabaseCommand({ + meta: { name: "other-url", description: "same profile, other server" }, + args: { ...connectionFlags }, + requires: ["measure.list"], + async run({ getClient }) { + const client = await getClient(); + seen(await client.measure.list()); + }, + }); + + await runCommand(cmd, { rawArgs: ["--url", "https://other.example.com"] }); + + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://other.example.com/api/session/properties", + "https://other.example.com/api/measure", + ]); + expect(seen.mock.calls).toEqual([[{ data: [], total: null }]]); + }); + + describe("re-probe on a shape error", () => { + const SHAPE_LEAD = + "On Metabase v0.59.0 the response shape was unexpected:\n" + + " Invalid input: expected array, received object"; + + function measureListCommand() { + return defineMetabaseCommand({ + meta: { name: "lists-measures", description: "lists measures" }, + args: {}, + requires: ["measure.list"], + async run({ getClient }) { + const client = await getClient(); + await client.measure.list(); + }, + }); + } + + it("re-probes once, writes a changed server back to the profile, and appends the change to the error", async () => { + await seedProbedProfile("default", fakeServerInfo(59)); + const before = await readProfileRecord("default"); + const capture = captureFetch([ + jsonResponse({}), + jsonResponse({ + version: { tag: "v0.63.4", date: "2026-09-01", hash: "abc1234" }, + "token-features": { library: true }, + }), + ]); + vi.stubGlobal("fetch", capture.fetch); + const stderr = captureStderr(); + + await runCommand(measureListCommand(), { rawArgs: [] }); + + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://m.example.com/api/measure", + "https://m.example.com/api/session/properties", + ]); + expect(errorEnvelopeOf(stderr)).toEqual( + shapeErrorEnvelope( + `${SHAPE_LEAD}\nThe server's version changed since the last probe (was v0.59.0, now v0.63.4); the profile was refreshed — retry the command.`, + ), + ); + expect(process.exitCode).toBe(1); + const after = await readProfileRecord("default"); + expect(after?.lastProbe).toEqual({ + at: after?.lastProbe?.at, + version: { tag: "v0.63.4", major: 63, patch: 4 }, + date: "2026-09-01", + hash: "abc1234", + tokenFeatures: { library: true }, + user: before?.lastProbe?.user, + }); + expect(after?.lastProbe?.at).not.toBe(before?.lastProbe?.at); + }); + + it("names changed premium features when the version is the same", async () => { + await seedProbedProfile("default", { + ...fakeServerInfo(59), + tokenFeatures: { library: false }, + }); + const capture = captureFetch([ + jsonResponse({}), + jsonResponse({ version: { tag: "v0.59.0" }, "token-features": { library: true } }), + ]); + vi.stubGlobal("fetch", capture.fetch); + const stderr = captureStderr(); + + await runCommand(measureListCommand(), { rawArgs: [] }); + + expect(errorEnvelopeOf(stderr)).toEqual( + shapeErrorEnvelope( + `${SHAPE_LEAD}\nThe server's premium features changed since the last probe; the profile was refreshed — retry the command.`, + ), + ); + const after = await readProfileRecord("default"); + expect(after?.lastProbe).toEqual({ + at: after?.lastProbe?.at, + version: { tag: "v0.59.0", major: 59, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { library: true }, + user: { id: 1, name: "Tester", isAdmin: true }, + }); + }); + + it("reports the error unchanged and leaves the profile alone when the fresh probe agrees with the cache", async () => { + await seedProbedProfile("default", fakeServerInfo(59)); + const before = await readProfileRecord("default"); + const capture = captureFetch([ + jsonResponse({}), + jsonResponse({ version: { tag: "v0.59.0" }, "token-features": {} }), + ]); + vi.stubGlobal("fetch", capture.fetch); + const stderr = captureStderr(); + + await runCommand(measureListCommand(), { rawArgs: [] }); + + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://m.example.com/api/measure", + "https://m.example.com/api/session/properties", + ]); + expect(errorEnvelopeOf(stderr)).toEqual(shapeErrorEnvelope(SHAPE_LEAD)); + expect(await readProfileRecord("default")).toEqual(before); + }); + + it("reports the error unchanged when the re-probe itself fails", async () => { + await seedProbedProfile("default", fakeServerInfo(59)); + const before = await readProfileRecord("default"); + const capture = captureFetch([jsonResponse({}), new TypeError("fetch failed")]); + vi.stubGlobal("fetch", capture.fetch); + const stderr = captureStderr(); + + await runCommand(measureListCommand(), { rawArgs: [] }); + + expect(errorEnvelopeOf(stderr)).toEqual(shapeErrorEnvelope(SHAPE_LEAD)); + expect(process.exitCode).toBe(1); + expect(await readProfileRecord("default")).toEqual(before); + }); + + it("never re-probes when the profile the client ran on was not the cached one", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }); + const capture = captureFetch([ + jsonResponse({ version: { tag: "v0.59.0" }, "token-features": {} }), + jsonResponse({}), + ]); + vi.stubGlobal("fetch", capture.fetch); + const stderr = captureStderr(); + + await runCommand(measureListCommand(), { rawArgs: [] }); + + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://m.example.com/api/session/properties", + "https://m.example.com/api/measure", + ]); + expect(errorEnvelopeOf(stderr)).toEqual( + shapeErrorEnvelope( + "Metabase returned unexpected response shape:\n" + + " Invalid input: expected array, received object", + ), + ); + }); + + it("re-probes on a shape error even when the preflight was skipped", async () => { + await seedProbedProfile("default", fakeServerInfo(58)); + const capture = captureFetch([ + jsonResponse({}), + jsonResponse({ version: { tag: "v0.63.0" }, "token-features": {} }), + ]); + vi.stubGlobal("fetch", capture.fetch); + const stderr = captureStderr(); + const cmd = defineMetabaseCommand({ + meta: { name: "skips-then-drifts", description: "skips preflight" }, + args: { ...connectionFlags }, + requires: ["measure.list"], + async run({ getClient }) { + const client = await getClient(); + await client.measure.list(); + }, + }); + + await runCommand(cmd, { rawArgs: ["--skip-preflight"] }); + + expect(errorEnvelopeOf(stderr)).toEqual( + shapeErrorEnvelope( + "On Metabase v0.58.0 the response shape was unexpected:\n" + + " Invalid input: expected array, received object\n" + + "The server's version changed since the last probe (was v0.58.0, now v0.63.0); the profile was refreshed — retry the command.", + ), + ); + }); + }); + it("bypasses the preflight check when --skip-preflight is passed", async () => { await seedProbedProfile("default", fakeServerInfo(58)); @@ -363,7 +786,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "skip-preflight-flag", description: "skip via flag" }, args: { ...connectionFlags }, - capabilities: { minVersion: 99 }, + requires: ["transformJob.setActive"], async run({ getClient }) { await getClient(); ran(); @@ -374,6 +797,53 @@ describe("defineMetabaseCommand", () => { expect(ran).toHaveBeenCalledOnce(); }); + it("lets the client refuse a gated method the command's own declaration does not cover", async () => { + await seedProbedProfile("default", fakeServerInfo(58)); + const stderr = captureStderr(); + + const ran = vi.fn(); + const cmd = defineMetabaseCommand({ + meta: { name: "under-declared", description: "declares less than it calls" }, + args: {}, + requires: [], + async run({ getClient }) { + const client = await getClient(); + await client.measure.list(); + ran(); + }, + }); + + await runCommand(cmd, { rawArgs: [] }); + + expect(stderr.join("")).toContain( + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", + ); + expect(process.exitCode).toBe(2); + expect(ran).not.toHaveBeenCalled(); + }); + + it("switches the client's own check off too when --skip-preflight is passed", async () => { + await seedProbedProfile("default", fakeServerInfo(58)); + const capture = captureFetch([jsonResponse([])]); + vi.stubGlobal("fetch", capture.fetch); + + const seen = vi.fn(); + const cmd = defineMetabaseCommand({ + meta: { name: "skip-reaches-wire", description: "skip reaches the wire" }, + args: { ...connectionFlags }, + requires: ["measure.list"], + async run({ getClient }) { + const client = await getClient(); + seen(await client.measure.list()); + }, + }); + + await runCommand(cmd, { rawArgs: ["--skip-preflight"] }); + + expect(capture.calls.map((call) => call.url)).toEqual(["https://m.example.com/api/measure"]); + expect(seen.mock.calls).toEqual([[{ data: [], total: null }]]); + }); + 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"; @@ -382,7 +852,7 @@ describe("defineMetabaseCommand", () => { const cmd = defineMetabaseCommand({ meta: { name: "skip-preflight", description: "skip" }, args: {}, - capabilities: { minVersion: 99 }, + requires: ["transformJob.setActive"], async run({ getClient }) { await getClient(); ran(); diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts index 63e5faad..44c5707c 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -3,19 +3,23 @@ import type { ArgsDef, CommandDef, CommandMeta, ParsedArgs } from "citty"; import type { ZodType } from "zod"; import type { MetabaseClient } from "@metabase/client/client"; -import { - BASELINE_CAPABILITIES, - checkCapabilities, - mergeCapabilities, - type Capabilities, -} from "@metabase/client/version/capabilities"; +import { MetabaseError, ResponseShapeError } from "@metabase/client/errors"; +import { normalizeUrl } from "@metabase/client/url"; +import { summarizeCapabilities } from "@metabase/client/version/capability-summary"; +import type { FeatureName } from "@metabase/client/version/features"; import { CapabilityError } from "@metabase/client/version/preflight-error"; -import { type ServerInfo } from "@metabase/client/version/probe"; +import { probeServer, type ServerInfo } from "@metabase/client/version/probe"; +import { createServerProfile, type ServerProfile } from "@metabase/client/version/profile"; +import { checkFeatures } from "@metabase/client/version/requirement-check"; +import { type MethodKey, methodRequirements } from "@metabase/client/version/requirements"; +import type { ProfileLastProbe } from "../core/auth/profile-record"; +import { serverChangeNote, skewNotice } from "../core/auth/server-summary"; import { consumeKeyringDowngradeWarning, consumeLegacyStorageWarning, readProfileRecord, + writeProbeResult, } from "../core/auth/storage"; import { createCredentialRefresher, @@ -29,7 +33,11 @@ import { consumeLegacyEnvWarnings } from "../core/env"; import { USER_AGENT } from "../core/user-agent"; import { reportError } from "../output/error"; import { warn } from "../output/notice"; -import { setMetabaseAugment, type SkillPointer } from "../runtime/command-augment"; +import { + type CommandRequirements, + setMetabaseAugment, + type SkillPointer, +} from "../runtime/command-augment"; import { interruptSignal } from "../runtime/interrupt"; import { resolveCommonFlags, @@ -46,7 +54,6 @@ interface MetabaseCommandContext { ctx: CommonContext; getClient: () => Promise; getResolvedConfig: () => Promise; - getServerInfo: () => Promise; } interface MetabaseCommandDef { @@ -57,15 +64,16 @@ interface MetabaseCommandDef { skills?: readonly SkillPointer[]; inputSchema?: ZodType; outputSchema?: ZodType; - capabilities: Partial | null; + // The client methods `run` calls; `null` for a command that never reaches a server. The features + // those methods need are what the preflight checks and what `help --json` reports. + requires: readonly MethodKey[] | null; run: (context: MetabaseCommandContext) => Promise | void; } export function defineMetabaseCommand( def: MetabaseCommandDef, ): CommandDef { - const required: Capabilities | null = - def.capabilities === null ? null : mergeCapabilities(def.capabilities); + const requirements = def.requires === null ? null : deriveRequirements(def.requires); const cmd = defineCommand({ meta: def.meta, args: def.args, @@ -78,46 +86,49 @@ export function defineMetabaseCommand( assertKnownFlags(rawArgs, def.args); let cachedConfig: ResolvedConfig | null = null; let cachedClient: MetabaseClient | null = null; - let cachedServerInfo: Promise | null = null; const getResolvedConfig = async (): Promise => { if (cachedConfig === null) { cachedConfig = await resolveConfig(buildConfigFlags(ctx)); } return cachedConfig; }; - const getServerInfo = (): Promise => { - if (cachedServerInfo === null) { - cachedServerInfo = loadServerInfo(getResolvedConfig); - } - return cachedServerInfo; - }; + const preflightSkipped = ctx.skipPreflight || isPreflightSkipped(); + let cachedServer: CachedServer | null = null; + const noticeSkew = createSkewNotifier(); // Imported here rather than at the top of the file so the resource namespaces `createClient` // composes — and the whole `domain/` layer behind them — stay off the chunk every command // loads, including `--help`, a flag error, and the commands that open no socket at all. const rawGetClient = async (): Promise => { if (cachedClient === null) { const resolved = await getResolvedConfig(); + const probe = await loadCachedProbe(resolved); + const server = probe === null ? null : createServerProfile(probe); const { createClient } = await import("@metabase/client/client"); cachedClient = createClient( { url: resolved.url, credential: resolved.credential }, { userAgent: USER_AGENT, - getServerTag: async () => (await getServerInfo())?.version?.tag ?? null, + ...(server !== null && { server }), refreshCredential: createCredentialRefresher(resolved.profile), signal: interruptSignal, + enforceRequirements: !preflightSkipped, }, ); + if (probe !== null && server !== null) { + cachedServer = { client: cachedClient, profileName: resolved.profile, probe }; + noticeSkew(server); + } } return cachedClient; }; const enforcePreflight = createPreflightEnforcer( - required, - getServerInfo, - ctx.skipPreflight, + requirements === null ? null : requirements.features, + preflightSkipped, + noticeSkew, ); const getClient = async (): Promise => { const client = await rawGetClient(); - await enforcePreflight(); + await enforcePreflight(client); return client; }; try { @@ -126,8 +137,9 @@ export function defineMetabaseCommand( ctx, getClient, getResolvedConfig, - getServerInfo, }); + } catch (error) { + throw await refreshProfileOnShapeError(error, cachedServer); } finally { emitPendingWarnings(); } @@ -142,11 +154,17 @@ export function defineMetabaseCommand( skills: def.skills ?? [], inputSchema: def.inputSchema ?? null, outputSchema: def.outputSchema ?? null, - capabilities: required, + requires: requirements, + capabilities: requirements === null ? null : summarizeCapabilities(requirements.features), }); return cmd; } +function deriveRequirements(methods: readonly MethodKey[]): CommandRequirements { + const features = [...new Set(methods.flatMap((key) => methodRequirements(key)))]; + return { methods, features }; +} + function emitPendingWarnings(): void { for (const message of consumeLegacyEnvWarnings()) { warn(message); @@ -161,54 +179,113 @@ function emitPendingWarnings(): void { } } -async function loadServerInfo( - getResolvedConfig: () => Promise, -): Promise { - const resolved = await getResolvedConfig(); +// The record's probe is the server the client was built on, and only when the record's URL is the +// one the command is talking to: `--url` or the environment can point the same profile name at +// another server, whose shape the cached probe says nothing about. Without a cached probe the +// client is left to ask the server itself on first need. +async function loadCachedProbe(resolved: ResolvedConfig): Promise { const record = await readProfileRecord(resolved.profile); if (record === null || record.lastProbe === null) { return null; } - return { - version: record.lastProbe.version, - tokenFeatures: record.lastProbe.tokenFeatures, + if (normalizeUrl(record.url) !== resolved.url) { + return null; + } + return record.lastProbe; +} + +type SkewNotifier = (profile: ServerProfile) => void; + +// The notice is about the server, not the command, so the first profile a command resolves — +// cached or freshly probed — is the one that speaks, and only once. +function createSkewNotifier(): SkewNotifier { + let noticed = false; + return (profile) => { + if (noticed) { + return; + } + noticed = true; + const notice = skewNotice(profile); + if (notice !== null) { + warn(notice); + } }; } -const NO_OP_ENFORCER: () => Promise = async () => {}; +type PreflightEnforcer = (client: MetabaseClient) => Promise; -const NO_PROBE_DATA_WARNING = - "Could not detect Metabase server version. Proceeding without preflight check; failures may produce confusing errors. Run `mb auth list` (or `mb auth login`) to populate the version cache."; +const NO_OP_ENFORCER: PreflightEnforcer = async () => {}; +// Anticipates the refusal the client's own `require()` would raise on the first gated call, so a +// command fails before it has done any work. `client.server()` is the cached profile when the +// profile record carries a probe and one live probe otherwise; a probe that fails ends the command +// here with that error, as the client's check would moments later. function createPreflightEnforcer( - required: Capabilities | null, - getServerInfo: () => Promise, + features: readonly FeatureName[] | null, skip: boolean, -): () => Promise { - if (required === null || skip || isPreflightSkipped() || isBaseline(required)) { + noticeSkew: SkewNotifier, +): PreflightEnforcer { + if (features === null || skip || features.length === 0) { return NO_OP_ENFORCER; } let done = false; - return async () => { + return async (client) => { if (done) { return; } done = true; - const info = await getServerInfo(); - if (info === null) { - warn(NO_PROBE_DATA_WARNING); - return; - } - const failure = checkCapabilities(info, required); - if (failure === null || failure.reason === "unknown-version") { - return; + const profile = await client.server(); + noticeSkew(profile); + const failure = checkFeatures(features, profile); + if (failure !== null) { + throw new CapabilityError(failure); } - throw new CapabilityError(failure); }; } -function isBaseline(caps: Capabilities): boolean { - return caps.minVersion === BASELINE_CAPABILITIES.minVersion && caps.tokenFeature === undefined; +// The client and the cached probe it was built on, kept for the one re-probe a shape error earns. +interface CachedServer { + client: MetabaseClient; + profileName: string; + probe: ProfileLastProbe; +} + +// A shape error under a cached profile may mean the server was upgraded since the probe and the +// wire schema was chosen for the wrong generation. One fresh probe settles it: a changed server is +// written back so the next run reads the right shape, and the error says so. The command is not +// retried — its request may have been a write. +async function refreshProfileOnShapeError( + error: unknown, + cached: CachedServer | null, +): Promise { + if (!(error instanceof ResponseShapeError) || cached === null) { + return error; + } + const note = await refreshChangedProbe(cached); + if (note === null) { + return error; + } + return new ResponseShapeError(`${error.message}\n${note}`, error.developerDetail); +} + +async function refreshChangedProbe(cached: CachedServer): Promise { + let fresh: ServerInfo; + try { + fresh = await probeServer(cached.client); + } catch (error) { + // A diagnosis on the way out: a server that cannot be reached or answered now must not + // displace the shape error the user is here for. Anything else is a bug and surfaces. + if (error instanceof MetabaseError) { + return null; + } + throw error; + } + const note = serverChangeNote(cached.probe, fresh); + if (note === null) { + return null; + } + await writeProbeResult(cached.profileName, { user: cached.probe.user, server: fresh }); + return note; } function pickCommonArgs(args: ParsedArgs): CommonArgs { diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index f4cde249..7dce0da9 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -25,7 +25,7 @@ export default defineMetabaseCommand({ details: "Ranks content against a query string. To simply enumerate a resource, prefer its `… list` verb.", skills: [{ skill: "core", purpose: "search vs. list" }], - capabilities: { minVersion: 58 }, + requires: ["search.query"], args: { ...outputFlags, ...listFlagsWithDefaultLimit(DEFAULT_LIMIT), diff --git a/packages/cli/src/commands/segment/archive.ts b/packages/cli/src/commands/segment/archive.ts index 2ba5bc09..28ef6866 100644 --- a/packages/cli/src/commands/segment/archive.ts +++ b/packages/cli/src/commands/segment/archive.ts @@ -8,7 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a segment by id" }, - capabilities: { minVersion: 58 }, + requires: ["segment.archive"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/segment/create.ts b/packages/cli/src/commands/segment/create.ts index 5b6cf24a..cc378ec5 100644 --- a/packages/cli/src/commands/segment/create.ts +++ b/packages/cli/src/commands/segment/create.ts @@ -19,7 +19,7 @@ export default defineMetabaseCommand({ details: "A segment is a reusable, saved row filter tied to a table. The JSON body needs `name`, `table_id`, and a `definition` (an MBQL query holding the filter). An MBQL 5 `definition` is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass.", skills: [{ skill: "mbql", purpose: "the definition filter clause" }], - capabilities: { minVersion: 58 }, + requires: ["segment.create"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/segment/get.ts b/packages/cli/src/commands/segment/get.ts index a429843c..00d2bbea 100644 --- a/packages/cli/src/commands/segment/get.ts +++ b/packages/cli/src/commands/segment/get.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a segment by id" }, - capabilities: { minVersion: 58 }, + requires: ["segment.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/segment/list.ts b/packages/cli/src/commands/segment/list.ts index 52075abd..49f075c8 100644 --- a/packages/cli/src/commands/segment/list.ts +++ b/packages/cli/src/commands/segment/list.ts @@ -10,7 +10,7 @@ export const SegmentListEnvelope = listEnvelopeSchema(SegmentCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List segments" }, - capabilities: { minVersion: 58 }, + requires: ["segment.list"], args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: SegmentListEnvelope, examples: ["mb segment list", "mb segment list --json"], diff --git a/packages/cli/src/commands/segment/update.ts b/packages/cli/src/commands/segment/update.ts index a185f9bd..f435927b 100644 --- a/packages/cli/src/commands/segment/update.ts +++ b/packages/cli/src/commands/segment/update.ts @@ -20,7 +20,7 @@ export default defineMetabaseCommand({ details: "Patches only the fields you send and must include `revision_message` (recorded in the audit log). When `definition` is an MBQL 5 query it is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass.", skills: [{ skill: "mbql", purpose: "the definition filter clause" }], - capabilities: { minVersion: 58 }, + requires: ["segment.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/setting/get.ts b/packages/cli/src/commands/setting/get.ts index a8e8954e..0fbf6002 100644 --- a/packages/cli/src/commands/setting/get.ts +++ b/packages/cli/src/commands/setting/get.ts @@ -8,7 +8,7 @@ import { parseSettingKey } from "./key"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a setting value by key" }, - capabilities: { minVersion: 58 }, + requires: ["setting.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/setting/list.ts b/packages/cli/src/commands/setting/list.ts index f08b907b..1a9f4aff 100644 --- a/packages/cli/src/commands/setting/list.ts +++ b/packages/cli/src/commands/setting/list.ts @@ -10,7 +10,7 @@ export const SettingListEnvelope = listEnvelopeSchema(SettingCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List all settings (admin)" }, - capabilities: { minVersion: 58 }, + requires: ["setting.list"], args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: SettingListEnvelope, examples: ["mb setting list", "mb setting list --json"], diff --git a/packages/cli/src/commands/setting/set.ts b/packages/cli/src/commands/setting/set.ts index 690f70ef..326df262 100644 --- a/packages/cli/src/commands/setting/set.ts +++ b/packages/cli/src/commands/setting/set.ts @@ -15,7 +15,7 @@ const SettingValueInput = z export default defineMetabaseCommand({ meta: { name: "set", description: "Set a setting value (parsed strictly as JSON)" }, - capabilities: { minVersion: 58 }, + requires: ["setting.set"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 2e6cd39b..d08aa7fd 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -12,7 +12,7 @@ export default defineMetabaseCommand({ name: "setup", description: "Complete the initial Metabase setup wizard with a default user", }, - capabilities: { minVersion: 58 }, + requires: ["setup.create"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/skills/get.test.ts b/packages/cli/src/commands/skills/get.test.ts index d750779c..e7f2c7a5 100644 --- a/packages/cli/src/commands/skills/get.test.ts +++ b/packages/cli/src/commands/skills/get.test.ts @@ -1,27 +1,73 @@ import { runCommand } from "citty"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ZodType } from "zod"; +import { parseJson } from "@metabase/client/json"; + +const hoisted = vi.hoisted(() => ({ + store: new Map(), + controls: { broken: false }, +})); + +vi.mock("@napi-rs/keyring", async () => { + const { createKeyringMockModule } = await import("../../core/auth/keyring-mock"); + return createKeyringMockModule(hoisted); +}); + +import { + probeAt, + seedProbedProfile, + setupTempConfigHome, + type TempConfigHome, +} from "../../core/auth/temp-config-home"; import { ENV_SKILLS_DIR } from "../../core/env"; import { findSkillByName, loadAllSkills, readSkillContent } from "../../core/skills"; -import { createTempSkillsDir, type TempSkillsDir } from "../../core/temp-skills-dir"; +import { + createTempSkillsDir, + GAMMA_SKILL_MD, + GAMMA_WITH_ACTIVATION, + GAMMA_WITHOUT_ACTIVATION, + type TempSkillsDir, +} from "../../core/temp-skills-dir"; import { fitWithinCap } from "../../output/cap"; import { DEFAULT_MAX_BYTES, FULL_RANGE } from "../../output/types"; import { windowList } from "../../output/window"; -import skillsGetCommand from "./get"; +import skillsGetCommand, { SkillGetEnvelope } from "./get"; + +interface CapturedStream { + chunks: string[]; + parse: (schema: ZodType) => T; +} -function captureStdout(): string[] { +function capture(stream: NodeJS.WriteStream): CapturedStream { const chunks: string[] = []; - vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + vi.spyOn(stream, "write").mockImplementation((chunk) => { chunks.push(String(chunk)); return true; }); - return chunks; + return { + chunks, + parse: (schema: ZodType) => parseJson(chunks.join(""), schema, { source: "stdout" }), + }; +} + +function gammaContent(body: string) { + return { + name: "gamma", + description: "The transform skill.", + body, + references: [], + templates: [], + }; } describe("skills get command", () => { let skills: TempSkillsDir; + let home: TempConfigHome; beforeEach(() => { + hoisted.store.clear(); + home = setupTempConfigHome(); skills = createTempSkillsDir(); vi.stubEnv(ENV_SKILLS_DIR, skills.path); }); @@ -30,19 +76,173 @@ describe("skills get command", () => { vi.unstubAllEnvs(); vi.restoreAllMocks(); skills.cleanup(); + home.cleanup(); }); it("windows the text output rather than accepting --limit and ignoring it", async () => { - const chunks = captureStdout(); + const stdout = capture(process.stdout); await runCommand(skillsGetCommand, { rawArgs: ["alpha,beta", "--format", "text", "--limit", "1"], }); - expect(chunks.join("")).toBe( + expect(stdout.chunks.join("")).toBe( "---\nname: alpha\ndescription: The first skill.\n---\n\nAlpha instructions.\n", ); }); + + it("prints a skill as written, markers included, and `unavailable: null` without a cached probe", async () => { + const stdout = capture(process.stdout); + const stderr = capture(process.stderr); + + await runCommand(skillsGetCommand, { rawArgs: ["gamma", "--json"] }); + + expect(stdout.parse(SkillGetEnvelope)).toEqual({ + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + unavailable: null, + data: [gammaContent(GAMMA_SKILL_MD)], + }); + expect(stderr.chunks).toEqual([]); + }); + + it("notes in text mode that nothing was filtered without a cached probe", async () => { + const stdout = capture(process.stdout); + const stderr = capture(process.stderr); + + await runCommand(skillsGetCommand, { rawArgs: ["gamma", "--format", "text"] }); + + expect(stdout.chunks.join("")).toBe(`${GAMMA_SKILL_MD.trimEnd()}\n`); + expect(stderr.chunks.join("")).toBe( + 'Skills are unfiltered: profile "default" has no cached server probe (run `mb auth list` to record one).\n', + ); + }); + + it("withholds a skill the cached server lacks a feature for and reports it under `unavailable`", async () => { + await seedProbedProfile("default", probeAt(58)); + const stdout = capture(process.stdout); + const stderr = capture(process.stderr); + + await runCommand(skillsGetCommand, { rawArgs: ["gamma", "--json"] }); + + expect(stdout.parse(SkillGetEnvelope)).toEqual({ + returned: 0, + offset: 0, + total: 0, + has_more: false, + next_offset: null, + unavailable: [ + { + name: "gamma", + failure: { + reason: "version-too-old", + detail: + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", + feature: "transforms", + since: 59, + tokenFeature: null, + serverVersion: "v0.58.0", + }, + }, + ], + data: [], + }); + expect(stderr.chunks).toEqual([]); + }); + + it("prints nothing but the skipped note in text mode for a withheld skill", async () => { + await seedProbedProfile("default", probeAt(58)); + const stdout = capture(process.stdout); + const stderr = capture(process.stderr); + + await runCommand(skillsGetCommand, { rawArgs: ["gamma", "--format", "text"] }); + + expect(stdout.chunks).toEqual([]); + expect(stderr.chunks.join("")).toBe( + 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --all to print it anyway.\n', + ); + }); + + it("strips a section the cached server lacks the feature for", async () => { + await seedProbedProfile("default", probeAt(60)); + const stdout = capture(process.stdout); + + await runCommand(skillsGetCommand, { rawArgs: ["gamma", "--json"] }); + + expect(stdout.parse(SkillGetEnvelope)).toEqual({ + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + unavailable: [], + data: [gammaContent(GAMMA_WITHOUT_ACTIVATION)], + }); + }); + + it("keeps a section the cached server has the feature for, without its markers", async () => { + await seedProbedProfile("default", probeAt(61)); + const stdout = capture(process.stdout); + + await runCommand(skillsGetCommand, { rawArgs: ["gamma", "--format", "text"] }); + + expect(stdout.chunks.join("")).toBe(`${GAMMA_WITH_ACTIVATION.trimEnd()}\n`); + }); + + it("--all with a name prints that skill as written regardless of the cached server", async () => { + await seedProbedProfile("default", probeAt(58)); + const stdout = capture(process.stdout); + const stderr = capture(process.stderr); + + await runCommand(skillsGetCommand, { rawArgs: ["gamma", "--all", "--json"] }); + + expect(stdout.parse(SkillGetEnvelope)).toEqual({ + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + unavailable: null, + data: [gammaContent(GAMMA_SKILL_MD)], + }); + expect(stderr.chunks).toEqual([]); + }); + + it("--all alone prints every non-hidden skill as written", async () => { + await seedProbedProfile("default", probeAt(58)); + const stdout = capture(process.stdout); + + await runCommand(skillsGetCommand, { rawArgs: ["--all", "--json", "--max-bytes", "0"] }); + + expect(stdout.parse(SkillGetEnvelope)).toEqual({ + returned: 3, + offset: 0, + total: 3, + has_more: false, + next_offset: null, + unavailable: null, + data: [ + { + name: "alpha", + description: "The first skill.", + body: "---\nname: alpha\ndescription: The first skill.\n---\n\nAlpha instructions.\n", + references: [], + templates: [], + }, + { + name: "beta", + description: "The second skill.", + body: "---\nname: beta\ndescription: The second skill.\n---\n\nBeta instructions.\n", + references: [], + templates: [], + }, + gammaContent(GAMMA_SKILL_MD), + ], + }); + }); }); // `core` is the skill every agent is told to load first, and it is a single indivisible row: once @@ -51,7 +251,10 @@ describe("skills get command", () => { describe("the shipped core skill", () => { it("fits the default output cap as a one-row json envelope", () => { const info = findSkillByName(loadAllSkills(), "core"); - const envelope = windowList([readSkillContent(info, { includeExtras: false })], FULL_RANGE); + const envelope = windowList( + [readSkillContent(info, { includeExtras: false, profile: null })], + FULL_RANGE, + ); expect(fitWithinCap(envelope, DEFAULT_MAX_BYTES)).toEqual({ count: 1, diff --git a/packages/cli/src/commands/skills/get.ts b/packages/cli/src/commands/skills/get.ts index 2a6fce23..df39cca2 100644 --- a/packages/cli/src/commands/skills/get.ts +++ b/packages/cli/src/commands/skills/get.ts @@ -1,22 +1,33 @@ import { ConfigError } from "@metabase/client/errors"; +import { readCachedServerProfile } from "../../core/auth/cached-server"; +import { resolveProfileName } from "../../core/config"; import { loadAllSkills, loadVisibleSkills, readSkillContent, + selectForProfile, selectSkillsByNames, SkillContent, type SkillInfo, } from "../../core/skills"; -import { renderList, writeText } from "../../output/render"; -import { listEnvelopeSchema } from "../../output/types"; +import { warn } from "../../output/notice"; +import { renderListWithExtras, writeText } from "../../output/render"; +import { + skillFilterNotices, + UnavailableSkills, + unavailableSkillsFlag, +} from "../../output/skill-list"; +import { listEnvelopeSchemaWithExtras } from "../../output/types"; import type { ResourceView } from "../../output/view"; import { windowList } from "../../output/window"; import { parseCsv } from "../../runtime/csv"; -import { listFlags, outputFlags } from "../flags"; +import { listFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -export const SkillGetEnvelope = listEnvelopeSchema(SkillContent); +export const SkillGetEnvelope = listEnvelopeSchemaWithExtras(SkillContent, { + unavailable: UnavailableSkills, +}); // A skill is one indivisible document, so the generic advice to narrow the fields or filter the // list buys nothing: the whole body is what was asked for. @@ -35,21 +46,20 @@ export default defineMetabaseCommand({ meta: { name: "get", description: - "Print one or more skills' SKILL.md content. Pass comma-separated names, or --all for every non-hidden skill. --full includes references and templates.", + "Print one or more skills' SKILL.md content, as the profile's server can use it: a skill it lacks the features for is reported under `unavailable`, and a section it cannot use is left out. Pass comma-separated names, or --all for every non-hidden skill; --all also prints everything regardless of the server. --full includes references and templates.", }, - capabilities: null, + requires: null, args: { ...outputFlags, ...listFlags, + ...profileFlag, names: { type: "positional", - description: "Skill name (or comma-separated list). Omit when using --all.", + description: + "Skill name (or comma-separated list). Omit to combine with --all for every non-hidden skill.", required: false, }, - all: { - type: "boolean", - description: "Fetch every non-hidden skill", - }, + ...unavailableSkillsFlag, }, outputSchema: SkillGetEnvelope, examples: [ @@ -57,17 +67,31 @@ export default defineMetabaseCommand({ "mb skills get core --full", "mb skills get git-sync,transform --json", "mb skills get --all --json", + "mb skills get transform --all", ], - run({ args, ctx }) { - const selected = pickSkills({ names: args.names, all: args.all === true }); - const payloads = selected.map((info) => readSkillContent(info, { includeExtras: ctx.full })); + async run({ args, ctx }) { + const profileName = resolveProfileName(args.profile); + const bypassed = args.all === true; + const profile = bypassed ? null : await readCachedServerProfile(profileName); + const selection = selectForProfile(pickSkills({ names: args.names, all: bypassed }), profile); + const payloads = selection.skills.map((info) => + readSkillContent(info, { includeExtras: ctx.full, profile }), + ); const envelope = windowList(payloads, ctx.range); if (ctx.format === "json") { - renderList(envelope, skillContentView, { ...ctx, oversizeHint: SKILL_OVERSIZE_HINT }); + renderListWithExtras(envelope, { unavailable: selection.unavailable }, skillContentView, { + ...ctx, + oversizeHint: SKILL_OVERSIZE_HINT, + }); return; } - writeText(renderText(envelope.data, ctx.full)); + if (envelope.data.length > 0) { + writeText(renderText(envelope.data, ctx.full)); + } + for (const notice of skillFilterNotices(selection.unavailable, { profileName, bypassed })) { + warn(notice); + } }, }); @@ -77,16 +101,13 @@ interface PickSkillsArgs { } function pickSkills({ names, all }: PickSkillsArgs): SkillInfo[] { - if (all && names !== undefined) { - throw new ConfigError("--all conflicts with a positional skill name"); + if (names !== undefined) { + return selectSkillsByNames(loadAllSkills(), parseCsv(names)); } if (all) { return loadVisibleSkills(); } - if (names === undefined) { - throw new ConfigError("provide a skill name (comma-separated for multiple) or --all"); - } - return selectSkillsByNames(loadAllSkills(), parseCsv(names)); + throw new ConfigError("provide a skill name (comma-separated for multiple) or --all"); } function renderText(payloads: readonly SkillContent[], includeExtras: boolean): string { diff --git a/packages/cli/src/commands/skills/list.test.ts b/packages/cli/src/commands/skills/list.test.ts index bba9f4dd..32eacb2b 100644 --- a/packages/cli/src/commands/skills/list.test.ts +++ b/packages/cli/src/commands/skills/list.test.ts @@ -1,23 +1,66 @@ import { runCommand } from "citty"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ZodType } from "zod"; +import { parseJson } from "@metabase/client/json"; + +const hoisted = vi.hoisted(() => ({ + store: new Map(), + controls: { broken: false }, +})); + +vi.mock("@napi-rs/keyring", async () => { + const { createKeyringMockModule } = await import("../../core/auth/keyring-mock"); + return createKeyringMockModule(hoisted); +}); + +import { + probeAt, + seedProbedProfile, + setupTempConfigHome, + type TempConfigHome, +} from "../../core/auth/temp-config-home"; import { ENV_SKILLS_DIR } from "../../core/env"; import { createTempSkillsDir, type TempSkillsDir } from "../../core/temp-skills-dir"; -import skillsListCommand from "./list"; +import skillsListCommand, { SkillListEnvelope } from "./list"; -function captureStdout(): string[] { +interface CapturedStream { + chunks: string[]; + parse: (schema: ZodType) => T; +} + +function capture(stream: NodeJS.WriteStream): CapturedStream { const chunks: string[] = []; - vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + vi.spyOn(stream, "write").mockImplementation((chunk) => { chunks.push(String(chunk)); return true; }); - return chunks; + return { + chunks, + parse: (schema: ZodType) => parseJson(chunks.join(""), schema, { source: "stdout" }), + }; } +const GAMMA_UNAVAILABLE_ON_58 = { + name: "gamma", + failure: { + reason: "version-too-old", + detail: + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", + feature: "transforms", + since: 59, + tokenFeature: null, + serverVersion: "v0.58.0", + }, +}; + describe("skills list command", () => { let skills: TempSkillsDir; + let home: TempConfigHome; beforeEach(() => { + hoisted.store.clear(); + home = setupTempConfigHome(); skills = createTempSkillsDir(); vi.stubEnv(ENV_SKILLS_DIR, skills.path); }); @@ -26,13 +69,128 @@ describe("skills list command", () => { vi.unstubAllEnvs(); vi.restoreAllMocks(); skills.cleanup(); + home.cleanup(); }); it("windows the text listing rather than accepting --limit and ignoring it", async () => { - const chunks = captureStdout(); + const stdout = capture(process.stdout); + const stderr = capture(process.stderr); await runCommand(skillsListCommand, { rawArgs: ["--format", "text", "--limit", "1"] }); - expect(chunks.join("")).toBe("alpha\n The first skill.\n\n"); + expect(stdout.chunks.join("")).toBe("alpha\n The first skill.\n\n"); + expect(stderr.chunks.join("")).toBe( + 'Skills are unfiltered: profile "default" has no cached server probe (run `mb auth list` to record one).\n', + ); + }); + + it("reports `unavailable: null` in JSON when there is no cached probe to filter by", async () => { + const stdout = capture(process.stdout); + + await runCommand(skillsListCommand, { rawArgs: ["--json"] }); + + expect(stdout.parse(SkillListEnvelope)).toEqual({ + returned: 3, + offset: 0, + total: 3, + has_more: false, + next_offset: null, + unavailable: null, + data: [ + { name: "alpha", description: "The first skill." }, + { name: "beta", description: "The second skill." }, + { name: "gamma", description: "The transform skill." }, + ], + }); + }); + + it("leaves out a skill the cached server lacks a feature for and reports it under `unavailable`", async () => { + await seedProbedProfile("default", probeAt(58)); + const stdout = capture(process.stdout); + + await runCommand(skillsListCommand, { rawArgs: ["--json"] }); + + expect(stdout.parse(SkillListEnvelope)).toEqual({ + returned: 2, + offset: 0, + total: 2, + has_more: false, + next_offset: null, + unavailable: [GAMMA_UNAVAILABLE_ON_58], + data: [ + { name: "alpha", description: "The first skill." }, + { name: "beta", description: "The second skill." }, + ], + }); + }); + + it("names each skipped skill and the way to read it anyway in text mode", async () => { + await seedProbedProfile("default", probeAt(58)); + const stdout = capture(process.stdout); + const stderr = capture(process.stderr); + + await runCommand(skillsListCommand, { rawArgs: ["--format", "text"] }); + + expect(stdout.chunks.join("")).toBe( + "alpha\n The first skill.\n\nbeta\n The second skill.\n\n", + ); + expect(stderr.chunks.join("")).toBe( + 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --all to print it anyway.\n', + ); + }); + + it("lists every skill and reports nothing filtered when the server has the features", async () => { + await seedProbedProfile("default", probeAt(61)); + const stdout = capture(process.stdout); + const stderr = capture(process.stderr); + + await runCommand(skillsListCommand, { rawArgs: ["--json"] }); + + expect(stdout.parse(SkillListEnvelope)).toEqual({ + returned: 3, + offset: 0, + total: 3, + has_more: false, + next_offset: null, + unavailable: [], + data: [ + { name: "alpha", description: "The first skill." }, + { name: "beta", description: "The second skill." }, + { name: "gamma", description: "The transform skill." }, + ], + }); + expect(stderr.chunks).toEqual([]); + }); + + it("--all bypasses the filter and says nothing about it", async () => { + await seedProbedProfile("default", probeAt(58)); + const stdout = capture(process.stdout); + const stderr = capture(process.stderr); + + await runCommand(skillsListCommand, { rawArgs: ["--all", "--json"] }); + + expect(stdout.parse(SkillListEnvelope)).toEqual({ + returned: 3, + offset: 0, + total: 3, + has_more: false, + next_offset: null, + unavailable: null, + data: [ + { name: "alpha", description: "The first skill." }, + { name: "beta", description: "The second skill." }, + { name: "gamma", description: "The transform skill." }, + ], + }); + expect(stderr.chunks).toEqual([]); + }); + + it("reads the profile named by --profile", async () => { + await seedProbedProfile("staging", probeAt(58)); + const stdout = capture(process.stdout); + + await runCommand(skillsListCommand, { rawArgs: ["--profile", "staging", "--json"] }); + + expect(stdout.parse(SkillListEnvelope).unavailable).toEqual([GAMMA_UNAVAILABLE_ON_58]); }); }); diff --git a/packages/cli/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts index 6c59b5b2..02dbd342 100644 --- a/packages/cli/src/commands/skills/list.ts +++ b/packages/cli/src/commands/skills/list.ts @@ -2,12 +2,20 @@ import { z } from "zod"; import type { ResourceView } from "../../output/view"; -import { loadVisibleSkills } from "../../core/skills"; -import { renderList } from "../../output/render"; -import { renderSkillList } from "../../output/skill-list"; -import { listEnvelopeSchema } from "../../output/types"; +import { readCachedServerProfile } from "../../core/auth/cached-server"; +import { resolveProfileName } from "../../core/config"; +import { loadVisibleSkills, selectForProfile } from "../../core/skills"; +import { renderListWithExtras } from "../../output/render"; +import { + renderSkillList, + skillFilterNotices, + UnavailableSkills, + unavailableSkillsFlag, +} from "../../output/skill-list"; +import { listEnvelopeSchemaWithExtras } from "../../output/types"; import { windowList } from "../../output/window"; -import { listFlags, outputFlags } from "../flags"; +import { warn } from "../../output/notice"; +import { listFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; const SkillSummary = z.object({ @@ -16,7 +24,9 @@ const SkillSummary = z.object({ }); type SkillSummaryJson = z.infer; -export const SkillListEnvelope = listEnvelopeSchema(SkillSummary); +export const SkillListEnvelope = listEnvelopeSchemaWithExtras(SkillSummary, { + unavailable: UnavailableSkills, +}); const skillSummaryView: ResourceView = { compactPick: SkillSummary, @@ -30,22 +40,31 @@ export default defineMetabaseCommand({ meta: { name: "list", description: - "List CLI-bundled skills — always consult the matching skill before acting on a task; they are the source of truth for every workflow.", + "List CLI-bundled skills — always consult the matching skill before acting on a task; they are the source of truth for every workflow. Skills the profile's server cannot use are left out; --all lists them too.", }, - capabilities: null, - args: { ...outputFlags, ...listFlags }, + requires: null, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...unavailableSkillsFlag }, outputSchema: SkillListEnvelope, - examples: ["mb skills list", "mb skills list --json"], - run({ ctx }) { - const items: SkillSummaryJson[] = loadVisibleSkills().map((s) => ({ + examples: ["mb skills list", "mb skills list --json", "mb skills list --all"], + async run({ args, ctx }) { + const profileName = resolveProfileName(args.profile); + const profile = args.all === true ? null : await readCachedServerProfile(profileName); + const selection = selectForProfile(loadVisibleSkills(), profile); + const items: SkillSummaryJson[] = selection.skills.map((s) => ({ name: s.name, description: s.description, })); const envelope = windowList(items, ctx.range); if (ctx.format === "json" || ctx.fields !== undefined || ctx.full) { - renderList(envelope, skillSummaryView, ctx); + renderListWithExtras(envelope, { unavailable: selection.unavailable }, skillSummaryView, ctx); return; } renderSkillList(envelope.data, ctx.maxBytes); + for (const notice of skillFilterNotices(selection.unavailable, { + profileName, + bypassed: args.all === true, + })) { + warn(notice); + } }, }); diff --git a/packages/cli/src/commands/skills/path.ts b/packages/cli/src/commands/skills/path.ts index 33c8e42b..7d3d3a65 100644 --- a/packages/cli/src/commands/skills/path.ts +++ b/packages/cli/src/commands/skills/path.ts @@ -31,7 +31,7 @@ export default defineMetabaseCommand({ description: "Print the absolute path to a skill (or all skills). Useful when an agent needs to read the SKILL.md or its references with the Read tool directly.", }, - capabilities: null, + requires: null, args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/snippet/archive.ts b/packages/cli/src/commands/snippet/archive.ts index 436d0289..9ebe10ce 100644 --- a/packages/cli/src/commands/snippet/archive.ts +++ b/packages/cli/src/commands/snippet/archive.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a native query snippet by id" }, - capabilities: { minVersion: 58 }, + requires: ["snippet.archive"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/snippet/create.ts b/packages/cli/src/commands/snippet/create.ts index d7e6e999..5b37bfe5 100644 --- a/packages/cli/src/commands/snippet/create.ts +++ b/packages/cli/src/commands/snippet/create.ts @@ -8,7 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "create", description: "Create a native query snippet from a JSON spec" }, - capabilities: { minVersion: 58 }, + requires: ["snippet.create"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/snippet/get.ts b/packages/cli/src/commands/snippet/get.ts index 056886f6..f63a05e2 100644 --- a/packages/cli/src/commands/snippet/get.ts +++ b/packages/cli/src/commands/snippet/get.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a native query snippet by id" }, - capabilities: { minVersion: 58 }, + requires: ["snippet.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/snippet/list.ts b/packages/cli/src/commands/snippet/list.ts index c8b176cc..b3ca4169 100644 --- a/packages/cli/src/commands/snippet/list.ts +++ b/packages/cli/src/commands/snippet/list.ts @@ -10,7 +10,7 @@ export const SnippetListEnvelope = listEnvelopeSchema(SnippetCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List native query snippets" }, - capabilities: { minVersion: 58 }, + requires: ["snippet.list"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/snippet/update.ts b/packages/cli/src/commands/snippet/update.ts index 953cff79..0f3c564f 100644 --- a/packages/cli/src/commands/snippet/update.ts +++ b/packages/cli/src/commands/snippet/update.ts @@ -9,7 +9,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "update", description: "Update a native query snippet by id" }, - capabilities: { minVersion: 58 }, + requires: ["snippet.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/subscription/archive.ts b/packages/cli/src/commands/subscription/archive.ts index 0c096b5f..41ef5a5c 100644 --- a/packages/cli/src/commands/subscription/archive.ts +++ b/packages/cli/src/commands/subscription/archive.ts @@ -12,7 +12,7 @@ export default defineMetabaseCommand({ }, details: "Archiving also disables every channel on the subscription. Restore it with `mb subscription update --body '{\"archived\":false}'`, then re-enable its channels.", - capabilities: { minVersion: 58 }, + requires: ["pulse.archive"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/subscription/create.ts b/packages/cli/src/commands/subscription/create.ts index cacf6c0b..381bd5f6 100644 --- a/packages/cli/src/commands/subscription/create.ts +++ b/packages/cli/src/commands/subscription/create.ts @@ -15,7 +15,7 @@ export default defineMetabaseCommand({ "`daily` needs `schedule_hour` (0-23); `weekly` also needs `schedule_day` (mon..sun); `monthly` also needs `schedule_frame` (first|mid|last).", 'Email recipients are `[{email: "a@b.com"} | {id: }]`; Slack targets a channel via `details: {channel: "#general"}`.', ].join(" "), - capabilities: { minVersion: 58 }, + requires: ["pulse.create"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/subscription/get.ts b/packages/cli/src/commands/subscription/get.ts index 0deb35bd..a61a617b 100644 --- a/packages/cli/src/commands/subscription/get.ts +++ b/packages/cli/src/commands/subscription/get.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a dashboard subscription by id" }, - capabilities: { minVersion: 58 }, + requires: ["pulse.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/subscription/list.ts b/packages/cli/src/commands/subscription/list.ts index bfdf346d..7e6dceec 100644 --- a/packages/cli/src/commands/subscription/list.ts +++ b/packages/cli/src/commands/subscription/list.ts @@ -11,7 +11,7 @@ export const SubscriptionListEnvelope = listEnvelopeSchema(PulseCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List dashboard subscriptions" }, - capabilities: { minVersion: 58 }, + requires: ["pulse.list"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/subscription/update.ts b/packages/cli/src/commands/subscription/update.ts index f39d6fd9..b77e1f0b 100644 --- a/packages/cli/src/commands/subscription/update.ts +++ b/packages/cli/src/commands/subscription/update.ts @@ -15,7 +15,7 @@ export default defineMetabaseCommand({ "`mb subscription get --full` prints the current ones.", "A subscription's `dashboard_id` and `collection_id` are fixed at creation and cannot be changed.", ].join(" "), - capabilities: { minVersion: 58 }, + requires: ["pulse.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/table/fields.ts b/packages/cli/src/commands/table/fields.ts index d347b0f4..1959a85b 100644 --- a/packages/cli/src/commands/table/fields.ts +++ b/packages/cli/src/commands/table/fields.ts @@ -15,7 +15,7 @@ export default defineMetabaseCommand({ name: "fields", description: "List fields on a table (projection over query_metadata.fields)", }, - capabilities: { minVersion: 58 }, + requires: ["table.queryMetadata"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/table/get.ts b/packages/cli/src/commands/table/get.ts index 9504f16b..84bb7b57 100644 --- a/packages/cli/src/commands/table/get.ts +++ b/packages/cli/src/commands/table/get.ts @@ -13,7 +13,7 @@ export default defineMetabaseCommand({ name: "get", description: "Get a table by id; pass --include fields to bundle hydrated fields", }, - capabilities: { minVersion: 58 }, + requires: ["table.queryMetadata", "table.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/table/list.ts b/packages/cli/src/commands/table/list.ts index 05534db9..51b9ee80 100644 --- a/packages/cli/src/commands/table/list.ts +++ b/packages/cli/src/commands/table/list.ts @@ -11,7 +11,7 @@ export const TableListEnvelope = listEnvelopeSchema(TableCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List tables (optionally filtered by database)" }, - capabilities: { minVersion: 58 }, + requires: ["table.list"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/table/update.ts b/packages/cli/src/commands/table/update.ts index f6e10cfe..4e6b70e6 100644 --- a/packages/cli/src/commands/table/update.ts +++ b/packages/cli/src/commands/table/update.ts @@ -12,7 +12,7 @@ export default defineMetabaseCommand({ name: "update", description: "Update a table (display name, description, visibility, etc.)", }, - capabilities: { minVersion: 58 }, + requires: ["table.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline-event/archive.ts b/packages/cli/src/commands/timeline-event/archive.ts index 8a86e30a..9c9d8fc0 100644 --- a/packages/cli/src/commands/timeline-event/archive.ts +++ b/packages/cli/src/commands/timeline-event/archive.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "archive", description: "Archive (soft-delete) a timeline event by id" }, - capabilities: { minVersion: 58 }, + requires: ["timelineEvent.archive"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline-event/create.ts b/packages/cli/src/commands/timeline-event/create.ts index 63aba116..431c0637 100644 --- a/packages/cli/src/commands/timeline-event/create.ts +++ b/packages/cli/src/commands/timeline-event/create.ts @@ -10,7 +10,7 @@ export default defineMetabaseCommand({ meta: { name: "create", description: "Create a timeline event from JSON" }, details: "The JSON body needs `name`, `timestamp` (ISO 8601), `timezone` (IANA name like UTC or America/New_York), `time_matters` (true when the time of day is significant, false when only the date is), and `timeline_id`; optional fields: `description`, `icon` (star|cake|mail|warning|bell|cloud, default: the timeline's icon).", - capabilities: { minVersion: 58 }, + requires: ["timelineEvent.create"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline-event/delete.ts b/packages/cli/src/commands/timeline-event/delete.ts index db239e87..5410b9bd 100644 --- a/packages/cli/src/commands/timeline-event/delete.ts +++ b/packages/cli/src/commands/timeline-event/delete.ts @@ -5,7 +5,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "delete", description: "Permanently delete a timeline event by id" }, - capabilities: { minVersion: 58 }, + requires: ["timelineEvent.delete"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline-event/get.ts b/packages/cli/src/commands/timeline-event/get.ts index 312641a6..b399d86e 100644 --- a/packages/cli/src/commands/timeline-event/get.ts +++ b/packages/cli/src/commands/timeline-event/get.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a timeline event by id" }, - capabilities: { minVersion: 58 }, + requires: ["timelineEvent.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline-event/update.ts b/packages/cli/src/commands/timeline-event/update.ts index f80a62c9..0a8b9fe0 100644 --- a/packages/cli/src/commands/timeline-event/update.ts +++ b/packages/cli/src/commands/timeline-event/update.ts @@ -11,7 +11,7 @@ export default defineMetabaseCommand({ meta: { name: "update", description: "Update a timeline event by id" }, details: "Patches only the fields you send: `name`, `description`, `timestamp`, `timezone`, `time_matters`, `icon`, `timeline_id` (moves the event to another timeline), `archived`.", - capabilities: { minVersion: 58 }, + requires: ["timelineEvent.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline/archive.ts b/packages/cli/src/commands/timeline/archive.ts index aa32f6a1..6ad5e82d 100644 --- a/packages/cli/src/commands/timeline/archive.ts +++ b/packages/cli/src/commands/timeline/archive.ts @@ -10,7 +10,7 @@ export default defineMetabaseCommand({ name: "archive", description: "Archive (soft-delete) a timeline and all its events by id", }, - capabilities: { minVersion: 58 }, + requires: ["timeline.archive"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline/create.ts b/packages/cli/src/commands/timeline/create.ts index b63e5708..ecab84f1 100644 --- a/packages/cli/src/commands/timeline/create.ts +++ b/packages/cli/src/commands/timeline/create.ts @@ -10,7 +10,7 @@ export default defineMetabaseCommand({ meta: { name: "create", description: "Create a timeline from JSON" }, details: "The JSON body needs `name`; optional fields: `description`, `icon` (star|cake|mail|warning|bell|cloud, default star), `collection_id` (null = root collection), `default`.", - capabilities: { minVersion: 58 }, + requires: ["timeline.create"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline/delete.ts b/packages/cli/src/commands/timeline/delete.ts index 51fe9c99..953d71d3 100644 --- a/packages/cli/src/commands/timeline/delete.ts +++ b/packages/cli/src/commands/timeline/delete.ts @@ -8,7 +8,7 @@ export default defineMetabaseCommand({ name: "delete", description: "Permanently delete a timeline and all its events by id", }, - capabilities: { minVersion: 58 }, + requires: ["timeline.delete"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline/events.ts b/packages/cli/src/commands/timeline/events.ts index 8c0c847a..d358b06e 100644 --- a/packages/cli/src/commands/timeline/events.ts +++ b/packages/cli/src/commands/timeline/events.ts @@ -11,7 +11,7 @@ export const TimelineEventListEnvelope = listEnvelopeSchema(TimelineEventCompact export default defineMetabaseCommand({ meta: { name: "events", description: "List events on a timeline" }, - capabilities: { minVersion: 58 }, + requires: ["timeline.events"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/timeline/get.ts b/packages/cli/src/commands/timeline/get.ts index 0911aa63..9e799ec3 100644 --- a/packages/cli/src/commands/timeline/get.ts +++ b/packages/cli/src/commands/timeline/get.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a timeline by id" }, - capabilities: { minVersion: 58 }, + requires: ["timeline.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/timeline/list.ts b/packages/cli/src/commands/timeline/list.ts index 531a1d07..b205ef74 100644 --- a/packages/cli/src/commands/timeline/list.ts +++ b/packages/cli/src/commands/timeline/list.ts @@ -10,7 +10,7 @@ export const TimelineListEnvelope = listEnvelopeSchema(TimelineCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List timelines" }, - capabilities: { minVersion: 58 }, + requires: ["timeline.list"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/timeline/update.ts b/packages/cli/src/commands/timeline/update.ts index d2309b5a..c6e48555 100644 --- a/packages/cli/src/commands/timeline/update.ts +++ b/packages/cli/src/commands/timeline/update.ts @@ -11,7 +11,7 @@ export default defineMetabaseCommand({ meta: { name: "update", description: "Update a timeline by id" }, details: "Patches only the fields you send: `name`, `description`, `icon`, `collection_id`, `default`, `archived`. Changing `archived` cascades to every event on the timeline.", - capabilities: { minVersion: 58 }, + requires: ["timeline.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform-job/create.ts b/packages/cli/src/commands/transform-job/create.ts index 102edb0a..fc54188b 100644 --- a/packages/cli/src/commands/transform-job/create.ts +++ b/packages/cli/src/commands/transform-job/create.ts @@ -8,7 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "create", description: "Create a transform job" }, - capabilities: { minVersion: 59 }, + requires: ["transformJob.create"], args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, inputSchema: TransformJobCreateInput, outputSchema: TransformJob, diff --git a/packages/cli/src/commands/transform-job/delete.ts b/packages/cli/src/commands/transform-job/delete.ts index 3307556a..5a57b0ad 100644 --- a/packages/cli/src/commands/transform-job/delete.ts +++ b/packages/cli/src/commands/transform-job/delete.ts @@ -5,7 +5,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "delete", description: "Delete a transform job by id" }, - capabilities: { minVersion: 59 }, + requires: ["transformJob.delete"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform-job/get.ts b/packages/cli/src/commands/transform-job/get.ts index 5eb360af..bfff3d03 100644 --- a/packages/cli/src/commands/transform-job/get.ts +++ b/packages/cli/src/commands/transform-job/get.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a transform job by id" }, - capabilities: { minVersion: 59 }, + requires: ["transformJob.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform-job/list.ts b/packages/cli/src/commands/transform-job/list.ts index 3ec4ba35..36da9cb9 100644 --- a/packages/cli/src/commands/transform-job/list.ts +++ b/packages/cli/src/commands/transform-job/list.ts @@ -10,7 +10,7 @@ export const TransformJobListEnvelope = listEnvelopeSchema(TransformJobCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List transform jobs" }, - capabilities: { minVersion: 59 }, + requires: ["transformJob.list"], args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: TransformJobListEnvelope, examples: ["mb transform-job list", "mb transform-job list --json"], diff --git a/packages/cli/src/commands/transform-job/run.ts b/packages/cli/src/commands/transform-job/run.ts index c420bd18..c447ea4f 100644 --- a/packages/cli/src/commands/transform-job/run.ts +++ b/packages/cli/src/commands/transform-job/run.ts @@ -13,16 +13,27 @@ export type TransformJobRunResultJson = TransformJobRunResult; const transformJobRunResultView: ResourceView = { compactPick: TransformJobRunResult, tableColumns: [ - { key: "job_run_id", label: "Job run" }, + { key: "started", label: "Started" }, + { key: "run_id", label: "Run" }, { key: "message", label: "Message" }, ], }; +function runSummary(id: number, result: TransformJobRunResult): string { + if (!result.started) { + return `Transform job ${id} was not started (already running, or it resolves to no transforms).`; + } + if (result.run_id === null) { + return `Started transform job ${id}.`; + } + return `Started transform job ${id} as run ${result.run_id}.`; +} + export default defineMetabaseCommand({ meta: { name: "run", description: "Trigger a transform job run by id" }, details: "Starts the job and returns immediately. The job runs every transform carrying one of its tags, plus those transforms' dependencies. Dependencies that are already fresh are skipped by default; --force-refresh re-runs the whole plan including them.", - capabilities: { minVersion: 59 }, + requires: ["transformJob.run"], args: { ...outputFlags, ...profileFlag, @@ -40,10 +51,6 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const client = await getClient(); const result = await client.transformJob.run(id, { run_all: args["force-refresh"] === true }); - const summary = - result.job_run_id === null - ? `Transform job ${id} was not started (already running, or it resolves to no transforms).` - : `Started transform job ${id}.`; - renderSummary(result, transformJobRunResultView, summary, ctx); + renderSummary(result, transformJobRunResultView, runSummary(id, result), ctx); }, }); diff --git a/packages/cli/src/commands/transform-job/set-active.ts b/packages/cli/src/commands/transform-job/set-active.ts index 838264b2..d09b23d0 100644 --- a/packages/cli/src/commands/transform-job/set-active.ts +++ b/packages/cli/src/commands/transform-job/set-active.ts @@ -25,7 +25,7 @@ export default defineMetabaseCommand({ meta: { name: "set-active", description: "Activate or deactivate every transform job" }, details: "Flips the active flag on every transform job at once. Inactive jobs do not run on schedule; manual runs ignore the flag. Requires admin.", - capabilities: { minVersion: 61 }, + requires: ["transformJob.setActive"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform-job/transforms.ts b/packages/cli/src/commands/transform-job/transforms.ts index 605a04b9..f291e773 100644 --- a/packages/cli/src/commands/transform-job/transforms.ts +++ b/packages/cli/src/commands/transform-job/transforms.ts @@ -13,7 +13,7 @@ export default defineMetabaseCommand({ meta: { name: "transforms", description: "List the transforms a job will run" }, details: "Resolves the transforms a job would execute, matched by the job's tags. The positional id is a JOB id, not a transform id.", - capabilities: { minVersion: 59 }, + requires: ["transformJob.transforms"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/transform-job/update.ts b/packages/cli/src/commands/transform-job/update.ts index f3d38bb4..931008e2 100644 --- a/packages/cli/src/commands/transform-job/update.ts +++ b/packages/cli/src/commands/transform-job/update.ts @@ -9,7 +9,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "update", description: "Update a transform job by id" }, - capabilities: { minVersion: 59 }, + requires: ["transformJob.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform-tag/create.ts b/packages/cli/src/commands/transform-tag/create.ts index 3fa474c2..5e389237 100644 --- a/packages/cli/src/commands/transform-tag/create.ts +++ b/packages/cli/src/commands/transform-tag/create.ts @@ -8,7 +8,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "create", description: "Create a transform tag" }, - capabilities: { minVersion: 59 }, + requires: ["transformTag.create"], args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, inputSchema: TransformTagCreateInput, outputSchema: TransformTag, diff --git a/packages/cli/src/commands/transform-tag/delete.ts b/packages/cli/src/commands/transform-tag/delete.ts index 25e7b6ee..12859869 100644 --- a/packages/cli/src/commands/transform-tag/delete.ts +++ b/packages/cli/src/commands/transform-tag/delete.ts @@ -5,7 +5,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "delete", description: "Delete a transform tag by id" }, - capabilities: { minVersion: 59 }, + requires: ["transformTag.delete"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform-tag/list.ts b/packages/cli/src/commands/transform-tag/list.ts index 0fc42bb0..0fb57661 100644 --- a/packages/cli/src/commands/transform-tag/list.ts +++ b/packages/cli/src/commands/transform-tag/list.ts @@ -10,7 +10,7 @@ export const TransformTagListEnvelope = listEnvelopeSchema(TransformTagCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List transform tags" }, - capabilities: { minVersion: 59 }, + requires: ["transformTag.list"], args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: TransformTagListEnvelope, examples: ["mb transform-tag list", "mb transform-tag list --json"], diff --git a/packages/cli/src/commands/transform-tag/update.ts b/packages/cli/src/commands/transform-tag/update.ts index 38049994..464f9860 100644 --- a/packages/cli/src/commands/transform-tag/update.ts +++ b/packages/cli/src/commands/transform-tag/update.ts @@ -9,7 +9,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "update", description: "Update a transform tag by id" }, - capabilities: { minVersion: 59 }, + requires: ["transformTag.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform/cancel.ts b/packages/cli/src/commands/transform/cancel.ts index 62605aff..3dd5b229 100644 --- a/packages/cli/src/commands/transform/cancel.ts +++ b/packages/cli/src/commands/transform/cancel.ts @@ -22,7 +22,7 @@ const transformCancelView: ResourceView = { export default defineMetabaseCommand({ meta: { name: "cancel", description: "Cancel the current run for a transform" }, - capabilities: { minVersion: 59 }, + requires: ["transform.cancel"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform/create.ts b/packages/cli/src/commands/transform/create.ts index ee6df658..a0118134 100644 --- a/packages/cli/src/commands/transform/create.ts +++ b/packages/cli/src/commands/transform/create.ts @@ -21,7 +21,7 @@ export default defineMetabaseCommand({ details: "The JSON body needs a `name`, a `source` (the query to run — native SQL or MBQL — under `source.query`), and a `target` (the warehouse table to write, with `database`/`schema`/`name`). When `source.query` is an MBQL 5 query it is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass.", skills: [{ skill: "mbql", purpose: "MBQL source.query bodies" }], - capabilities: { minVersion: 59 }, + requires: ["transform.create"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform/delete-table.ts b/packages/cli/src/commands/transform/delete-table.ts index eae221d0..21584061 100644 --- a/packages/cli/src/commands/transform/delete-table.ts +++ b/packages/cli/src/commands/transform/delete-table.ts @@ -8,7 +8,7 @@ export default defineMetabaseCommand({ name: "delete-table", description: "Drop a transform's materialized output table (keeps the transform definition)", }, - capabilities: { minVersion: 59 }, + requires: ["transform.deleteTable"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform/delete.ts b/packages/cli/src/commands/transform/delete.ts index ff033a8e..8af305b1 100644 --- a/packages/cli/src/commands/transform/delete.ts +++ b/packages/cli/src/commands/transform/delete.ts @@ -5,7 +5,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "delete", description: "Delete a transform by id" }, - capabilities: { minVersion: 59 }, + requires: ["transform.delete"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform/dependencies.ts b/packages/cli/src/commands/transform/dependencies.ts index 4c7bf0a8..c1814127 100644 --- a/packages/cli/src/commands/transform/dependencies.ts +++ b/packages/cli/src/commands/transform/dependencies.ts @@ -13,7 +13,7 @@ export default defineMetabaseCommand({ meta: { name: "dependencies", description: "List the transforms a transform depends on" }, details: "Returns the upstream transforms in this transform's dependency graph — the ones that must run before it. The positional id is a transform id.", - capabilities: { minVersion: 59 }, + requires: ["transform.dependencies"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/transform/get-run.ts b/packages/cli/src/commands/transform/get-run.ts index 376256d4..c47ff8e1 100644 --- a/packages/cli/src/commands/transform/get-run.ts +++ b/packages/cli/src/commands/transform/get-run.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get-run", description: "Get a transform run by run id (not the transform id)" }, - capabilities: { minVersion: 59 }, + requires: ["transform.getRun"], details: "The positional id is a RUN id (from `transform runs`), not a transform id. To fetch a transform itself, use `transform get `.", args: { diff --git a/packages/cli/src/commands/transform/get.ts b/packages/cli/src/commands/transform/get.ts index 8585e840..32c5b1c5 100644 --- a/packages/cli/src/commands/transform/get.ts +++ b/packages/cli/src/commands/transform/get.ts @@ -7,7 +7,7 @@ import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ meta: { name: "get", description: "Get a transform by id" }, - capabilities: { minVersion: 59 }, + requires: ["transform.get"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform/list.ts b/packages/cli/src/commands/transform/list.ts index c99c647f..e64ee6b8 100644 --- a/packages/cli/src/commands/transform/list.ts +++ b/packages/cli/src/commands/transform/list.ts @@ -10,7 +10,7 @@ export const TransformListEnvelope = listEnvelopeSchema(TransformCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List transforms" }, - capabilities: { minVersion: 59 }, + requires: ["transform.list"], args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags }, outputSchema: TransformListEnvelope, examples: ["mb transform list", "mb transform list --json"], diff --git a/packages/cli/src/commands/transform/run.ts b/packages/cli/src/commands/transform/run.ts index a2e7a613..32f321a5 100644 --- a/packages/cli/src/commands/transform/run.ts +++ b/packages/cli/src/commands/transform/run.ts @@ -20,7 +20,7 @@ export default defineMetabaseCommand({ meta: { name: "run", description: "Trigger a transform run by id" }, details: "Starts a run and returns immediately. --wait polls the run to a terminal status. --sync additionally waits until the run's output table is registered and returns its `target_table_id`, so you can build MBQL cards against it — the run registers the table itself, so no separate `db sync-schema` is needed; --sync implies waiting for the run.", - capabilities: { minVersion: 59 }, + requires: ["transform.run"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/transform/runs.ts b/packages/cli/src/commands/transform/runs.ts index bd40963b..461b235b 100644 --- a/packages/cli/src/commands/transform/runs.ts +++ b/packages/cli/src/commands/transform/runs.ts @@ -12,7 +12,7 @@ export const TransformRunListEnvelope = listEnvelopeSchema(TransformRunCompact); export default defineMetabaseCommand({ meta: { name: "runs", description: "List recent transform runs" }, - capabilities: { minVersion: 59 }, + requires: ["transform.runPages"], args: { ...outputFlags, ...listFlags, diff --git a/packages/cli/src/commands/transform/update.ts b/packages/cli/src/commands/transform/update.ts index 74e4c9bf..7e14fced 100644 --- a/packages/cli/src/commands/transform/update.ts +++ b/packages/cli/src/commands/transform/update.ts @@ -22,7 +22,7 @@ export default defineMetabaseCommand({ details: "Patches only the fields you send (any of `name`, `source`, `target`, `tag_ids`, …). When a new `source.query` is an MBQL 5 query it is checked against a bundled JSON Schema (print it with `mb query --print-schema`) before sending; pass --skip-validate to bypass.", skills: [{ skill: "mbql", purpose: "MBQL source.query bodies" }], - capabilities: { minVersion: 59 }, + requires: ["transform.update"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/upgrade.ts b/packages/cli/src/commands/upgrade.ts index 47f015fe..7743bbf0 100644 --- a/packages/cli/src/commands/upgrade.ts +++ b/packages/cli/src/commands/upgrade.ts @@ -60,7 +60,7 @@ export default defineMetabaseCommand({ name: "upgrade", description: "Upgrade the Metabase CLI itself to the latest published release", }, - capabilities: null, + requires: null, args: { ...outputFlags, check: { diff --git a/packages/cli/src/commands/upload/append.ts b/packages/cli/src/commands/upload/append.ts index 27b47dc5..7b354cb0 100644 --- a/packages/cli/src/commands/upload/append.ts +++ b/packages/cli/src/commands/upload/append.ts @@ -11,7 +11,7 @@ export default defineMetabaseCommand({ meta: { name: "append", description: "Append a CSV file's rows to an existing uploaded table" }, details: "Inserts the rows of the CSV into the table with the given id. The table must have been created by a CSV upload and the CSV columns must match.", - capabilities: { minVersion: 58 }, + requires: ["table.appendCsv"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/upload/csv.ts b/packages/cli/src/commands/upload/csv.ts index ee00cdd8..5cf1e082 100644 --- a/packages/cli/src/commands/upload/csv.ts +++ b/packages/cli/src/commands/upload/csv.ts @@ -13,7 +13,7 @@ export default defineMetabaseCommand({ meta: { name: "csv", description: "Upload a CSV file as a new table and model" }, details: "Uploads the CSV to the server's configured uploads database, creating a new table plus a model over it, and prints the new model id and table id. Requires an uploads database to be configured on the server. Pass --collection to place the model in a specific collection (defaults to root).", - capabilities: { minVersion: 58 }, + requires: ["upload.createFromCsv"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/upload/replace.ts b/packages/cli/src/commands/upload/replace.ts index 0ac70bd2..93020c21 100644 --- a/packages/cli/src/commands/upload/replace.ts +++ b/packages/cli/src/commands/upload/replace.ts @@ -14,7 +14,7 @@ export default defineMetabaseCommand({ }, details: "Replaces the contents of the table with the given id with the rows of the CSV. The table must have been created by a CSV upload and the CSV columns must match.", - capabilities: { minVersion: 58 }, + requires: ["table.replaceCsv"], args: { ...outputFlags, ...profileFlag, diff --git a/packages/cli/src/commands/uuid.ts b/packages/cli/src/commands/uuid.ts index 37e6681c..5a8b6e11 100644 --- a/packages/cli/src/commands/uuid.ts +++ b/packages/cli/src/commands/uuid.ts @@ -20,7 +20,7 @@ export default defineMetabaseCommand({ details: "For MBQL `lib/uuid` slots, native template-tag ids, and other UUID fields. Mint fresh values here rather than authoring them by hand — the MBQL 5 schema rejects placeholder strings.", skills: [{ skill: "mbql", purpose: "where lib/uuid values are used" }], - capabilities: null, + requires: null, args: { ...outputFlags, count: { diff --git a/packages/cli/src/core/auth/cached-server.test.ts b/packages/cli/src/core/auth/cached-server.test.ts new file mode 100644 index 00000000..683037d7 --- /dev/null +++ b/packages/cli/src/core/auth/cached-server.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createServerProfile } from "@metabase/client/version/profile"; + +const hoisted = vi.hoisted(() => ({ + store: new Map(), + controls: { broken: false }, +})); + +vi.mock("@napi-rs/keyring", async () => { + const { createKeyringMockModule } = await import("./keyring-mock"); + return createKeyringMockModule(hoisted); +}); + +import { readCachedServerProfile } from "./cached-server"; +import { writeProfile } from "./storage"; +import { + probeAt, + seedProbedProfile, + setupTempConfigHome, + type TempConfigHome, +} from "./temp-config-home"; + +describe("readCachedServerProfile", () => { + let home: TempConfigHome; + + beforeEach(() => { + hoisted.store.clear(); + home = setupTempConfigHome(); + }); + + afterEach(() => { + home.cleanup(); + }); + + it("returns null when the profile has no record", async () => { + expect(await readCachedServerProfile("default")).toBeNull(); + }); + + it("returns null when the record was never probed", async () => { + await writeProfile({ url: "http://127.0.0.1:1", apiKey: "secret-key" }, "default"); + + expect(await readCachedServerProfile("default")).toBeNull(); + }); + + it("derives the profile from the record's cached probe", async () => { + await seedProbedProfile("staging", probeAt(61, { library: true })); + + expect(await readCachedServerProfile("staging")).toEqual( + createServerProfile(probeAt(61, { library: true })), + ); + }); +}); diff --git a/packages/cli/src/core/auth/cached-server.ts b/packages/cli/src/core/auth/cached-server.ts new file mode 100644 index 00000000..75234383 --- /dev/null +++ b/packages/cli/src/core/auth/cached-server.ts @@ -0,0 +1,14 @@ +import { createServerProfile, type ServerProfile } from "@metabase/client/version/profile"; + +import { readProfileRecord } from "./storage"; + +// The server a profile last probed, derived at read time, or `null` when the record has no probe +// to derive it from. Nothing here reaches the network, so a caller that only needs to know what +// the server can do keeps working offline. +export async function readCachedServerProfile(profileName: string): Promise { + const record = await readProfileRecord(profileName); + if (record === null || record.lastProbe === null) { + return null; + } + return createServerProfile(record.lastProbe); +} diff --git a/packages/cli/src/core/auth/profile-record.ts b/packages/cli/src/core/auth/profile-record.ts index 874985b4..c5b4f8c5 100644 --- a/packages/cli/src/core/auth/profile-record.ts +++ b/packages/cli/src/core/auth/profile-record.ts @@ -10,9 +10,14 @@ export const ProbedUser = z.object({ }); export type ProbedUser = z.infer; +// The raw probe, never anything derived from it: a profile built from a stale derivation would +// outlive the client that wrote it. `date` and `hash` read as `null` off a record written before +// they were kept. export const ProfileLastProbe = z.object({ at: z.iso.datetime(), version: ParsedVersion.nullable(), + date: z.string().nullable().default(null), + hash: z.string().nullable().default(null), tokenFeatures: TokenFeatures.nullable(), user: ProbedUser, }); diff --git a/packages/cli/src/core/auth/server-summary.test.ts b/packages/cli/src/core/auth/server-summary.test.ts new file mode 100644 index 00000000..fb74012b --- /dev/null +++ b/packages/cli/src/core/auth/server-summary.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import type { ServerInfo } from "@metabase/client/version/probe"; +import { createServerProfile, KNOWN_RANGE } from "@metabase/client/version/profile"; + +import { serverChangeNote, skewNotice, summarizeServer } from "./server-summary"; + +function serverAt(major: number, tokenFeatures: ServerInfo["tokenFeatures"] = null): ServerInfo { + return { + version: { tag: `v0.${major}.0`, major, patch: 0 }, + date: null, + hash: null, + tokenFeatures, + }; +} + +const UNPARSEABLE: ServerInfo = { version: null, date: null, hash: null, tokenFeatures: null }; + +describe("summarizeServer", () => { + it("reports every server fact as null without a probe and still names the CLI's own range", () => { + expect(summarizeServer(null)).toEqual({ + version: null, + edition: null, + skew: null, + knownRange: { min: KNOWN_RANGE.min, max: KNOWN_RANGE.max }, + tokenFeatures: null, + features: null, + }); + }); + + it("carries the profile's derived facts beside the raw probe", () => { + const info = serverAt(KNOWN_RANGE.min, { library: true }); + const profile = createServerProfile(info); + expect(summarizeServer(info)).toEqual({ + version: info.version, + edition: "oss", + skew: "supported", + knownRange: { min: KNOWN_RANGE.min, max: KNOWN_RANGE.max }, + tokenFeatures: { library: true }, + features: profile.features, + }); + }); +}); + +describe("skewNotice", () => { + it("is silent inside the known range", () => { + expect(skewNotice(createServerProfile(serverAt(KNOWN_RANGE.max)))).toBeNull(); + }); + + it("points a newer server at mb upgrade and names the major it is read as", () => { + const beyond = KNOWN_RANGE.max + 1; + expect(skewNotice(createServerProfile(serverAt(beyond)))).toBe( + `Metabase v0.${beyond}.0 is newer than this CLI supports (up to v${KNOWN_RANGE.max}); commands run as if it were v${KNOWN_RANGE.max}. Run \`mb upgrade\` for a newer CLI.`, + ); + }); + + it("says an unparseable version is read as the newest supported", () => { + expect(skewNotice(createServerProfile(UNPARSEABLE))).toBe( + `Could not parse the Metabase version; assuming the newest supported (v${KNOWN_RANGE.max}).`, + ); + }); +}); + +describe("serverChangeNote", () => { + it("names both tags when the version changed", () => { + expect(serverChangeNote(serverAt(59), serverAt(63))).toBe( + "The server's version changed since the last probe (was v0.59.0, now v0.63.0); the profile was refreshed — retry the command.", + ); + }); + + it("describes a version that stopped parsing as unparseable", () => { + expect(serverChangeNote(serverAt(59), UNPARSEABLE)).toBe( + "The server's version changed since the last probe (was v0.59.0, now an unparseable version); the profile was refreshed — retry the command.", + ); + }); + + it("reports a premium feature the server started granting under the same version", () => { + expect( + serverChangeNote(serverAt(59, { library: false }), serverAt(59, { library: true })), + ).toBe( + "The server's premium features changed since the last probe; the profile was refreshed — retry the command.", + ); + }); + + it("reports a premium feature the server stopped granting", () => { + expect(serverChangeNote(serverAt(59, { library: true }), serverAt(59, null))).toBe( + "The server's premium features changed since the last probe; the profile was refreshed — retry the command.", + ); + }); + + it("treats an unreported map, an empty one, and a denied feature as the same grants", () => { + expect(serverChangeNote(serverAt(59, null), serverAt(59, {}))).toBeNull(); + expect(serverChangeNote(serverAt(59, {}), serverAt(59, { library: false }))).toBeNull(); + }); + + it("is null when tag and grants agree even though the build moved", () => { + const cached = serverAt(59, { library: true }); + const fresh: ServerInfo = { ...cached, date: "2026-09-16", hash: "abc1234" }; + expect(serverChangeNote(cached, fresh)).toBeNull(); + }); +}); diff --git a/packages/cli/src/core/auth/server-summary.ts b/packages/cli/src/core/auth/server-summary.ts new file mode 100644 index 00000000..89bb1f0a --- /dev/null +++ b/packages/cli/src/core/auth/server-summary.ts @@ -0,0 +1,111 @@ +import { z } from "zod"; + +import { TokenFeatures } from "@metabase/client/domain/session-properties"; +import { Features } from "@metabase/client/version/features"; +import type { ServerInfo } from "@metabase/client/version/probe"; +import { + createServerProfile, + KNOWN_RANGE, + type ServerProfile, + Skew, +} from "@metabase/client/version/profile"; +import { Edition, ParsedVersion } from "@metabase/client/version/tag"; + +const KnownRange = z.object({ + min: z.number().int(), + max: z.number().int(), +}); + +// What a probe says about the server, as every auth payload reports it: the raw facts and what +// this CLI derives from them, side by side. Derived when read, never stored — a persisted +// derivation would outlive the CLI that wrote it. Every server field is `null` without a probe; +// `knownRange` is the CLI's own and always present. +export const ServerSummary = z.object({ + version: ParsedVersion.nullable(), + edition: Edition.nullable(), + skew: Skew.nullable(), + knownRange: KnownRange, + tokenFeatures: TokenFeatures.nullable(), + features: Features.nullable(), +}); +export type ServerSummary = z.infer; + +export function summarizeServer(info: ServerInfo | null): ServerSummary { + if (info === null) { + return { + version: null, + edition: null, + skew: null, + knownRange: KNOWN_RANGE, + tokenFeatures: null, + features: null, + }; + } + const profile = createServerProfile(info); + return { + version: profile.version, + edition: profile.edition, + skew: profile.skew, + knownRange: KNOWN_RANGE, + tokenFeatures: profile.tokenFeatures, + features: profile.features, + }; +} + +const UNPARSEABLE_VERSION_LABEL = "an unparseable version"; + +function versionLabel(version: ParsedVersion | null): string { + return version === null ? UNPARSEABLE_VERSION_LABEL : version.tag; +} + +// One line for a server outside the window this CLI was built against, or `null` inside it. +export function skewNotice(profile: ServerProfile): string | null { + const max = KNOWN_RANGE.max; + switch (profile.skew) { + case "supported": { + return null; + } + case "newer-than-known": { + return `Metabase ${versionLabel(profile.version)} is newer than this CLI supports (up to v${max}); commands run as if it were v${max}. Run \`mb upgrade\` for a newer CLI.`; + } + case "unknown": { + return `Could not parse the Metabase version; assuming the newest supported (v${max}).`; + } + } +} + +const PROFILE_REFRESHED_REMEDY = "the profile was refreshed — retry the command."; + +// What a fresh probe says that the cached one did not, or `null` when the two agree on everything +// a feature switch reads: the version tag and the premium features. +export function serverChangeNote(cached: ServerInfo, fresh: ServerInfo): string | null { + const before = versionLabel(cached.version); + const after = versionLabel(fresh.version); + if (before !== after) { + return `The server's version changed since the last probe (was ${before}, now ${after}); ${PROFILE_REFRESHED_REMEDY}`; + } + if (!sameTokenFeatures(cached.tokenFeatures, fresh.tokenFeatures)) { + return `The server's premium features changed since the last probe; ${PROFILE_REFRESHED_REMEDY}`; + } + return null; +} + +// Only a granted feature turns a switch on, so a map the server did not report, an empty one, and +// one that names a feature it denies all read the same. +function sameTokenFeatures( + a: Readonly | null, + b: Readonly | null, +): boolean { + const grantedA = grantedFeatures(a); + const grantedB = grantedFeatures(b); + return grantedA.length === grantedB.length && grantedA.every((key, i) => key === grantedB[i]); +} + +function grantedFeatures(features: Readonly | null): string[] { + if (features === null) { + return []; + } + return Object.keys(features) + .filter((key) => features[key] === true) + .toSorted(); +} diff --git a/packages/cli/src/core/auth/storage.test.ts b/packages/cli/src/core/auth/storage.test.ts index 4f481024..613ce62d 100644 --- a/packages/cli/src/core/auth/storage.test.ts +++ b/packages/cli/src/core/auth/storage.test.ts @@ -264,6 +264,8 @@ describe("writeProbeResult and writeProbeFailure", () => { user: { id: 42, name: "Alice", isAdmin: true }, server: { version: { tag: "v0.58.7", major: 58, patch: 7 }, + date: null, + hash: null, tokenFeatures: null, }, }); @@ -281,6 +283,8 @@ describe("writeProbeResult and writeProbeFailure", () => { user: { id: 1, name: "n", isAdmin: false }, server: { version: { tag: "v0.58.7", major: 58, patch: 7 }, + date: null, + hash: null, tokenFeatures: null, }, }); @@ -294,6 +298,8 @@ describe("writeProbeResult and writeProbeFailure", () => { user: { id: 1, name: "Alice", isAdmin: true }, server: { version: { tag: "v0.58.7", major: 58, patch: 7 }, + date: null, + hash: null, tokenFeatures: null, }, }); diff --git a/packages/cli/src/core/auth/storage.ts b/packages/cli/src/core/auth/storage.ts index 045a4db7..496a593d 100644 --- a/packages/cli/src/core/auth/storage.ts +++ b/packages/cli/src/core/auth/storage.ts @@ -451,6 +451,8 @@ export async function writeProbeResult( const probe = ProfileLastProbe.parse({ at: new Date().toISOString(), version: input.server.version, + date: input.server.date, + hash: input.server.hash, tokenFeatures: input.server.tokenFeatures, user: input.user, }); diff --git a/packages/cli/src/core/auth/temp-config-home.ts b/packages/cli/src/core/auth/temp-config-home.ts index 0ee39443..ea4e569d 100644 --- a/packages/cli/src/core/auth/temp-config-home.ts +++ b/packages/cli/src/core/auth/temp-config-home.ts @@ -2,6 +2,14 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { ServerInfo } from "@metabase/client/version/probe"; + +import type { ProbedUser } from "./profile-record"; +import { writeProbeResult, writeProfile } from "./storage"; + +const UNREACHABLE_URL = "http://127.0.0.1:1"; +const SEED_USER: ProbedUser = { id: 1, name: "Tester", isAdmin: true }; + export interface TempConfigHome { path: string; cleanup(): void; @@ -20,3 +28,27 @@ export function setupTempConfigHome(): TempConfigHome { }, }; } + +// A profile record as `auth login` would have left it: the key stored, the server probed. The URL +// never answers, so a test reading the cached probe cannot be quietly served by a live server. +export async function seedProbedProfile( + name: string, + server: ServerInfo, + user: ProbedUser = SEED_USER, +): Promise { + await writeProfile({ url: UNREACHABLE_URL, apiKey: "secret-key" }, name); + await writeProbeResult(name, { user, server }); +} + +// A released OSS server at `major`, as `probeServer` would report it. +export function probeAt( + major: number, + tokenFeatures: ServerInfo["tokenFeatures"] = null, +): ServerInfo { + return { + version: { tag: `v0.${major}.0`, major, patch: 0 }, + date: null, + hash: null, + tokenFeatures, + }; +} diff --git a/packages/cli/src/core/auth/verify.test.ts b/packages/cli/src/core/auth/verify.test.ts index 8352f76a..1fa60d43 100644 --- a/packages/cli/src/core/auth/verify.test.ts +++ b/packages/cli/src/core/auth/verify.test.ts @@ -54,7 +54,7 @@ describe("verifyAndProbe", () => { await answered; return jsonResponse(body); }; - const capture = captureFetch([held(CURRENT_USER), held(SESSION_PROPERTIES)]); + const capture = captureFetch([held(SESSION_PROPERTIES), held(CURRENT_USER)]); vi.stubGlobal("fetch", capture.fetch); const pending = verifyAndProbe(BASE_URL, CREDENTIAL); @@ -62,13 +62,13 @@ describe("verifyAndProbe", () => { await flushPending(); expect(capture.calls).toEqual([ { - url: `${BASE_URL}/api/user/current`, + url: `${BASE_URL}/api/session/properties`, method: "GET", headers: READ_HEADERS, body: null, }, { - url: `${BASE_URL}/api/session/properties`, + url: `${BASE_URL}/api/user/current`, method: "GET", headers: READ_HEADERS, body: null, @@ -83,6 +83,8 @@ describe("verifyAndProbe", () => { user: { id: 1, name: "Admin", isAdmin: true }, server: { version: { tag: "v0.58.0", major: 58, patch: 0 }, + date: null, + hash: null, tokenFeatures: { transforms: true }, }, }); diff --git a/packages/cli/src/core/config.test.ts b/packages/cli/src/core/config.test.ts index 558405a5..9188eeeb 100644 --- a/packages/cli/src/core/config.test.ts +++ b/packages/cli/src/core/config.test.ts @@ -249,6 +249,8 @@ describe("resolveConfig", () => { user: { id: 1, name: "Tester", isAdmin: true }, server: { version: { tag: "v0.58.7", major: 58, patch: 7 }, + date: null, + hash: null, tokenFeatures: null, }, }); diff --git a/packages/cli/src/core/skills.test.ts b/packages/cli/src/core/skills.test.ts index 005f42e3..6e0d3902 100644 --- a/packages/cli/src/core/skills.test.ts +++ b/packages/cli/src/core/skills.test.ts @@ -2,26 +2,45 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import * as fc from "fast-check"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ConfigError } from "@metabase/client/errors"; +import { FEATURE_NAMES } from "@metabase/client/version/features"; +import type { ServerInfo } from "@metabase/client/version/probe"; +import { createServerProfile, type ServerProfile } from "@metabase/client/version/profile"; import { ENV_SKILLS_DIR } from "./env"; import { availableSkillNames, discoverSkills, findSkillByName, + loadAllSkills, parseFrontmatter, readSkillContent, + resolveSections, resolveSkillDirs, + selectForProfile, selectSkillsByNames, type SkillInfo, } from "./skills"; +function profileAt( + major: number, + tokenFeatures: ServerInfo["tokenFeatures"] = null, +): ServerProfile { + return createServerProfile({ + version: { tag: `v0.${major}.0`, major, patch: 0 }, + date: null, + hash: null, + tokenFeatures, + }); +} + describe("parseFrontmatter", () => { it("parses a minimal frontmatter block", () => { expect( parseFrontmatter("---\nname: test-skill\ndescription: A test skill.\n---\n\nBody."), - ).toEqual({ name: "test-skill", description: "A test skill.", hidden: false }); + ).toEqual({ name: "test-skill", description: "A test skill.", hidden: false, requires: [] }); }); it("returns null when there is no frontmatter delimiter", () => { @@ -46,6 +65,7 @@ describe("parseFrontmatter", () => { name: "multi", description: "First sentence. Second line. Third line.", hidden: false, + requires: [], }); }); @@ -54,16 +74,19 @@ describe("parseFrontmatter", () => { name: "a", description: "x", hidden: true, + requires: [], }); expect(parseFrontmatter("---\nname: b\ndescription: x\nhidden: false\n---\n")).toEqual({ name: "b", description: "x", hidden: false, + requires: [], }); expect(parseFrontmatter("---\nname: c\ndescription: x\n---\n")).toEqual({ name: "c", description: "x", hidden: false, + requires: [], }); }); @@ -91,6 +114,7 @@ interface WriteSkillFrontmatter { name: string; description: string; hidden?: boolean; + requires?: readonly string[]; } function writeSkill( @@ -105,6 +129,9 @@ function writeSkill( if (frontmatter.hidden === true) { lines.push("hidden: true"); } + if (frontmatter.requires !== undefined) { + lines.push(`requires: [${frontmatter.requires.join(", ")}]`); + } lines.push("---", "", body); writeFileSync(join(dir, "SKILL.md"), lines.join("\n"), "utf8"); return dir; @@ -137,17 +164,25 @@ describe("discoverSkills", () => { ); expect(discoverSkills([temp.skills, temp.skillData])).toEqual([ - { name: "core", description: "Core.", hidden: false, dir: join(temp.skillData, "core") }, + { + name: "core", + description: "Core.", + hidden: false, + requires: [], + dir: join(temp.skillData, "core"), + }, { name: "metabase-cli", description: "Stub.", hidden: true, + requires: [], dir: join(temp.skills, "metabase-cli"), }, { name: "transform", description: "Transforms.", hidden: false, + requires: [], dir: join(temp.skillData, "transform"), }, ]); @@ -161,7 +196,13 @@ describe("discoverSkills", () => { writeSkill(temp.skillData, "real", { name: "real", description: "Real." }, "real body"); expect(discoverSkills([temp.skillData])).toEqual([ - { name: "real", description: "Real.", hidden: false, dir: join(temp.skillData, "real") }, + { + name: "real", + description: "Real.", + hidden: false, + requires: [], + dir: join(temp.skillData, "real"), + }, ]); }); @@ -170,6 +211,241 @@ describe("discoverSkills", () => { [], ); }); + + it("reads `requires` as feature names", () => { + writeSkill( + temp.skillData, + "transform", + { name: "transform", description: "Transforms.", requires: ["transforms", "measures"] }, + "body", + ); + + expect(discoverSkills([temp.skillData])).toEqual([ + { + name: "transform", + description: "Transforms.", + hidden: false, + requires: ["transforms", "measures"], + dir: join(temp.skillData, "transform"), + }, + ]); + }); + + it("throws ConfigError naming an unknown feature in `requires` rather than skipping the skill", () => { + writeSkill( + temp.skillData, + "typo", + { name: "typo", description: "Typo.", requires: ["transforms", "transfroms"] }, + "body", + ); + + expect(() => discoverSkills([temp.skillData])).toThrow( + new ConfigError( + `skill typo: unknown feature in requires: transfroms (known: ${FEATURE_NAMES.join(", ")})`, + ), + ); + }); +}); + +describe("resolveSections", () => { + const TEXT = [ + "Intro.", + "", + "", + "## Transforms", + "", + "Run one.", + "", + "", + "", + "Publish it.", + "", + "", + "Outro.", + ].join("\n"); + + it("returns the text as written, markers included, without features to resolve against", () => { + expect(resolveSections(TEXT, null, "skill x")).toBe(TEXT); + }); + + it("keeps a met section without its markers and drops an unmet one with the blank line after it", () => { + expect(resolveSections(TEXT, profileAt(61).features, "skill x")).toBe( + ["Intro.", "", "## Transforms", "", "Run one.", "", "Outro."].join("\n"), + ); + }); + + it("needs every feature a section names", () => { + const withLibraryOnly = profileAt(61, { library: true }).features; + const withBoth = profileAt(61, { library: true, remote_sync: true }).features; + + expect(resolveSections(TEXT, withLibraryOnly, "skill x")).not.toContain("Publish it."); + expect(resolveSections(TEXT, withBoth, "skill x")).toBe( + ["Intro.", "", "## Transforms", "", "Run one.", "", "Publish it.", "", "Outro."].join("\n"), + ); + }); + + it("drops every section on a server that has none of the features", () => { + expect(resolveSections(TEXT, profileAt(58).features, "skill x")).toBe( + ["Intro.", "", "Outro."].join("\n"), + ); + }); + + it("resolves markers the formatter surrounded with blank lines to single blank lines either way", () => { + const text = [ + "A", + "", + "", + "", + "B", + "", + "", + "", + "C", + ].join("\n"); + + expect(resolveSections(text, profileAt(61).features, "skill x")).toBe("A\n\nB\n\nC"); + expect(resolveSections(text, profileAt(58).features, "skill x")).toBe("A\n\nC"); + }); + + it("keeps the single blank line between neighbours when a dropped section sat between them", () => { + const text = ["A", "", "B", "", "C"].join("\n"); + expect(resolveSections(text, profileAt(58).features, "skill x")).toBe("A\nC"); + }); + + it("returns any text without a marker unchanged, whatever the features", () => { + const plainLine = fc.string().filter((line) => !line.includes("`].concat(inner, [""]), + ); + const document = fc + .array(fc.oneof(fc.array(plainLine, { maxLength: 3 }), section)) + .map((blocks) => blocks.flat().join("\n")); + fc.assert( + fc.property(document, (text) => { + expect(resolveSections(text, null, "skill x")).toBe(text); + const resolved = resolveSections(text, profileAt(61).features, "skill x"); + expect(resolved).not.toContain("", + "", + "", + "", + ].join("\n"); + expect(() => resolveSections(text, null, "skill x")).toThrow( + new ConfigError("skill x: nested requires section at line 2 (opened at line 1)"), + ); + }); + + it("throws ConfigError on a close without an open", () => { + expect(() => resolveSections("text\n", null, "skill x")).toThrow( + new ConfigError("skill x: requires section closed at line 2 was never opened"), + ); + }); + + it("throws ConfigError on an open that is never closed", () => { + expect(() => + resolveSections("\ntext", profileAt(61).features, "skill x"), + ).toThrow(new ConfigError("skill x: requires section opened at line 1 is never closed")); + }); + + it("throws ConfigError on a marker sharing a line with other text", () => { + expect(() => + resolveSections( + "Only here\n", + null, + "skill x", + ), + ).toThrow(new ConfigError("skill x: a requires marker must be on its own line (line 1)")); + }); + + it("throws ConfigError on a section naming no feature", () => { + expect(() => + resolveSections("\n", null, "skill x"), + ).toThrow(new ConfigError("skill x: requires section at line 1 names no feature")); + }); + + it("throws ConfigError on an unknown feature even when nothing is being resolved", () => { + expect(() => + resolveSections("\n", null, "skill x"), + ).toThrow( + new ConfigError( + `skill x line 1: unknown feature in requires: transfroms (known: ${FEATURE_NAMES.join(", ")})`, + ), + ); + }); +}); + +describe("selectForProfile", () => { + const skills: SkillInfo[] = [ + { name: "core", description: "Core.", hidden: false, requires: [], dir: "/x/core" }, + { + name: "git-sync", + description: "Sync.", + hidden: false, + requires: ["remoteSync"], + dir: "/x/git-sync", + }, + { + name: "transform", + description: "Transforms.", + hidden: false, + requires: ["transforms"], + dir: "/x/transform", + }, + ]; + + it("filters nothing and reports `null` without a profile", () => { + expect(selectForProfile(skills, null)).toEqual({ skills, unavailable: null }); + }); + + it("sets aside each skill the profile lacks a feature for, with the client's own failure", () => { + expect(selectForProfile(skills, profileAt(61))).toEqual({ + skills: [skills[0], skills[2]], + unavailable: [ + { + name: "git-sync", + failure: { + reason: "missing-token-feature", + detail: + "This operation requires the 'remote_sync' premium feature (not enabled on this server).", + feature: "remoteSync", + since: 60, + tokenFeature: "remote_sync", + serverVersion: "v0.61.0", + }, + }, + ], + }); + }); + + it("reports an empty `unavailable` when the profile has every feature", () => { + expect(selectForProfile(skills, profileAt(61, { remote_sync: true }))).toEqual({ + skills, + unavailable: [], + }); + }); }); describe("readSkillContent", () => { @@ -200,10 +476,11 @@ describe("readSkillContent", () => { name: "core", description: "Core skill.", hidden: false, + requires: [], dir: skillDir, }; - expect(readSkillContent(info, { includeExtras: true })).toEqual({ + expect(readSkillContent(info, { includeExtras: true, profile: null })).toEqual({ name: "core", description: "Core skill.", body: "---\nname: core\ndescription: Core skill.\n---\n\nmain body content", @@ -229,10 +506,11 @@ describe("readSkillContent", () => { name: "core", description: "Core skill.", hidden: false, + requires: [], dir: skillDir, }; - expect(readSkillContent(info, { includeExtras: false })).toEqual({ + expect(readSkillContent(info, { includeExtras: false, profile: null })).toEqual({ name: "core", description: "Core skill.", body: "---\nname: core\ndescription: Core skill.\n---\n\nbody", @@ -242,12 +520,97 @@ describe("readSkillContent", () => { }); }); +describe("readSkillContent against a profile", () => { + let temp: TempDirs; + + beforeEach(() => { + temp = makeSkillsRoot(); + }); + + afterEach(() => { + rmSync(temp.root, { recursive: true, force: true }); + }); + + it("resolves the sections of the body and of every reference, never of a template", () => { + const skillDir = writeSkill( + temp.skillData, + "core", + { name: "core", description: "Core skill." }, + "body\n\ngated\n", + ); + mkdirSync(join(skillDir, "references")); + writeFileSync( + join(skillDir, "references", "a.md"), + "ref\n\ngated ref\n", + "utf8", + ); + mkdirSync(join(skillDir, "templates")); + writeFileSync(join(skillDir, "templates", "t.md"), "", "utf8"); + const info: SkillInfo = { + name: "core", + description: "Core skill.", + hidden: false, + requires: [], + dir: skillDir, + }; + + expect(readSkillContent(info, { includeExtras: true, profile: profileAt(58) })).toEqual({ + name: "core", + description: "Core skill.", + body: "---\nname: core\ndescription: Core skill.\n---\n\nbody", + references: [{ path: "references/a.md", content: "ref" }], + templates: [{ path: "templates/t.md", content: "" }], + }); + }); + + it("names the reference file in a marker error", () => { + const skillDir = writeSkill( + temp.skillData, + "core", + { name: "core", description: "C." }, + "body", + ); + mkdirSync(join(skillDir, "references")); + writeFileSync(join(skillDir, "references", "a.md"), "", "utf8"); + const info: SkillInfo = { + name: "core", + description: "C.", + hidden: false, + requires: [], + dir: skillDir, + }; + + expect(() => readSkillContent(info, { includeExtras: true, profile: null })).toThrow( + new ConfigError( + "skill core (references/a.md): requires section closed at line 1 was never opened", + ), + ); + }); +}); + +// The shipped skills are read here in full so a typo in a `requires` list or an unbalanced marker +// fails the gate instead of hiding a skill or a section from every user. +describe("the shipped skills", () => { + it("declare exactly these skill-level requirements and carry only well-formed section markers", () => { + const all = loadAllSkills(); + const bound = Object.fromEntries( + all.filter((skill) => skill.requires.length > 0).map((skill) => [skill.name, skill.requires]), + ); + + expect(bound).toEqual({ "git-sync": ["remoteSync"], transform: ["transforms"] }); + for (const skill of all) { + const content = readSkillContent(skill, { includeExtras: true, profile: profileAt(58) }); + expect(content.body).not.toContain("$/; +const SECTION_OPEN = /^$/; +const SECTION_CLOSE = /^$/; +const SECTION_MARKER_TEXT = /` … `` pair marks text only a server with every +// named feature can use. With features in hand the markers are resolved: a met section keeps its +// text and loses its markers, an unmet one goes entirely, and a blank line that only separated a +// removed line from a blank one goes with it. Without features the text is returned as written, +// markers included, so a reader still sees what each section needs. The markers are validated +// either way, so a typo fails on every read rather than only against some server. +export function resolveSections(text: string, features: Features | null, where: string): string { + const out: string[] = []; + let open: OpenSection | null = null; + let collapseBlank = false; + for (const [index, line] of text.split("\n").entries()) { + const lineNumber = index + 1; + const trimmed = line.trim(); + if (SECTION_OPEN.test(trimmed)) { + if (open !== null) { + throw new ConfigError( + `${where}: nested requires section at line ${lineNumber} (opened at line ${open.line})`, + ); + } + const named = parseFeatureNames(sectionFeatureNames(trimmed), `${where} line ${lineNumber}`); + if (named.length === 0) { + throw new ConfigError(`${where}: requires section at line ${lineNumber} names no feature`); + } + open = { line: lineNumber, met: features === null || named.every((name) => features[name]) }; + collapseBlank = dropMarker(out, line, features); + continue; + } + if (SECTION_CLOSE.test(trimmed)) { + if (open === null) { + throw new ConfigError( + `${where}: requires section closed at line ${lineNumber} was never opened`, + ); + } + collapseBlank = dropMarker(out, line, features); + open = null; + continue; + } + if (SECTION_MARKER_TEXT.test(line)) { + throw new ConfigError( + `${where}: a requires marker must be on its own line (line ${lineNumber})`, + ); + } + if (collapseBlank) { + collapseBlank = false; + if (trimmed === "") { + continue; + } + } + if (open === null || open.met) { + out.push(line); + } + } + if (open !== null) { + throw new ConfigError(`${where}: requires section opened at line ${open.line} is never closed`); + } + return out.join("\n"); +} + +// Keeps the marker when nothing is being resolved; otherwise leaves it out and says whether the +// line after it, if blank, would now only double a blank line already kept. +function dropMarker(out: string[], marker: string, features: Features | null): boolean { + if (features === null) { + out.push(marker); + return false; + } + return out.length === 0 || out[out.length - 1] === ""; +} + +function sectionFeatureNames(marker: string): string[] { + return parseCsv(marker.replace(SECTION_OPEN_PREFIX, "").replace(MARKER_END, "")); +} + function collectExtraFiles(skillDir: string, subdirName: string): SkillExtraFile[] { const subdir = join(skillDir, subdirName); if (!isDirectory(subdir)) { diff --git a/packages/cli/src/core/temp-skills-dir.ts b/packages/cli/src/core/temp-skills-dir.ts index 533f38c7..71c12605 100644 --- a/packages/cli/src/core/temp-skills-dir.ts +++ b/packages/cli/src/core/temp-skills-dir.ts @@ -15,14 +15,24 @@ interface SkillFixture { body: string; } +const GAMMA_FRONTMATTER = + "---\nname: gamma\ndescription: The transform skill.\nrequires: [transforms]\n---\n\n"; + +// The gamma SKILL.md as written, and as `readSkillContent` resolves it against a server with and +// without the section's feature. +export const GAMMA_SKILL_MD = `${GAMMA_FRONTMATTER}Gamma instructions.\n\nGamma on activation.\n\n`; +export const GAMMA_WITH_ACTIVATION = `${GAMMA_FRONTMATTER}Gamma instructions.\nGamma on activation.\n`; +export const GAMMA_WITHOUT_ACTIVATION = `${GAMMA_FRONTMATTER}Gamma instructions.\n`; + +// Two skills is the smallest collection that can show a window dropping one, and a third one bound +// to a feature is the smallest that can show the profile filter at work. Pointing MB_SKILLS_DIR +// here keeps an assertion over rendered output independent of whatever skill-data/ happens to +// ship. const FIXTURES: readonly SkillFixture[] = [ - { name: "alpha", description: "The first skill.", body: "Alpha instructions." }, - { name: "beta", description: "The second skill.", body: "Beta instructions." }, + { name: "alpha", description: "The first skill.", body: "Alpha instructions.\n" }, + { name: "beta", description: "The second skill.", body: "Beta instructions.\n" }, ]; -// Two skills is the smallest collection that can show a window dropping one. Pointing -// MB_SKILLS_DIR here keeps an assertion over rendered output independent of whatever -// skill-data/ happens to ship. export function createTempSkillsDir(): TempSkillsDir { const path = mkdtempSync(join(tmpdir(), "mb-skills-")); for (const fixture of FIXTURES) { @@ -30,6 +40,8 @@ export function createTempSkillsDir(): TempSkillsDir { mkdirSync(dir); writeFileSync(join(dir, SKILL_MD_FILENAME), skillMarkdown(fixture), "utf8"); } + mkdirSync(join(path, "gamma")); + writeFileSync(join(path, "gamma", SKILL_MD_FILENAME), GAMMA_SKILL_MD, "utf8"); return { path, cleanup() { @@ -39,5 +51,5 @@ export function createTempSkillsDir(): TempSkillsDir { } function skillMarkdown(fixture: SkillFixture): string { - return `---\nname: ${fixture.name}\ndescription: ${fixture.description}\n---\n\n${fixture.body}\n`; + return `---\nname: ${fixture.name}\ndescription: ${fixture.description}\n---\n\n${fixture.body}`; } diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 420a0ace..86683b55 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -58,6 +58,7 @@ setMetabaseAugment(main, { ], inputSchema: null, outputSchema: null, + requires: null, capabilities: null, }); diff --git a/packages/cli/src/output/error.test.ts b/packages/cli/src/output/error.test.ts index acdf17dc..b6dbdc68 100644 --- a/packages/cli/src/output/error.test.ts +++ b/packages/cli/src/output/error.test.ts @@ -13,8 +13,9 @@ import { ValidationError, } from "@metabase/client/errors"; import { HttpError } from "@metabase/client/http/errors"; -import { checkCapabilities } from "@metabase/client/version/capabilities"; import { CapabilityError } from "@metabase/client/version/preflight-error"; +import { createServerProfile } from "@metabase/client/version/profile"; +import { checkFeatures } from "@metabase/client/version/requirement-check"; import { exitCodeFor, reportError } from "./error"; interface CapturedStreams { @@ -142,9 +143,14 @@ describe("reportError", () => { }); it("offers the client downgrade when the server is below the required version", () => { - const failure = checkCapabilities( - { version: { tag: "v0.58.0", major: 58, patch: 0 }, tokenFeatures: null }, - { minVersion: 61 }, + const failure = checkFeatures( + ["transformJobActivation"], + createServerProfile({ + version: { tag: "v0.58.0", major: 58, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, + }), ); assert(failure !== null); reportError(new CapabilityError(failure)); @@ -157,9 +163,14 @@ describe("reportError", () => { }); it("withholds the client downgrade when a premium feature is missing, which no client version supplies", () => { - const failure = checkCapabilities( - { version: { tag: "v0.61.0", major: 61, patch: 0 }, tokenFeatures: null }, - { minVersion: 61, tokenFeature: "library" }, + const failure = checkFeatures( + ["library"], + createServerProfile({ + version: { tag: "v0.61.0", major: 61, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, + }), ); assert(failure !== null); reportError(new CapabilityError(failure)); @@ -170,9 +181,14 @@ describe("reportError", () => { }); it("carries the client downgrade into the JSON envelope, where there is no second line to print it on", () => { - const failure = checkCapabilities( - { version: { tag: "v0.58.0", major: 58, patch: 0 }, tokenFeatures: null }, - { minVersion: 61 }, + const failure = checkFeatures( + ["transformJobActivation"], + createServerProfile({ + version: { tag: "v0.58.0", major: 58, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, + }), ); assert(failure !== null); reportError(new CapabilityError(failure), "json"); @@ -325,6 +341,7 @@ describe("reportError", () => { status: 200, zodIssues: result.error.issues, serverTag: null, + serverSkew: null, }); reportError(error); diff --git a/packages/cli/src/output/error.ts b/packages/cli/src/output/error.ts index d4a9d3c9..0325cd01 100644 --- a/packages/cli/src/output/error.ts +++ b/packages/cli/src/output/error.ts @@ -1,6 +1,6 @@ import { AbortError, toMetabaseError } from "@metabase/client/errors"; import type { ErrorCategory, MetabaseError } from "@metabase/client/errors"; -import type { PreflightFailure } from "@metabase/client/version/capabilities"; +import type { RequirementFailure } from "@metabase/client/version/preflight-error"; import { consumeLegacyEnvWarnings, ENV_VERBOSE, readEnv } from "../core/env"; import { warn } from "./notice"; @@ -74,7 +74,7 @@ export function reportError(error: unknown, format?: Format): void { process.exitCode = exitCode; } -function isVersionTooOld(detail: unknown): detail is PreflightFailure { +function isVersionTooOld(detail: unknown): detail is RequirementFailure { return ( typeof detail === "object" && detail !== null && diff --git a/packages/cli/src/output/help.test.ts b/packages/cli/src/output/help.test.ts index 6c979ef1..33747840 100644 --- a/packages/cli/src/output/help.test.ts +++ b/packages/cli/src/output/help.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { parseJson } from "@metabase/client/json"; -import { BASELINE_CAPABILITIES } from "@metabase/client/version/capabilities"; +import { summarizeCapabilities } from "@metabase/client/version/capability-summary"; import { connectionFlags, outputFlags, profileFlag } from "../commands/flags"; import { defineCommandGroup } from "../commands/group"; @@ -64,7 +64,7 @@ describe("showUsage", () => { it("appends an EXAMPLES section when defineMetabaseCommand declares examples", async () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo cmd" }, - capabilities: {}, + requires: [], args: {}, examples: ["mb demo --json", "mb demo --profile staging"], outputSchema: z.object({ ok: z.boolean() }), @@ -83,7 +83,7 @@ describe("showUsage", () => { it("omits the EXAMPLES section when no examples are declared", async () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo cmd" }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -98,7 +98,7 @@ describe("showUsage", () => { it("renders the details block after the description and before USAGE when declared", async () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "Short summary" }, - capabilities: {}, + requires: [], args: {}, details: "Longer per-command knowledge shown only on this page.", run() { @@ -119,7 +119,7 @@ describe("showUsage", () => { it("omits the details block when none is declared", async () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "Short summary" }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -168,7 +168,7 @@ describe("showUsage", () => { it("appends a machine-readable help hint pointing at --help --json on a leaf page", async () => { const cmd = defineMetabaseCommand({ meta: { name: "bar", description: "demo bar" }, - capabilities: {}, + requires: [], args: {}, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -200,7 +200,7 @@ describe("showUsage", () => { it("separates the EXAMPLES footer from the body with a blank line", async () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo cmd" }, - capabilities: {}, + requires: [], args: {}, examples: ["mb demo --json"], outputSchema: z.object({ ok: z.boolean() }), @@ -217,7 +217,7 @@ describe("showUsage", () => { it("renders multi-character flag aliases as a single working --kebab form", async () => { const cmd = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, - capabilities: {}, + requires: [], args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -240,7 +240,7 @@ describe("showUsage", () => { it("lists -h, --help in the OPTIONS block", async () => { const cmd = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, - capabilities: {}, + requires: [], args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -257,7 +257,7 @@ describe("showUsage", () => { it("prepends the full breadcrumb to a leaf USAGE line", async () => { const cmd = defineMetabaseCommand({ meta: { name: "bar", description: "demo bar" }, - capabilities: {}, + requires: [], args: { ...outputFlags }, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -291,7 +291,7 @@ describe("showUsage", () => { it("renders an AGENT SKILLS section from declared skill pointers, below the body", async () => { const cmd = defineMetabaseCommand({ meta: { name: "create", description: "demo create" }, - capabilities: {}, + requires: [], args: {}, skills: [ { skill: "mbql", purpose: "author the dataset_query" }, @@ -313,7 +313,7 @@ describe("showUsage", () => { it("omits the AGENT SKILLS section when no skill pointers are declared", async () => { const cmd = defineMetabaseCommand({ meta: { name: "demo", description: "demo cmd" }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -340,11 +340,12 @@ describe("showUsage", () => { skills: [{ skill: "core", purpose: "auth and conventions" }], inputSchema: null, outputSchema: null, + requires: null, capabilities: null, }); const leaf = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, - capabilities: {}, + requires: [], args: {}, skills: [{ skill: "core", purpose: "auth and conventions" }], run() { @@ -375,7 +376,7 @@ describe("showUsage", () => { }); const leaf = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, - capabilities: {}, + requires: [], args: {}, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -414,7 +415,7 @@ describe("showUsageJson", () => { it("emits the full entry for a leaf command", async () => { const leaf = defineMetabaseCommand({ meta: { name: "bar", description: "demo bar" }, - capabilities: {}, + requires: ["card.list"], args: {}, outputSchema: z.object({ ok: z.boolean() }), examples: ["mb foo bar --json"], @@ -440,14 +441,15 @@ describe("showUsageJson", () => { required: ["ok"], additionalProperties: false, }, - capabilities: BASELINE_CAPABILITIES, + requires: { methods: ["card.list"], features: [] }, + capabilities: summarizeCapabilities([]), }); }); it("emits the group's own description and skills alongside a full-path index", async () => { const bar = defineMetabaseCommand({ meta: { name: "bar", description: "demo bar" }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -455,7 +457,7 @@ describe("showUsageJson", () => { }); const baz = defineMetabaseCommand({ meta: { name: "baz", description: "demo baz" }, - capabilities: {}, + requires: [], args: {}, run() { return; diff --git a/packages/cli/src/output/render.test.ts b/packages/cli/src/output/render.test.ts index 944c7025..3c2564d1 100644 --- a/packages/cli/src/output/render.test.ts +++ b/packages/cli/src/output/render.test.ts @@ -5,12 +5,20 @@ import { ConfigError } from "@metabase/client/errors"; import { parseJson } from "@metabase/client/json"; import { capListEnvelope } from "./cap"; -import { renderSummary, renderItem, renderList, writeJson, writeText } from "./render"; +import { + renderItem, + renderList, + renderListWithExtras, + renderSummary, + writeJson, + writeText, +} from "./render"; import { renderTable } from "./table"; import { DEFAULT_MAX_BYTES, FULL_RANGE, listEnvelopeSchema, + listEnvelopeSchemaWithExtras, type ListEnvelope, type RenderOptions, } from "./types"; @@ -330,6 +338,51 @@ describe("renderList — JSON format", () => { }); }); +describe("renderListWithExtras", () => { + const CardListWithNote = listEnvelopeSchemaWithExtras(CardCompact, { note: z.string() }); + + it("emits the extras between the metadata and `data`, and measures the cap over them", () => { + const item = { id: 1, name: "x".repeat(400), archived: false }; + const note = "n".repeat(50); + const fullBytes = Buffer.byteLength( + JSON.stringify({ ...windowList([{ id: item.id, name: item.name }], FULL_RANGE), note }), + "utf8", + ); + + renderListWithExtras(windowList([item], FULL_RANGE), { note }, cardView, { + ...baseOpts, + maxBytes: 100, + }); + + expect(parseJson(streams.stdout, CardListWithNote)).toEqual({ + data: [], + returned: 0, + offset: 0, + total: 1, + has_more: true, + next_offset: null, + truncated: { reason: "max_bytes", bytes: fullBytes }, + note, + }); + const prefix = `{"returned":0,"offset":0,"total":1,"has_more":true,"next_offset":null,"truncated":{"reason":"max_bytes","bytes":${fullBytes}},"note":"${note}","data":[]}`; + expect(streams.stdout).toBe(`${prefix}\n`); + }); + + it("renders the window alone in text mode", () => { + renderListWithExtras( + windowList([{ id: 1, name: "one", archived: false }], FULL_RANGE), + { note: "ignored" }, + cardView, + { ...baseOpts, format: "text" }, + ); + + expect(streams.stdout).toBe( + renderTable([{ id: 1, name: "one", archived: false }], cardView.tableColumns) + "\n", + ); + expect(streams.stdout).not.toContain("ignored"); + }); +}); + describe("renderList — text format", () => { it("renders a table when data is non-empty", () => { const envelope = windowList( diff --git a/packages/cli/src/output/render.ts b/packages/cli/src/output/render.ts index dd9afdde..2801c295 100644 --- a/packages/cli/src/output/render.ts +++ b/packages/cli/src/output/render.ts @@ -66,9 +66,20 @@ export function renderList( envelope: ListEnvelope, view: ResourceView, opts: RenderOptions, +): void { + renderListWithExtras(envelope, {}, view, opts); +} + +// `extras` are fields the JSON envelope carries beside the window; the text rendering is the +// window alone, so a command with something to say there says it itself. +export function renderListWithExtras( + envelope: ListEnvelope, + extras: X, + view: ResourceView, + opts: RenderOptions, ): void { if (opts.format === "json") { - renderJsonEnvelope(envelope, view, opts); + renderJsonEnvelope(envelope, extras, view, opts); return; } @@ -123,15 +134,20 @@ function projectedEnvelope( return { ...envelope, data: envelope.data.map((item) => projectForList(item, view, opts)) }; } -function renderJsonEnvelope( +function renderJsonEnvelope( envelope: ListEnvelope, + extras: X, view: ResourceView, opts: RenderOptions, ): void { - const capped = capListEnvelope(projectedEnvelope(envelope, view, opts), opts.maxBytes); + // The extras are printed too, so they count against the cap. + const capped = capListEnvelope( + { ...projectedEnvelope(envelope, view, opts), ...extras }, + opts.maxBytes, + ); // Metadata precedes `data` so counts and the truncation marker survive when a downstream // consumer (an agent harness, a pager) cuts the tail of the output. - const ordered: ListEnvelope = { + const ordered: ListEnvelope & X = { returned: capped.returned, offset: capped.offset, limit: capped.limit, @@ -139,6 +155,7 @@ function renderJsonEnvelope( has_more: capped.has_more, next_offset: capped.next_offset, truncated: capped.truncated, + ...extras, data: capped.data, }; process.stdout.write(serializeJson(ordered, stdoutPretty()) + "\n"); diff --git a/packages/cli/src/output/skill-list.test.ts b/packages/cli/src/output/skill-list.test.ts index 196eacd9..389f8a7f 100644 --- a/packages/cli/src/output/skill-list.test.ts +++ b/packages/cli/src/output/skill-list.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderSkillList, type SkillListRow } from "./skill-list"; +import type { UnavailableSkill } from "../core/skills"; +import { renderSkillList, skillFilterNotices, type SkillListRow } from "./skill-list"; interface Streams { stdout: string; @@ -71,3 +72,38 @@ describe("renderSkillList", () => { ); }); }); + +describe("skillFilterNotices", () => { + const skipped: UnavailableSkill[] = [ + { + name: "transform", + failure: { + reason: "version-too-old", + detail: + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", + feature: "transforms", + since: 59, + tokenFeature: null, + serverVersion: "v0.58.0", + }, + }, + ]; + + it("says nothing when the filter was bypassed", () => { + expect(skillFilterNotices(skipped, { profileName: "default", bypassed: true })).toEqual([]); + expect(skillFilterNotices(null, { profileName: "default", bypassed: true })).toEqual([]); + }); + + it("names the profile that had no probe to filter by", () => { + expect(skillFilterNotices(null, { profileName: "staging", bypassed: false })).toEqual([ + 'Skills are unfiltered: profile "staging" has no cached server probe (run `mb auth list` to record one).', + ]); + }); + + it("names each skipped skill with the client's reason and the way around it", () => { + expect(skillFilterNotices(skipped, { profileName: "default", bypassed: false })).toEqual([ + 'Skipped skill "transform": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --all to print it anyway.', + ]); + expect(skillFilterNotices([], { profileName: "default", bypassed: false })).toEqual([]); + }); +}); diff --git a/packages/cli/src/output/skill-list.ts b/packages/cli/src/output/skill-list.ts index 2354a068..8ccc3e88 100644 --- a/packages/cli/src/output/skill-list.ts +++ b/packages/cli/src/output/skill-list.ts @@ -1,3 +1,8 @@ +import { z } from "zod"; + +import { RequirementFailure } from "@metabase/client/version/preflight-error"; + +import type { UnavailableSkill } from "../core/skills"; import { listTruncationNotice, warn } from "./notice"; export interface SkillListRow { @@ -5,6 +10,51 @@ export interface SkillListRow { description: string; } +const UnavailableSkillJson: z.ZodType = z.object({ + name: z.string(), + failure: RequirementFailure, +}); + +export const UnavailableSkills = z + .array(UnavailableSkillJson) + .nullable() + .describe( + "Skills left out because the profile's server lacks a feature they need, each with the client's account of the first one missing; `null` when nothing was filtered — `--all` was passed, or the profile has no cached server probe to filter by.", + ); + +export const unavailableSkillsFlag = { + all: { + type: "boolean", + description: + "Every non-hidden skill, in full, regardless of what the profile's server supports", + }, +} as const; + +interface SkillFilterContext { + profileName: string; + bypassed: boolean; +} + +// What the text rendering says beyond the rows: that nothing was filtered because there was no +// server to filter by, or which skills were and why. `--all` asked for the lot and gets no note. +export function skillFilterNotices( + unavailable: readonly UnavailableSkill[] | null, + context: SkillFilterContext, +): string[] { + if (context.bypassed) { + return []; + } + if (unavailable === null) { + return [ + `Skills are unfiltered: profile "${context.profileName}" has no cached server probe (run \`mb auth list\` to record one).`, + ]; + } + return unavailable.map( + (skill) => + `Skipped skill "${skill.name}": ${skill.failure.detail} Pass --all to print it anyway.`, + ); +} + const DEFAULT_TERMINAL_WIDTH = 80; const MIN_WRAP_WIDTH = 24; const DESCRIPTION_INDENT = " "; diff --git a/packages/cli/src/output/types.test.ts b/packages/cli/src/output/types.test.ts index af60be31..6c681fa3 100644 --- a/packages/cli/src/output/types.test.ts +++ b/packages/cli/src/output/types.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { listEnvelopeSchema, type ListEnvelope } from "./types"; +import { listEnvelopeSchema, listEnvelopeSchemaWithExtras, type ListEnvelope } from "./types"; const Person = z.object({ id: z.number().int(), name: z.string() }).strict(); @@ -107,3 +107,23 @@ describe("listEnvelopeSchema", () => { expect(result.success).toBe(false); }); }); + +describe("listEnvelopeSchemaWithExtras", () => { + const schema = listEnvelopeSchemaWithExtras(Person, { note: z.string().nullable() }); + + it("accepts the envelope fields plus the extras", () => { + const envelope = { + data: [{ id: 1, name: "x" }], + returned: 1, + offset: 0, + has_more: false, + note: null, + }; + expect(schema.parse(envelope)).toEqual(envelope); + }); + + it("rejects an envelope missing an extra", () => { + const result = schema.safeParse({ data: [], returned: 0, offset: 0, has_more: false }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/cli/src/output/types.ts b/packages/cli/src/output/types.ts index 52edd4b2..cf9f256d 100644 --- a/packages/cli/src/output/types.ts +++ b/packages/cli/src/output/types.ts @@ -27,7 +27,21 @@ export interface ListEnvelope { } export function listEnvelopeSchema(item: ZodType): ZodType> { - return z.object({ + return z.object(listEnvelopeShape(item)); +} + +// A list whose envelope carries fields beyond the window — what `renderListWithExtras` emits +// between the metadata and `data`. The shape's fit to `ListEnvelope` is what `listEnvelopeSchema` +// asserts; this one is left to inference so the extras keep their own types. +export function listEnvelopeSchemaWithExtras( + item: ZodType, + extras: X, +) { + return z.object({ ...listEnvelopeShape(item), ...extras }); +} + +function listEnvelopeShape(item: ZodType) { + return { data: z.array(item).describe("The items in this window."), returned: z .number() @@ -77,7 +91,7 @@ export function listEnvelopeSchema(item: ZodType): ZodType .describe( "Present when the `--max-bytes` cap cut the output, not when the data ran out; its `bytes` is what the untruncated answer would have measured, so narrow rows with `--fields` rather than raising the cap.", ), - }); + }; } // The window a list command was asked to return; it flows through to server-side paging when the diff --git a/packages/cli/src/runtime/command-augment.test.ts b/packages/cli/src/runtime/command-augment.test.ts index dd702fdc..de61ac78 100644 --- a/packages/cli/src/runtime/command-augment.test.ts +++ b/packages/cli/src/runtime/command-augment.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { BASELINE_CAPABILITIES } from "@metabase/client/version/capabilities"; - import { getMetabaseAugment, setMetabaseAugment, type MetabaseAugment } from "./command-augment"; describe("getMetabaseAugment / setMetabaseAugment", () => { @@ -19,7 +17,8 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: z.object({ id: z.number() }), - capabilities: BASELINE_CAPABILITIES, + requires: null, + capabilities: null, }; setMetabaseAugment(cmd, augment); expect(getMetabaseAugment(cmd)).toBe(augment); @@ -34,7 +33,8 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: null, - capabilities: BASELINE_CAPABILITIES, + requires: null, + capabilities: null, }; const secondAugment: MetabaseAugment = { examples: ["b"], @@ -42,7 +42,8 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: null, - capabilities: BASELINE_CAPABILITIES, + requires: null, + capabilities: null, }; setMetabaseAugment(first, firstAugment); setMetabaseAugment(second, secondAugment); @@ -58,7 +59,8 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: null, - capabilities: BASELINE_CAPABILITIES, + requires: null, + capabilities: null, }; const replacement: MetabaseAugment = { examples: ["after"], @@ -66,7 +68,8 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: null, - capabilities: BASELINE_CAPABILITIES, + requires: null, + capabilities: null, }; setMetabaseAugment(cmd, initial); setMetabaseAugment(cmd, replacement); @@ -81,7 +84,8 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: null, - capabilities: BASELINE_CAPABILITIES, + requires: null, + capabilities: null, }; setMetabaseAugment(cmd, augment); const recalled = getMetabaseAugment(cmd); @@ -91,7 +95,8 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: null, - capabilities: BASELINE_CAPABILITIES, + requires: null, + capabilities: null, }); }); }); diff --git a/packages/cli/src/runtime/command-augment.ts b/packages/cli/src/runtime/command-augment.ts index 959f02cc..0ccbcf43 100644 --- a/packages/cli/src/runtime/command-augment.ts +++ b/packages/cli/src/runtime/command-augment.ts @@ -1,19 +1,27 @@ import type { ZodType } from "zod"; -import type { Capabilities } from "@metabase/client/version/capabilities"; +import type { CapabilitySummary } from "@metabase/client/version/capability-summary"; +import type { FeatureName } from "@metabase/client/version/features"; +import type { MethodKey } from "@metabase/client/version/requirements"; export interface SkillPointer { skill: string; purpose: string; } +export interface CommandRequirements { + methods: readonly MethodKey[]; + features: readonly FeatureName[]; +} + export interface MetabaseAugment { examples: readonly string[]; details: string | null; skills: readonly SkillPointer[]; inputSchema: ZodType | null; outputSchema: ZodType | null; - capabilities: Capabilities | null; + requires: CommandRequirements | null; + capabilities: CapabilitySummary | null; } const augments = new WeakMap(); diff --git a/packages/cli/src/runtime/command-help.test.ts b/packages/cli/src/runtime/command-help.test.ts index aac33b4f..58b482da 100644 --- a/packages/cli/src/runtime/command-help.test.ts +++ b/packages/cli/src/runtime/command-help.test.ts @@ -2,7 +2,8 @@ import { defineCommand } from "citty"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { BASELINE_CAPABILITIES } from "@metabase/client/version/capabilities"; +import { summarizeCapabilities } from "@metabase/client/version/capability-summary"; +import { type MethodKey, methodRequirements } from "@metabase/client/version/requirements"; import { defineMetabaseCommand } from "../commands/runtime"; import main from "../main"; @@ -13,7 +14,7 @@ describe("buildHelpIndex", () => { it("walks lazy subCommands into full-path index entries", async () => { const leaf = defineMetabaseCommand({ meta: { name: "leaf", description: "a leaf" }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -40,7 +41,7 @@ describe("buildHelpIndex", () => { it("skips commands marked meta.hidden = true (and their subtrees)", async () => { const visible = defineMetabaseCommand({ meta: { name: "visible", description: "visible" }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -48,7 +49,7 @@ describe("buildHelpIndex", () => { }); const hidden = defineMetabaseCommand({ meta: { name: "hidden", description: "hidden", hidden: true }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -98,7 +99,7 @@ describe("buildHelpEntry", () => { it("converts citty args to entries with type, required, default, alias, options", async () => { const leaf = defineMetabaseCommand({ meta: { name: "leaf", description: "leaf" }, - capabilities: {}, + requires: [], args: { flag: { type: "string", description: "a flag", alias: "f" }, toggle: { type: "boolean", description: "toggle", default: false }, @@ -146,7 +147,7 @@ describe("buildHelpEntry", () => { it("emits the JSON Schema of outputSchema and forwards examples for metabase commands", async () => { const leaf = defineMetabaseCommand({ meta: { name: "leaf", description: "leaf" }, - capabilities: {}, + requires: [], args: {}, outputSchema: z.object({ ok: z.boolean(), name: z.string() }), examples: ["root leaf --json"], @@ -174,14 +175,15 @@ describe("buildHelpEntry", () => { required: ["ok", "name"], additionalProperties: false, }, - capabilities: BASELINE_CAPABILITIES, + requires: { methods: [], features: [] }, + capabilities: summarizeCapabilities([]), }); }); it("emits the JSON Schema of inputSchema for commands that declare a body contract", async () => { const leaf = defineMetabaseCommand({ meta: { name: "create", description: "create" }, - capabilities: {}, + requires: [], args: {}, inputSchema: z.object({ name: z.string() }), run() { @@ -205,7 +207,7 @@ describe("buildHelpEntry", () => { it("forwards details for metabase commands that declare it and omits the key otherwise", async () => { const withDetails = defineMetabaseCommand({ meta: { name: "with", description: "short" }, - capabilities: {}, + requires: [], args: {}, details: "the long per-command knowledge", run() { @@ -214,7 +216,7 @@ describe("buildHelpEntry", () => { }); const without = defineMetabaseCommand({ meta: { name: "without", description: "short" }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -231,7 +233,7 @@ describe("buildHelpEntry", () => { it("forwards declared skill pointers and defaults to an empty array otherwise", async () => { const withSkills = defineMetabaseCommand({ meta: { name: "with", description: "short" }, - capabilities: {}, + requires: [], args: {}, skills: [{ skill: "mbql", purpose: "author the dataset_query" }], run() { @@ -240,7 +242,7 @@ describe("buildHelpEntry", () => { }); const without = defineMetabaseCommand({ meta: { name: "without", description: "short" }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -273,6 +275,7 @@ describe("buildHelpEntry", () => { args: [], inputSchema: null, outputSchema: null, + requires: null, capabilities: null, }); }); @@ -296,6 +299,7 @@ describe("buildHelpEntry", () => { args: [], inputSchema: null, outputSchema: null, + requires: null, capabilities: null, }); }); @@ -445,13 +449,9 @@ const ALL_COMMANDS = [ "skills path", ]; -const MEASURE_CAPABILITIES = { minVersion: 59 } as const; -const CONTENT_TRANSLATION_CAPABILITIES = { - minVersion: 58, - tokenFeature: "content_translation", -} as const; -const TRANSFORM_CAPABILITIES = { minVersion: 59 } as const; -const TRANSFORM_JOB_SET_ACTIVE_CAPABILITIES = { minVersion: 61 } as const; +function requiresOf(method: MethodKey): CommandHelpEntry["requires"] { + return { methods: [method], features: Array.from(methodRequirements(method)) }; +} let cachedEntries: Promise | null = null; @@ -493,68 +493,93 @@ describe("command tree contract", () => { } }); - it("gates every measure command at v59 and keeps card commands at baseline", async () => { + it("reports the measure methods and the feature they need, and card list as baseline", async () => { const entries = await allEntries(); - const measureCapabilities = Object.fromEntries( + const measureRequires = Object.fromEntries( entries .filter((entry) => entry.command.startsWith("measure ")) - .map((entry) => [entry.command, entry.capabilities]), + .map((entry) => [entry.command, entry.requires]), ); - expect(measureCapabilities).toEqual({ - "measure list": MEASURE_CAPABILITIES, - "measure get": MEASURE_CAPABILITIES, - "measure create": MEASURE_CAPABILITIES, - "measure update": MEASURE_CAPABILITIES, - "measure archive": MEASURE_CAPABILITIES, + expect(measureRequires).toEqual({ + "measure list": { methods: ["measure.list"], features: ["measures"] }, + "measure get": { methods: ["measure.get"], features: ["measures"] }, + "measure create": { methods: ["measure.create"], features: ["measures"] }, + "measure update": { methods: ["measure.update"], features: ["measures"] }, + "measure archive": { methods: ["measure.archive"], features: ["measures"] }, }); const cardList = entries.find((entry) => entry.command === "card list"); - expect(cardList?.capabilities).toEqual(BASELINE_CAPABILITIES); + expect(cardList?.requires).toEqual({ methods: ["card.list"], features: [] }); }); - it("gates every content translation command on its premium feature", async () => { + it("reports the premium feature behind every content translation command", async () => { const entries = await allEntries(); - const capabilities = Object.fromEntries( + const requires = Object.fromEntries( entries .filter((entry) => entry.command.startsWith("content-translation ")) - .map((entry) => [entry.command, entry.capabilities]), + .map((entry) => [entry.command, entry.requires]), ); - expect(capabilities).toEqual({ - "content-translation download": CONTENT_TRANSLATION_CAPABILITIES, - "content-translation upload": CONTENT_TRANSLATION_CAPABILITIES, + expect(requires).toEqual({ + "content-translation download": { + methods: ["contentTranslation.download"], + features: ["contentTranslation"], + }, + "content-translation upload": { + methods: ["contentTranslation.upload"], + features: ["contentTranslation"], + }, }); }); - it("carries the transform version gates through to every transform command", async () => { + it("carries each transform method's requirements through to its command", async () => { const entries = await allEntries(); - const transformCapabilities = Object.fromEntries( + const transformRequires = Object.fromEntries( entries .filter( (entry) => entry.command.startsWith("transform ") || entry.command.startsWith("transform-job "), ) + .map((entry) => [entry.command, entry.requires]), + ); + expect(transformRequires).toEqual({ + "transform list": requiresOf("transform.list"), + "transform get": requiresOf("transform.get"), + "transform dependencies": requiresOf("transform.dependencies"), + "transform create": requiresOf("transform.create"), + "transform update": requiresOf("transform.update"), + "transform delete": requiresOf("transform.delete"), + "transform run": requiresOf("transform.run"), + "transform runs": requiresOf("transform.runPages"), + "transform get-run": requiresOf("transform.getRun"), + "transform cancel": requiresOf("transform.cancel"), + "transform delete-table": requiresOf("transform.deleteTable"), + "transform-job list": requiresOf("transformJob.list"), + "transform-job get": requiresOf("transformJob.get"), + "transform-job create": requiresOf("transformJob.create"), + "transform-job update": requiresOf("transformJob.update"), + "transform-job delete": requiresOf("transformJob.delete"), + "transform-job run": requiresOf("transformJob.run"), + "transform-job transforms": requiresOf("transformJob.transforms"), + "transform-job set-active": requiresOf("transformJob.setActive"), + }); + }); + + it("summarizes a command's features as the single floor and token its capabilities carry", async () => { + const entries = await allEntries(); + const summaries = Object.fromEntries( + entries + .filter((entry) => + ["card list", "measure list", "transform-job set-active", "library get"].includes( + entry.command, + ), + ) .map((entry) => [entry.command, entry.capabilities]), ); - expect(transformCapabilities).toEqual({ - "transform list": TRANSFORM_CAPABILITIES, - "transform get": TRANSFORM_CAPABILITIES, - "transform dependencies": TRANSFORM_CAPABILITIES, - "transform create": TRANSFORM_CAPABILITIES, - "transform update": TRANSFORM_CAPABILITIES, - "transform delete": TRANSFORM_CAPABILITIES, - "transform run": TRANSFORM_CAPABILITIES, - "transform runs": TRANSFORM_CAPABILITIES, - "transform get-run": TRANSFORM_CAPABILITIES, - "transform cancel": TRANSFORM_CAPABILITIES, - "transform delete-table": TRANSFORM_CAPABILITIES, - "transform-job list": TRANSFORM_CAPABILITIES, - "transform-job get": TRANSFORM_CAPABILITIES, - "transform-job create": TRANSFORM_CAPABILITIES, - "transform-job update": TRANSFORM_CAPABILITIES, - "transform-job delete": TRANSFORM_CAPABILITIES, - "transform-job run": TRANSFORM_CAPABILITIES, - "transform-job transforms": TRANSFORM_CAPABILITIES, - "transform-job set-active": TRANSFORM_JOB_SET_ACTIVE_CAPABILITIES, + expect(summaries).toEqual({ + "card list": summarizeCapabilities([]), + "measure list": summarizeCapabilities(["measures"]), + "transform-job set-active": summarizeCapabilities(["transformJobActivation", "transforms"]), + "library get": summarizeCapabilities(["library"]), }); }); @@ -576,11 +601,12 @@ describe("command tree contract", () => { }); }); - it("reports null capabilities for exactly the commands that never touch a Metabase server", async () => { + it("reports null requires and capabilities for exactly the commands that never touch a Metabase server", async () => { const entries = await allEntries(); - const local = entries - .filter((entry) => entry.capabilities === null) - .map((entry) => entry.command); + const local = entries.filter((entry) => entry.requires === null).map((entry) => entry.command); + expect( + entries.filter((entry) => entry.capabilities === null).map((entry) => entry.command), + ).toEqual(local); expect(local.toSorted()).toEqual([ "auth status", "skills get", diff --git a/packages/cli/src/runtime/command-help.ts b/packages/cli/src/runtime/command-help.ts index 43b081ef..57144e0f 100644 --- a/packages/cli/src/runtime/command-help.ts +++ b/packages/cli/src/runtime/command-help.ts @@ -1,9 +1,16 @@ import type { ArgDef, ArgsDef, CommandDef, CommandMeta } from "citty"; import { z } from "zod"; -import { Capabilities } from "@metabase/client/version/capabilities"; +import { CapabilitySummary } from "@metabase/client/version/capability-summary"; +import { FEATURE_NAMES } from "@metabase/client/version/features"; +import { METHOD_KEYS } from "@metabase/client/version/requirements"; + import { resolveCitty, toAliasArray } from "./citty"; -import { getMetabaseAugment, type MetabaseAugment } from "./command-augment"; +import { + type CommandRequirements, + getMetabaseAugment, + type MetabaseAugment, +} from "./command-augment"; const CommandHelpArg = z.object({ name: z.string(), @@ -22,6 +29,11 @@ const CommandHelpSkill = z.object({ }); type CommandHelpSkill = z.infer; +const CommandHelpRequires: z.ZodType = z.object({ + methods: z.array(z.enum(METHOD_KEYS)), + features: z.array(z.enum(FEATURE_NAMES)), +}); + export const CommandHelpEntry = z.object({ command: z.string(), description: z.string().nullable(), @@ -31,7 +43,8 @@ export const CommandHelpEntry = z.object({ args: z.array(CommandHelpArg), inputSchema: z.unknown().nullable(), outputSchema: z.unknown().nullable(), - capabilities: Capabilities.nullable(), + requires: CommandHelpRequires.nullable(), + capabilities: CapabilitySummary.nullable(), }); export type CommandHelpEntry = z.infer; @@ -54,6 +67,7 @@ const EMPTY_AUGMENT: MetabaseAugment = { skills: [], inputSchema: null, outputSchema: null, + requires: null, capabilities: null, }; @@ -72,6 +86,7 @@ export async function buildHelpEntry( args: convertArgs(args), inputSchema: augment.inputSchema ? z.toJSONSchema(augment.inputSchema) : null, outputSchema: augment.outputSchema ? z.toJSONSchema(augment.outputSchema) : null, + requires: augment.requires, capabilities: augment.capabilities, }; if (augment.details !== null) { diff --git a/packages/client/README.md b/packages/client/README.md index a450b28f..45e054ee 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -1,7 +1,7 @@ # @metabase/client The Metabase API client behind [`@metabase/cli`](../../README.md). It owns the HTTP boundary, a Zod -schema per Metabase resource, the OAuth login flow, and the server version/capability model. +schema per Metabase resource, the OAuth login flow, and the server version model. The package is workspace-internal (`private: true`). `@metabase/cli` consumes it from source and inlines it at build time via tsdown's `noExternal`, so the published CLI has no runtime dependency @@ -282,6 +282,34 @@ console.log(cards.data.length, finished.status); A poll that ends because the caller's signal aborted rethrows that signal's own reason, so a cancellation is never reported back as a polling `TimeoutError`. +### Servers of different versions + +The client supports a window of Metabase majors, `KNOWN_RANGE`, and resolves a `ServerProfile` for the server it talks to — from `/api/session/properties` on the first call that needs it, or from a profile the host cached earlier and passes as `ClientOptions.server`. Every resource method declares the features it needs and refuses before any request leaves when the profile lacks one, so a consumer never sends a request the server cannot answer. + +```ts +import { CapabilityError, createClient } from "@metabase/client"; + +const mb = createClient( + { url: "https://metabase.example.com", credential: { kind: "apiKey", apiKey: "mb_..." } }, + { userAgent: "my-app/1.0.0" }, +); + +const server = await mb.server(); +console.log(server.version?.tag, server.edition, server.skew, server.features.transforms); + +try { + await mb.transformJob.setActive(true); +} catch (error) { + if (error instanceof CapabilityError) { + console.log(error.developerDetail.reason, error.developerDetail.feature, error.message); + } +} +``` + +A method's return type is the shape the newest supported server answers. Where an older server answers differently, the method parses that server's exact wire shape and converts it forward, so `mb.transform.get(5)` carries `target_table_id` on every server (a hydrated `table` on the oldest generations, the column on the rest), `mb.transformJob.run(2)` answers `{ message, started, run_id }` whether the server reports an opaque stub or a numeric run id, and each of `mb.library.get()`'s `effective_children` is `{ id, name, description, type, is_remote_synced }`. A field the server cannot report is `null`; the profile's `features` say why. A response that fails its generation's schema is a `ResponseShapeError` naming the server version and the fields that were off — never a silent fallback to another shape. + +A server newer than the window, or a head build whose tag does not parse, is treated as the newest known major with `skew` set to `"newer-than-known"` or `"unknown"`; its additions pass through the loose schemas, and only a token feature can refuse it. The client never takes a version from the caller: a profile comes from a probe, so two consumers of one server cannot disagree about what it is. + ### Endpoints the client does not model `createClient` forwards `requestParsed`, `requestRaw` and `requestStream` off the transport it builds, @@ -337,7 +365,7 @@ on network and timeout failures, and `RequestOptions.idempotent` overrides eithe `TransportRequestOptions` is the whole wire request the three methods accept. `RequestOptions` is the `{ signal, timeoutMs, retries, idempotent }` subset a caller hands to a higher-level method, leaving `method`, `query`, `body` and `expectContentType` to that method. `ClientOptions.getServerTag` (a -`ServerTagResolver`) lets the host name the Metabase version a `ResponseShapeError` reports. +`ServerTagResolver`) lets the host name the Metabase version a `ResponseShapeError` reports; the error's `serverSkew` is the profile's `skew`, and a `newer-than-known` server is named as newer than the client supports in the message's lead line. Types: `MetabaseClient`, `Transport`, `ClientCredentials`, `ClientOptions`, `RequestOptions`, `TransportRequestOptions`, `ServerTagResolver`, `HttpMethod`, `ExpectedContentType`, `QueryValue`, @@ -382,13 +410,19 @@ revocation endpoint. Types: `Credential` (the `ApiKeyCredential | OAuthCredentia ### Version and capabilities -`probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo` — the parsed -version tag and the token-feature map, each `null` when the server does not report it. -`checkCapabilities(info, required)` returns a `PreflightFailure` or `null`. `Capabilities` is the -`{ minVersion, tokenFeature? }` schema, `ParsedVersion` the `{ tag, major, patch }` schema a probed -version tag parses to, `BASELINE_CAPABILITIES` the minimum supported server, and `mergeCapabilities` -fills a partial declaration from it. `CapabilityError` is what a caller throws on a failed -preflight. Types: `ServerInfo`, `PreflightFailure`. +`probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo` — the parsed version tag, the build date and hash, and the token-feature map, each `null` when the server does not report it. `ParsedVersion` is the `{ tag, major, patch }` schema a probed version tag parses to; `editionFromTag(tag)` reads the `Edition` (`"oss"` | `"ee"`) Metabase stamps into the tag's semver major, `null` when the tag carries none. + +`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` inside `KNOWN_RANGE` (`{ min, max }`, the majors the client is built against), `"newer-than-known"` above it, `"unknown"` when the tag did not parse. A newer or unparseable server is evaluated as `KNOWN_RANGE.max + 1`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile passed as `ClientOptions.server`, or probes once on first call and shares the result; `getServerTag` defaults to that profile's tag. There is no option to assume a version. + +`Features` is a record of named booleans over `FEATURE_RULES`, the one table that relates a behaviour to the majors and token feature it needs (`FeatureRule`: `{ since, until?, tokenFeature? }`); `evaluateFeatures(effectiveMajor, tokenFeatures)` evaluates it, `FEATURE_NAMES` lists the keys, and `isFeatureName` narrows a string to one. Names describe behaviour (`transformTargetTableId`, `libraryChildrenCarryType`), never a version. Consumers branch on a feature, never on a major — `features.ts` is the only module that compares one. + +`METHOD_REQUIREMENTS` names, per client method (`"transform.get"`, `"gitSync.branches"` — the `MethodKey` as the method is reached on the client), the features it needs, strictest first; `methodRequirements(key)` reads one entry, `METHOD_KEYS` lists the keys, and `isMethodKey` narrows a string to one. Every resource method starts by asking `transport.require(key)`, which throws a `CapabilityError` before any request leaves when the connected profile lacks a feature — a version gate reads `This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.`, a token gate `This operation requires the 'remote_sync' premium feature (not enabled on this server).` — and the error's `developerDetail` is a `RequirementFailure` carrying `reason`, `detail`, `feature`, `since`, `tokenFeature` and `serverVersion` — a Zod schema as well as a type (with `RequirementReason` for its `reason` enum), so a consumer that reports the refusal in its own output can describe it. A method that needs nothing never consults the server; a newer or unparseable server is placed at the newest known major, so only a token can refuse it. `ClientOptions.enforceRequirements: false` sends every method to the wire and leaves the server to answer for itself; a method whose wire shape is chosen by generation still reads the profile. `checkRequirements(key, profile)` is the check itself, `checkFeatures(features, profile)` the same check over a bare feature list, `featureGap(profile, feature)` says which half of a rule the profile fails (`FeatureGap`: `{ kind: "version" }` or `{ kind: "token", tokenFeature }`), and `ruleGap` is the same question asked of a bare rule. A consumer that anticipates the refusal — a CLI preflight, say — throws the same `CapabilityError` from the same `RequirementFailure`. + +`summarizeCapabilities(features)` collapses a feature list into a `CapabilitySummary` — `{ minVersion, tokenFeature? }`, the oldest server that has every feature and the one premium feature it must grant — for a consumer that wants a single floor rather than named switches. + +A resource whose wire shape differs between generations exports from its `@metabase/client/domain/` module, beside the canonical schema, a reader that picks the exact schema for a `Features` and converts forward: `transformDetailSchema(features)` and `transformRowSchema(features)` read a `Transform` (the older generations' hydrated `table` on the detail endpoint, and no link on the rest, become `target_table_id`), `transformJobRunResultSchema(features)` reads a `TransformJobRunResult` (`{ message, started, run_id }` — a stub id is `started: true, run_id: null`; a numeric or null id is `started: id !== null, run_id: id`), and `libraryWireSchema(features)` with `toLibrary(wire, listing)` reads a `Library` whose `LibraryChild` entries carry `type` from the wire where the generation sends it and `is_remote_synced` from the collection listing on every generation (`LibraryCollectionInfo`, `LibraryListing`). Each `null` in a canonical field means "this server cannot say". `ResponseShapeError.developerDetail.serverSkew` carries the profile's `skew`, and a `newer-than-known` server is named as such in the message's lead line. + +Types: `ServerInfo`, `ServerProfile`, `Skew`, `Edition`, `FeatureName`, `FeatureRule`, `Features`, `FeatureGap`, `MethodKey`, `RequirementFailure`, `RequirementReason`, `CapabilitySummary`. ### Resource schemas diff --git a/packages/client/package.json b/packages/client/package.json index 83b2dff5..d500b2d3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -36,9 +36,13 @@ "./http/oauth": "./dist/http/oauth.js", "./http/retry": "./dist/http/retry.js", "./http/transport": "./dist/http/transport.js", - "./version/capabilities": "./dist/version/capabilities.js", + "./version/capability-summary": "./dist/version/capability-summary.js", + "./version/features": "./dist/version/features.js", + "./version/profile": "./dist/version/profile.js", "./version/preflight-error": "./dist/version/preflight-error.js", "./version/probe": "./dist/version/probe.js", + "./version/requirement-check": "./dist/version/requirement-check.js", + "./version/requirements": "./dist/version/requirements.js", "./version/tag": "./dist/version/tag.js", "./testing/fake-client": "./dist/testing/fake-client.js", "./testing/fetch-capture": "./dist/testing/fetch-capture.js", diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 4ad45d80..e2db77b7 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -60,6 +60,7 @@ export function createClient(config: ClientCredentials, options: ClientOptions) transformTag: transformTagResource(transport), upload: uploadResource(transport), user: userResource(transport), + server: transport.server, // The escape hatch: an endpoint the client never models still has to cost a consumer nothing. requestParsed: transport.requestParsed, requestRaw: transport.requestRaw, diff --git a/packages/client/src/domain/collection.ts b/packages/client/src/domain/collection.ts index 42d4517c..74748584 100644 --- a/packages/client/src/domain/collection.ts +++ b/packages/client/src/domain/collection.ts @@ -5,7 +5,7 @@ export type CollectionId = z.infer; const CollectionAuthorityLevel = z.enum(["official"]); -const CollectionType = z.enum([ +export const CollectionType = z.enum([ "instance-analytics", "trash", "library", @@ -13,6 +13,7 @@ const CollectionType = z.enum([ "library-metrics", "tenant-specific-root-collection", ]); +export type CollectionType = z.infer; export const CollectionNamespace = z.enum([ "transforms", diff --git a/packages/client/src/domain/library.test.ts b/packages/client/src/domain/library.test.ts new file mode 100644 index 00000000..ddd49ba7 --- /dev/null +++ b/packages/client/src/domain/library.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { evaluateFeatures, type Features } from "../version/features"; + +import { type LibraryCollectionInfo, libraryWireSchema, toLibrary } from "./library"; + +const CHILDREN_WITHOUT_TYPE = evaluateFeatures(61, { library: true }); +const CHILDREN_WITH_TYPE = evaluateFeatures(62, { library: true }); + +const ROOT = { id: 10, name: "Library", type: "library", description: null, location: "/" }; + +const BARE_CHILDREN = [ + { id: 11, name: "Data", description: null }, + { id: 12, name: "Metrics", description: null }, +]; + +const LISTING: ReadonlyMap = new Map([ + [11, { id: 11, type: "library-data", is_remote_synced: false }], + [12, { id: 12, type: "library-metrics", is_remote_synced: true }], +]); + +const LIBRARY = { + ...ROOT, + effective_children: [ + { id: 11, name: "Data", description: null, type: "library-data", is_remote_synced: false }, + { + id: 12, + name: "Metrics", + description: null, + type: "library-metrics", + is_remote_synced: true, + }, + ], +}; + +function readLibrary(features: Features, body: unknown) { + const wire = libraryWireSchema(features).parse(body); + if (wire === null) { + throw new Error("expected a Library on the wire"); + } + return toLibrary(wire, LISTING); +} + +describe("libraryWireSchema and toLibrary", () => { + it("fills both type and sync flag from the listing when the children carry neither", () => { + expect( + readLibrary(CHILDREN_WITHOUT_TYPE, { ...ROOT, effective_children: BARE_CHILDREN }), + ).toEqual(LIBRARY); + }); + + it("fills only the sync flag from the listing when the children carry a type", () => { + const typed = [ + { id: 11, name: "Data", description: null, type: "library-data" }, + { id: 12, name: "Metrics", description: null, type: "library-metrics" }, + ]; + + expect(readLibrary(CHILDREN_WITH_TYPE, { ...ROOT, effective_children: typed })).toEqual( + LIBRARY, + ); + }); + + it("keeps the wire's type over the listing's when the children carry one", () => { + const disagreeing = [ + { id: 11, name: "Data", description: null, type: "library-metrics" }, + { id: 12, name: "Metrics", description: null, type: "library-data" }, + ]; + + expect(readLibrary(CHILDREN_WITH_TYPE, { ...ROOT, effective_children: disagreeing })).toEqual({ + ...ROOT, + effective_children: [ + { + id: 11, + name: "Data", + description: null, + type: "library-metrics", + is_remote_synced: false, + }, + { + id: 12, + name: "Metrics", + description: null, + type: "library-data", + is_remote_synced: true, + }, + ], + }); + }); + + it("reads null for what the listing does not describe", () => { + const unlisted = [{ id: 99, name: "Elsewhere", description: null }]; + + expect(readLibrary(CHILDREN_WITHOUT_TYPE, { ...ROOT, effective_children: unlisted })).toEqual({ + ...ROOT, + effective_children: [ + { id: 99, name: "Elsewhere", description: null, type: null, is_remote_synced: null }, + ], + }); + }); + + it("never looks a non-numeric child id up in the listing", () => { + const entityId = [ + { id: "NuFrFzRZgvqcMGjSjOOJH", name: "Data", description: null, type: "library-data" }, + ]; + + expect(readLibrary(CHILDREN_WITH_TYPE, { ...ROOT, effective_children: entityId })).toEqual({ + ...ROOT, + effective_children: [ + { + id: "NuFrFzRZgvqcMGjSjOOJH", + name: "Data", + description: null, + type: "library-data", + is_remote_synced: null, + }, + ], + }); + }); + + it("drops a type the wire carries on the generation whose children carry none", () => { + expect( + readLibrary(CHILDREN_WITHOUT_TYPE, { + ...ROOT, + effective_children: [ + { id: 99, name: "Elsewhere", description: null, type: "library-data" }, + ], + }), + ).toEqual({ + ...ROOT, + effective_children: [ + { id: 99, name: "Elsewhere", description: null, type: null, is_remote_synced: null }, + ], + }); + }); + + it("reads an absent Library as null on either generation", () => { + expect(libraryWireSchema(CHILDREN_WITHOUT_TYPE).parse({ data: null })).toBeNull(); + expect(libraryWireSchema(CHILDREN_WITH_TYPE).parse({ data: null })).toBeNull(); + }); +}); diff --git a/packages/client/src/domain/library.ts b/packages/client/src/domain/library.ts index ffe824ee..c98fa224 100644 --- a/packages/client/src/domain/library.ts +++ b/packages/client/src/domain/library.ts @@ -1,14 +1,23 @@ import { z } from "zod"; -import { Collection } from "./collection"; +import type { Features } from "../version/features"; +import { Collection, CollectionId, CollectionType } from "./collection"; -export const LibraryChild = Collection.pick({ - id: true, - name: true, - type: true, - description: true, - is_remote_synced: true, -}).strip(); +// The endpoint hydrates its children as a projection: one generation adds `type`, none carries +// `is_remote_synced`. +const LibraryChildWireV59 = z.object({ + id: CollectionId, + name: z.string(), + description: z.string().nullable(), +}); +const LibraryChildWireV62 = LibraryChildWireV59.extend({ type: CollectionType.nullable() }); + +// `type` tells the Data and Metrics collections apart and `is_remote_synced` says whether a child +// is in the git-sync scope; the collection listing fills whatever the projection left out, and +// null is a child the listing does not describe. +export const LibraryChild = LibraryChildWireV62.extend({ + is_remote_synced: z.boolean().nullable(), +}); export type LibraryChild = z.infer; export const Library = Collection.extend({ @@ -23,3 +32,98 @@ export const LibraryCompact = Library.pick({ effective_children: true, }).strip(); export type LibraryCompact = z.infer; + +// The listing's virtual root row carries neither `type` nor `is_remote_synced`, so the projection +// keeps both optional rather than letting an unrelated row decide whether the Library resolves — +// the same reason it does not pin `namespace` or `authority_level`. +export const LibraryCollectionInfo = Collection.pick({ + id: true, + type: true, + is_remote_synced: true, +}).strip(); +export type LibraryCollectionInfo = z.infer; + +export type LibraryListing = ReadonlyMap; + +const LibraryWireV59 = Collection.extend({ + effective_children: z.array(LibraryChildWireV59), +}).loose(); +const LibraryWireV62 = Collection.extend({ + effective_children: z.array(LibraryChildWireV62), +}).loose(); + +// An instance with no Library answers the root with `{ data: null }` rather than a 404. +const AbsentLibrary = z.object({ data: z.null() }); + +interface LibraryWireWithoutChildType { + readonly childrenCarryType: false; + readonly root: z.infer; +} + +interface LibraryWireWithChildType { + readonly childrenCarryType: true; + readonly root: z.infer; +} + +export type LibraryWire = LibraryWireWithoutChildType | LibraryWireWithChildType; + +/** + * The shape `GET /api/ee/library/` answers on a server with `features`: the Library as sent, + * awaiting `toLibrary`, or `null` on an instance that has none. + */ +export function libraryWireSchema(features: Features): z.ZodType { + const present = features.libraryChildrenCarryType + ? LibraryWireV62.transform((root): LibraryWire => ({ childrenCarryType: true, root })) + : LibraryWireV59.transform((root): LibraryWire => ({ childrenCarryType: false, root })); + return presentOrAbsent(present); +} + +// A union of the two answers reports only its root as invalid; parsing the present form on its +// own keeps the field that failed in the message. +function presentOrAbsent(present: z.ZodType): z.ZodType { + return z.unknown().transform((value, ctx): T | null => { + if (AbsentLibrary.safeParse(value).success) { + return null; + } + const parsed = present.safeParse(value); + if (parsed.success) { + return parsed.data; + } + for (const issue of parsed.error.issues) { + ctx.addIssue({ ...issue }); + } + return z.NEVER; + }); +} + +/** `wire` read as `Library`, with what its children's projection omitted taken from `listing`. */ +export function toLibrary(wire: LibraryWire, listing: LibraryListing): Library { + if (wire.childrenCarryType) { + const effective_children = wire.root.effective_children.map((child) => ({ + ...child, + is_remote_synced: listedSyncFlag(child.id, listing), + })); + return { ...wire.root, effective_children }; + } + const effective_children = wire.root.effective_children.map((child) => ({ + ...child, + type: listedType(child.id, listing), + is_remote_synced: listedSyncFlag(child.id, listing), + })); + return { ...wire.root, effective_children }; +} + +function listed(id: CollectionId, listing: LibraryListing): LibraryCollectionInfo | null { + if (typeof id !== "number") { + return null; + } + return listing.get(id) ?? null; +} + +function listedType(id: CollectionId, listing: LibraryListing): CollectionType | null { + return listed(id, listing)?.type ?? null; +} + +function listedSyncFlag(id: CollectionId, listing: LibraryListing): boolean | null { + return listed(id, listing)?.is_remote_synced ?? null; +} diff --git a/packages/client/src/domain/transform-job.test.ts b/packages/client/src/domain/transform-job.test.ts new file mode 100644 index 00000000..855ea6b8 --- /dev/null +++ b/packages/client/src/domain/transform-job.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { evaluateFeatures } from "../version/features"; + +import { transformJobRunResultSchema } from "./transform-job"; + +const STUB_RUN_ID_SERVER = evaluateFeatures(61, null); +const NUMERIC_RUN_ID_SERVER = evaluateFeatures(64, null); + +const RUN_STARTED_MESSAGE = "Job run started"; + +describe("transformJobRunResultSchema", () => { + it("reads an opaque stub id as a start whose run id the server cannot say", () => { + expect( + transformJobRunResultSchema(STUB_RUN_ID_SERVER).parse({ + message: RUN_STARTED_MESSAGE, + job_run_id: "stub-3-1767225600000", + }), + ).toEqual({ message: RUN_STARTED_MESSAGE, started: true, run_id: null }); + }); + + it("reads a numeric run id as a start with that id", () => { + expect( + transformJobRunResultSchema(NUMERIC_RUN_ID_SERVER).parse({ + message: RUN_STARTED_MESSAGE, + job_run_id: 11, + }), + ).toEqual({ message: RUN_STARTED_MESSAGE, started: true, run_id: 11 }); + }); + + it("reads a null run id as nothing started", () => { + expect( + transformJobRunResultSchema(NUMERIC_RUN_ID_SERVER).parse({ + message: RUN_STARTED_MESSAGE, + job_run_id: null, + }), + ).toEqual({ message: RUN_STARTED_MESSAGE, started: false, run_id: null }); + }); +}); diff --git a/packages/client/src/domain/transform-job.ts b/packages/client/src/domain/transform-job.ts index 2446d8e6..f69013c4 100644 --- a/packages/client/src/domain/transform-job.ts +++ b/packages/client/src/domain/transform-job.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import type { Features } from "../version/features"; import { CronUiDisplayType } from "./cron"; const JobRunStatus = z.enum(["started", "succeeded", "failed", "timeout"]); @@ -79,15 +80,42 @@ export const TransformJobUpdateInput = z .loose(); export type TransformJobUpdateInput = z.infer; -// Released servers answer with an opaque stub string; head returns the numeric run id, or null when -// nothing was started — the job is already running, or resolves to no transforms. `message` is the -// constant "Job run started" in every case, so `job_run_id` is the only signal a caller can read. +// One generation of servers answers `job_run_id` as an opaque stub string that only says a run was +// started; the other answers the run's numeric id, or null when nothing was started (the job is +// already running, or it resolves to no transforms). `started` keeps the first generation's one bit +// and `run_id` the second's, so neither answer is folded into the other. export const TransformJobRunResult = z.object({ message: z.string(), - job_run_id: z.union([z.string(), z.number().int().positive()]).nullable(), + started: z.boolean(), + run_id: z.number().int().positive().nullable(), }); export type TransformJobRunResult = z.infer; +const TransformJobRunWireV59 = z.object({ + message: z.string(), + job_run_id: z.string(), +}); + +const TransformJobRunWireV64 = z.object({ + message: z.string(), + job_run_id: z.number().int().positive().nullable(), +}); + +function fromStubRunId(wire: z.infer): TransformJobRunResult { + return { message: wire.message, started: true, run_id: null }; +} + +function fromNumericRunId(wire: z.infer): TransformJobRunResult { + return { message: wire.message, started: wire.job_run_id !== null, run_id: wire.job_run_id }; +} + +/** The shape `POST /api/transform-job/{id}/run` answers on a server with `features`, read as `TransformJobRunResult`. */ +export function transformJobRunResultSchema(features: Features): z.ZodType { + return features.transformJobRunIdIsNumeric + ? TransformJobRunWireV64.transform(fromNumericRunId) + : TransformJobRunWireV59.transform(fromStubRunId); +} + export const TransformJobActiveResult = z.object({ updated: z.number().int(), failed: z.number().int(), diff --git a/packages/client/src/domain/transform.test.ts b/packages/client/src/domain/transform.test.ts index 84ef17e2..f43760f6 100644 --- a/packages/client/src/domain/transform.test.ts +++ b/packages/client/src/domain/transform.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; -import { isTransformRunFailed, isTransformRunTerminal, type TransformRunStatus } from "./transform"; +import { evaluateFeatures } from "../version/features"; + +import { + isTransformRunFailed, + isTransformRunTerminal, + transformDetailSchema, + transformRowSchema, + type TransformRunStatus, +} from "./transform"; const ALL_STATUSES: TransformRunStatus[] = [ "started", @@ -24,3 +32,48 @@ describe("isTransformRunFailed", () => { expect(failures).toEqual(["failed", "timeout", "canceled"]); }); }); + +const HYDRATED_TABLE_SERVER = evaluateFeatures(59, null); + +const ROW = { + id: 7, + name: "Daily orders", + description: null, + source: { type: "query", query: { database: 1, type: "native", native: { query: "select 1" } } }, + target: { type: "table", database: 1, schema: "public", name: "daily_orders" }, + source_type: "native", + entity_id: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + creator_id: 1, + collection_id: null, + creator: { id: 1, email: "admin@example.com" }, +}; + +describe("transformDetailSchema", () => { + it("reads the hydrated table's id into target_table_id and drops the table", () => { + const detail = transformDetailSchema(HYDRATED_TABLE_SERVER).parse({ + ...ROW, + table: { id: 42, name: "daily_orders", schema: "public" }, + }); + + expect(detail).toEqual({ ...ROW, target_table_id: 42 }); + expect("table" in detail).toBe(false); + }); + + it("reads a hydrated null table as no target table yet", () => { + expect(transformDetailSchema(HYDRATED_TABLE_SERVER).parse({ ...ROW, table: null })).toEqual({ + ...ROW, + target_table_id: null, + }); + }); +}); + +describe("transformRowSchema", () => { + it("answers no target table for a row from a server that links it on the detail alone", () => { + expect(transformRowSchema(HYDRATED_TABLE_SERVER).parse(ROW)).toEqual({ + ...ROW, + target_table_id: null, + }); + }); +}); diff --git a/packages/client/src/domain/transform.ts b/packages/client/src/domain/transform.ts index 22eba827..347819ee 100644 --- a/packages/client/src/domain/transform.ts +++ b/packages/client/src/domain/transform.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import type { Features } from "../version/features"; + const TransformSourceType = z.enum(["native", "mbql", "python"]); export const TransformRunStatus = z.enum([ @@ -174,7 +176,7 @@ export const TransformRunResult = z.object({ }); export type TransformRunResult = z.infer; -export const Transform = z +const TransformBase = z .object({ id: z.number().int(), name: z.string(), @@ -184,8 +186,6 @@ export const Transform = z source_type: TransformSourceType, source_database_id: z.number().int().nullable().optional(), target_db_id: z.number().int().nullable().optional(), - target_table_id: z.number().int().nullable().optional(), - table: z.object({ id: z.number().int() }).loose().nullable().optional(), entity_id: z.string().nullable(), created_at: z.string(), updated_at: z.string(), @@ -196,14 +196,49 @@ export const Transform = z tag_ids: z.array(z.number().int()).optional(), }) .loose(); + +// `target_table_id` is the output table's id once the server has registered one, and null until +// then. Servers without the column answer it through the converters below, so null also covers +// "this server cannot say on this endpoint". +export const Transform = TransformBase.extend({ + target_table_id: z.number().int().nullable(), +}); export type Transform = z.infer; +// Before the column existed, `GET /api/transform/{id}` hydrated the registered table instead, and +// every other transform endpoint carried no link at all. +const TransformWireV59Detail = TransformBase.extend({ + table: z.object({ id: z.number().int() }).loose().nullable(), +}); + +function fromHydratedTable(wire: z.infer): Transform { + const { table, ...rest } = wire; + return { ...rest, target_table_id: table === null ? null : table.id }; +} + +function withoutLink(row: z.infer): Transform { + return { ...row, target_table_id: null }; +} + +/** The shape `GET /api/transform/{id}` answers on a server with `features`, read as `Transform`. */ +export function transformDetailSchema(features: Features): z.ZodType { + return features.transformTargetTableId + ? Transform + : TransformWireV59Detail.transform(fromHydratedTable); +} + +/** The shape every other transform endpoint answers on a server with `features`, read as `Transform`. */ +export function transformRowSchema(features: Features): z.ZodType { + return features.transformTargetTableId ? Transform : TransformBase.transform(withoutLink); +} + export const TransformCompact = Transform.pick({ id: true, name: true, description: true, source_type: true, target_db_id: true, + target_table_id: true, }) .strip() .extend({ target: TransformTargetCompact }); diff --git a/packages/client/src/errors.test.ts b/packages/client/src/errors.test.ts index 29ea1dd1..3a5f7fea 100644 --- a/packages/client/src/errors.test.ts +++ b/packages/client/src/errors.test.ts @@ -230,6 +230,7 @@ function buildResponseShapeError( status: 200, zodIssues, serverTag, + serverSkew: serverTag === null ? null : "supported", }); } diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index a8cfde73..4dce43a3 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -1,6 +1,7 @@ import { core as zodCore, ZodError } from "zod"; import { escapeJsonPointerSegment } from "./json-pointer"; +import { KNOWN_RANGE, type Skew } from "./version/profile"; export type ErrorCategory = | "network" @@ -47,6 +48,7 @@ export interface ZodResponseShapeDetail { status: number; zodIssues: ZodError["issues"]; serverTag: string | null; + serverSkew: Skew | null; } export interface DecodedResponseShapeDetail { @@ -164,24 +166,31 @@ export class ResponseShapeError extends MetabaseError { } static fromZodIssues(developerDetail: ZodResponseShapeDetail): ResponseShapeError { - return new ResponseShapeError( - formatResponseShapeMessage(developerDetail.zodIssues, developerDetail.serverTag), - developerDetail, - ); + return new ResponseShapeError(formatResponseShapeMessage(developerDetail), developerDetail); } } -function formatResponseShapeMessage(issues: ZodError["issues"], serverTag: string | null): string { +function formatResponseShapeMessage(detail: ZodResponseShapeDetail): string { + const { zodIssues: issues, serverTag, serverSkew } = detail; const lead = serverTag === null ? RESPONSE_SHAPE_LEAD_UNKNOWN_VERSION - : `On Metabase ${serverTag} the response shape was unexpected`; + : `On Metabase ${describeServer(serverTag, serverSkew)} the response shape was unexpected`; if (issues.length === 0) { return lead; } return `${lead}:\n${formatIssueLines(issues, RESPONSE_SHAPE_ISSUE_FORMAT)}`; } +// A server above the known range answers with shapes this client has never been taught, so the +// lead says so before the issues are listed. +function describeServer(tag: string, skew: Skew | null): string { + if (skew === "newer-than-known") { + return `${tag} (newer than this client supports, up to v${KNOWN_RANGE.max})`; + } + return tag; +} + export class ConfigError extends MetabaseError { readonly category = "config"; readonly isRetryable = false; diff --git a/packages/client/src/http/errors.test.ts b/packages/client/src/http/errors.test.ts index 44e120f3..ff6b7c41 100644 --- a/packages/client/src/http/errors.test.ts +++ b/packages/client/src/http/errors.test.ts @@ -306,13 +306,13 @@ describe("HttpError kind classification", () => { expect(buildHttpError({ status: 503 }).kind).toBe("server-error"); }); - it("classifies 404 with Metabase route-not-found body as route-missing", () => { + it("classifies 404 with Metabase's route-not-found JSON string literal as route-missing", () => { const error = buildHttpError({ status: 404, method: "GET", url: "https://example.invalid/api/this-does-not-exist", - responseHeaders: textHeaders(), - rawBody: "API endpoint does not exist.", + responseHeaders: jsonHeaders(), + rawBody: '"API endpoint does not exist."', }); expect(error.kind).toBe("route-missing"); }); @@ -328,7 +328,7 @@ describe("HttpError kind classification", () => { expect(error.kind).toBe("resource-missing"); }); - it("classifies 404 with a text/plain Not-found body as resource-missing (Metabase v0.58)", () => { + it("classifies 404 with the bare text/plain Not-found body of check-404 as resource-missing", () => { const error = buildHttpError({ status: 404, method: "GET", diff --git a/packages/client/src/http/errors.ts b/packages/client/src/http/errors.ts index 7bc1cb33..d56fe18a 100644 --- a/packages/client/src/http/errors.ts +++ b/packages/client/src/http/errors.ts @@ -209,9 +209,10 @@ function isRouteMissingResponse( if (sanitizedBody?.includes(ROUTE_MISSING_LITERAL)) { return true; } - // Metabase ≤ v0.58 serves resource-missing 404s as text/plain "Not found." (newer - // versions use a JSON envelope); without this the plain-text body falls through to the - // non-JSON branch below and is misread as a missing route. + // Every supported Metabase answers a missing row through `check-404`, whose bare "Not found." + // message is served as text/plain; a JSON envelope only appears when the throw site attached + // data of its own. Without this the plain-text body falls through to the non-JSON branch below + // and is misread as a missing route. if (sanitizedBody?.includes(RESOURCE_MISSING_LITERAL)) { return false; } diff --git a/packages/client/src/http/response-shape.ts b/packages/client/src/http/response-shape.ts index a0612ea7..4577cc85 100644 --- a/packages/client/src/http/response-shape.ts +++ b/packages/client/src/http/response-shape.ts @@ -2,6 +2,7 @@ import type { ZodType } from "zod"; import { ResponseShapeError, ValidationError } from "../errors"; import { parseJson } from "../json"; +import type { Skew } from "../version/profile"; import type { HttpMethod, ServerTagResolver } from "./transport"; @@ -12,6 +13,7 @@ export interface ResponseContext { url: string; status: number; getServerTag: ServerTagResolver; + serverSkew: Skew | null; } // The server tag is resolved lazily: naming the version costs a request, and only a failed parse @@ -32,6 +34,7 @@ export async function parseJsonResponse( status: context.status, zodIssues: error.developerDetail.zodIssues, serverTag: await context.getServerTag(), + serverSkew: context.serverSkew, }); } throw error; diff --git a/packages/client/src/http/transport.test.ts b/packages/client/src/http/transport.test.ts index 0f4c9443..cb0d5a46 100644 --- a/packages/client/src/http/transport.test.ts +++ b/packages/client/src/http/transport.test.ts @@ -14,6 +14,9 @@ import { } from "./transport"; import { HttpError } from "./errors"; import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; +import { CapabilityError } from "../version/preflight-error"; +import { PROBE_PATH } from "../version/probe"; +import { createServerProfile, KNOWN_RANGE } from "../version/profile"; const CONFIG: ClientCredentials = { url: "https://m.example.com", @@ -247,6 +250,7 @@ describe("createTransport.requestParsed", () => { status: 200, zodIssues: expectedIssues, serverTag: null, + serverSkew: null, }); }); @@ -274,6 +278,7 @@ describe("createTransport.requestParsed", () => { status: 200, zodIssues: expectedIssues, serverTag: "v0.58.7", + serverSkew: null, }); expect(error.userMessage).toBe( "On Metabase v0.58.7 the response shape was unexpected:\n" + @@ -387,6 +392,235 @@ describe("createTransport.requestParsed", () => { }); }); +describe("createTransport.server", () => { + const PROBE_BODY = { + version: { tag: "v1.61.2", date: "2026-05-19", hash: "0c64e27" }, + "token-features": { library: true }, + }; + const PROBED_PROFILE = createServerProfile({ + version: { tag: "v1.61.2", major: 61, patch: 2 }, + date: "2026-05-19", + hash: "0c64e27", + tokenFeatures: { library: true }, + }); + + it("returns the profile it was constructed with and never probes", async () => { + const fakeFetch = captureFetch([]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + server: PROBED_PROFILE, + }); + + expect(await client.server()).toBe(PROBED_PROFILE); + expect(fakeFetch.calls).toEqual([]); + }); + + it("probes once on first call and shares that profile with every later call", async () => { + const fakeFetch = captureFetch([jsonResponse(PROBE_BODY)]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const [first, second] = await Promise.all([client.server(), client.server()]); + const third = await client.server(); + + expect(first).toEqual(PROBED_PROFILE); + expect(second).toBe(first); + expect(third).toBe(first); + expect(fakeFetch.calls).toEqual([ + { + url: `https://m.example.com${PROBE_PATH}`, + method: "GET", + headers: { + "x-api-key": "mb_test_key_abcdef0123", + accept: "application/json", + "user-agent": TEST_USER_AGENT, + }, + body: null, + }, + ]); + }); + + it("probes again after a failed probe instead of replaying the failure", async () => { + const fakeFetch = captureFetch([ + new Response("oops", { status: 500 }), + jsonResponse(PROBE_BODY), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const failure = await client.server().catch((caught: unknown) => caught); + expect(failure).toBeInstanceOf(HttpError); + expect(await client.server()).toEqual(PROBED_PROFILE); + expect(fakeFetch.calls).toHaveLength(2); + }); + + it("aborts a lazy probe with the client-wide signal's reason", async () => { + const controller = new AbortController(); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: HANGING_FETCH, + signal: controller.signal, + }); + + const pending = client.server(); + controller.abort(); + + await expect(pending).rejects.toBeInstanceOf(AbortError); + }); + + it("names the supplied profile's version in a shape error without a getServerTag", async () => { + const body = { id: "not-a-number", email: "a@b.com" }; + const expectedIssues = PingResponse.safeParse(body).error?.issues; + assert(expectedIssues !== undefined, "expected zod failure for fixture body"); + const fakeFetch = captureFetch([jsonResponse(body)]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + server: PROBED_PROFILE, + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current") + .catch((caught: unknown) => caught); + + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + expect(error.developerDetail).toEqual({ + kind: "zod", + method: "GET", + url: "https://m.example.com/api/user/current", + status: 200, + zodIssues: expectedIssues, + serverTag: "v1.61.2", + serverSkew: "supported", + }); + expect(error.userMessage).toBe( + "On Metabase v1.61.2 the response shape was unexpected:\n" + + " id: Invalid input: expected number, received string", + ); + }); + + it("says a supplied profile above the known range is newer than the client supports in a shape error", async () => { + const beyond = KNOWN_RANGE.max + 2; + const fakeFetch = captureFetch([jsonResponse({ id: "not-a-number", email: "a@b.com" })]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + server: createServerProfile({ + version: { tag: `v0.${beyond}.0`, major: beyond, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, + }), + }); + + const error = await client + .requestParsed(PingResponse, "/api/user/current") + .catch((caught: unknown) => caught); + + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + assert(error.developerDetail.kind === "zod", "expected a schema-parse detail"); + expect(error.developerDetail.serverSkew).toBe("newer-than-known"); + expect(error.userMessage).toBe( + `On Metabase v0.${beyond}.0 (newer than this client supports, up to v${KNOWN_RANGE.max}) the response shape was unexpected:\n` + + " id: Invalid input: expected number, received string", + ); + }); +}); + +describe("createTransport.require", () => { + const OSS_58 = createServerProfile({ + version: { tag: "v0.58.0", major: 58, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, + }); + const VERSION_TOO_OLD = { + reason: "version-too-old", + detail: + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", + feature: "measures", + since: 59, + tokenFeature: null, + serverVersion: "v0.58.0", + }; + + it("resolves when the supplied profile satisfies the method, without a request", async () => { + const fakeFetch = captureFetch([]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + server: OSS_58, + }); + + await expect(client.require("card.list")).resolves.toBeUndefined(); + + expect(fakeFetch.calls).toEqual([]); + }); + + it("throws CapabilityError carrying the check's failure before any request leaves", async () => { + const fakeFetch = captureFetch([]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + server: OSS_58, + }); + + const error = await client.require("measure.list").catch((caught: unknown) => caught); + + assert(error instanceof CapabilityError, "expected CapabilityError"); + expect(error.userMessage).toBe(VERSION_TOO_OLD.detail); + expect(error.developerDetail).toEqual(VERSION_TOO_OLD); + expect(fakeFetch.calls).toEqual([]); + }); + + it("never probes for a method that needs nothing", async () => { + const fakeFetch = captureFetch([]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + await expect(client.require("card.list")).resolves.toBeUndefined(); + + expect(fakeFetch.calls).toEqual([]); + }); + + it("probes lazily for a gated method when no profile was supplied", async () => { + const fakeFetch = captureFetch([ + jsonResponse({ version: { tag: "v0.58.0" }, "token-features": {} }), + ]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client.require("measure.list").catch((caught: unknown) => caught); + + assert(error instanceof CapabilityError, "expected CapabilityError"); + expect(error.developerDetail).toEqual(VERSION_TOO_OLD); + expect(fakeFetch.calls.map((call) => call.url)).toEqual([`https://m.example.com${PROBE_PATH}`]); + }); + + it("resolves without checking when enforcement is switched off", async () => { + const fakeFetch = captureFetch([]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + server: OSS_58, + enforceRequirements: false, + }); + + await expect(client.require("measure.list")).resolves.toBeUndefined(); + + expect(fakeFetch.calls).toEqual([]); + }); +}); + describe("createTransport cancellation", () => { it("installs no process signal handler, so an embedder keeps its own Ctrl-C semantics", async () => { const before = process.listenerCount("SIGINT"); diff --git a/packages/client/src/http/transport.ts b/packages/client/src/http/transport.ts index 89ee0523..b2bf6167 100644 --- a/packages/client/src/http/transport.ts +++ b/packages/client/src/http/transport.ts @@ -4,6 +4,11 @@ import { errorMessage, NetworkError, TimeoutError } from "../errors"; import { JSON_CONTENT_TYPE } from "../json"; import { combineAborts, throwIfAborted } from "../signal"; import { normalizeUrl } from "../url"; +import { CapabilityError } from "../version/preflight-error"; +import { probeServer } from "../version/probe"; +import { createServerProfile, type ServerProfile, type Skew } from "../version/profile"; +import { checkRequirements } from "../version/requirement-check"; +import { type MethodKey, methodRequirements } from "../version/requirements"; import { assertCredentialHeaderSafe, @@ -57,6 +62,12 @@ export interface Transport { requestParsed(schema: ZodType, path: string, opts?: TransportRequestOptions): Promise; requestRaw(path: string, opts?: TransportRequestOptions): Promise; requestStream(path: string, opts?: TransportRequestOptions): Promise>; + // The profile handed in at construction, or else the one the first call probes and every later + // call shares. + server(): Promise; + // Throws `CapabilityError` when the server lacks a feature the method needs, before any request + // leaves. A method that needs nothing resolves without consulting the server. + require(key: MethodKey): Promise; } export interface ClientCredentials { @@ -90,18 +101,27 @@ export type ServerTagResolver = () => Promise; export interface ClientOptions { userAgent: string; fetchImpl?: typeof fetch; + // A profile the caller already holds — from its own cache, or from a probe it ran to verify the + // credential — so the client never asks the server what the caller can tell it. + server?: ServerProfile; + // Names the server in a shape error. Defaults to the tag of `server`; a caller that resolves + // the tag some other way passes its own. getServerTag?: ServerTagResolver; refreshCredential?: CredentialRefresher; // Cancels every request this client makes; composed with the per-request `signal` and the // timeout. A process-level interrupt is the caller's to own and to hand over here. signal?: AbortSignal; + // `false` sends every method to the wire whatever the profile says, leaving the server to answer + // for itself. Defaults to `true`. + enforceRequirements?: boolean; } export function createTransport(config: ClientCredentials, options: ClientOptions): Transport { const baseUrl = normalizeUrl(config.url); assertCredentialHeaderSafe(config.credential); const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); - const getServerTag = options.getServerTag ?? NO_SERVER_TAG; + const getServerTag = options.getServerTag ?? tagResolverFor(options.server); + const serverSkew: Skew | null = options.server === undefined ? null : options.server.skew; const refreshCredential = options.refreshCredential; let credential = config.credential; const knownSecrets = new Set(credentialSecrets(credential)); @@ -277,7 +297,37 @@ export function createTransport(config: ClientCredentials, options: ClientOption }; } - return { + let serverProfile: Promise | null = + options.server === undefined ? null : Promise.resolve(options.server); + + // A failed probe is not memoized: the next call asks again rather than replaying one transient + // failure for the life of the client. + function server(): Promise { + if (serverProfile === null) { + const probe = probeServer(transport).then(createServerProfile); + probe.catch(() => { + serverProfile = null; + }); + serverProfile = probe; + } + return serverProfile; + } + + const enforceRequirements = options.enforceRequirements ?? true; + + async function requireFeatures(key: MethodKey): Promise { + if (!enforceRequirements || methodRequirements(key).length === 0) { + return; + } + const failure = checkRequirements(key, await server()); + if (failure !== null) { + throw new CapabilityError(failure); + } + } + + const transport: Transport = { + server, + require: requireFeatures, async requestRaw(path, opts) { return (await executeWithAuthRefresh(path, opts ?? {})).response; }, @@ -292,6 +342,7 @@ export function createTransport(config: ClientCredentials, options: ClientOption url: prepared.url, status: response.status, getServerTag, + serverSkew, }); }, async requestStream(path, opts) { @@ -309,6 +360,15 @@ export function createTransport(config: ClientCredentials, options: ClientOption return response.body; }, }; + return transport; +} + +function tagResolverFor(server: ServerProfile | undefined): ServerTagResolver { + if (server === undefined) { + return NO_SERVER_TAG; + } + const tag = server.version === null ? null : server.version.tag; + return async () => tag; } function buildUrl( diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 7a46af25..3606c7f6 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -292,6 +292,7 @@ describe("@metabase/client barrel as a consumer surface", () => { status: 200, zodIssues: parsed.error.issues, serverTag: null, + serverSkew: null, }); expect(zodResponseShapeDetailOf(error)).toEqual({ kind: "zod", @@ -300,6 +301,7 @@ describe("@metabase/client barrel as a consumer surface", () => { status: 200, zodIssues: parsed.error.issues, serverTag: null, + serverSkew: null, }); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 356dcb0a..1cacb45b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -56,15 +56,39 @@ export type { export { probeServer } from "./version/probe"; export type { ServerInfo } from "./version/probe"; -export { ParsedVersion } from "./version/tag"; +export { Edition, editionFromTag, ParsedVersion } from "./version/tag"; export { - BASELINE_CAPABILITIES, - Capabilities, - checkCapabilities, - mergeCapabilities, -} from "./version/capabilities"; -export type { PreflightFailure } from "./version/capabilities"; -export { CapabilityError } from "./version/preflight-error"; + evaluateFeatures, + FEATURE_NAMES, + FEATURE_RULES, + Features, + isFeatureName, + ruleGap, +} from "./version/features"; +export type { + FeatureGap, + FeatureName, + FeatureRule, + TokenGap, + VersionGap, +} from "./version/features"; +export { + createServerProfile, + featureGap, + KNOWN_RANGE, + ServerProfile, + Skew, +} from "./version/profile"; +export { + isMethodKey, + METHOD_KEYS, + METHOD_REQUIREMENTS, + methodRequirements, +} from "./version/requirements"; +export type { MethodKey } from "./version/requirements"; +export { checkFeatures, checkRequirements } from "./version/requirement-check"; +export { CapabilitySummary, summarizeCapabilities } from "./version/capability-summary"; +export { CapabilityError, RequirementFailure, RequirementReason } from "./version/preflight-error"; export type { Page, PaginateOptions } from "./paginate"; export { pollUntil } from "./poll"; diff --git a/packages/client/src/json.test.ts b/packages/client/src/json.test.ts index 06f16516..3e79e86a 100644 --- a/packages/client/src/json.test.ts +++ b/packages/client/src/json.test.ts @@ -250,6 +250,20 @@ describe("parseJsonOrPlain", () => { ); }); + it("keeps a text/plain body that happens to be valid JSON as the string it is", () => { + expect(parseJsonOrPlain("123", "text/plain", z.string())).toBe("123"); + }); + + it("parses a bare boolean literal under application/json as a boolean", () => { + expect(parseJsonOrPlain("false", "application/json; charset=utf-8", z.boolean())).toBe(false); + }); + + it("parses a bare number literal under application/json as a number", () => { + expect(parseJsonOrPlain("15152.63298", "application/json; charset=utf-8", z.number())).toBe( + 15152.63298, + ); + }); + it("rejects schema mismatches on JSON content with ValidationError", () => { const error = captureThrown(() => parseJsonOrPlain('{"id":"x","name":"y"}', "application/json", Person, { source: "fixture" }), diff --git a/packages/client/src/json.ts b/packages/client/src/json.ts index fd1275c5..375ab006 100644 --- a/packages/client/src/json.ts +++ b/packages/client/src/json.ts @@ -48,13 +48,13 @@ export function parseJsonResult( return { ok: true, value: parsed.data }; } -// The server's content-type can lie: Metabase routes that return non-collection -// bodies (strings, numbers) can come back as `Content-Type: application/json` -// with a body that is bare text. Trust the body, not the header — try -// JSON.parse first, and on parse failure wrap the body as a JSON string -// literal so the schema can validate the shape. A caller that expected an -// object then sees a ValidationError carrying the actual body in -// `developerDetail.zodIssues`. +// Metabase labels every non-string body `application/json` but only JSON-encodes collections; a +// number or boolean is streamed as its literal, which happens to be JSON, while a keyword is +// streamed as its bare name, which is not. A `text/plain` body is a string whatever it looks like +// (a string-typed setting holding "123" stays "123"). So the header decides between text and +// JSON, and within JSON the body decides: try JSON.parse first, and on parse failure wrap the body +// as a JSON string literal so the schema can validate the shape. A caller that expected an object +// then sees a ValidationError carrying the actual body in `developerDetail.zodIssues`. export function parseJsonOrPlain( text: string, contentType: string | null, diff --git a/packages/client/src/resources/card.ts b/packages/client/src/resources/card.ts index cbc85be3..a3f4c3dc 100644 --- a/packages/client/src/resources/card.ts +++ b/packages/client/src/resources/card.ts @@ -36,6 +36,7 @@ export function cardResource(transport: Transport) { params: CardListParams = {}, options: RequestOptions = {}, ): Promise> { + await transport.require("card.list"); const data = await transport.requestParsed(CardApiList, "/api/card", { ...options, query: { f: params.f, model_id: params.model_id }, @@ -45,11 +46,13 @@ export function cardResource(transport: Transport) { /** Get one card by id. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("card.get"); return transport.requestParsed(Card, `/api/card/${id}`, { ...options }); } /** Create a card — a question, a model, or a metric — from a full card body. */ async function create(params: CardCreateInput, options: RequestOptions = {}): Promise { + await transport.require("card.create"); return transport.requestParsed(Card, "/api/card", { ...options, method: "POST", @@ -63,6 +66,7 @@ export function cardResource(transport: Transport) { params: CardUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("card.update"); return transport.requestParsed(Card, `/api/card/${id}`, { ...options, method: "PUT", @@ -72,6 +76,7 @@ export function cardResource(transport: Transport) { /** Archive (soft-delete) a card by id. Metabase models this as an update, not its own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { + await transport.require("card.archive"); return update(id, { archived: true }, options); } @@ -81,6 +86,7 @@ export function cardResource(transport: Transport) { params: CardQueryParams, options: RequestOptions = {}, ): Promise { + await transport.require("card.query"); return transport.requestParsed(CardQueryResult, `/api/card/${id}/query`, { ...options, method: "POST", @@ -98,6 +104,7 @@ export function cardResource(transport: Transport) { params: CardExportParams, options: RequestOptions = {}, ): Promise> { + await transport.require("card.exportQuery"); const body = new URLSearchParams({ parameters: JSON.stringify(params.parameters), format_rows: String(params.format_rows), diff --git a/packages/client/src/resources/collection.ts b/packages/client/src/resources/collection.ts index 2dafa780..adcfb265 100644 --- a/packages/client/src/resources/collection.ts +++ b/packages/client/src/resources/collection.ts @@ -74,6 +74,7 @@ export function collectionResource(transport: Transport) { params: CollectionListParams = {}, options: RequestOptions = {}, ): Promise> { + await transport.require("collection.list"); const data = await transport.requestParsed(CollectionApiList, "/api/collection", { ...options, query: COLLECTION_LIST_QUERY[params.filter ?? DEFAULT_LIST_FILTER], @@ -87,12 +88,14 @@ export function collectionResource(transport: Transport) { * caller resolves both. */ async function listWithLibrary(options: RequestOptions = {}): Promise> { + await transport.require("collection.listWithLibrary"); const data = await listCollectionsWithLibrary(transport, Collection, options); return { data, total: null }; } /** Get one collection by id, entity id, or the `root`/`trash` alias. */ async function get(ref: CollectionId, options: RequestOptions = {}): Promise { + await transport.require("collection.get"); return transport.requestParsed(Collection, `/api/collection/${refPath(ref)}`, { ...options }); } @@ -101,6 +104,7 @@ export function collectionResource(transport: Transport) { params: CollectionCreateInput, options: RequestOptions = {}, ): Promise { + await transport.require("collection.create"); return transport.requestParsed(Collection, "/api/collection", { ...options, method: "POST", @@ -114,6 +118,7 @@ export function collectionResource(transport: Transport) { params: CollectionUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("collection.update"); return transport.requestParsed(Collection, `/api/collection/${refPath(ref)}`, { ...options, method: "PUT", @@ -123,6 +128,7 @@ export function collectionResource(transport: Transport) { /** Archive (soft-delete) a collection. Metabase models this as an update, not its own endpoint. */ async function archive(ref: CollectionId, options: RequestOptions = {}): Promise { + await transport.require("collection.archive"); return update(ref, { archived: true }, options); } @@ -130,12 +136,13 @@ export function collectionResource(transport: Transport) { * Walk the items inside a collection one page at a time. This endpoint pages on the server, so * the caller consumes pages rather than a single list and decides how far to pull. */ - function itemPages( + async function* itemPages( ref: CollectionId, params: CollectionItemListParams = {}, options: CollectionItemPageOptions = {}, ): AsyncIterable> { - return paginatePages(transport, `/api/collection/${refPath(ref)}/items`, CollectionItem, { + await transport.require("collection.itemPages"); + yield* paginatePages(transport, `/api/collection/${refPath(ref)}/items`, CollectionItem, { query: { models: params.models, archived: params.archived, @@ -150,6 +157,7 @@ export function collectionResource(transport: Transport) { /** Fetch the collection hierarchy as a forest of nested nodes. */ async function tree(options: RequestOptions = {}): Promise> { + await transport.require("collection.tree"); const data = await transport.requestParsed(CollectionTreeApiList, "/api/collection/tree", { ...options, }); diff --git a/packages/client/src/resources/content-translation.test.ts b/packages/client/src/resources/content-translation.test.ts index dfdb692f..f7ce8784 100644 --- a/packages/client/src/resources/content-translation.test.ts +++ b/packages/client/src/resources/content-translation.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { createClient } from "../client"; import type { ClientCredentials } from "../http/transport"; import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; +import { createServerProfile } from "../version/profile"; import type { CsvFile } from "./csv-upload"; @@ -30,11 +31,21 @@ const JSON_READ_HEADERS = { "x-api-key": "mb_wire_test_key", }; +// The least server that answers this resource, so a method asking for more than the resource's +// own feature is refused here before it reaches the scripted wire. +const SERVER = createServerProfile({ + version: { tag: "v1.58.0", major: 58, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { content_translation: true }, +}); + function clientOver(responses: Array) { const capture = captureFetch(responses); const mb = createClient(CREDENTIALS, { userAgent: TEST_USER_AGENT, fetchImpl: capture.fetch, + server: SERVER, }); return { mb, capture }; } diff --git a/packages/client/src/resources/content-translation.ts b/packages/client/src/resources/content-translation.ts index 1c817465..2b714d9c 100644 --- a/packages/client/src/resources/content-translation.ts +++ b/packages/client/src/resources/content-translation.ts @@ -8,6 +8,7 @@ const CONTENT_TRANSLATION_PATH = "/api/ee/content-translation"; export function contentTranslationResource(transport: Transport) { /** Download the complete content translation dictionary as CSV. */ async function download(options: RequestOptions = {}): Promise> { + await transport.require("contentTranslation.download"); return transport.requestStream(`${CONTENT_TRANSLATION_PATH}/csv`, options); } @@ -16,6 +17,7 @@ export function contentTranslationResource(transport: Transport) { file: CsvFile, options: RequestOptions = {}, ): Promise { + await transport.require("contentTranslation.upload"); const form = buildCsvFormData(file); return transport.requestParsed( ContentTranslationUploadResult, diff --git a/packages/client/src/resources/dashboard.ts b/packages/client/src/resources/dashboard.ts index 94c20579..c3c7ca37 100644 --- a/packages/client/src/resources/dashboard.ts +++ b/packages/client/src/resources/dashboard.ts @@ -75,6 +75,7 @@ export function dashboardResource(transport: Transport) { params: DashboardListParams = {}, options: RequestOptions = {}, ): Promise> { + await transport.require("dashboard.list"); const data = await transport.requestParsed(DashboardApiList, "/api/dashboard", { ...options, query: { f: params.f }, @@ -84,6 +85,7 @@ export function dashboardResource(transport: Transport) { /** Get one dashboard by id, with its dashcards, tabs and parameters. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("dashboard.get"); return transport.requestParsed(DashboardDetail, `/api/dashboard/${id}`, { ...options }); } @@ -95,6 +97,7 @@ export function dashboardResource(transport: Transport) { params: DashboardCreateInput, options: RequestOptions = {}, ): Promise { + await transport.require("dashboard.create"); return transport.requestParsed(Dashboard, "/api/dashboard", { ...options, method: "POST", @@ -111,6 +114,7 @@ export function dashboardResource(transport: Transport) { params: DashboardUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("dashboard.update"); return transport.requestParsed(DashboardDetail, `/api/dashboard/${id}`, { ...options, method: "PUT", @@ -124,6 +128,7 @@ export function dashboardResource(transport: Transport) { * an archive is a state change on the dashboard itself, and its dashcards are not part of it. */ async function archive(id: number, options: RequestOptions = {}): Promise { + await transport.require("dashboard.archive"); return transport.requestParsed(Dashboard, `/api/dashboard/${id}`, { ...options, method: "PUT", @@ -142,6 +147,7 @@ export function dashboardResource(transport: Transport) { params: DashcardPatchInput, options: RequestOptions = {}, ): Promise { + await transport.require("dashboard.updateDashcard"); const dashboard = await get(dashboardId, options); const target = dashboard.dashcards.find((dashcard) => dashcard.id === dashcardId); if (target === undefined) { @@ -171,6 +177,7 @@ export function dashboardResource(transport: Transport) { dashcards: ReadonlyArray | null | undefined, options: RequestOptions = {}, ): Promise { + await transport.require("dashboard.checkCardReferences"); const grouped = groupByCardId(collectCardReferences(dashcards)); const checks = await Promise.all( Array.from(grouped.entries()).map( @@ -220,6 +227,7 @@ export function dashboardResource(transport: Transport) { parameterId: string, options: RequestOptions = {}, ): Promise { + await transport.require("dashboard.parameterValues"); return transport.requestParsed( ParameterValues, `/api/dashboard/${dashboardId}/params/${encodeURIComponent(parameterId)}/values`, @@ -237,6 +245,7 @@ export function dashboardResource(transport: Transport) { query: string, options: RequestOptions = {}, ): Promise { + await transport.require("dashboard.searchParameterValues"); const parameter = encodeURIComponent(parameterId); return transport.requestParsed( ParameterValues, diff --git a/packages/client/src/resources/database.ts b/packages/client/src/resources/database.ts index 43ec4372..2c54a0f0 100644 --- a/packages/client/src/resources/database.ts +++ b/packages/client/src/resources/database.ts @@ -52,6 +52,7 @@ export function databaseResource(transport: Transport) { params: DatabaseListParams = {}, options: RequestOptions = {}, ): Promise> { + await transport.require("database.list"); const response = await transport.requestParsed(DatabaseApiList, "/api/database", { ...options, query: { include: params.include, saved: params.saved }, @@ -65,6 +66,7 @@ export function databaseResource(transport: Transport) { params: DatabaseGetParams = {}, options: RequestOptions = {}, ): Promise { + await transport.require("database.get"); return transport.requestParsed(Database, `/api/database/${id}`, { ...options, query: { include: params.include }, @@ -73,6 +75,7 @@ export function databaseResource(transport: Transport) { /** List the schema names in a database. */ async function schemas(id: number, options: RequestOptions = {}): Promise> { + await transport.require("database.schemas"); const data = await transport.requestParsed( DatabaseApiSchemaList, `/api/database/${id}/schemas`, @@ -87,6 +90,7 @@ export function databaseResource(transport: Transport) { schema: string, options: RequestOptions = {}, ): Promise> { + await transport.require("database.schemaTables"); const data = await transport.requestParsed( DatabaseApiSchemaTableList, `/api/database/${id}/schema/${encodeURIComponent(schema)}`, @@ -104,6 +108,7 @@ export function databaseResource(transport: Transport) { params: DatabaseSyncSchemaParams = {}, options: RequestOptions = {}, ): Promise { + await transport.require("database.syncSchema"); const ack = await transport.requestParsed(DatabaseTaskAck, `/api/database/${id}/sync_schema`, { ...options, method: "POST", @@ -126,6 +131,7 @@ export function databaseResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise { + await transport.require("database.rescanValues"); const ack = await transport.requestParsed( DatabaseTaskAck, `/api/database/${id}/rescan_values`, diff --git a/packages/client/src/resources/dataset.ts b/packages/client/src/resources/dataset.ts index c6a7a4ab..014b44fd 100644 --- a/packages/client/src/resources/dataset.ts +++ b/packages/client/src/resources/dataset.ts @@ -7,6 +7,7 @@ export function datasetResource(transport: Transport) { * MBQL, or native — rather than a reference to a saved one, so nothing here is a card. */ async function query(body: unknown, options: RequestOptions = {}): Promise { + await transport.require("dataset.query"); return transport.requestParsed(CardQueryResult, "/api/dataset", { ...options, method: "POST", diff --git a/packages/client/src/resources/document.ts b/packages/client/src/resources/document.ts index 79327500..296dbd2f 100644 --- a/packages/client/src/resources/document.ts +++ b/packages/client/src/resources/document.ts @@ -11,6 +11,7 @@ const DocumentApiList = z.object({ items: z.array(Document) }).loose(); export function documentResource(transport: Transport) { /** Get existing documents. */ async function list(options: RequestOptions = {}): Promise> { + await transport.require("document.list"); const response = await transport.requestParsed(DocumentApiList, "/api/document", { ...options, }); @@ -19,6 +20,7 @@ export function documentResource(transport: Transport) { /** Return an existing document by id. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("document.get"); return transport.requestParsed(Document, `/api/document/${id}`, { ...options }); } @@ -27,6 +29,7 @@ export function documentResource(transport: Transport) { params: DocumentCreateInput, options: RequestOptions = {}, ): Promise { + await transport.require("document.create"); return transport.requestParsed(Document, "/api/document", { ...options, method: "POST", @@ -40,6 +43,7 @@ export function documentResource(transport: Transport) { params: DocumentUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("document.update"); return transport.requestParsed(Document, `/api/document/${id}`, { ...options, method: "PUT", @@ -49,6 +53,7 @@ export function documentResource(transport: Transport) { /** Archive (soft-delete) a document by id. Metabase models this as an update, not its own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { + await transport.require("document.archive"); return update(id, { archived: true }, options); } diff --git a/packages/client/src/resources/eid-translation.ts b/packages/client/src/resources/eid-translation.ts index 441079a7..3b7eda8f 100644 --- a/packages/client/src/resources/eid-translation.ts +++ b/packages/client/src/resources/eid-translation.ts @@ -11,6 +11,7 @@ export function eidTranslationResource(transport: Transport) { params: EidTranslateInput, options: RequestOptions = {}, ): Promise { + await transport.require("eidTranslation.translate"); return transport.requestParsed(EidTranslateResult, "/api/eid-translation/translate", { ...options, method: "POST", diff --git a/packages/client/src/resources/field.ts b/packages/client/src/resources/field.ts index 69a442e2..70fd7fcd 100644 --- a/packages/client/src/resources/field.ts +++ b/packages/client/src/resources/field.ts @@ -13,6 +13,7 @@ const FieldApiSummary = z.tuple([ export function fieldResource(transport: Transport) { /** Get one field by id. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("field.get"); return transport.requestParsed(Field, `/api/field/${id}`, { ...options }); } @@ -22,6 +23,7 @@ export function fieldResource(transport: Transport) { params: FieldUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("field.update"); return transport.requestParsed(Field, `/api/field/${id}`, { ...options, method: "PUT", @@ -31,6 +33,7 @@ export function fieldResource(transport: Transport) { /** Get the row count and the distinct-value count for a field. */ async function summary(id: number, options: RequestOptions = {}): Promise { + await transport.require("field.summary"); const [[, count], [, distincts]] = await transport.requestParsed( FieldApiSummary, `/api/field/${id}/summary`, @@ -41,6 +44,7 @@ export function fieldResource(transport: Transport) { /** Get the cached distinct values Metabase holds for a field. */ async function values(id: number, options: RequestOptions = {}): Promise { + await transport.require("field.values"); return transport.requestParsed(FieldValues, `/api/field/${id}/values`, { ...options }); } diff --git a/packages/client/src/resources/git-sync.test.ts b/packages/client/src/resources/git-sync.test.ts index 9447b203..a9744aba 100644 --- a/packages/client/src/resources/git-sync.test.ts +++ b/packages/client/src/resources/git-sync.test.ts @@ -4,6 +4,7 @@ import { createClient } from "../client"; import { HttpError } from "../http/errors"; import type { ClientCredentials } from "../http/transport"; import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; +import { createServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { url: "https://mb.example.com/metabase", @@ -56,11 +57,21 @@ const BINARY_READ_HEADERS = { const IMMEDIATE_POLL = { intervalMs: 1, timeoutMs: 1_000 }; +// The least server that answers this resource, so a method asking for more than the resource's +// own feature is refused here before it reaches the scripted wire. +const SERVER = createServerProfile({ + version: { tag: "v1.60.0", major: 60, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { remote_sync: true }, +}); + function clientOver(responses: Array) { const capture = captureFetch(responses); const mb = createClient(CREDENTIALS, { userAgent: TEST_USER_AGENT, fetchImpl: capture.fetch, + server: SERVER, }); return { mb, capture }; } diff --git a/packages/client/src/resources/git-sync.ts b/packages/client/src/resources/git-sync.ts index 140138c8..9ea86483 100644 --- a/packages/client/src/resources/git-sync.ts +++ b/packages/client/src/resources/git-sync.ts @@ -100,11 +100,13 @@ export interface SyncCreateBranchParams { export function gitSyncResource(transport: Transport) { /** Get the running or most recently finished sync task, or null when the server has none. */ async function currentTask(options: RequestOptions = {}): Promise { + await transport.require("gitSync.currentTask"); return fetchOptionalParsed(transport, "/api/ee/remote-sync/current-task", SyncTask, options); } /** Request cancellation of the running sync task, and answer it in the state that left it. */ async function cancelTask(options: RequestOptions = {}): Promise { + await transport.require("gitSync.cancelTask"); return transport.requestParsed(SyncTask, "/api/ee/remote-sync/current-task/cancel", { ...options, method: "POST", @@ -113,6 +115,7 @@ export function gitSyncResource(transport: Transport) { /** Whether Metabase holds content changes the remote has not been told about. */ async function isDirty(options: RequestOptions = {}): Promise { + await transport.require("gitSync.isDirty"); const flag = await transport.requestParsed(SyncDirtyFlag, "/api/ee/remote-sync/is-dirty", { ...options, }); @@ -121,6 +124,7 @@ export function gitSyncResource(transport: Transport) { /** List the objects whose local state differs from the remote. */ async function dirty(options: RequestOptions = {}): Promise> { + await transport.require("gitSync.dirty"); const response = await transport.requestParsed(SyncDirtyList, "/api/ee/remote-sync/dirty", { ...options, }); @@ -135,6 +139,7 @@ export function gitSyncResource(transport: Transport) { params: SyncRemoteChangesParams = {}, options: RequestOptions = {}, ): Promise { + await transport.require("gitSync.hasRemoteChanges"); return transport.requestParsed(SyncRemoteChanges, "/api/ee/remote-sync/has-remote-changes", { ...options, query: { "force-refresh": params["force-refresh"] }, @@ -150,6 +155,7 @@ export function gitSyncResource(transport: Transport) { params: SyncImportParams = {}, options: RequestOptions = {}, ): Promise { + await transport.require("gitSync.import"); const started = await transport.requestParsed(SyncImportStarted, "/api/ee/remote-sync/import", { ...options, method: "POST", @@ -170,6 +176,7 @@ export function gitSyncResource(transport: Transport) { params: SyncExportParams = {}, options: RequestOptions = {}, ): Promise { + await transport.require("gitSync.export"); const started = await transport.requestParsed(SyncExportStarted, "/api/ee/remote-sync/export", { ...options, method: "POST", @@ -193,6 +200,7 @@ export function gitSyncResource(transport: Transport) { params: SyncStashParams, options: RequestOptions = {}, ): Promise { + await transport.require("gitSync.stash"); const started = await transport.requestParsed(SyncStashStarted, "/api/ee/remote-sync/stash", { ...options, method: "POST", @@ -211,6 +219,7 @@ export function gitSyncResource(transport: Transport) { /** List the branches the configured remote carries. */ async function branches(options: RequestOptions = {}): Promise> { + await transport.require("gitSync.branches"); const response = await transport.requestParsed(SyncBranchList, "/api/ee/remote-sync/branches", { ...options, }); @@ -222,6 +231,7 @@ export function gitSyncResource(transport: Transport) { params: SyncCreateBranchParams, options: RequestOptions = {}, ): Promise { + await transport.require("gitSync.createBranch"); return transport.requestParsed(SyncBranchCreated, "/api/ee/remote-sync/create-branch", { ...options, method: "POST", @@ -238,6 +248,7 @@ export function gitSyncResource(transport: Transport) { synced: boolean, options: RequestOptions = {}, ): Promise { + await transport.require("gitSync.setCollectionSynced"); return transport.requestParsed(SyncSettingsUpdateResult, "/api/ee/remote-sync/settings", { ...options, method: "PUT", @@ -249,12 +260,14 @@ export function gitSyncResource(transport: Transport) { async function syncedCollections( options: RequestOptions = {}, ): Promise> { + await transport.require("gitSync.syncedCollections"); const data = await listCollectionsWithLibrary(transport, SyncScopeCollection, options); return { data: data.filter((entry) => entry.is_remote_synced === true), total: null }; } /** The remote's URL, or null when none is configured or the caller may not read it. */ async function remoteUrl(options: RequestOptions = {}): Promise { + await transport.require("gitSync.remoteUrl"); try { const url = await fetchOptionalParsed( transport, @@ -273,6 +286,7 @@ export function gitSyncResource(transport: Transport) { /** The branch git-sync tracks, or null when none is configured or the caller may not read it. */ async function branch(options: RequestOptions = {}): Promise { + await transport.require("gitSync.branch"); return fetchOptionalParsed( transport, "/api/setting/remote-sync-branch", @@ -289,6 +303,7 @@ export function gitSyncResource(transport: Transport) { wait: PollOptions, options: RequestOptions = {}, ): Promise { + await transport.require("gitSync.waitForTask"); return settle(wait, options); } diff --git a/packages/client/src/resources/library.test.ts b/packages/client/src/resources/library.test.ts index 9f8d9721..d87ed3b6 100644 --- a/packages/client/src/resources/library.test.ts +++ b/packages/client/src/resources/library.test.ts @@ -1,36 +1,42 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; +import { ResponseShapeError } from "../errors"; import type { ClientCredentials } from "../http/transport"; import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; +import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { url: "https://mb.example.com/metabase", credential: { kind: "apiKey", apiKey: "mb_wire_test_key" }, }; -// Released servers (v0.59-v0.61) omit `type` and `is_remote_synced` from each `effective_children` -// entry; the collection listing is where both come from. -const UNHYDRATED_LIBRARY = { - id: 10, - name: "Library", - type: "library", - description: null, - location: "/", +const LIBRARY_ROOT = { id: 10, name: "Library", type: "library", description: null, location: "/" }; + +const BARE_LIBRARY = { + ...LIBRARY_ROOT, effective_children: [ { id: 11, name: "Data", description: null }, { id: 12, name: "Metrics", description: null }, ], }; +const TYPED_LIBRARY = { + ...LIBRARY_ROOT, + effective_children: [ + { id: 11, name: "Data", description: null, type: "library-data" }, + { id: 12, name: "Metrics", description: null, type: "library-metrics" }, + ], +}; + const LIBRARY_COLLECTIONS = [ { id: 10, name: "Library", type: "library", location: "/", is_remote_synced: false }, { id: 11, name: "Data", type: "library-data", location: "/10/", is_remote_synced: false }, { id: 12, name: "Metrics", type: "library-metrics", location: "/10/", is_remote_synced: true }, ]; -const HYDRATED_LIBRARY = { - ...UNHYDRATED_LIBRARY, +const LIBRARY = { + ...LIBRARY_ROOT, effective_children: [ { id: 11, name: "Data", description: null, type: "library-data", is_remote_synced: false }, { @@ -54,6 +60,9 @@ const UNPINNED_ENUM_COLLECTION = { const ABSENT_LIBRARY = { data: null }; +const MISSING_TYPE_ISSUE = + 'Invalid option: expected one of "instance-analytics"|"trash"|"library"|"library-data"|"library-metrics"|"tenant-specific-root-collection"'; + const TARGET_COLLECTION = { id: 11, name: "Data", @@ -94,11 +103,29 @@ const READ_COLLECTIONS_CALL = { body: null, }; -function clientOver(responses: Array) { +// The least server that answers this resource, so a method asking for more than the resource's +// own feature is refused here before it reaches the scripted wire. +const SERVER = createServerProfile({ + version: { tag: "v1.59.0", major: 59, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { library: true }, +}); + +// The first generation whose `effective_children` carry each child's `type`. +const TYPED_CHILDREN_SERVER = createServerProfile({ + version: { tag: "v1.62.0", major: 62, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { library: true }, +}); + +function clientOver(responses: Array, server: ServerProfile = SERVER) { const capture = captureFetch(responses); const mb = createClient(CREDENTIALS, { userAgent: TEST_USER_AGENT, fetchImpl: capture.fetch, + server, }); return { mb, capture }; } @@ -106,7 +133,7 @@ function clientOver(responses: Array) { describe("library resource wire requests", () => { it("sends the get request and the collection listing that resolves its children", async () => { const { mb, capture } = clientOver([ - jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(BARE_LIBRARY), jsonResponse(LIBRARY_COLLECTIONS), ]); @@ -115,11 +142,22 @@ describe("library resource wire requests", () => { expect(capture.calls).toEqual([READ_ROOT_CALL, READ_COLLECTIONS_CALL]); }); + it("still reads the collection listing when the children carry a type, for the sync flag", async () => { + const { mb, capture } = clientOver( + [jsonResponse(TYPED_LIBRARY), jsonResponse(LIBRARY_COLLECTIONS)], + TYPED_CHILDREN_SERVER, + ); + + await mb.library.get(); + + expect(capture.calls).toEqual([READ_ROOT_CALL, READ_COLLECTIONS_CALL]); + }); + it("sends the bodyless create POST and refetches when no Library exists", async () => { const { mb, capture } = clientOver([ jsonResponse(ABSENT_LIBRARY), jsonResponse({ id: 10, name: "Library" }), - jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(BARE_LIBRARY), jsonResponse(LIBRARY_COLLECTIONS), ]); @@ -135,9 +173,9 @@ describe("library resource wire requests", () => { it("repeats only the reads and never POSTs when create runs twice against an existing Library", async () => { const { mb, capture } = clientOver([ - jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(BARE_LIBRARY), jsonResponse(LIBRARY_COLLECTIONS), - jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(BARE_LIBRARY), jsonResponse(LIBRARY_COLLECTIONS), ]); @@ -154,7 +192,7 @@ describe("library resource wire requests", () => { it("resolves the Data collection id from the create reads alone", async () => { const { mb, capture } = clientOver([ - jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(BARE_LIBRARY), jsonResponse(LIBRARY_COLLECTIONS), ]); @@ -196,27 +234,53 @@ describe("library resource wire requests", () => { describe("library resource results", () => { it("resolves each child's type and sync flag from the collection listing", async () => { - const { mb } = clientOver([ - jsonResponse(UNHYDRATED_LIBRARY), - jsonResponse(LIBRARY_COLLECTIONS), - ]); + const { mb } = clientOver([jsonResponse(BARE_LIBRARY), jsonResponse(LIBRARY_COLLECTIONS)]); - expect(await mb.library.get()).toEqual(HYDRATED_LIBRARY); + expect(await mb.library.get()).toEqual(LIBRARY); }); it("resolves the children past a collection whose enum fields carry unpinned values", async () => { const { mb } = clientOver([ - jsonResponse(UNHYDRATED_LIBRARY), + jsonResponse(BARE_LIBRARY), jsonResponse([...LIBRARY_COLLECTIONS, UNPINNED_ENUM_COLLECTION]), ]); - expect(await mb.library.get()).toEqual(HYDRATED_LIBRARY); + expect(await mb.library.get()).toEqual(LIBRARY); + }); + + it("resolves typed children with only the sync flag taken from the listing", async () => { + const { mb } = clientOver( + [jsonResponse(TYPED_LIBRARY), jsonResponse(LIBRARY_COLLECTIONS)], + TYPED_CHILDREN_SERVER, + ); + + expect(await mb.library.get()).toEqual(LIBRARY); }); - it("keeps a hydrated response's own values when the listing holds no matching collection", async () => { - const { mb } = clientOver([jsonResponse(HYDRATED_LIBRARY), jsonResponse([])]); + it("reads null for a child the listing does not describe", async () => { + const { mb } = clientOver([jsonResponse(BARE_LIBRARY), jsonResponse([])]); - expect(await mb.library.get()).toEqual(HYDRATED_LIBRARY); + expect(await mb.library.get()).toEqual({ + ...LIBRARY_ROOT, + effective_children: [ + { id: 11, name: "Data", description: null, type: null, is_remote_synced: null }, + { id: 12, name: "Metrics", description: null, type: null, is_remote_synced: null }, + ], + }); + }); + + it("refuses children without a type from a server whose children carry one", async () => { + const { mb } = clientOver([jsonResponse(BARE_LIBRARY)], TYPED_CHILDREN_SERVER); + + const error = await mb.library.get().catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ResponseShapeError); + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + expect(error.userMessage).toBe( + "On Metabase v1.62.0 the response shape was unexpected:\n" + + ` effective_children[0].type: ${MISSING_TYPE_ISSUE}\n` + + ` effective_children[1].type: ${MISSING_TYPE_ISSUE}`, + ); }); it("answers null on an instance that has no Library", async () => { @@ -226,12 +290,9 @@ describe("library resource results", () => { }); it("returns the existing Library rather than creating a second one", async () => { - const { mb } = clientOver([ - jsonResponse(UNHYDRATED_LIBRARY), - jsonResponse(LIBRARY_COLLECTIONS), - ]); + const { mb } = clientOver([jsonResponse(BARE_LIBRARY), jsonResponse(LIBRARY_COLLECTIONS)]); - expect(await mb.library.create()).toEqual(HYDRATED_LIBRARY); + expect(await mb.library.create()).toEqual(LIBRARY); }); it("throws when the refetch after the create POST still finds no Library", async () => { @@ -247,17 +308,14 @@ describe("library resource results", () => { }); it("returns the Data collection's id", async () => { - const { mb } = clientOver([ - jsonResponse(UNHYDRATED_LIBRARY), - jsonResponse(LIBRARY_COLLECTIONS), - ]); + const { mb } = clientOver([jsonResponse(BARE_LIBRARY), jsonResponse(LIBRARY_COLLECTIONS)]); expect(await mb.library.ensureDataCollectionId()).toBe(11); }); it("throws when the Library carries no Data collection", async () => { const { mb } = clientOver([ - jsonResponse({ ...UNHYDRATED_LIBRARY, effective_children: [] }), + jsonResponse({ ...BARE_LIBRARY, effective_children: [] }), jsonResponse(LIBRARY_COLLECTIONS), ]); @@ -267,13 +325,18 @@ describe("library resource results", () => { }); it("throws when the Data collection's id is not numeric", async () => { - const { mb } = clientOver([ - jsonResponse({ - ...UNHYDRATED_LIBRARY, - effective_children: [{ id: "NuFrFzRZgvqcMGjSjOOJH", name: "Data", type: "library-data" }], - }), - jsonResponse(LIBRARY_COLLECTIONS), - ]); + const { mb } = clientOver( + [ + jsonResponse({ + ...LIBRARY_ROOT, + effective_children: [ + { id: "NuFrFzRZgvqcMGjSjOOJH", name: "Data", description: null, type: "library-data" }, + ], + }), + jsonResponse(LIBRARY_COLLECTIONS), + ], + TYPED_CHILDREN_SERVER, + ); await expect(mb.library.ensureDataCollectionId()).rejects.toThrow( new Error("Library Data collection has a non-numeric id NuFrFzRZgvqcMGjSjOOJH"), diff --git a/packages/client/src/resources/library.ts b/packages/client/src/resources/library.ts index a78bad02..ecf94dad 100644 --- a/packages/client/src/resources/library.ts +++ b/packages/client/src/resources/library.ts @@ -1,7 +1,13 @@ import { z } from "zod"; import { Collection } from "../domain/collection"; -import { Library } from "../domain/library"; +import { + type Library, + LibraryCollectionInfo, + type LibraryListing, + libraryWireSchema, + toLibrary, +} from "../domain/library"; import type { RequestOptions, Transport } from "../http/transport"; import { listCollectionsWithLibrary } from "./collection"; @@ -13,22 +19,8 @@ const UNPUBLISH_TABLES_PATH = "/api/ee/data-studio/table/unpublish-tables"; const LIBRARY_DATA_TYPE = "library-data"; -// An instance with no Library answers the root with `{ data: null }` rather than a 404. -const AbsentLibrary = z.object({ data: z.null() }); -const LibraryOrAbsent = z.union([Library, AbsentLibrary]); - const PublishTablesResponse = z.object({ target_collection: Collection.nullable() }); -// Hydrating the Library's children means reading every collection on the instance, so they arrive -// through a projection: a collection whose `namespace` or `authority_level` carries a value outside -// `Collection`'s pinned enums is unrelated to the Library and must not decide whether it resolves. -const LibraryCollectionInfo = Collection.pick({ - id: true, - type: true, - is_remote_synced: true, -}).strip(); -type LibraryCollectionInfo = z.infer; - export interface LibraryTableSelectors { table_ids?: number[] | undefined; database_ids?: number[] | undefined; @@ -42,37 +34,18 @@ export interface LibraryPublishParams extends LibraryTableSelectors { export function libraryResource(transport: Transport) { /** Get the Library root and its child collections, or `null` on an instance that has none. */ async function get(options: RequestOptions = {}): Promise { - const result = await transport.requestParsed(LibraryOrAbsent, LIBRARY_ROOT_PATH, { + await transport.require("library.get"); + const { features } = await transport.server(); + const wire = await transport.requestParsed(libraryWireSchema(features), LIBRARY_ROOT_PATH, { ...options, }); - if (!("effective_children" in result)) { + if (wire === null) { return null; } - // GET /api/ee/library/ doesn't send each child's `type` or `is_remote_synced` in - // `effective_children` on released servers (v0.59-v0.61) — the frontend's own LibraryChild - // type omits them too. Resolve both from the collection list so callers can tell the Data - // and Metrics collections apart and see whether each is in the git-sync scope. - const infoById = await libraryCollectionsById(options); - const effective_children = result.effective_children.map((child) => { - if (typeof child.id !== "number") { - return child; - } - const info = infoById.get(child.id); - if (info === undefined) { - return child; - } - return { - ...child, - type: info.type ?? child.type, - is_remote_synced: info.is_remote_synced ?? child.is_remote_synced, - }; - }); - return { ...result, effective_children }; + return toLibrary(wire, await libraryListing(options)); } - async function libraryCollectionsById( - options: RequestOptions, - ): Promise> { + async function libraryListing(options: RequestOptions): Promise { const data = await listCollectionsWithLibrary(transport, LibraryCollectionInfo, options); const byId = new Map(); for (const collection of data) { @@ -90,6 +63,7 @@ export function libraryResource(transport: Transport) { * back from a refetch, which together make this idempotent. */ async function create(options: RequestOptions = {}): Promise { + await transport.require("library.create"); const existing = await get(options); if (existing !== null) { return existing; @@ -104,6 +78,7 @@ export function libraryResource(transport: Transport) { /** The id of the Library's Data collection, creating the Library first when it does not exist. */ async function ensureDataCollectionId(options: RequestOptions = {}): Promise { + await transport.require("library.ensureDataCollectionId"); const library = await create(options); const data = library.effective_children.find((child) => child.type === LIBRARY_DATA_TYPE); if (data === undefined) { @@ -123,6 +98,7 @@ export function libraryResource(transport: Transport) { params: LibraryPublishParams, options: RequestOptions = {}, ): Promise { + await transport.require("library.publishTables"); const response = await transport.requestParsed(PublishTablesResponse, PUBLISH_TABLES_PATH, { ...options, method: "POST", @@ -139,6 +115,7 @@ export function libraryResource(transport: Transport) { params: LibraryTableSelectors, options: RequestOptions = {}, ): Promise { + await transport.require("library.unpublishTables"); await transport.requestRaw(UNPUBLISH_TABLES_PATH, { ...options, method: "POST", diff --git a/packages/client/src/resources/measure.test.ts b/packages/client/src/resources/measure.test.ts index 6e8069eb..d7419a2c 100644 --- a/packages/client/src/resources/measure.test.ts +++ b/packages/client/src/resources/measure.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { createClient } from "../client"; import type { ClientCredentials } from "../http/transport"; import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; +import { createServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { url: "https://mb.example.com/metabase", @@ -35,11 +36,21 @@ const JSON_READ_HEADERS = { "x-api-key": "mb_wire_test_key", }; +// The least server that answers this resource, so a method asking for more than the resource's +// own feature is refused here before it reaches the scripted wire. +const SERVER = createServerProfile({ + version: { tag: "v0.59.0", major: 59, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, +}); + function clientOver(responses: Array) { const capture = captureFetch(responses); const mb = createClient(CREDENTIALS, { userAgent: TEST_USER_AGENT, fetchImpl: capture.fetch, + server: SERVER, }); return { mb, capture }; } diff --git a/packages/client/src/resources/measure.ts b/packages/client/src/resources/measure.ts index b81457da..1d89be46 100644 --- a/packages/client/src/resources/measure.ts +++ b/packages/client/src/resources/measure.ts @@ -15,12 +15,14 @@ export interface MeasureArchiveParams { export function measureResource(transport: Transport) { /** List measures. */ async function list(options: RequestOptions = {}): Promise> { + await transport.require("measure.list"); const data = await transport.requestParsed(MeasureApiList, "/api/measure", { ...options }); return { data, total: null }; } /** Get one measure by id. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("measure.get"); return transport.requestParsed(Measure, `/api/measure/${id}`, { ...options }); } @@ -29,6 +31,7 @@ export function measureResource(transport: Transport) { params: MeasureCreateInput, options: RequestOptions = {}, ): Promise { + await transport.require("measure.create"); return transport.requestParsed(Measure, "/api/measure", { ...options, method: "POST", @@ -45,6 +48,7 @@ export function measureResource(transport: Transport) { params: MeasureUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("measure.update"); return transport.requestParsed(Measure, `/api/measure/${id}`, { ...options, method: "PUT", @@ -61,6 +65,7 @@ export function measureResource(transport: Transport) { params: MeasureArchiveParams, options: RequestOptions = {}, ): Promise { + await transport.require("measure.archive"); return update(id, { archived: true, revision_message: params.revision_message }, options); } diff --git a/packages/client/src/resources/notification.ts b/packages/client/src/resources/notification.ts index 8d0a175c..620f1ee7 100644 --- a/packages/client/src/resources/notification.ts +++ b/packages/client/src/resources/notification.ts @@ -76,6 +76,7 @@ export function notificationResource(transport: Transport) { params: NotificationListParams = {}, options: RequestOptions = {}, ): Promise> { + await transport.require("notification.list"); const data = await transport.requestParsed(NotificationApiList, "/api/notification", { ...options, query: { @@ -91,6 +92,7 @@ export function notificationResource(transport: Transport) { /** Get one question alert by id, refusing an id that names a system-event notification. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("notification.get"); const notification = await transport.requestParsed(Notification, `/api/notification/${id}`, { ...options, }); @@ -102,6 +104,7 @@ export function notificationResource(transport: Transport) { params: NotificationCreateInput, options: RequestOptions = {}, ): Promise { + await transport.require("notification.create"); return transport.requestParsed(Notification, "/api/notification", { ...options, method: "POST", @@ -115,6 +118,7 @@ export function notificationResource(transport: Transport) { params: NotificationUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("notification.update"); const current = await get(id, options); return transport.requestParsed(Notification, `/api/notification/${id}`, { ...options, @@ -125,6 +129,7 @@ export function notificationResource(transport: Transport) { /** Archive a question alert by id, stopping every delivery. Metabase models this as an update. */ async function archive(id: number, options: RequestOptions = {}): Promise { + await transport.require("notification.archive"); return update(id, { active: false }, options); } @@ -133,6 +138,7 @@ export function notificationResource(transport: Transport) { * body. */ async function send(id: number, options: RequestOptions = {}): Promise { + await transport.require("notification.send"); await get(id, options); await transport.requestRaw(`/api/notification/${id}/send`, { ...options, diff --git a/packages/client/src/resources/optional-parsed.ts b/packages/client/src/resources/optional-parsed.ts index dc901114..f6cdf92f 100644 --- a/packages/client/src/resources/optional-parsed.ts +++ b/packages/client/src/resources/optional-parsed.ts @@ -5,8 +5,11 @@ import { parseJsonOrPlain } from "../json"; const NO_CONTENT_STATUS = 204; -// Endpoints that answer 204 for "the value is unset" rather than 404, and answer the value itself -// as either JSON or bare text depending on the server version. `null` is the unset value, not an +// Endpoints that answer 204 for "the value is unset" rather than 404, and whose value is encoded by +// its runtime type rather than by a response schema: a string is served bare as text/plain, +// everything else as application/json — a real JSON document for a collection, number or boolean, +// but the bare name for a keyword. Every supported server does this the same way; the sniff in +// `parseJsonOrPlain` is the only reading that fits all of them. `null` is the unset value, not an // error. export async function fetchOptionalParsed( client: Transport, diff --git a/packages/client/src/resources/pulse.ts b/packages/client/src/resources/pulse.ts index 21b96a55..962fbb9b 100644 --- a/packages/client/src/resources/pulse.ts +++ b/packages/client/src/resources/pulse.ts @@ -34,6 +34,7 @@ export function pulseResource(transport: Transport) { params: PulseListParams = {}, options: RequestOptions = {}, ): Promise> { + await transport.require("pulse.list"); const data = await transport.requestParsed(PulseApiList, "/api/pulse", { ...options, query: { dashboard_id: params.dashboard_id, archived: params.archived }, @@ -43,11 +44,13 @@ export function pulseResource(transport: Transport) { /** Get one dashboard subscription by id. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("pulse.get"); return transport.requestParsed(Pulse, `/api/pulse/${id}`, { ...options }); } /** Create a dashboard subscription — its cards and its delivery channels — from a full body. */ async function create(params: PulseCreateInput, options: RequestOptions = {}): Promise { + await transport.require("pulse.create"); return transport.requestParsed(Pulse, "/api/pulse", { ...options, method: "POST", @@ -61,6 +64,7 @@ export function pulseResource(transport: Transport) { params: PulseUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("pulse.update"); const current = await get(id, options); return transport.requestParsed(Pulse, `/api/pulse/${id}`, { ...options, @@ -74,6 +78,7 @@ export function pulseResource(transport: Transport) { * update, and disables each of the subscription's channels as a side effect. */ async function archive(id: number, options: RequestOptions = {}): Promise { + await transport.require("pulse.archive"); return update(id, { archived: true }, options); } diff --git a/packages/client/src/resources/search.ts b/packages/client/src/resources/search.ts index 706383fe..dfb78ea7 100644 --- a/packages/client/src/resources/search.ts +++ b/packages/client/src/resources/search.ts @@ -39,6 +39,7 @@ export function searchResource(transport: Transport) { params: SearchParams = {}, options: RequestOptions = {}, ): Promise { + await transport.require("search.query"); const response = await transport.requestParsed(SearchApiResponse, "/api/search", { ...options, query: { diff --git a/packages/client/src/resources/segment.ts b/packages/client/src/resources/segment.ts index 109f2ed8..6f185631 100644 --- a/packages/client/src/resources/segment.ts +++ b/packages/client/src/resources/segment.ts @@ -16,12 +16,14 @@ export interface SegmentArchiveParams { export function segmentResource(transport: Transport) { /** List every segment the caller can see. */ async function list(options: RequestOptions = {}): Promise> { + await transport.require("segment.list"); const data = await transport.requestParsed(SegmentApiList, "/api/segment", { ...options }); return { data, total: null }; } /** Get one segment by id. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("segment.get"); return transport.requestParsed(Segment, `/api/segment/${id}`, { ...options }); } @@ -30,6 +32,7 @@ export function segmentResource(transport: Transport) { params: SegmentCreateInput, options: RequestOptions = {}, ): Promise { + await transport.require("segment.create"); return transport.requestParsed(Segment, "/api/segment", { ...options, method: "POST", @@ -46,6 +49,7 @@ export function segmentResource(transport: Transport) { params: SegmentUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("segment.update"); return transport.requestParsed(Segment, `/api/segment/${id}`, { ...options, method: "PUT", @@ -62,6 +66,7 @@ export function segmentResource(transport: Transport) { params: SegmentArchiveParams, options: RequestOptions = {}, ): Promise { + await transport.require("segment.archive"); return update(id, { archived: true, revision_message: params.revision_message }, options); } diff --git a/packages/client/src/resources/setting.test.ts b/packages/client/src/resources/setting.test.ts index a37cecd8..2eb7eb33 100644 --- a/packages/client/src/resources/setting.test.ts +++ b/packages/client/src/resources/setting.test.ts @@ -42,6 +42,14 @@ const BINARY_WRITE_HEADERS = { // Metabase rejects an unknown key with a 400 that names it as a Clojure keyword. const UNKNOWN_SETTING_RESPONSE = { message: "Unknown setting: :totally-bogus" }; +// The server encodes a setting's value by its runtime type: a string arrives bare as text/plain, +// everything else under application/json — a keyword as its bare name, the rest as JSON. +function wireResponse(body: string, contentType: string): Response { + return new Response(body, { status: 200, headers: { "content-type": contentType } }); +} + +const JSON_CHARSET_CONTENT_TYPE = "application/json;charset=utf-8"; + function clientOver(responses: Array) { const capture = captureFetch(responses); const mb = createClient(CREDENTIALS, { @@ -97,6 +105,49 @@ describe("setting resource wire requests", () => { expect(await mb.setting.get("remote-sync-branch")).toBeNull(); }); + it("reads a string setting's bare text/plain body as the string", async () => { + const { mb } = clientOver([wireResponse("admin@e2e.test", "text/plain")]); + + expect(await mb.setting.get("admin-email")).toBe("admin@e2e.test"); + }); + + it("keeps a string setting's text/plain body a string even when it reads as JSON", async () => { + const { mb } = clientOver([wireResponse("123", "text/plain")]); + + expect(await mb.setting.get("email-from-name")).toBe("123"); + }); + + it("reads a boolean setting's JSON literal as the boolean", async () => { + const { mb } = clientOver([wireResponse("false", JSON_CHARSET_CONTENT_TYPE)]); + + expect(await mb.setting.get("enable-public-sharing")).toBe(false); + }); + + it("reads a numeric setting's JSON literal as the number", async () => { + const { mb } = clientOver([wireResponse("15152.63298", JSON_CHARSET_CONTENT_TYPE)]); + + expect(await mb.setting.get("startup-time-millis")).toBe(15152.63298); + }); + + it("reads a keyword setting's bare name under application/json as the string", async () => { + const { mb } = clientOver([wireResponse("strict", JSON_CHARSET_CONTENT_TYPE)]); + + expect(await mb.setting.get("session-cookie-samesite")).toBe("strict"); + }); + + it("reads a JSON setting's document as the parsed value", async () => { + const { mb } = clientOver([ + wireResponse( + '{"us_states":{"name":"United States","builtin":true}}', + JSON_CHARSET_CONTENT_TYPE, + ), + ]); + + expect(await mb.setting.get("custom-geojson")).toEqual({ + us_states: { name: "United States", builtin: true }, + }); + }); + it("percent-encodes the key into the set path and wraps the value in the body", async () => { const { mb, capture } = clientOver([new Response(null, { status: 204 })]); diff --git a/packages/client/src/resources/setting.ts b/packages/client/src/resources/setting.ts index 4d5605cd..5529bd40 100644 --- a/packages/client/src/resources/setting.ts +++ b/packages/client/src/resources/setting.ts @@ -24,15 +24,17 @@ function rethrowSettingError(error: unknown, key: string): never { export function settingResource(transport: Transport) { /** Get all settings and their values, with descriptions and environment-variable names. */ async function list(options: RequestOptions = {}): Promise> { + await transport.require("setting.list"); const data = await transport.requestParsed(SettingApiList, "/api/setting", { ...options }); return { data, total: null }; } /** * Fetch a single setting's value by key. An unset setting answers 204 rather than 404, which - * reaches the caller as `null`; older servers answer the value as bare text rather than JSON. + * reaches the caller as `null`; a string-typed setting answers bare text rather than JSON. */ async function get(key: string, options: RequestOptions = {}): Promise { + await transport.require("setting.get"); const path = `/api/setting/${encodeURIComponent(key)}`; return fetchOptionalParsed(transport, path, z.unknown(), options).catch((error: unknown) => rethrowSettingError(error, key), @@ -41,6 +43,7 @@ export function settingResource(transport: Transport) { /** Set a single setting's value by key. The endpoint answers 204 with no body. */ async function set(key: string, value: unknown, options: RequestOptions = {}): Promise { + await transport.require("setting.set"); await transport .requestRaw(`/api/setting/${encodeURIComponent(key)}`, { ...options, diff --git a/packages/client/src/resources/setup.ts b/packages/client/src/resources/setup.ts index b3e941b5..46051a5b 100644 --- a/packages/client/src/resources/setup.ts +++ b/packages/client/src/resources/setup.ts @@ -7,6 +7,7 @@ export function setupResource(transport: Transport) { * applying the instance preferences. The setup token is single-use. */ async function create(params: SetupInput, options: RequestOptions = {}): Promise { + await transport.require("setup.create"); return transport.requestParsed(SetupResult, "/api/setup", { ...options, method: "POST", diff --git a/packages/client/src/resources/snippet.ts b/packages/client/src/resources/snippet.ts index 4414e752..7251bef6 100644 --- a/packages/client/src/resources/snippet.ts +++ b/packages/client/src/resources/snippet.ts @@ -18,6 +18,7 @@ export function snippetResource(transport: Transport) { params: SnippetListParams = {}, options: RequestOptions = {}, ): Promise> { + await transport.require("snippet.list"); const data = await transport.requestParsed(SnippetApiList, "/api/native-query-snippet", { ...options, query: { archived: params.archived }, @@ -27,6 +28,7 @@ export function snippetResource(transport: Transport) { /** Get one native query snippet by id. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("snippet.get"); return transport.requestParsed(Snippet, `/api/native-query-snippet/${id}`, { ...options }); } @@ -35,6 +37,7 @@ export function snippetResource(transport: Transport) { params: SnippetCreateInput, options: RequestOptions = {}, ): Promise { + await transport.require("snippet.create"); return transport.requestParsed(Snippet, "/api/native-query-snippet", { ...options, method: "POST", @@ -48,6 +51,7 @@ export function snippetResource(transport: Transport) { params: SnippetUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("snippet.update"); return transport.requestParsed(Snippet, `/api/native-query-snippet/${id}`, { ...options, method: "PUT", @@ -60,6 +64,7 @@ export function snippetResource(transport: Transport) { * own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { + await transport.require("snippet.archive"); return update(id, { archived: true }, options); } diff --git a/packages/client/src/resources/table.ts b/packages/client/src/resources/table.ts index 78a68fca..6696a4b4 100644 --- a/packages/client/src/resources/table.ts +++ b/packages/client/src/resources/table.ts @@ -19,12 +19,14 @@ const UPLOAD_UPDATE_PATHS: Record = { export function tableResource(transport: Transport) { /** List every table the caller can see, across all databases. */ async function list(options: RequestOptions = {}): Promise> { + await transport.require("table.list"); const data = await transport.requestParsed(TableApiList, "/api/table", { ...options }); return { data, total: null }; } /** Get one table by id, without its fields. */ async function get(id: number, options: RequestOptions = {}): Promise
{ + await transport.require("table.get"); return transport.requestParsed(Table, `/api/table/${id}`, { ...options }); } @@ -34,6 +36,7 @@ export function tableResource(transport: Transport) { params: TableUpdateInput, options: RequestOptions = {}, ): Promise
{ + await transport.require("table.update"); return transport.requestParsed(Table, `/api/table/${id}`, { ...options, method: "PUT", @@ -46,6 +49,7 @@ export function tableResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise { + await transport.require("table.queryMetadata"); return transport.requestParsed(TableQueryMetadata, `/api/table/${id}/query_metadata`, { ...options, }); @@ -77,6 +81,7 @@ export function tableResource(transport: Transport) { file: CsvFile, options: RequestOptions = {}, ): Promise { + await transport.require("table.appendCsv"); return updateFromCsv(id, "append", file, options); } @@ -91,6 +96,7 @@ export function tableResource(transport: Transport) { file: CsvFile, options: RequestOptions = {}, ): Promise { + await transport.require("table.replaceCsv"); return updateFromCsv(id, "replace", file, options); } diff --git a/packages/client/src/resources/timeline-event.ts b/packages/client/src/resources/timeline-event.ts index 739362c0..2f859f55 100644 --- a/packages/client/src/resources/timeline-event.ts +++ b/packages/client/src/resources/timeline-event.ts @@ -8,6 +8,7 @@ import type { RequestOptions, Transport } from "../http/transport"; export function timelineEventResource(transport: Transport) { /** Get one timeline event by id. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("timelineEvent.get"); return transport.requestParsed(TimelineEvent, `/api/timeline-event/${id}`, { ...options }); } @@ -16,6 +17,7 @@ export function timelineEventResource(transport: Transport) { params: TimelineEventCreateInput, options: RequestOptions = {}, ): Promise { + await transport.require("timelineEvent.create"); return transport.requestParsed(TimelineEvent, "/api/timeline-event", { ...options, method: "POST", @@ -32,6 +34,7 @@ export function timelineEventResource(transport: Transport) { params: TimelineEventUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("timelineEvent.update"); return transport.requestParsed(TimelineEvent, `/api/timeline-event/${id}`, { ...options, method: "PUT", @@ -44,11 +47,13 @@ export function timelineEventResource(transport: Transport) { * endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { + await transport.require("timelineEvent.archive"); return update(id, { archived: true }, options); } /** Permanently delete a timeline event by id. The server answers with no body. */ async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.require("timelineEvent.delete"); await transport.requestRaw(`/api/timeline-event/${id}`, { ...options, method: "DELETE", diff --git a/packages/client/src/resources/timeline.ts b/packages/client/src/resources/timeline.ts index 4458b192..fb9e63ef 100644 --- a/packages/client/src/resources/timeline.ts +++ b/packages/client/src/resources/timeline.ts @@ -31,6 +31,7 @@ export function timelineResource(transport: Transport) { params: TimelineListParams = {}, options: RequestOptions = {}, ): Promise> { + await transport.require("timeline.list"); const data = await transport.requestParsed(TimelineApiList, "/api/timeline", { ...options, query: { archived: params.archived }, @@ -40,6 +41,7 @@ export function timelineResource(transport: Transport) { /** Get one timeline by id, without its events. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("timeline.get"); return transport.requestParsed(Timeline, `/api/timeline/${id}`, { ...options }); } @@ -52,6 +54,7 @@ export function timelineResource(transport: Transport) { params: TimelineEventsParams = {}, options: RequestOptions = {}, ): Promise> { + await transport.require("timeline.events"); const timeline = await transport.requestParsed(TimelineWithEvents, `/api/timeline/${id}`, { ...options, query: { include: "events", archived: params.archived }, @@ -64,6 +67,7 @@ export function timelineResource(transport: Transport) { params: TimelineCreateInput, options: RequestOptions = {}, ): Promise { + await transport.require("timeline.create"); return transport.requestParsed(Timeline, "/api/timeline", { ...options, method: "POST", @@ -80,6 +84,7 @@ export function timelineResource(transport: Transport) { params: TimelineUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("timeline.update"); return transport.requestParsed(Timeline, `/api/timeline/${id}`, { ...options, method: "PUT", @@ -92,11 +97,13 @@ export function timelineResource(transport: Transport) { * not its own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { + await transport.require("timeline.archive"); return update(id, { archived: true }, options); } /** Permanently delete a timeline and all its events by id. The server answers with no body. */ async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.require("timeline.delete"); await transport.requestRaw(`/api/timeline/${id}`, { ...options, method: "DELETE", diff --git a/packages/client/src/resources/transform-job.test.ts b/packages/client/src/resources/transform-job.test.ts index 2f2195d6..2600f9b9 100644 --- a/packages/client/src/resources/transform-job.test.ts +++ b/packages/client/src/resources/transform-job.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; +import { ResponseShapeError } from "../errors"; import type { ClientCredentials } from "../http/transport"; import { captureFetch, @@ -8,6 +9,7 @@ import { jsonResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; +import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { url: "https://mb.example.com/metabase", @@ -39,6 +41,7 @@ const TRANSFORM = { updated_at: "2026-01-01T00:00:00Z", creator_id: 1, collection_id: null, + target_table_id: 42, }; const JSON_REQUEST_HEADERS = { @@ -60,11 +63,33 @@ const BINARY_READ_HEADERS = { "x-api-key": "mb_wire_test_key", }; -function clientOver(responses: FetchScript) { +// The least server that answers this resource, so a method asking for more than the resource's +// own feature is refused here before it reaches the scripted wire. It is also of the generation +// that answers a job run with an opaque stub id. +const SERVER = createServerProfile({ + version: { tag: "v0.61.0", major: 61, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, +}); + +// The first generation answering a job run with the run's numeric id, or null for no run. +const NUMERIC_RUN_ID_SERVER = createServerProfile({ + version: { tag: "v0.64.0", major: 64, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, +}); + +const STUB_RUN_RESPONSE = { message: "Job run started", job_run_id: "stub-3-1767225600000" }; +const NUMERIC_RUN_RESPONSE = { message: "Job run started", job_run_id: 11 }; + +function clientOver(responses: FetchScript, server: ServerProfile = SERVER) { const capture = captureFetch(responses); const mb = createClient(CREDENTIALS, { userAgent: TEST_USER_AGENT, fetchImpl: capture.fetch, + server, }); return { mb, capture }; } @@ -152,9 +177,7 @@ describe("transform-job resource wire requests", () => { }); it("sends the run request as a POST carrying the run_all flag", async () => { - const { mb, capture } = clientOver([ - jsonResponse({ message: "Job run started", job_run_id: 11 }), - ]); + const { mb, capture } = clientOver([jsonResponse(STUB_RUN_RESPONSE)]); await mb.transformJob.run(3, { run_all: true }); @@ -168,27 +191,30 @@ describe("transform-job resource wire requests", () => { ]); }); - it("reads the numeric run id head answers", async () => { - const { mb } = clientOver([jsonResponse({ message: "Job run started", job_run_id: 11 })]); + it("refuses a numeric run id from a server that answers a stub", async () => { + const { mb } = clientOver([jsonResponse(NUMERIC_RUN_RESPONSE)]); - expect(await mb.transformJob.run(3)).toEqual({ message: "Job run started", job_run_id: 11 }); - }); + const error = await mb.transformJob.run(3).catch((caught: unknown) => caught); - it("reads the opaque stub id released servers answer", async () => { - const { mb } = clientOver([ - jsonResponse({ message: "Job run started", job_run_id: "job-3-1767225600000" }), - ]); - - expect(await mb.transformJob.run(3)).toEqual({ - message: "Job run started", - job_run_id: "job-3-1767225600000", - }); + expect(error).toBeInstanceOf(ResponseShapeError); + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + expect(error.userMessage).toBe( + "On Metabase v0.61.0 the response shape was unexpected:\n" + + " job_run_id: Invalid input: expected string, received number", + ); }); - it("reads the null run id a server answers when it started nothing", async () => { - const { mb } = clientOver([jsonResponse({ message: "Job run started", job_run_id: null })]); + it("refuses a stub run id from a server that answers a number", async () => { + const { mb } = clientOver([jsonResponse(STUB_RUN_RESPONSE)], NUMERIC_RUN_ID_SERVER); + + const error = await mb.transformJob.run(3).catch((caught: unknown) => caught); - expect(await mb.transformJob.run(3)).toEqual({ message: "Job run started", job_run_id: null }); + expect(error).toBeInstanceOf(ResponseShapeError); + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + expect(error.userMessage).toBe( + "On Metabase v0.64.0 (newer than this client supports, up to v63) the response shape was unexpected:\n" + + " job_run_id: Invalid input: expected number, received string", + ); }); it("sends the transforms request against the job id", async () => { diff --git a/packages/client/src/resources/transform-job.ts b/packages/client/src/resources/transform-job.ts index 42a6fd36..f7dca28f 100644 --- a/packages/client/src/resources/transform-job.ts +++ b/packages/client/src/resources/transform-job.ts @@ -1,11 +1,12 @@ import { z } from "zod"; -import { Transform } from "../domain/transform"; +import { type Transform, transformRowSchema } from "../domain/transform"; import { TransformJob, TransformJobActiveResult, type TransformJobCreateInput, - TransformJobRunResult, + type TransformJobRunResult, + transformJobRunResultSchema, type TransformJobUpdateInput, } from "../domain/transform-job"; import type { RequestOptions, Transport } from "../http/transport"; @@ -15,11 +16,6 @@ import type { ListResult } from "../list"; // count a caller reads off `ListResult` is the array's own length and the server reports none. const TransformJobApiList = z.array(TransformJob); -// `GET /api/transform-job/{id}/transforms` answers a bare array of transforms. `resources/transform` -// declares the same shape for its own listings; the two files may not reach into each other, and a -// module both could import would be the bucket module the layout rules forbid. -const TransformApiList = z.array(Transform); - export interface TransformJobRunParams { /** Re-run the whole plan, including dependencies that are already fresh. */ run_all?: boolean | undefined; @@ -29,6 +25,7 @@ export interface TransformJobRunParams { export function transformJobResource(transport: Transport) { /** List every transform job the caller can see. */ async function list(options: RequestOptions = {}): Promise> { + await transport.require("transformJob.list"); const data = await transport.requestParsed(TransformJobApiList, "/api/transform-job", { ...options, }); @@ -37,6 +34,7 @@ export function transformJobResource(transport: Transport) { /** Get one transform job by id. */ async function get(id: number, options: RequestOptions = {}): Promise { + await transport.require("transformJob.get"); return transport.requestParsed(TransformJob, `/api/transform-job/${id}`, { ...options }); } @@ -45,6 +43,7 @@ export function transformJobResource(transport: Transport) { params: TransformJobCreateInput, options: RequestOptions = {}, ): Promise { + await transport.require("transformJob.create"); return transport.requestParsed(TransformJob, "/api/transform-job", { ...options, method: "POST", @@ -58,6 +57,7 @@ export function transformJobResource(transport: Transport) { params: TransformJobUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("transformJob.update"); return transport.requestParsed(TransformJob, `/api/transform-job/${id}`, { ...options, method: "PUT", @@ -67,6 +67,7 @@ export function transformJobResource(transport: Transport) { /** Delete a transform job by id, leaving the transforms it ran untouched. */ async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.require("transformJob.delete"); await transport.requestRaw(`/api/transform-job/${id}`, { ...options, method: "DELETE", @@ -83,11 +84,13 @@ export function transformJobResource(transport: Transport) { params: TransformJobRunParams = {}, options: RequestOptions = {}, ): Promise { - return transport.requestParsed(TransformJobRunResult, `/api/transform-job/${id}/run`, { - ...options, - method: "POST", - body: { run_all: params.run_all }, - }); + await transport.require("transformJob.run"); + const { features } = await transport.server(); + return transport.requestParsed( + transformJobRunResultSchema(features), + `/api/transform-job/${id}/run`, + { ...options, method: "POST", body: { run_all: params.run_all } }, + ); } /** List the transforms a job would execute, resolved from the job's tags. */ @@ -95,8 +98,10 @@ export function transformJobResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise> { + await transport.require("transformJob.transforms"); + const { features } = await transport.server(); const data = await transport.requestParsed( - TransformApiList, + z.array(transformRowSchema(features)), `/api/transform-job/${id}/transforms`, { ...options }, ); @@ -111,6 +116,7 @@ export function transformJobResource(transport: Transport) { active: boolean, options: RequestOptions = {}, ): Promise { + await transport.require("transformJob.setActive"); return transport.requestParsed(TransformJobActiveResult, "/api/transform-job/active", { ...options, method: "PUT", diff --git a/packages/client/src/resources/transform-tag.test.ts b/packages/client/src/resources/transform-tag.test.ts index e5e74ad4..d06c19d4 100644 --- a/packages/client/src/resources/transform-tag.test.ts +++ b/packages/client/src/resources/transform-tag.test.ts @@ -8,6 +8,7 @@ import { jsonResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; +import { createServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { url: "https://mb.example.com/metabase", @@ -40,11 +41,21 @@ const BINARY_READ_HEADERS = { "x-api-key": "mb_wire_test_key", }; +// The least server that answers this resource, so a method asking for more than the resource's +// own feature is refused here before it reaches the scripted wire. +const SERVER = createServerProfile({ + version: { tag: "v0.59.0", major: 59, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, +}); + function clientOver(responses: FetchScript) { const capture = captureFetch(responses); const mb = createClient(CREDENTIALS, { userAgent: TEST_USER_AGENT, fetchImpl: capture.fetch, + server: SERVER, }); return { mb, capture }; } diff --git a/packages/client/src/resources/transform-tag.ts b/packages/client/src/resources/transform-tag.ts index f3cc64e2..b3531560 100644 --- a/packages/client/src/resources/transform-tag.ts +++ b/packages/client/src/resources/transform-tag.ts @@ -16,6 +16,7 @@ const TransformTagApiList = z.array(TransformTag); export function transformTagResource(transport: Transport) { /** List every transform tag the caller can see, built-in tags included. */ async function list(options: RequestOptions = {}): Promise> { + await transport.require("transformTag.list"); const data = await transport.requestParsed(TransformTagApiList, "/api/transform-tag", { ...options, }); @@ -27,6 +28,7 @@ export function transformTagResource(transport: Transport) { params: TransformTagCreateInput, options: RequestOptions = {}, ): Promise { + await transport.require("transformTag.create"); return transport.requestParsed(TransformTag, "/api/transform-tag", { ...options, method: "POST", @@ -40,6 +42,7 @@ export function transformTagResource(transport: Transport) { params: TransformTagUpdateInput, options: RequestOptions = {}, ): Promise { + await transport.require("transformTag.update"); return transport.requestParsed(TransformTag, `/api/transform-tag/${id}`, { ...options, method: "PUT", @@ -49,6 +52,7 @@ export function transformTagResource(transport: Transport) { /** Delete a transform tag by id, detaching it from every transform and job carrying it. */ async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.require("transformTag.delete"); await transport.requestRaw(`/api/transform-tag/${id}`, { ...options, method: "DELETE", diff --git a/packages/client/src/resources/transform.test.ts b/packages/client/src/resources/transform.test.ts index fde759c7..3e0b6b2a 100644 --- a/packages/client/src/resources/transform.test.ts +++ b/packages/client/src/resources/transform.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { createClient } from "../client"; +import { ResponseShapeError } from "../errors"; import type { ClientCredentials } from "../http/transport"; import { captureFetch, @@ -8,6 +9,7 @@ import { jsonResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; +import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { url: "https://mb.example.com/metabase", @@ -39,6 +41,10 @@ const TRANSFORM_WITH_TARGET_TABLE_ID = { ...TRANSFORM, target_table_id: 42 }; const TRANSFORM_WITH_HYDRATED_TABLE = { ...TRANSFORM, table: { id: 42, name: "daily_orders" } }; +const TRANSFORM_UNLINKED = { ...TRANSFORM, target_table_id: null }; + +const TRANSFORM_DETAIL_UNLINKED = { ...TRANSFORM, table: null }; + const RUN = { id: 31, transform_id: 7, @@ -91,11 +97,30 @@ const IMMEDIATE_POLL = { intervalMs: 1, timeoutMs: 1_000 }; // answering so the wait ends on the deadline rather than on an exhausted queue. const EXPIRING_POLL = { intervalMs: 1, timeoutMs: 5 }; -function clientOver(responses: FetchScript) { +// The least server that answers this resource, so a method asking for more than the resource's +// own feature is refused here before it reaches the scripted wire. It is also the generation that +// links the output table only on the detail, as a hydrated `table`. +const SERVER = createServerProfile({ + version: { tag: "v0.59.0", major: 59, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, +}); + +// The first generation carrying `target_table_id` as a column on every transform endpoint. +const TABLE_ID_COLUMN_SERVER = createServerProfile({ + version: { tag: "v0.61.0", major: 61, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, +}); + +function clientOver(responses: FetchScript, server: ServerProfile = SERVER) { const capture = captureFetch(responses); const mb = createClient(CREDENTIALS, { userAgent: TEST_USER_AGENT, fetchImpl: capture.fetch, + server, }); return { mb, capture }; } @@ -123,11 +148,11 @@ describe("transform resource wire requests", () => { it("reports no total for the listing, which the server does not count", async () => { const { mb } = clientOver([jsonResponse([TRANSFORM])]); - expect(await mb.transform.list()).toEqual({ data: [TRANSFORM], total: null }); + expect(await mb.transform.list()).toEqual({ data: [TRANSFORM_UNLINKED], total: null }); }); it("sends the get request", async () => { - const { mb, capture } = clientOver([jsonResponse(TRANSFORM)]); + const { mb, capture } = clientOver([jsonResponse(TRANSFORM_DETAIL_UNLINKED)]); await mb.transform.get(7); @@ -219,7 +244,7 @@ describe("transform resource wire requests", () => { it("sends the dependencies request", async () => { const { mb, capture } = clientOver([jsonResponse([TRANSFORM])]); - expect(await mb.transform.dependencies(7)).toEqual({ data: [TRANSFORM], total: null }); + expect(await mb.transform.dependencies(7)).toEqual({ data: [TRANSFORM_UNLINKED], total: null }); expect(capture.calls).toEqual([ { url: "https://mb.example.com/metabase/api/transform/7/dependencies", @@ -335,12 +360,15 @@ describe("transform resource wire requests", () => { ]); }); - it("reads the output table off target_table_id, as v61 and later report it", async () => { - const { mb } = clientOver([ - jsonResponse(KICKOFF), - jsonResponse(SUCCEEDED_RUN), - jsonResponse(TRANSFORM_WITH_TARGET_TABLE_ID), - ]); + it("reads the output table off the target_table_id column where the server has one", async () => { + const { mb } = clientOver( + [ + jsonResponse(KICKOFF), + jsonResponse(SUCCEEDED_RUN), + jsonResponse(TRANSFORM_WITH_TARGET_TABLE_ID), + ], + TABLE_ID_COLUMN_SERVER, + ); expect(await mb.transform.run(7, { wait: IMMEDIATE_POLL, syncTarget: true })).toEqual({ message: "Transform run started", @@ -350,7 +378,7 @@ describe("transform resource wire requests", () => { }); }); - it("reads the output table off the hydrated table, as v59 and v60 report it", async () => { + it("reads the output table off the hydrated table where the server links it that way", async () => { const { mb } = clientOver([ jsonResponse(KICKOFF), jsonResponse(SUCCEEDED_RUN), @@ -369,7 +397,7 @@ describe("transform resource wire requests", () => { const { mb, capture } = clientOver([ jsonResponse(KICKOFF), jsonResponse(SUCCEEDED_RUN), - jsonResponse(TRANSFORM_WITH_TARGET_TABLE_ID), + jsonResponse(TRANSFORM_WITH_HYDRATED_TABLE), ]); await mb.transform.run(7, { syncTarget: true }); @@ -400,7 +428,7 @@ describe("transform resource wire requests", () => { const { mb } = clientOver([ jsonResponse(KICKOFF), jsonResponse(SUCCEEDED_RUN), - ...repeated(TRANSFORM, 50), + ...repeated(TRANSFORM_DETAIL_UNLINKED, 50), ]); expect(await mb.transform.run(7, { wait: EXPIRING_POLL, syncTarget: true })).toEqual({ @@ -410,4 +438,33 @@ describe("transform resource wire requests", () => { target_table_id: null, }); }); + + it("refuses a detail without the target_table_id column on a server that has one", async () => { + const { mb } = clientOver( + [jsonResponse(TRANSFORM_WITH_HYDRATED_TABLE)], + TABLE_ID_COLUMN_SERVER, + ); + + const error = await mb.transform.get(7).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ResponseShapeError); + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + expect(error.userMessage).toBe( + "On Metabase v0.61.0 the response shape was unexpected:\n" + + " target_table_id: Invalid input: expected number, received undefined", + ); + }); + + it("refuses a detail without the hydrated table on a server that links it that way", async () => { + const { mb } = clientOver([jsonResponse(TRANSFORM_WITH_TARGET_TABLE_ID)]); + + const error = await mb.transform.get(7).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ResponseShapeError); + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + expect(error.userMessage).toBe( + "On Metabase v0.59.0 the response shape was unexpected:\n" + + " table: Invalid input: expected object, received undefined", + ); + }); }); diff --git a/packages/client/src/resources/transform.ts b/packages/client/src/resources/transform.ts index 66415ff5..f9ab7278 100644 --- a/packages/client/src/resources/transform.ts +++ b/packages/client/src/resources/transform.ts @@ -3,22 +3,27 @@ import { z } from "zod"; import { isTransformRunFailed, isTransformRunTerminal, - Transform, + type Transform, type TransformCreateInput, + transformDetailSchema, TransformRun, type TransformRunResult, + transformRowSchema, type TransformUpdateInput, } from "../domain/transform"; import { TimeoutError } from "../errors"; -import type { RequestOptions, Transport } from "../http/transport"; +import type { RequestOptions, Transport, TransportRequestOptions } from "../http/transport"; import type { ListResult } from "../list"; import { type Page, type PaginateOptions, paginatePages } from "../paginate"; import { type PollOptions, pollUntil } from "../poll"; +import type { Features } from "../version/features"; // `GET /api/transform` and `GET /api/transform/{id}/dependencies` both answer a bare array rather // than a `{ data, total }` envelope, so the count a caller reads off `ListResult` is the array's // own length and the server reports none. -const TransformApiList = z.array(Transform); +function transformListSchema(features: Features) { + return z.array(transformRowSchema(features)); +} // The run endpoint answers as soon as the run is queued, and says nothing about how it went. const TransformRunKickoff = z.object({ @@ -45,15 +50,34 @@ export type TransformRunPageOptions = Omit; // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformResource(transport: Transport) { + // The wire shape is the server's generation's to decide, so every read goes through the profile. + async function requestTransformList( + path: string, + opts: TransportRequestOptions, + ): Promise> { + const { features } = await transport.server(); + const data = await transport.requestParsed(transformListSchema(features), path, opts); + return { data, total: null }; + } + + async function requestTransform(path: string, opts: TransportRequestOptions): Promise { + const { features } = await transport.server(); + return transport.requestParsed(transformRowSchema(features), path, opts); + } + /** List every transform the caller can see. */ async function list(options: RequestOptions = {}): Promise> { - const data = await transport.requestParsed(TransformApiList, "/api/transform", { ...options }); - return { data, total: null }; + await transport.require("transform.list"); + return requestTransformList("/api/transform", { ...options }); } /** Get one transform by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - return transport.requestParsed(Transform, `/api/transform/${id}`, { ...options }); + await transport.require("transform.get"); + const { features } = await transport.server(); + return transport.requestParsed(transformDetailSchema(features), `/api/transform/${id}`, { + ...options, + }); } /** Create a transform — a query plus the warehouse table it writes — from a full body. */ @@ -61,11 +85,8 @@ export function transformResource(transport: Transport) { params: TransformCreateInput, options: RequestOptions = {}, ): Promise { - return transport.requestParsed(Transform, "/api/transform", { - ...options, - method: "POST", - body: params, - }); + await transport.require("transform.create"); + return requestTransform("/api/transform", { ...options, method: "POST", body: params }); } /** Update a transform by id, patching only the fields the body carries. */ @@ -74,15 +95,13 @@ export function transformResource(transport: Transport) { params: TransformUpdateInput, options: RequestOptions = {}, ): Promise { - return transport.requestParsed(Transform, `/api/transform/${id}`, { - ...options, - method: "PUT", - body: params, - }); + await transport.require("transform.update"); + return requestTransform(`/api/transform/${id}`, { ...options, method: "PUT", body: params }); } /** Delete a transform by id, leaving any table it already materialized in place. */ async function remove(id: number, options: RequestOptions = {}): Promise { + await transport.require("transform.delete"); await transport.requestRaw(`/api/transform/${id}`, { ...options, method: "DELETE", @@ -92,6 +111,7 @@ export function transformResource(transport: Transport) { /** Drop a transform's materialized output table, keeping the transform definition. */ async function deleteTable(id: number, options: RequestOptions = {}): Promise { + await transport.require("transform.deleteTable"); await transport.requestRaw(`/api/transform/${id}/table`, { ...options, method: "DELETE", @@ -104,16 +124,13 @@ export function transformResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise> { - const data = await transport.requestParsed( - TransformApiList, - `/api/transform/${id}/dependencies`, - { ...options }, - ); - return { data, total: null }; + await transport.require("transform.dependencies"); + return requestTransformList(`/api/transform/${id}/dependencies`, { ...options }); } /** Request cancellation of a transform's current run. */ async function cancel(id: number, options: RequestOptions = {}): Promise { + await transport.require("transform.cancel"); await transport.requestRaw(`/api/transform/${id}/cancel`, { ...options, method: "POST", @@ -123,15 +140,17 @@ export function transformResource(transport: Transport) { /** Get one transform run by run id — not by the id of the transform that produced it. */ async function getRun(runId: number, options: RequestOptions = {}): Promise { + await transport.require("transform.getRun"); return transport.requestParsed(TransformRun, `/api/transform/run/${runId}`, { ...options }); } /** Walk the run history, newest first, optionally narrowed to a single transform. */ - function runPages( + async function* runPages( params: TransformRunPageParams = {}, options: TransformRunPageOptions = {}, ): AsyncIterable> { - return paginatePages(transport, "/api/transform/run", TransformRun, { + await transport.require("transform.runPages"); + yield* paginatePages(transport, "/api/transform/run", TransformRun, { query: { "transform-ids": params["transform-ids"] }, ...(options.offset !== undefined && { offset: options.offset }), ...(options.max !== undefined && { max: options.max }), @@ -150,6 +169,7 @@ export function transformResource(transport: Transport) { params: TransformRunParams = {}, options: RequestOptions = {}, ): Promise { + await transport.require("transform.run"); const kickoff = await transport.requestParsed(TransformRunKickoff, `/api/transform/${id}/run`, { ...options, method: "POST", @@ -175,10 +195,9 @@ export function transformResource(transport: Transport) { } // A successful run registers its own output table — Metabase syncs the single materialized table - // as part of run completion, so no explicit database sync is needed. The linkage surfaces as - // `target_table_id` on v61+ and as the hydrated `table.id` on v59/v60; poll until either lands. - // A poll timeout answers null rather than throwing, because the table may still be syncing and - // the run itself already succeeded. + // as part of run completion, so no explicit database sync is needed. A poll timeout answers null + // rather than throwing, because the table may still be syncing and the run itself already + // succeeded. async function awaitTargetTableId( id: number, wait: PollOptions, @@ -187,10 +206,10 @@ export function transformResource(transport: Transport) { try { const linked = await pollUntil( async (signal) => get(id, { ...options, signal }), - (transform) => linkedTableId(transform) !== null, + (transform) => transform.target_table_id !== null, wait, ); - return linkedTableId(linked); + return linked.target_table_id; } catch (error) { if (error instanceof TimeoutError) { return null; @@ -213,7 +232,3 @@ export function transformResource(transport: Transport) { run, }; } - -function linkedTableId(transform: Transform): number | null { - return transform.target_table_id ?? transform.table?.id ?? null; -} diff --git a/packages/client/src/resources/upload.ts b/packages/client/src/resources/upload.ts index c3e2c856..0dc6c053 100644 --- a/packages/client/src/resources/upload.ts +++ b/packages/client/src/resources/upload.ts @@ -22,6 +22,7 @@ export function uploadResource(transport: Transport) { params: UploadCsvParams, options: RequestOptions = {}, ): Promise { + await transport.require("upload.createFromCsv"); const form = buildCsvFormData(file); form.append("collection_id", params.collection_id); const response = await transport.requestRaw("/api/upload/csv", { diff --git a/packages/client/src/resources/user.ts b/packages/client/src/resources/user.ts index c360dfec..63f62284 100644 --- a/packages/client/src/resources/user.ts +++ b/packages/client/src/resources/user.ts @@ -4,6 +4,7 @@ import type { RequestOptions, Transport } from "../http/transport"; export function userResource(transport: Transport) { /** Get the user the request's credentials authenticate as. */ async function current(options: RequestOptions = {}): Promise { + await transport.require("user.current"); return transport.requestParsed(CurrentUser, "/api/user/current", { ...options }); } diff --git a/packages/client/src/testing/fake-client.test.ts b/packages/client/src/testing/fake-client.test.ts index fc7d8db4..1c1d3bf5 100644 --- a/packages/client/src/testing/fake-client.test.ts +++ b/packages/client/src/testing/fake-client.test.ts @@ -6,6 +6,7 @@ import { ResponseShapeError } from "../errors"; import { type PaginatedEnvelope, paginatePages } from "../paginate"; import { type ClientCredentials, createTransport } from "../http/transport"; +import { createServerProfile } from "../version/profile"; import { createFakeClient, type FakeClientCall } from "./fake-client"; import { captureFetch, jsonResponse, TEST_USER_AGENT } from "./fetch-capture"; @@ -107,6 +108,37 @@ describe("createFakeClient", () => { }); }); + it("hands back the planned server profile", async () => { + const server = createServerProfile({ + version: { tag: "v0.61.2", major: 61, patch: 2 }, + date: null, + hash: null, + tokenFeatures: null, + }); + const { client } = createFakeClient({ server }); + expect(await client.server()).toBe(server); + }); + + it("rejects a server() call the plan did not provide for", async () => { + const { client } = createFakeClient(); + await expect(client.server()).rejects.toThrow("no server profile in fake client plan"); + }); + + it("records each required key with the requests already served, and refuses none", async () => { + const fake = createFakeClient({ + routes: [{ path: PATH, reply: { kind: "body", body: { id: 1, email: "read@b.com" } } }], + }); + + await fake.client.require("gitSync.branches"); + await fake.client.requestParsed(PingResponse, PATH); + await fake.client.require("card.list"); + + expect(fake.required).toEqual([ + { key: "gitSync.branches", precedingRequests: 0 }, + { key: "card.list", precedingRequests: 1 }, + ]); + }); + it("throws naming the method and path when no route matches", async () => { const { client } = createFakeClient({ routes: [{ path: PATH, reply: { kind: "body", body: { id: 1, email: "read@b.com" } } }], diff --git a/packages/client/src/testing/fake-client.ts b/packages/client/src/testing/fake-client.ts index 36417a55..fe521b67 100644 --- a/packages/client/src/testing/fake-client.ts +++ b/packages/client/src/testing/fake-client.ts @@ -7,6 +7,8 @@ import { type TransportRequestOptions, } from "../http/transport"; import { NO_SERVER_TAG, parseJsonResponse } from "../http/response-shape"; +import type { ServerProfile } from "../version/profile"; +import type { MethodKey } from "../version/requirements"; const FAKE_STATUS = 200; @@ -43,15 +45,27 @@ export interface FakeRoute { export interface FakeClientPlan { readonly routes?: ReadonlyArray; + readonly server?: ServerProfile; } +// A key a resource method required, with how many requests the fake had already served by then — +// zero proves the method asked before it reached for the wire. +export interface FakeRequirement { + readonly key: MethodKey; + readonly precedingRequests: number; +} + +// The fake records what a method required and never refuses: enforcement belongs to the real +// transport and is proven there, so a resource test needs no profile to reach its wire assertions. export interface FakeClient { readonly client: Transport; readonly calls: ReadonlyArray; + readonly required: ReadonlyArray; } export function createFakeClient(plan: FakeClientPlan = {}): FakeClient { const calls: FakeClientCall[] = []; + const required: FakeRequirement[] = []; const client: Transport = { async requestParsed( schema: ZodType, @@ -77,6 +91,7 @@ export function createFakeClient(plan: FakeClientPlan = {}): FakeClient { url: call.path, status: FAKE_STATUS, getServerTag: NO_SERVER_TAG, + serverSkew: null, }); }, async requestRaw() { @@ -85,6 +100,15 @@ export function createFakeClient(plan: FakeClientPlan = {}): FakeClient { async requestStream() { throw new Error("requestStream not implemented in fake client"); }, + async server() { + if (plan.server === undefined) { + throw new Error("no server profile in fake client plan"); + } + return plan.server; + }, + async require(key) { + required.push({ key, precedingRequests: calls.length }); + }, }; - return { client, calls }; + return { client, calls, required }; } diff --git a/packages/client/src/version/capabilities.test.ts b/packages/client/src/version/capabilities.test.ts deleted file mode 100644 index 8801707e..00000000 --- a/packages/client/src/version/capabilities.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { ServerInfo } from "./probe"; - -import { - BASELINE_CAPABILITIES, - checkCapabilities, - mergeCapabilities, - type Capabilities, -} from "./capabilities"; - -interface InfoOverrides { - major?: number | null; - tokenFeatures?: ServerInfo["tokenFeatures"]; -} - -function infoFor(overrides: InfoOverrides = {}): ServerInfo { - if (overrides.major === null) { - return { version: null, tokenFeatures: overrides.tokenFeatures ?? null }; - } - const major = overrides.major ?? 58; - return { - version: { tag: `v0.${major}.0`, major, patch: 0 }, - tokenFeatures: overrides.tokenFeatures ?? null, - }; -} - -describe("mergeCapabilities", () => { - it("returns BASELINE_CAPABILITIES when overrides are undefined", () => { - expect(mergeCapabilities()).toBe(BASELINE_CAPABILITIES); - }); - - it("overlays minVersion onto baseline", () => { - expect(mergeCapabilities({ minVersion: 60 })).toEqual({ minVersion: 60 }); - }); - - it("overlays tokenFeature onto baseline", () => { - expect(mergeCapabilities({ tokenFeature: "transforms" })).toEqual({ - minVersion: 58, - tokenFeature: "transforms", - }); - }); - - it("overlays both fields at once", () => { - expect(mergeCapabilities({ minVersion: 60, tokenFeature: "scim" })).toEqual({ - minVersion: 60, - tokenFeature: "scim", - }); - }); -}); - -describe("checkCapabilities", () => { - it("returns null when the server matches baseline (major 58)", () => { - expect(checkCapabilities(infoFor({ major: 58 }), BASELINE_CAPABILITIES)).toBeNull(); - }); - - it("returns null when major equals minVersion exactly", () => { - const required: Capabilities = { minVersion: 58 }; - expect(checkCapabilities(infoFor({ major: 58 }), required)).toBeNull(); - }); - - it("returns null when major exceeds minVersion (purely numeric, no patch axis)", () => { - const required: Capabilities = { minVersion: 99 }; - expect(checkCapabilities(infoFor({ major: 100 }), required)).toBeNull(); - }); - - it("returns version-too-old when major is below minVersion", () => { - const required: Capabilities = { minVersion: 60 }; - expect(checkCapabilities(infoFor({ major: 58 }), required)).toEqual({ - reason: "version-too-old", - detail: - "This operation requires Metabase v60+ (this server is v0.58.0). Upgrade Metabase to use it.", - }); - }); - - it("returns missing-token-feature when the required feature is absent from token-features", () => { - const required: Capabilities = { minVersion: 58, tokenFeature: "transforms" }; - expect( - checkCapabilities( - infoFor({ major: 58, tokenFeatures: { transforms: false, embedding: true } }), - required, - ), - ).toEqual({ - reason: "missing-token-feature", - detail: - "This operation requires the 'transforms' premium feature (not enabled on this server).", - }); - }); - - it("returns null when the required tokenFeature is enabled", () => { - const required: Capabilities = { minVersion: 58, tokenFeature: "transforms" }; - expect( - checkCapabilities(infoFor({ major: 58, tokenFeatures: { transforms: true } }), required), - ).toBeNull(); - }); - - it("returns unknown-version when the probe failed to identify the server", () => { - const required: Capabilities = { minVersion: 60 }; - expect(checkCapabilities(infoFor({ major: null }), required)).toEqual({ - reason: "unknown-version", - detail: - "Could not detect Metabase server version. Proceeding without preflight check; failures may produce confusing errors.", - }); - }); -}); diff --git a/packages/client/src/version/capabilities.ts b/packages/client/src/version/capabilities.ts deleted file mode 100644 index a9442980..00000000 --- a/packages/client/src/version/capabilities.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { z } from "zod"; - -import type { ServerInfo } from "./probe"; - -export const Capabilities = z.object({ - minVersion: z.number(), - tokenFeature: z.string().optional(), -}); -export type Capabilities = z.infer; - -export const BASELINE_CAPABILITIES: Capabilities = Object.freeze({ - minVersion: 58, -}); - -export function mergeCapabilities(overrides?: Partial): Capabilities { - if (overrides === undefined) { - return BASELINE_CAPABILITIES; - } - return { - minVersion: overrides.minVersion ?? BASELINE_CAPABILITIES.minVersion, - ...(overrides.tokenFeature === undefined ? {} : { tokenFeature: overrides.tokenFeature }), - }; -} - -type PreflightReason = "version-too-old" | "missing-token-feature" | "unknown-version"; - -export interface PreflightFailure { - readonly reason: PreflightReason; - readonly detail: string; -} - -export function checkCapabilities( - info: ServerInfo, - required: Capabilities, -): PreflightFailure | null { - if (info.version === null) { - return { - reason: "unknown-version", - detail: - "Could not detect Metabase server version. Proceeding without preflight check; failures may produce confusing errors.", - }; - } - - if (info.version.major < required.minVersion) { - return { - reason: "version-too-old", - detail: `This operation requires Metabase v${required.minVersion}+ (this server is ${info.version.tag}). Upgrade Metabase to use it.`, - }; - } - - if (required.tokenFeature !== undefined) { - const enabled = info.tokenFeatures?.[required.tokenFeature] === true; - if (!enabled) { - return { - reason: "missing-token-feature", - detail: `This operation requires the '${required.tokenFeature}' premium feature (not enabled on this server).`, - }; - } - } - - return null; -} diff --git a/packages/client/src/version/capability-summary.test.ts b/packages/client/src/version/capability-summary.test.ts new file mode 100644 index 00000000..9f86dff2 --- /dev/null +++ b/packages/client/src/version/capability-summary.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { summarizeCapabilities } from "./capability-summary"; +import { KNOWN_RANGE } from "./profile"; + +describe("summarizeCapabilities", () => { + it("reports the oldest known major as the floor of no features at all", () => { + expect(summarizeCapabilities([])).toEqual({ minVersion: KNOWN_RANGE.min }); + }); + + it("reports the highest `since` among the features as the floor", () => { + expect(summarizeCapabilities(["transforms", "transformJobActivation"])).toEqual({ + minVersion: 61, + }); + }); + + it("carries the one premium feature the list needs", () => { + expect(summarizeCapabilities(["library"])).toEqual({ minVersion: 59, tokenFeature: "library" }); + }); + + it("names the same premium feature once when several features share it", () => { + expect(summarizeCapabilities(["library", "library"])).toEqual({ + minVersion: 59, + tokenFeature: "library", + }); + }); + + it("refuses a list that needs two premium features, which one field cannot carry", () => { + expect(() => summarizeCapabilities(["library", "remoteSync"])).toThrow( + "a capability summary names one premium feature, got 2: library, remote_sync", + ); + }); +}); diff --git a/packages/client/src/version/capability-summary.ts b/packages/client/src/version/capability-summary.ts new file mode 100644 index 00000000..ec941762 --- /dev/null +++ b/packages/client/src/version/capability-summary.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import { FEATURE_RULES, type FeatureName, type FeatureRule } from "./features"; +import { KNOWN_RANGE } from "./profile"; + +// What a set of features amounts to for a consumer that thinks in terms of one server floor and +// one premium feature rather than named switches. +export const CapabilitySummary = z.object({ + minVersion: z.number(), + tokenFeature: z.string().optional(), +}); +export type CapabilitySummary = z.infer; + +export function summarizeCapabilities(features: readonly FeatureName[]): CapabilitySummary { + const rules: FeatureRule[] = features.map((feature) => FEATURE_RULES[feature]); + const minVersion = Math.max(KNOWN_RANGE.min, ...rules.map((rule) => rule.since)); + const tokenFeatures = [ + ...new Set( + rules.flatMap((rule) => (rule.tokenFeature === undefined ? [] : [rule.tokenFeature])), + ), + ]; + if (tokenFeatures.length > 1) { + throw new Error( + `a capability summary names one premium feature, got ${tokenFeatures.length}: ${tokenFeatures.join(", ")}`, + ); + } + const [tokenFeature] = tokenFeatures; + return tokenFeature === undefined ? { minVersion } : { minVersion, tokenFeature }; +} diff --git a/packages/client/src/version/features.test.ts b/packages/client/src/version/features.test.ts new file mode 100644 index 00000000..2372c3da --- /dev/null +++ b/packages/client/src/version/features.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluateFeatures, + FEATURE_NAMES, + FEATURE_RULES, + type FeatureName, + type FeatureRule, + ruleGap, +} from "./features"; +import { KNOWN_RANGE } from "./profile"; + +const RULES: ReadonlyArray = Object.values(FEATURE_RULES); + +const ALL_TOKEN_FEATURES: Record = Object.fromEntries( + RULES.flatMap((rule) => (rule.tokenFeature === undefined ? [] : [[rule.tokenFeature, true]])), +); + +function ruleFor(name: FeatureName): FeatureRule { + return FEATURE_RULES[name]; +} + +// A token-gated rule may hold on every major in the window; the token, not the major, is what +// turns it off. +const VERSION_GATED_NAMES = FEATURE_NAMES.filter( + (name) => ruleFor(name).tokenFeature === undefined, +); + +// Every effective major `createServerProfile` can produce: the window itself plus the slot a +// newer or unparseable server lands in. +function effectiveMajors(): number[] { + const majors: number[] = []; + for (let major = KNOWN_RANGE.min; major <= KNOWN_RANGE.max + 1; major += 1) { + majors.push(major); + } + return majors; +} + +function outcomesAcrossWindow(name: FeatureName): Set { + return new Set( + effectiveMajors().map((major) => evaluateFeatures(major, ALL_TOKEN_FEATURES)[name]), + ); +} + +describe("FEATURE_RULES", () => { + it.each(VERSION_GATED_NAMES)("%s is not constant across the known window", (name) => { + expect(outcomesAcrossWindow(name)).toEqual(new Set([false, true])); + }); + + it("names every feature after a behaviour, never a version", () => { + expect(FEATURE_NAMES.filter((name) => /v\d{2}/i.test(name))).toEqual([]); + }); +}); + +describe("evaluateFeatures", () => { + it("turns a `since` rule on from that major and keeps it on above", () => { + expect(evaluateFeatures(60, null).transformTargetTableId).toBe(false); + expect(evaluateFeatures(61, null).transformTargetTableId).toBe(true); + expect(evaluateFeatures(99, null).transformTargetTableId).toBe(true); + }); + + it("turns an `until` rule off past its last major", () => { + expect(evaluateFeatures(60, null).transformTargetTableLinkedOnCreate).toBe(false); + expect(evaluateFeatures(61, null).transformTargetTableLinkedOnCreate).toBe(true); + expect(evaluateFeatures(62, null).transformTargetTableLinkedOnCreate).toBe(false); + }); + + it("holds a `tokenFeature` rule only when the server grants that feature", () => { + expect(evaluateFeatures(61, null).library).toBe(false); + expect(evaluateFeatures(61, { library: false }).library).toBe(false); + expect(evaluateFeatures(61, { library: true }).library).toBe(true); + expect(evaluateFeatures(58, { library: true }).library).toBe(false); + }); + + it("answers for every rule in the table and nothing else", () => { + expect(Object.keys(evaluateFeatures(61, null))).toEqual([...FEATURE_NAMES]); + }); +}); + +describe("ruleGap", () => { + const gated: FeatureRule = { since: 60, until: 62, tokenFeature: "remote_sync" }; + + it("answers null when the major and the token both satisfy the rule", () => { + expect(ruleGap(gated, 61, { remote_sync: true })).toBeNull(); + }); + + it("names the version below `since` before it looks at the token", () => { + expect(ruleGap(gated, 59, null)).toEqual({ kind: "version" }); + expect(ruleGap(gated, 59, { remote_sync: true })).toEqual({ kind: "version" }); + }); + + it("names the version past `until`", () => { + expect(ruleGap(gated, 63, { remote_sync: true })).toEqual({ kind: "version" }); + }); + + it("names the token the server does not grant once the major fits", () => { + expect(ruleGap(gated, 61, null)).toEqual({ kind: "token", tokenFeature: "remote_sync" }); + expect(ruleGap(gated, 61, { remote_sync: false })).toEqual({ + kind: "token", + tokenFeature: "remote_sync", + }); + }); + + it("never names a token for a rule that has none", () => { + expect(ruleGap({ since: 59 }, 61, null)).toBeNull(); + }); +}); diff --git a/packages/client/src/version/features.ts b/packages/client/src/version/features.ts new file mode 100644 index 00000000..1e96c06c --- /dev/null +++ b/packages/client/src/version/features.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; + +import type { TokenFeatures } from "../domain/session-properties"; + +// A rule holds on every major from `since` through `until` (both inclusive) on which the server +// also grants `tokenFeature`. Names describe the behaviour a consumer branches on, never the +// version it arrived in, so a rule reads the same after the majors around it leave the window. +export interface FeatureRule { + readonly since: number; + readonly until?: number; + readonly tokenFeature?: string; +} + +export const FEATURE_RULES = { + transforms: { since: 59 }, + transformJobActivation: { since: 61 }, + measures: { since: 59 }, + library: { since: 59, tokenFeature: "library" }, + remoteSync: { since: 60, tokenFeature: "remote_sync" }, + contentTranslation: { since: 58, tokenFeature: "content_translation" }, + transformTargetTableId: { since: 61 }, + transformTargetTableLinkedOnCreate: { since: 61, until: 61 }, + transformJobRunIdIsNumeric: { since: 64 }, + libraryChildrenCarryType: { since: 62 }, + collectionItemsTotalOnEmptyPage: { since: 62 }, + invalidMbqlIsBadRequest: { since: 62 }, + queryNormalizedBeforeValidation: { since: 62 }, + boxplotDisplay: { since: 59 }, + nativeTableTemplateTag: { since: 59 }, + smartLinkMeasureModel: { since: 60 }, + dashboardSubscriptionFilters: { since: 58, tokenFeature: "dashboard_subscription_filters" }, +} satisfies Record; + +export type FeatureName = keyof typeof FEATURE_RULES; + +export function isFeatureName(name: string): name is FeatureName { + return Object.hasOwn(FEATURE_RULES, name); +} + +export const FEATURE_NAMES: ReadonlyArray = + Object.keys(FEATURE_RULES).filter(isFeatureName); + +export const Features = z.record(z.enum(FEATURE_NAMES), z.boolean()); +export type Features = z.infer; + +export function evaluateFeatures( + effectiveMajor: number, + tokenFeatures: Readonly | null, +): Features { + const entries = FEATURE_NAMES.map((name) => [ + name, + ruleHolds(FEATURE_RULES[name], effectiveMajor, tokenFeatures), + ]); + return Features.parse(Object.fromEntries(entries)); +} + +function ruleHolds( + rule: FeatureRule, + effectiveMajor: number, + tokenFeatures: Readonly | null, +): boolean { + return ruleGap(rule, effectiveMajor, tokenFeatures) === null; +} + +export interface VersionGap { + readonly kind: "version"; +} + +export interface TokenGap { + readonly kind: "token"; + readonly tokenFeature: string; +} + +export type FeatureGap = VersionGap | TokenGap; + +// The half of the rule the server fails, version first: an upgrade is the first step either way, +// and whether a token grants a route only matters once the route exists. +export function ruleGap( + rule: FeatureRule, + effectiveMajor: number, + tokenFeatures: Readonly | null, +): FeatureGap | null { + if (effectiveMajor < rule.since) { + return { kind: "version" }; + } + if (rule.until !== undefined && effectiveMajor > rule.until) { + return { kind: "version" }; + } + if (rule.tokenFeature === undefined) { + return null; + } + const granted = tokenFeatures !== null && tokenFeatures[rule.tokenFeature] === true; + return granted ? null : { kind: "token", tokenFeature: rule.tokenFeature }; +} diff --git a/packages/client/src/version/major-comparison-guard.test.ts b/packages/client/src/version/major-comparison-guard.test.ts new file mode 100644 index 00000000..bb45f337 --- /dev/null +++ b/packages/client/src/version/major-comparison-guard.test.ts @@ -0,0 +1,74 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, "..", "..", "..", ".."); +const SOURCE_ROOTS = ["packages/client/src", "packages/cli/src"]; + +const MAJOR_COMPARISON = /\.major\s*(<=|>=|===|!==|<|>)/; +const MIN_VERSION = /\bminVersion\b/; + +const GUARD = "packages/client/src/version/major-comparison-guard.test.ts"; + +// The files that turn a version into a decision. Everything else asks `features`. +const EVALUATORS: ReadonlySet = new Set([ + "packages/client/src/version/features.ts", + "packages/client/src/version/features.test.ts", + "packages/client/src/version/profile.ts", + "packages/client/src/version/profile.test.ts", + "packages/client/src/version/tag.ts", +]); + +// A single server floor is a summary the version layer derives from features for consumers that +// want one number; nothing outside it may declare one. +const VERSION_DIR = "packages/client/src/version/"; + +function sourceFiles(): string[] { + return SOURCE_ROOTS.flatMap((root) => + readdirSync(resolve(REPO_ROOT, root), { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".ts")) + .map((entry) => + relative(REPO_ROOT, resolve(entry.parentPath, entry.name)).split(sep).join("/"), + ) + .filter((file) => file !== GUARD), + ); +} + +function offendingLines(file: string, pattern: RegExp): string[] { + return readFileSync(resolve(REPO_ROOT, file), "utf8") + .split("\n") + .flatMap((line, index) => (pattern.test(line) ? [`${file}:${index + 1}: ${line.trim()}`] : [])); +} + +function mayCompareMajors(file: string): boolean { + return EVALUATORS.has(file); +} + +function mayDeclareMinVersion(file: string): boolean { + return file.startsWith(VERSION_DIR); +} + +describe("Metabase majors are compared in one place", () => { + it("no source outside the version evaluators compares a parsed major", () => { + const hits = sourceFiles() + .filter((file) => !mayCompareMajors(file)) + .flatMap((file) => offendingLines(file, MAJOR_COMPARISON)); + expect(hits).toEqual([]); + }); + + it("no source outside the version layer names a minVersion", () => { + const hits = sourceFiles() + .filter((file) => !mayDeclareMinVersion(file)) + .flatMap((file) => offendingLines(file, MIN_VERSION)); + expect(hits).toEqual([]); + }); + + it("every allowlisted file still exists, so a deleted file leaves no stale exemption", () => { + const existing = new Set(sourceFiles()); + const stale = [...EVALUATORS].filter((file) => !existing.has(file)); + expect(stale).toEqual([]); + }); +}); diff --git a/packages/client/src/version/preflight-error.ts b/packages/client/src/version/preflight-error.ts index a0b5ea69..ef48a91a 100644 --- a/packages/client/src/version/preflight-error.ts +++ b/packages/client/src/version/preflight-error.ts @@ -1,13 +1,41 @@ +import { z } from "zod"; + import { MetabaseError } from "../errors"; -import type { PreflightFailure } from "./capabilities"; +import { FEATURE_NAMES } from "./features"; + +export const RequirementReason = z.enum(["version-too-old", "missing-token-feature"]); +export type RequirementReason = z.infer; + +// The feature a client method needed and the profile lacked, with the rule that decided it, so a +// consumer can explain the refusal without re-reading the feature table. A schema rather than a +// bare type because a consumer that reports the refusal in its own output wants to describe it. +export const RequirementFailure = z.object({ + reason: RequirementReason, + detail: z.string(), + feature: z.enum(FEATURE_NAMES), + since: z.number().int(), + tokenFeature: z.string().nullable(), + serverVersion: z.string().nullable(), +}); +export type RequirementFailure = z.infer; + +export function versionTooOldMessage(since: number, serverVersion: string | null): string { + const server = + serverVersion === null ? "this server's version is unknown" : `this server is ${serverVersion}`; + return `This operation requires Metabase v${since}+ (${server}). Upgrade Metabase to use it.`; +} + +export function missingTokenFeatureMessage(tokenFeature: string): string { + return `This operation requires the '${tokenFeature}' premium feature (not enabled on this server).`; +} export class CapabilityError extends MetabaseError { readonly category = "capability"; readonly isRetryable = false; - readonly developerDetail: PreflightFailure; + readonly developerDetail: RequirementFailure; - constructor(failure: PreflightFailure) { + constructor(failure: RequirementFailure) { super(failure.detail); this.name = "CapabilityError"; this.developerDetail = failure; diff --git a/packages/client/src/version/probe.test.ts b/packages/client/src/version/probe.test.ts index a1a45fee..b2e129fd 100644 --- a/packages/client/src/version/probe.test.ts +++ b/packages/client/src/version/probe.test.ts @@ -24,6 +24,8 @@ describe("probeServer", () => { ); expect(await probeServer(client)).toEqual({ version: { tag: "v1.58.7", major: 58, patch: 7 }, + date: "2025-12-15", + hash: "abc1234", tokenFeatures: { advanced_permissions: true, audit_app: true, embedding: true }, }); }); @@ -34,10 +36,12 @@ describe("probeServer", () => { expect(calls).toEqual([EXPECTED_PROBE_CALL]); }); - it("returns tokenFeatures: null when the server omits the field", async () => { + it("returns null for token-features, date and hash when the server omits them", async () => { const { client } = createFakeClient(planning({ version: { tag: "v0.58.7" } })); expect(await probeServer(client)).toEqual({ version: { tag: "v0.58.7", major: 58, patch: 7 }, + date: null, + hash: null, tokenFeatures: null, }); }); @@ -51,6 +55,8 @@ describe("probeServer", () => { ); expect(await probeServer(client)).toEqual({ version: null, + date: null, + hash: null, tokenFeatures: { transforms: true }, }); }); diff --git a/packages/client/src/version/probe.ts b/packages/client/src/version/probe.ts index bf1254d6..24c30d09 100644 --- a/packages/client/src/version/probe.ts +++ b/packages/client/src/version/probe.ts @@ -8,6 +8,8 @@ export const PROBE_TIMEOUT_MS = 10_000; export interface ServerInfo { readonly version: ParsedVersion | null; + readonly date: string | null; + readonly hash: string | null; readonly tokenFeatures: Readonly | null; } @@ -15,7 +17,13 @@ interface ProbeOptions { retries?: number; } -export async function probeServer(client: Transport, opts: ProbeOptions = {}): Promise { +// The probe runs before a profile exists, so it asks only for the wire and never for `require`. +type ProbeTransport = Pick; + +export async function probeServer( + client: ProbeTransport, + opts: ProbeOptions = {}, +): Promise { const properties = await client.requestParsed(SessionProperties, PROBE_PATH, { timeoutMs: PROBE_TIMEOUT_MS, retries: opts.retries ?? 0, @@ -23,6 +31,8 @@ export async function probeServer(client: Transport, opts: ProbeOptions = {}): P const version = tryParseTag(properties.version.tag); return { version, + date: properties.version.date ?? null, + hash: properties.version.hash ?? null, tokenFeatures: properties["token-features"] ?? null, }; } diff --git a/packages/client/src/version/profile.test.ts b/packages/client/src/version/profile.test.ts new file mode 100644 index 00000000..144b7052 --- /dev/null +++ b/packages/client/src/version/profile.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; + +import { evaluateFeatures } from "./features"; +import type { ServerInfo } from "./probe"; +import { createServerProfile, featureGap, KNOWN_RANGE } from "./profile"; + +const HEAD_SLOT = KNOWN_RANGE.max + 1; + +function released(tag: string, major: number, tokenFeatures: ServerInfo["tokenFeatures"]) { + return { + version: { tag, major, patch: 2 }, + date: "2026-05-19", + hash: "0c64e27", + tokenFeatures, + } satisfies ServerInfo; +} + +describe("createServerProfile", () => { + it("keeps a major inside the window as supported, evaluated at its own major", () => { + const info = released("v0.61.2", 61, { library: false }); + expect(createServerProfile(info)).toEqual({ + version: { tag: "v0.61.2", major: 61, patch: 2 }, + buildDate: "2026-05-19", + hash: "0c64e27", + edition: "oss", + tokenFeatures: { library: false }, + features: evaluateFeatures(61, { library: false }), + skew: "supported", + }); + }); + + it("reads a major above the window as the head slot and reports newer-than-known", () => { + const info = released(`v1.${HEAD_SLOT + 5}.2`, HEAD_SLOT + 5, { library: true }); + expect(createServerProfile(info)).toEqual({ + version: { tag: `v1.${HEAD_SLOT + 5}.2`, major: HEAD_SLOT + 5, patch: 2 }, + buildDate: "2026-05-19", + hash: "0c64e27", + edition: "ee", + tokenFeatures: { library: true }, + features: evaluateFeatures(HEAD_SLOT, { library: true }), + skew: "newer-than-known", + }); + }); + + it("reads an unparseable tag as the head slot and reports unknown", () => { + const info: ServerInfo = { + version: null, + date: "2026-09-16", + hash: "548573f", + tokenFeatures: { library: false }, + }; + expect(createServerProfile(info)).toEqual({ + version: null, + buildDate: "2026-09-16", + hash: "548573f", + edition: "oss", + tokenFeatures: { library: false }, + features: evaluateFeatures(HEAD_SLOT, { library: false }), + skew: "unknown", + }); + }); + + it("keeps a major below the window at its real major, still supported, so each feature refuses by name", () => { + const info = released("v0.57.2", 57, null); + expect(createServerProfile(info)).toEqual({ + version: { tag: "v0.57.2", major: 57, patch: 2 }, + buildDate: "2026-05-19", + hash: "0c64e27", + edition: "oss", + tokenFeatures: null, + features: evaluateFeatures(57, null), + skew: "supported", + }); + }); + + it("takes the edition from the tag even when no premium feature is granted", () => { + expect(createServerProfile(released("v1.61.2", 61, null)).edition).toBe("ee"); + }); + + it("takes the edition from a granted premium feature when the tag says nothing", () => { + const info: ServerInfo = { + version: null, + date: null, + hash: null, + tokenFeatures: { library: true, remote_sync: false }, + }; + expect(createServerProfile(info).edition).toBe("ee"); + }); + + it("carries a probe without date, hash or token features as nulls", () => { + const info: ServerInfo = { version: null, date: null, hash: null, tokenFeatures: null }; + expect(createServerProfile(info)).toEqual({ + version: null, + buildDate: null, + hash: null, + edition: "oss", + tokenFeatures: null, + features: evaluateFeatures(HEAD_SLOT, null), + skew: "unknown", + }); + }); +}); + +describe("featureGap", () => { + it("agrees with the profile's own feature switch", () => { + const profile = createServerProfile(released("v1.60.2", 60, { library: true })); + expect(profile.features.library).toBe(true); + expect(featureGap(profile, "library")).toBeNull(); + expect(profile.features.remoteSync).toBe(false); + expect(featureGap(profile, "remoteSync")).toEqual({ + kind: "token", + tokenFeature: "remote_sync", + }); + }); + + it("names the version on a major below the rule's first", () => { + const profile = createServerProfile(released("v0.58.2", 58, null)); + expect(featureGap(profile, "transforms")).toEqual({ kind: "version" }); + }); + + it("places an unparseable tag at the head slot, so only a token can be missing", () => { + const info: ServerInfo = { version: null, date: null, hash: null, tokenFeatures: null }; + const profile = createServerProfile(info); + expect(featureGap(profile, "transformJobRunIdIsNumeric")).toBeNull(); + expect(featureGap(profile, "library")).toEqual({ kind: "token", tokenFeature: "library" }); + }); +}); diff --git a/packages/client/src/version/profile.ts b/packages/client/src/version/profile.ts new file mode 100644 index 00000000..1a25f0fe --- /dev/null +++ b/packages/client/src/version/profile.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +import { TokenFeatures } from "../domain/session-properties"; + +import { + evaluateFeatures, + FEATURE_RULES, + type FeatureGap, + type FeatureName, + Features, + ruleGap, +} from "./features"; +import type { ServerInfo } from "./probe"; +import { Edition, editionFromTag, ParsedVersion } from "./tag"; + +// The majors this client was built and tested against. A server above `max` is read as `max + 1`: +// its additions pass through the loose schemas, and a shape it changed fails loudly instead of +// being guessed at. A server below `min` keeps its real major so every feature it lacks is refused +// by name. +export const KNOWN_RANGE = { min: 58, max: 63 } as const; + +export const Skew = z.enum(["supported", "newer-than-known", "unknown"]); +export type Skew = z.infer; + +export const ServerProfile = z.object({ + version: ParsedVersion.nullable(), + buildDate: z.string().nullable(), + hash: z.string().nullable(), + edition: Edition, + tokenFeatures: TokenFeatures.nullable(), + features: Features, + skew: Skew, +}); +export type ServerProfile = z.infer; + +interface Placement { + readonly effectiveMajor: number; + readonly skew: Skew; +} + +export function createServerProfile(info: ServerInfo): ServerProfile { + const placement = place(info.version); + return { + version: info.version, + buildDate: info.date, + hash: info.hash, + edition: detectEdition(info), + tokenFeatures: info.tokenFeatures, + features: evaluateFeatures(placement.effectiveMajor, info.tokenFeatures), + skew: placement.skew, + }; +} + +// Why a profile lacks a feature — the same placement `createServerProfile` used, so the answer +// agrees with the boolean in `features`. +export function featureGap(profile: ServerProfile, feature: FeatureName): FeatureGap | null { + const placement = place(profile.version); + return ruleGap(FEATURE_RULES[feature], placement.effectiveMajor, profile.tokenFeatures); +} + +function place(version: ParsedVersion | null): Placement { + if (version === null) { + return { effectiveMajor: KNOWN_RANGE.max + 1, skew: "unknown" }; + } + if (version.major > KNOWN_RANGE.max) { + return { effectiveMajor: KNOWN_RANGE.max + 1, skew: "newer-than-known" }; + } + return { effectiveMajor: version.major, skew: "supported" }; +} + +// The tag is the authoritative signal. Head images and dev checkouts report `vUNKNOWN`, and +// `/api/session/properties` carries no other edition field — OSS sends the same all-false +// `token-features` map an unlicensed EE does — so for them the edition is whatever the token +// proves: a granted premium feature means EE, and an instance granting none behaves as OSS for +// everything this client can do. +function detectEdition(info: ServerInfo): Edition { + const fromTag = info.version === null ? null : editionFromTag(info.version.tag); + if (fromTag !== null) { + return fromTag; + } + return grantsAnyFeature(info.tokenFeatures) ? "ee" : "oss"; +} + +function grantsAnyFeature(tokenFeatures: Readonly | null): boolean { + return tokenFeatures !== null && Object.values(tokenFeatures).some((enabled) => enabled); +} diff --git a/packages/client/src/version/requirement-check.test.ts b/packages/client/src/version/requirement-check.test.ts new file mode 100644 index 00000000..0776f445 --- /dev/null +++ b/packages/client/src/version/requirement-check.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; + +import type { ServerInfo } from "./probe"; +import { createServerProfile } from "./profile"; +import { checkFeatures, checkRequirements } from "./requirement-check"; + +function profileOf(tag: string, major: number, tokenFeatures: ServerInfo["tokenFeatures"]) { + return createServerProfile({ + version: { tag, major, patch: 0 }, + date: null, + hash: null, + tokenFeatures, + }); +} + +describe("checkRequirements", () => { + it("answers null when the profile grants every feature the method needs", () => { + expect(checkRequirements("transform.get", profileOf("v0.60.4", 60, null))).toBeNull(); + }); + + it("answers null for a method that needs nothing, whatever the server", () => { + expect(checkRequirements("card.list", profileOf("v0.57.0", 57, null))).toBeNull(); + }); + + it("names the rule's floor and the server's tag for a version gate", () => { + expect(checkRequirements("measure.list", profileOf("v0.58.0", 58, null))).toEqual({ + reason: "version-too-old", + detail: + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", + feature: "measures", + since: 59, + tokenFeature: null, + serverVersion: "v0.58.0", + }); + }); + + it("names the premium feature for a token gate", () => { + expect(checkRequirements("gitSync.branches", profileOf("v1.60.4", 60, null))).toEqual({ + reason: "missing-token-feature", + detail: + "This operation requires the 'remote_sync' premium feature (not enabled on this server).", + feature: "remoteSync", + since: 60, + tokenFeature: "remote_sync", + serverVersion: "v1.60.4", + }); + }); + + it("reports the version before the token when a gated rule fails both", () => { + expect(checkRequirements("library.get", profileOf("v0.58.0", 58, null))).toEqual({ + reason: "version-too-old", + detail: + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", + feature: "library", + since: 59, + tokenFeature: "library", + serverVersion: "v0.58.0", + }); + }); + + it("reports the first missing feature of a method that needs several", () => { + expect(checkRequirements("transformJob.setActive", profileOf("v0.60.4", 60, null))).toEqual({ + reason: "version-too-old", + detail: + "This operation requires Metabase v61+ (this server is v0.60.4). Upgrade Metabase to use it.", + feature: "transformJobActivation", + since: 61, + tokenFeature: null, + serverVersion: "v0.60.4", + }); + }); + + it("names the strictest floor of a method that needs several, not the first one the server clears", () => { + expect(checkRequirements("transformJob.setActive", profileOf("v0.58.0", 58, null))).toEqual({ + reason: "version-too-old", + detail: + "This operation requires Metabase v61+ (this server is v0.58.0). Upgrade Metabase to use it.", + feature: "transformJobActivation", + since: 61, + tokenFeature: null, + serverVersion: "v0.58.0", + }); + }); + + it("places an unparseable tag at the newest known major, so only a token can refuse it", () => { + const head = createServerProfile({ + version: null, + date: "2026-09-16", + hash: "548573f", + tokenFeatures: { remote_sync: false }, + }); + expect(checkRequirements("transformJob.setActive", head)).toBeNull(); + expect(checkRequirements("gitSync.branches", head)).toEqual({ + reason: "missing-token-feature", + detail: + "This operation requires the 'remote_sync' premium feature (not enabled on this server).", + feature: "remoteSync", + since: 60, + tokenFeature: "remote_sync", + serverVersion: null, + }); + }); +}); + +describe("checkFeatures", () => { + it("answers null for an empty list, whatever the server", () => { + expect(checkFeatures([], profileOf("v0.57.0", 57, null))).toBeNull(); + }); + + it("reports the first feature in the list the profile lacks", () => { + expect(checkFeatures(["measures", "library"], profileOf("v0.59.0", 59, null))).toEqual({ + reason: "missing-token-feature", + detail: "This operation requires the 'library' premium feature (not enabled on this server).", + feature: "library", + since: 59, + tokenFeature: "library", + serverVersion: "v0.59.0", + }); + }); +}); diff --git a/packages/client/src/version/requirement-check.ts b/packages/client/src/version/requirement-check.ts new file mode 100644 index 00000000..3105a265 --- /dev/null +++ b/packages/client/src/version/requirement-check.ts @@ -0,0 +1,58 @@ +import { FEATURE_RULES, type FeatureGap, type FeatureName, type FeatureRule } from "./features"; +import { + missingTokenFeatureMessage, + type RequirementFailure, + versionTooOldMessage, +} from "./preflight-error"; +import { featureGap, type ServerProfile } from "./profile"; +import { type MethodKey, methodRequirements } from "./requirements"; + +/** The first feature a method needs that the profile lacks, or `null` when it may proceed. */ +export function checkRequirements( + key: MethodKey, + profile: ServerProfile, +): RequirementFailure | null { + return checkFeatures(methodRequirements(key), profile); +} + +/** The first of `features` the profile lacks, or `null` when it has every one. */ +export function checkFeatures( + features: readonly FeatureName[], + profile: ServerProfile, +): RequirementFailure | null { + for (const feature of features) { + const gap = featureGap(profile, feature); + if (gap !== null) { + return describeGap(gap, feature, profile); + } + } + return null; +} + +function describeGap( + gap: FeatureGap, + feature: FeatureName, + profile: ServerProfile, +): RequirementFailure { + const rule: FeatureRule = FEATURE_RULES[feature]; + const serverVersion = profile.version === null ? null : profile.version.tag; + const tokenFeature = rule.tokenFeature ?? null; + if (gap.kind === "token") { + return { + reason: "missing-token-feature", + detail: missingTokenFeatureMessage(gap.tokenFeature), + feature, + since: rule.since, + tokenFeature, + serverVersion, + }; + } + return { + reason: "version-too-old", + detail: versionTooOldMessage(rule.since, serverVersion), + feature, + since: rule.since, + tokenFeature, + serverVersion, + }; +} diff --git a/packages/client/src/version/requirements.test.ts b/packages/client/src/version/requirements.test.ts new file mode 100644 index 00000000..5885eef1 --- /dev/null +++ b/packages/client/src/version/requirements.test.ts @@ -0,0 +1,341 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { createClient } from "../client"; +import type { ClientCredentials, Transport } from "../http/transport"; +import { cardResource } from "../resources/card"; +import { collectionResource } from "../resources/collection"; +import { contentTranslationResource } from "../resources/content-translation"; +import { dashboardResource } from "../resources/dashboard"; +import { databaseResource } from "../resources/database"; +import { datasetResource } from "../resources/dataset"; +import { documentResource } from "../resources/document"; +import { eidTranslationResource } from "../resources/eid-translation"; +import { fieldResource } from "../resources/field"; +import { gitSyncResource } from "../resources/git-sync"; +import { libraryResource } from "../resources/library"; +import { measureResource } from "../resources/measure"; +import { notificationResource } from "../resources/notification"; +import { pulseResource } from "../resources/pulse"; +import { searchResource } from "../resources/search"; +import { segmentResource } from "../resources/segment"; +import { settingResource } from "../resources/setting"; +import { setupResource } from "../resources/setup"; +import { snippetResource } from "../resources/snippet"; +import { tableResource } from "../resources/table"; +import { timelineEventResource } from "../resources/timeline-event"; +import { timelineResource } from "../resources/timeline"; +import { transformJobResource } from "../resources/transform-job"; +import { transformResource } from "../resources/transform"; +import { transformTagResource } from "../resources/transform-tag"; +import { uploadResource } from "../resources/upload"; +import { userResource } from "../resources/user"; +import { createFakeClient } from "../testing/fake-client"; +import { TEST_USER_AGENT } from "../testing/fetch-capture"; + +import { FEATURE_RULES, type FeatureRule } from "./features"; +import { createServerProfile } from "./profile"; +import { + isMethodKey, + METHOD_KEYS, + METHOD_REQUIREMENTS, + type MethodKey, + methodRequirements, +} from "./requirements"; + +const RESOURCES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "resources"); + +const CREDENTIALS: ClientCredentials = { + url: "https://m.example.com", + credential: { kind: "apiKey", apiKey: "mb_requirements_key" }, +}; + +// A method that picks its wire shape by generation reads the profile between asking and requesting. +const SERVER = createServerProfile({ + version: { tag: "v0.63.0", major: 63, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, +}); + +const ESCAPE_HATCHES: ReadonlySet = new Set([ + "server", + "requestParsed", + "requestRaw", + "requestStream", +]); + +const TABLE_KEYS: ReadonlyArray = Object.keys(METHOD_REQUIREMENTS).toSorted(); + +const noNetwork: typeof fetch = () => { + throw new Error("the requirements table is enumerated without a socket"); +}; + +function methodKeysOnClient(): string[] { + const client = createClient(CREDENTIALS, { userAgent: TEST_USER_AGENT, fetchImpl: noNetwork }); + const keys: string[] = []; + for (const [namespace, resource] of Object.entries(client)) { + if (ESCAPE_HATCHES.has(namespace) || typeof resource !== "object" || resource === null) { + continue; + } + for (const [method, value] of Object.entries(resource)) { + if (typeof value === "function") { + keys.push(`${namespace}.${method}`); + } + } + } + return keys.toSorted(); +} + +function namespaceOf(fileName: string): string { + const [head, ...rest] = fileName.replace(/\.ts$/, "").split("-"); + return [head, ...rest.map((part) => part[0]?.toUpperCase() + part.slice(1))].join(""); +} + +interface RequiredLiteral { + readonly file: string; + readonly key: string; +} + +function requiredLiterals(): RequiredLiteral[] { + return readdirSync(RESOURCES_DIR) + .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) + .flatMap((file) => + [ + ...readFileSync(resolve(RESOURCES_DIR, file), "utf8").matchAll( + /transport\.require\("([^"]+)"\)/g, + ), + ] + .map((match) => match[1]) + .filter((key): key is string => key !== undefined) + .map((key) => ({ file, key })), + ); +} + +// One call per resource, with the wire request the fake refuses once the method reaches it — the +// refusal proves the method went to the wire only after it asked. +interface ResourceDrive { + readonly key: MethodKey; + readonly invoke: (transport: Transport) => Promise; + readonly wireError: string; +} + +const CSV = { filename: "rows.csv", bytes: new TextEncoder().encode("a\n1\n") }; + +const DRIVES: ReadonlyArray = [ + { + key: "card.get", + invoke: (t) => cardResource(t).get(1), + wireError: "unexpected request: GET /api/card/1", + }, + { + key: "collection.tree", + invoke: (t) => collectionResource(t).tree(), + wireError: "unexpected request: GET /api/collection/tree", + }, + { + key: "contentTranslation.download", + invoke: (t) => contentTranslationResource(t).download(), + wireError: "requestStream not implemented in fake client", + }, + { + key: "dashboard.get", + invoke: (t) => dashboardResource(t).get(1), + wireError: "unexpected request: GET /api/dashboard/1", + }, + { + key: "database.schemas", + invoke: (t) => databaseResource(t).schemas(1), + wireError: "unexpected request: GET /api/database/1/schemas", + }, + { + key: "dataset.query", + invoke: (t) => datasetResource(t).query({}), + wireError: "unexpected request: POST /api/dataset", + }, + { + key: "document.get", + invoke: (t) => documentResource(t).get(1), + wireError: "unexpected request: GET /api/document/1", + }, + { + key: "eidTranslation.translate", + invoke: (t) => eidTranslationResource(t).translate({ entity_ids: {} }), + wireError: "unexpected request: POST /api/eid-translation/translate", + }, + { + key: "field.get", + invoke: (t) => fieldResource(t).get(1), + wireError: "unexpected request: GET /api/field/1", + }, + { + key: "gitSync.branches", + invoke: (t) => gitSyncResource(t).branches(), + wireError: "unexpected request: GET /api/ee/remote-sync/branches", + }, + { + key: "library.get", + invoke: (t) => libraryResource(t).get(), + wireError: "unexpected request: GET /api/ee/library/", + }, + { + key: "measure.get", + invoke: (t) => measureResource(t).get(1), + wireError: "unexpected request: GET /api/measure/1", + }, + { + key: "notification.get", + invoke: (t) => notificationResource(t).get(1), + wireError: "unexpected request: GET /api/notification/1", + }, + { + key: "pulse.get", + invoke: (t) => pulseResource(t).get(1), + wireError: "unexpected request: GET /api/pulse/1", + }, + { + key: "search.query", + invoke: (t) => searchResource(t).query(), + wireError: "unexpected request: GET /api/search", + }, + { + key: "segment.get", + invoke: (t) => segmentResource(t).get(1), + wireError: "unexpected request: GET /api/segment/1", + }, + { + key: "setting.list", + invoke: (t) => settingResource(t).list(), + wireError: "unexpected request: GET /api/setting", + }, + { + key: "setup.create", + invoke: (t) => + setupResource(t).create({ + token: "setup-token", + user: { email: "admin@example.com", password: "pw" }, + prefs: { site_name: "Test" }, + }), + wireError: "unexpected request: POST /api/setup", + }, + { + key: "snippet.get", + invoke: (t) => snippetResource(t).get(1), + wireError: "unexpected request: GET /api/native-query-snippet/1", + }, + { + key: "table.get", + invoke: (t) => tableResource(t).get(1), + wireError: "unexpected request: GET /api/table/1", + }, + { + key: "timeline.get", + invoke: (t) => timelineResource(t).get(1), + wireError: "unexpected request: GET /api/timeline/1", + }, + { + key: "timelineEvent.get", + invoke: (t) => timelineEventResource(t).get(1), + wireError: "unexpected request: GET /api/timeline-event/1", + }, + { + key: "transform.get", + invoke: (t) => transformResource(t).get(1), + wireError: "unexpected request: GET /api/transform/1", + }, + { + key: "transformJob.get", + invoke: (t) => transformJobResource(t).get(1), + wireError: "unexpected request: GET /api/transform-job/1", + }, + { + key: "transformTag.list", + invoke: (t) => transformTagResource(t).list(), + wireError: "unexpected request: GET /api/transform-tag", + }, + { + key: "upload.createFromCsv", + invoke: (t) => uploadResource(t).createFromCsv(CSV, { collection_id: "1" }), + wireError: "requestRaw not implemented in fake client", + }, + { + key: "user.current", + invoke: (t) => userResource(t).current(), + wireError: "unexpected request: GET /api/user/current", + }, +]; + +async function messageOf(pending: Promise): Promise { + try { + await pending; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + return "resolved"; +} + +describe("METHOD_REQUIREMENTS", () => { + it("names exactly the methods the client exposes", () => { + expect(methodKeysOnClient()).toEqual(TABLE_KEYS); + }); + + it("is required by every resource method under the method's own key, and by nothing else", () => { + const literals = requiredLiterals(); + const foreign = literals.filter(({ file, key }) => !key.startsWith(`${namespaceOf(file)}.`)); + expect(foreign).toEqual([]); + expect(literals.map(({ key }) => key).toSorted()).toEqual(TABLE_KEYS); + }); + + // A requirement says the route exists on the server; a rule that ends at some major describes + // a shape an older server had, and refusing on it would name a floor the server is above. + it("requires no feature bounded by `until`", () => { + const bounded = Object.entries(METHOD_REQUIREMENTS).flatMap(([key, features]) => + features + .filter((feature) => { + const rule: FeatureRule = FEATURE_RULES[feature]; + return rule.until !== undefined; + }) + .map((feature) => `${key}: ${feature}`), + ); + expect(bounded).toEqual([]); + }); + + it("has every resource namespace driven below", () => { + const driven = new Set(DRIVES.map((drive) => drive.key.split(".")[0])); + const named = new Set(TABLE_KEYS.map((key) => key.split(".")[0])); + expect([...driven].toSorted()).toEqual([...named].toSorted()); + }); + + it.each(DRIVES)("$key asks the transport before its first request", async (drive) => { + const fake = createFakeClient({ server: SERVER }); + + expect(await messageOf(drive.invoke(fake.client))).toBe(drive.wireError); + expect(fake.required).toEqual([{ key: drive.key, precedingRequests: 0 }]); + }); +}); + +describe("isMethodKey", () => { + it("admits a key the table names and refuses one it does not", () => { + expect(isMethodKey("card.list")).toBe(true); + expect(isMethodKey("card.explode")).toBe(false); + }); +}); + +describe("METHOD_KEYS", () => { + it("lists every key of the table in its own order", () => { + expect(METHOD_KEYS).toEqual(Object.keys(METHOD_REQUIREMENTS)); + }); +}); + +describe("methodRequirements", () => { + it("answers the table's own entry", () => { + expect(methodRequirements("transformJob.setActive")).toEqual([ + "transformJobActivation", + "transforms", + ]); + expect(methodRequirements("card.list")).toEqual([]); + }); +}); diff --git a/packages/client/src/version/requirements.ts b/packages/client/src/version/requirements.ts new file mode 100644 index 00000000..1c31f065 --- /dev/null +++ b/packages/client/src/version/requirements.ts @@ -0,0 +1,158 @@ +import type { FeatureName } from "./features"; + +// Keyed `.` as the method is reached on the client, so a key reads the same in a +// command, a skill and an error. An empty list is a promise that every supported server answers. A +// list is ordered strictest-first: the first feature the server lacks is the one the refusal names, +// and naming the lowest floor would send a user through one upgrade only to be refused again. +export const METHOD_REQUIREMENTS = { + "card.list": [], + "card.get": [], + "card.create": [], + "card.update": [], + "card.archive": [], + "card.query": [], + "card.exportQuery": [], + "collection.list": [], + "collection.listWithLibrary": [], + "collection.get": [], + "collection.create": [], + "collection.update": [], + "collection.archive": [], + "collection.itemPages": [], + "collection.tree": [], + "contentTranslation.download": ["contentTranslation"], + "contentTranslation.upload": ["contentTranslation"], + "dashboard.list": [], + "dashboard.get": [], + "dashboard.create": [], + "dashboard.update": [], + "dashboard.archive": [], + "dashboard.updateDashcard": [], + "dashboard.checkCardReferences": [], + "dashboard.parameterValues": [], + "dashboard.searchParameterValues": [], + "database.list": [], + "database.get": [], + "database.schemas": [], + "database.schemaTables": [], + "database.syncSchema": [], + "database.rescanValues": [], + "dataset.query": [], + "document.list": [], + "document.get": [], + "document.create": [], + "document.update": [], + "document.archive": [], + "eidTranslation.translate": [], + "field.get": [], + "field.update": [], + "field.summary": [], + "field.values": [], + "gitSync.currentTask": ["remoteSync"], + "gitSync.cancelTask": ["remoteSync"], + "gitSync.isDirty": ["remoteSync"], + "gitSync.dirty": ["remoteSync"], + "gitSync.hasRemoteChanges": ["remoteSync"], + "gitSync.import": ["remoteSync"], + "gitSync.export": ["remoteSync"], + "gitSync.stash": ["remoteSync"], + "gitSync.branches": ["remoteSync"], + "gitSync.createBranch": ["remoteSync"], + "gitSync.setCollectionSynced": ["remoteSync"], + "gitSync.syncedCollections": ["remoteSync"], + "gitSync.remoteUrl": [], + "gitSync.branch": [], + "gitSync.waitForTask": ["remoteSync"], + "library.get": ["library"], + "library.create": ["library"], + "library.ensureDataCollectionId": ["library"], + "library.publishTables": ["library"], + "library.unpublishTables": ["library"], + "measure.list": ["measures"], + "measure.get": ["measures"], + "measure.create": ["measures"], + "measure.update": ["measures"], + "measure.archive": ["measures"], + "notification.list": [], + "notification.get": [], + "notification.create": [], + "notification.update": [], + "notification.archive": [], + "notification.send": [], + "pulse.list": [], + "pulse.get": [], + "pulse.create": [], + "pulse.update": [], + "pulse.archive": [], + "search.query": [], + "segment.list": [], + "segment.get": [], + "segment.create": [], + "segment.update": [], + "segment.archive": [], + "setting.list": [], + "setting.get": [], + "setting.set": [], + "setup.create": [], + "snippet.list": [], + "snippet.get": [], + "snippet.create": [], + "snippet.update": [], + "snippet.archive": [], + "table.list": [], + "table.get": [], + "table.update": [], + "table.queryMetadata": [], + "table.appendCsv": [], + "table.replaceCsv": [], + "timeline.list": [], + "timeline.get": [], + "timeline.events": [], + "timeline.create": [], + "timeline.update": [], + "timeline.archive": [], + "timeline.delete": [], + "timelineEvent.get": [], + "timelineEvent.create": [], + "timelineEvent.update": [], + "timelineEvent.archive": [], + "timelineEvent.delete": [], + "transform.list": ["transforms"], + "transform.get": ["transforms"], + "transform.create": ["transforms"], + "transform.update": ["transforms"], + "transform.delete": ["transforms"], + "transform.deleteTable": ["transforms"], + "transform.dependencies": ["transforms"], + "transform.cancel": ["transforms"], + "transform.getRun": ["transforms"], + "transform.runPages": ["transforms"], + "transform.run": ["transforms"], + "transformJob.list": ["transforms"], + "transformJob.get": ["transforms"], + "transformJob.create": ["transforms"], + "transformJob.update": ["transforms"], + "transformJob.delete": ["transforms"], + "transformJob.run": ["transforms"], + "transformJob.transforms": ["transforms"], + "transformJob.setActive": ["transformJobActivation", "transforms"], + "transformTag.list": ["transforms"], + "transformTag.create": ["transforms"], + "transformTag.update": ["transforms"], + "transformTag.delete": ["transforms"], + "upload.createFromCsv": [], + "user.current": [], +} satisfies Record; + +export type MethodKey = keyof typeof METHOD_REQUIREMENTS; + +export function isMethodKey(key: string): key is MethodKey { + return Object.hasOwn(METHOD_REQUIREMENTS, key); +} + +export const METHOD_KEYS: ReadonlyArray = + Object.keys(METHOD_REQUIREMENTS).filter(isMethodKey); + +export function methodRequirements(key: MethodKey): readonly FeatureName[] { + return METHOD_REQUIREMENTS[key]; +} diff --git a/packages/client/src/version/tag.test.ts b/packages/client/src/version/tag.test.ts index 4b348a92..7a691649 100644 --- a/packages/client/src/version/tag.test.ts +++ b/packages/client/src/version/tag.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { tryParseTag } from "./tag"; +import { editionFromTag, tryParseTag } from "./tag"; describe("tryParseTag", () => { it("parses a v0.* (OSS-prefixed) tag", () => { @@ -33,3 +33,22 @@ describe("tryParseTag", () => { expect(tryParseTag(input)).toBeNull(); }); }); + +describe("editionFromTag", () => { + it.each([ + ["a released OSS tag", "v0.61.2", "oss"], + ["a released EE tag", "v1.61.2", "ee"], + ["a locally built OSS jar", "v0.62.0-SNAPSHOT", "oss"], + ["a locally built EE jar", "v1.62.0-SNAPSHOT", "ee"], + ])("reads the edition off %s", (_label, input, expected) => { + expect(editionFromTag(input)).toBe(expected); + }); + + it.each([ + ["a head/nightly build tag", "vUNKNOWN"], + ["a dev checkout tag", "vLOCAL_DEV"], + ["a semver major outside 0|1", "v2.58.7"], + ])("returns null on %s", (_label, input) => { + expect(editionFromTag(input)).toBeNull(); + }); +}); diff --git a/packages/client/src/version/tag.ts b/packages/client/src/version/tag.ts index e54a5d95..89b6cab7 100644 --- a/packages/client/src/version/tag.ts +++ b/packages/client/src/version/tag.ts @@ -8,6 +8,15 @@ export const ParsedVersion = z.object({ }); export type ParsedVersion = z.infer; +export const Edition = z.enum(["oss", "ee"]); +export type Edition = z.infer; + +// Metabase's build stamps the edition into the semver major: an OSS jar is tagged `v0..` +// and an EE jar `v1..`, released and `-SNAPSHOT` alike. A tag that parses to neither +// (`vUNKNOWN`, `vLOCAL_DEV`) says nothing about the edition. +const OSS_SEMVER_MAJOR = 0; +const EE_SEMVER_MAJOR = 1; + // A dev build reports a tag that either fails semver outright ("vUNKNOWN", "vLOCAL_DEV") or parses // to a number that means nothing — a locally built jar reports "v0.1.0-SNAPSHOT", which would read // as Metabase v1 and make every version gate fire against a server that actually carries the newest @@ -19,7 +28,7 @@ export function tryParseTag(tag: string): ParsedVersion | null { return null; } const parsed = parseSemver(tag); - if (parsed === null || (parsed.major !== 0 && parsed.major !== 1)) { + if (parsed === null || editionOfSemverMajor(parsed.major) === null) { return null; } return { @@ -28,3 +37,18 @@ export function tryParseTag(tag: string): ParsedVersion | null { patch: parsed.patch, }; } + +export function editionFromTag(tag: string): Edition | null { + const parsed = parseSemver(tag); + return parsed === null ? null : editionOfSemverMajor(parsed.major); +} + +function editionOfSemverMajor(semverMajor: number): Edition | null { + if (semverMajor === OSS_SEMVER_MAJOR) { + return "oss"; + } + if (semverMajor === EE_SEMVER_MAJOR) { + return "ee"; + } + return null; +} diff --git a/tests/e2e/auth.e2e.test.ts b/tests/e2e/auth.e2e.test.ts index 60257a78..0a6d85ce 100644 --- a/tests/e2e/auth.e2e.test.ts +++ b/tests/e2e/auth.e2e.test.ts @@ -9,6 +9,10 @@ import { AuthProfileListEnvelope } from "../../packages/cli/src/commands/auth/li import { LogoutResult } from "../../packages/cli/src/commands/auth/logout"; import { AuthStatus } from "../../packages/cli/src/commands/auth/status"; import { ProfilesFile } from "../../packages/cli/src/core/auth/profile-record"; +import { + type ServerSummary, + summarizeServer, +} from "../../packages/cli/src/core/auth/server-summary"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; @@ -19,6 +23,17 @@ function profilesPath(configHome: string): string { return join(configHome, "metabase-cli", "profiles.json"); } +function serverFields(payload: ServerSummary): ServerSummary { + return { + version: payload.version, + edition: payload.edition, + skew: payload.skew, + knownRange: payload.knownRange, + tokenFeatures: payload.tokenFeatures, + features: payload.features, + }; +} + async function readProfilesJson(configHome: string): Promise { const raw = await fs.readFile(profilesPath(configHome), "utf8"); return parseJson(raw, ProfilesFile, { source: profilesPath(configHome) }); @@ -72,6 +87,8 @@ describe("auth e2e", () => { if (loginPayload.version !== null) { expect(loginPayload.version.tag.startsWith("v")).toBe(true); } + const expectedServer = summarizeServer(bootstrap.server); + expect(serverFields(loginPayload)).toEqual(expectedServer); const fileAfterLogin = await readProfilesJson(configHome); expect(fileAfterLogin.profiles).toHaveLength(1); @@ -89,8 +106,13 @@ describe("auth e2e", () => { expect(statusPayload.present).toBe(true); expect(statusPayload.url).toBe(bootstrap.baseUrl); expect(statusPayload.user?.id).toBe(loginPayload.user?.id); - expect(statusPayload.version?.tag).toBe(loginPayload.version?.tag); + expect(serverFields(statusPayload)).toEqual(expectedServer); expect(statusPayload.lastFailure).toBeNull(); + + const list = await runCli({ args: ["auth", "list", "--json"], configHome }); + expect(list.exitCode, list.stderr).toBe(0); + const listed = parseJson(list.stdout, AuthProfileListEnvelope).data.map(serverFields); + expect(listed).toEqual([expectedServer]); }); it("first-time login with an invalid api key fails verification and leaves profiles.json untouched", async () => { diff --git a/tests/e2e/bootstrap-data.ts b/tests/e2e/bootstrap-data.ts index 86e1a47f..92000b5f 100644 --- a/tests/e2e/bootstrap-data.ts +++ b/tests/e2e/bootstrap-data.ts @@ -44,6 +44,8 @@ export type SeededIds = z.infer; export const ServerIdentity = z.object({ version: ParsedVersion.nullable(), + date: z.string().nullable().default(null), + hash: z.string().nullable().default(null), tokenFeatures: TokenFeatures.nullable(), // Whether the server supports full-API OAuth login (Metabase v63+, full-access scope advertised // in discovery); probed live during bootstrap. Defaults false for bootstrap files written before diff --git a/tests/e2e/collection.e2e.test.ts b/tests/e2e/collection.e2e.test.ts index fb4dca6a..0d4ed879 100644 --- a/tests/e2e/collection.e2e.test.ts +++ b/tests/e2e/collection.e2e.test.ts @@ -10,7 +10,7 @@ import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; -import { serverVersionBelow } from "./server-gate"; +import { serverHas } from "./server-gate"; const DEFAULT_COLLECTION_NAME = "E2E Default"; @@ -62,12 +62,10 @@ const TRASH_COMPACT = { is_remote_synced: false, } as const; -// Through v61 the items endpoint reads its total off the first row's window column, so an empty -// page reports no total at all; from v62 it reports 0. -const EMPTY_ITEMS_TOTAL_VERSION = 62; - +// A server that reads the total off the first row's window column has none to report for an +// empty page. function emptyItemsPageTotal(): number | null { - return serverVersionBelow(EMPTY_ITEMS_TOTAL_VERSION) ? null : 0; + return serverHas("collectionItemsTotalOnEmptyPage") ? 0 : null; } describe("collection e2e", () => { diff --git a/tests/e2e/content-translation.e2e.test.ts b/tests/e2e/content-translation.e2e.test.ts index 5b62be2c..d7ef10de 100644 --- a/tests/e2e/content-translation.e2e.test.ts +++ b/tests/e2e/content-translation.e2e.test.ts @@ -18,10 +18,7 @@ const INVALID_LOCALE_CSV = "Language,String,Translation\nxx,Title,Rubrik\n"; const skipReason = requireServer( "content-translation › content translation e2e against EE endpoints", - { - minVersion: 58, - tokenFeature: "content_translation", - }, + ["contentTranslation"], ); describe("content-translation arg validation e2e (no Metabase contact required)", () => { diff --git a/tests/e2e/eid-translation.e2e.test.ts b/tests/e2e/eid-translation.e2e.test.ts index 874fc8cf..837d3e09 100644 --- a/tests/e2e/eid-translation.e2e.test.ts +++ b/tests/e2e/eid-translation.e2e.test.ts @@ -12,7 +12,7 @@ import { requireServer } from "./server-gate"; const transformsSkip = requireServer( "eid-translation › accepts the previously-missing transform model in the closed enum", - { minVersion: 59 }, + ["transforms"], ); describe("eid e2e", () => { diff --git a/tests/e2e/git-sync.e2e.test.ts b/tests/e2e/git-sync.e2e.test.ts index f791082a..346219ba 100644 --- a/tests/e2e/git-sync.e2e.test.ts +++ b/tests/e2e/git-sync.e2e.test.ts @@ -13,13 +13,9 @@ import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { requireServer } from "./server-gate"; -// The remote-sync API has breaking server-side differences through v59 (the git source layer -// was reworked and v59 NPEs on the idempotent `remove-collection` no-op path); it settles at -// v60, which is the minVersion every git-sync command declares. -const skipReason = requireServer("git-sync › git-sync e2e against EE git-sync endpoints", { - minVersion: 60, - tokenFeature: "remote_sync", -}); +const skipReason = requireServer("git-sync › git-sync e2e against EE git-sync endpoints", [ + "remoteSync", +]); describe("git-sync arg validation e2e (no Metabase contact required)", () => { const tempDirs: string[] = []; diff --git a/tests/e2e/help-json.e2e.test.ts b/tests/e2e/help-json.e2e.test.ts index 81636e5a..341a81c5 100644 --- a/tests/e2e/help-json.e2e.test.ts +++ b/tests/e2e/help-json.e2e.test.ts @@ -50,6 +50,61 @@ describe("--help --json e2e", () => { expect(index).toEqual(await buildHelpIndex(card, ["card"])); }); + it("reports the client methods a gated command calls, their features, and the floor they summarize to", async () => { + const result = await runCli({ + args: ["transform-job", "set-active", "--help", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + + const entry = parseJson(result.stdout, CommandHelpEntry, { source: "--help --json" }); + expect(entry.requires).toEqual({ + methods: ["transformJob.setActive"], + features: ["transformJobActivation", "transforms"], + }); + expect(entry.capabilities).toEqual({ minVersion: 61 }); + }); + + it("reports the premium feature behind a token-gated command", async () => { + const result = await runCli({ + args: ["library", "get", "--help", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + + const entry = parseJson(result.stdout, CommandHelpEntry, { source: "--help --json" }); + expect(entry.requires).toEqual({ methods: ["library.get"], features: ["library"] }); + expect(entry.capabilities).toEqual({ minVersion: 59, tokenFeature: "library" }); + }); + + it("reports a baseline command's methods with no features and the baseline floor", async () => { + const result = await runCli({ + args: ["card", "list", "--help", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + + const entry = parseJson(result.stdout, CommandHelpEntry, { source: "--help --json" }); + expect(entry.requires).toEqual({ methods: ["card.list"], features: [] }); + expect(entry.capabilities).toEqual({ minVersion: 58 }); + }); + + it("reports null requires and capabilities for a command that never reaches a server", async () => { + const result = await runCli({ + args: ["uuid", "--help", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + + const entry = parseJson(result.stdout, CommandHelpEntry, { source: "--help --json" }); + expect(entry.requires).toBeNull(); + expect(entry.capabilities).toBeNull(); + }); + it("emits the full entry with output schema and examples for a leaf command", async () => { const result = await runCli({ args: ["card", "query", "--help", "--json"], diff --git a/tests/e2e/library.e2e.test.ts b/tests/e2e/library.e2e.test.ts index 81a848c9..8f60c7a6 100644 --- a/tests/e2e/library.e2e.test.ts +++ b/tests/e2e/library.e2e.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { Library } from "@metabase/client/domain/library"; +import { Library, type LibraryChild } from "@metabase/client/domain/library"; import { TableCompact } from "@metabase/client/domain/table"; import { parseJson } from "@metabase/client/json"; @@ -12,13 +12,10 @@ import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { SEEDED } from "./seed/seeded"; import { requireServer } from "./server-gate"; -const LIBRARY_UNAVAILABLE = requireServer("library › with the library feature", { - minVersion: 59, - tokenFeature: "library", -}); +const LIBRARY_UNAVAILABLE = requireServer("library › with the library feature", ["library"]); const REMOTE_SYNC_UNAVAILABLE = requireServer( "library › with the library and remote_sync features", - { minVersion: 60, tokenFeature: "remote_sync" }, + ["remoteSync"], ); const SYNC_SCOPE_HINT_MARKER = "is not marked for git-sync"; @@ -36,6 +33,29 @@ const REVIEWS_COMPACT = { const NO_SELECTOR_MESSAGE = "provide at least one selector: --table-ids, --db-ids, or --schemas"; +// The Library's children arrive as a set, so they are compared in name order; only the Data +// collection's id is seeded. +function childrenByName(library: Library): LibraryChild[] { + return library.effective_children.toSorted((a, b) => a.name.localeCompare(b.name)); +} + +const LIBRARY_CHILDREN = [ + { + id: SEEDED.libraryDataCollectionId, + name: "Data", + description: null, + type: "library-data", + is_remote_synced: false, + }, + { + id: expect.any(Number), + name: "Metrics", + description: null, + type: "library-metrics", + is_remote_synced: false, + }, +]; + describe("library e2e", () => { let bootstrap: E2EBootstrap; const tempDirs: string[] = []; @@ -118,16 +138,7 @@ describe("library e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - const data = parseJson(result.stdout, Library).effective_children.find( - (child) => child.type === "library-data", - ); - expect(data).toEqual({ - id: SEEDED.libraryDataCollectionId, - name: "Data", - type: "library-data", - description: null, - is_remote_synced: false, - }); + expect(childrenByName(parseJson(result.stdout, Library))).toEqual(LIBRARY_CHILDREN); }); it("create is idempotent and returns the existing Library", async () => { @@ -138,16 +149,7 @@ describe("library e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - const data = parseJson(result.stdout, Library).effective_children.find( - (child) => child.type === "library-data", - ); - expect(data).toEqual({ - id: SEEDED.libraryDataCollectionId, - name: "Data", - type: "library-data", - description: null, - is_remote_synced: false, - }); + expect(childrenByName(parseJson(result.stdout, Library))).toEqual(LIBRARY_CHILDREN); }); it("publish resolves the Data collection and sets is_published, unpublish restores it", async () => { diff --git a/tests/e2e/measure.e2e.test.ts b/tests/e2e/measure.e2e.test.ts index f9dc2a57..562fce24 100644 --- a/tests/e2e/measure.e2e.test.ts +++ b/tests/e2e/measure.e2e.test.ts @@ -37,7 +37,7 @@ const NEW_MEASURE_BODY: MeasureCreateInput = { }, }; -const skipReason = requireServer("measure › measure e2e", { minVersion: 59 }); +const skipReason = requireServer("measure › measure e2e", ["measures"]); describe.skipIf(skipReason !== null)("measure e2e", () => { let bootstrap: E2EBootstrap; diff --git a/tests/e2e/run-cli.e2e.test.ts b/tests/e2e/run-cli.e2e.test.ts index cc5c5c93..ab54e00e 100644 --- a/tests/e2e/run-cli.e2e.test.ts +++ b/tests/e2e/run-cli.e2e.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { parseJson } from "@metabase/client/json"; import { AuthStatus } from "../../packages/cli/src/commands/auth/status"; +import { summarizeServer } from "../../packages/cli/src/core/auth/server-summary"; import { DEFAULT_PROFILE } from "../../packages/cli/src/core/auth/storage"; import { cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; @@ -59,8 +60,7 @@ describe("runCli env isolation e2e", () => { url: null, method: null, user: null, - version: null, - tokenFeatures: null, + ...summarizeServer(null), lastProbedAt: null, lastFailure: null, }); diff --git a/tests/e2e/seed-profile.ts b/tests/e2e/seed-profile.ts new file mode 100644 index 00000000..27337bec --- /dev/null +++ b/tests/e2e/seed-profile.ts @@ -0,0 +1,66 @@ +import type { ServerInfo } from "@metabase/client/version/probe"; + +import { writeProbeResult, writeProfile } from "../../packages/cli/src/core/auth/storage"; + +// The seeded records are written straight through the CLI's own storage, so the CLI under test +// reads a profile it could have written itself; the port-1 URL never answers, which keeps a test +// on the cached probe honest about opening no socket. +export const UNREACHABLE_URL = "http://127.0.0.1:1"; +export const SEED_USER = { id: 1, name: "Tester", isAdmin: true }; + +export interface SeedTarget { + url: string; + apiKey: string; +} + +export const UNREACHABLE: SeedTarget = { url: UNREACHABLE_URL, apiKey: "secret-key" }; + +export function versionAt(major: number): ServerInfo["version"] { + return { tag: `v0.${major}.0`, major, patch: 0 }; +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +async function withSeedEnv(configHome: string, seed: () => Promise): Promise { + const prevXdg = process.env["XDG_CONFIG_HOME"]; + const prevKeyring = process.env["MB_CLI_DISABLE_KEYRING"]; + process.env["XDG_CONFIG_HOME"] = configHome; + process.env["MB_CLI_DISABLE_KEYRING"] = "1"; + try { + await seed(); + } finally { + restoreEnv("XDG_CONFIG_HOME", prevXdg); + restoreEnv("MB_CLI_DISABLE_KEYRING", prevKeyring); + } +} + +export async function seedProfile(configHome: string): Promise { + await withSeedEnv(configHome, async () => { + await writeProfile(UNREACHABLE, "default"); + }); +} + +export async function seedProbedProfileAt( + configHome: string, + target: SeedTarget, + version: ServerInfo["version"], + tokenFeatures: ServerInfo["tokenFeatures"] = null, +): Promise { + await withSeedEnv(configHome, async () => { + await writeProfile(target, "default"); + await writeProbeResult("default", { + user: SEED_USER, + server: { version, date: null, hash: null, tokenFeatures }, + }); + }); +} + +export async function seedProbedProfile(configHome: string, major: number): Promise { + await seedProbedProfileAt(configHome, UNREACHABLE, versionAt(major)); +} diff --git a/tests/e2e/seed/ids.ts b/tests/e2e/seed/ids.ts index 8ca01d54..1068904d 100644 --- a/tests/e2e/seed/ids.ts +++ b/tests/e2e/seed/ids.ts @@ -2,3 +2,9 @@ export const E2E_GROUPS = { ALL_USERS: 1, ADMIN: 2, } as const; + +// The transform jobs every server creates for itself, in creation order. +export const E2E_BUILTIN_TRANSFORM_JOBS = { + HOURLY: 1, + DAILY: 2, +} as const; diff --git a/tests/e2e/server-gate.ts b/tests/e2e/server-gate.ts index f28849d3..93a0653e 100644 --- a/tests/e2e/server-gate.ts +++ b/tests/e2e/server-gate.ts @@ -6,23 +6,13 @@ import { z } from "zod"; import { isFileNotFoundError } from "@metabase/client/errors"; import { parseJson } from "@metabase/client/json"; -import { - checkCapabilities, - mergeCapabilities, - type Capabilities, -} from "@metabase/client/version/capabilities"; -import type { ServerInfo } from "@metabase/client/version/probe"; +import type { FeatureName } from "@metabase/client/version/features"; +import { createServerProfile, type ServerProfile } from "@metabase/client/version/profile"; +import { checkFeatures } from "@metabase/client/version/requirement-check"; import { readBootstrapSync } from "./bootstrap-data"; import { resolveStackId } from "./defaults"; -// A dev/head build ("vUNKNOWN", "vLOCAL_DEV", or any "-SNAPSHOT" tag) probes to -// version: null. Treat that as the latest version so every version-gated suite runs — head and -// local dev builds carry the newest features, and skipping them would hide regressions there. -// The premium token-feature is still checked against the live probe, so this only relaxes the -// version that genuinely can't be parsed; a suite whose token-feature the server lacks still skips. -const HEAD_ASSUMED_MAJOR = 9999; - const HERE = dirname(fileURLToPath(import.meta.url)); // Gates run at module top level in the test worker, where `isolate: true` resets the module @@ -70,22 +60,18 @@ function recordGateSkip(lane: string, reason: string): void { writeFileSync(GATE_SKIP_FILE_PATH, `${JSON.stringify([...logged, { lane, reason }], null, 2)}\n`); } -function resolveServerInfo(): ServerInfo { - const { server } = readBootstrapSync(); - if (server.version !== null) { - return server; - } - return { - version: { tag: "vHEAD", major: HEAD_ASSUMED_MAJOR, patch: 0 }, - tokenFeatures: server.tokenFeatures, - }; +// The same profile the CLI builds from a cached probe, so a gate and the command it guards agree: +// a head build's unparseable tag lands on the newest known major and every version-gated suite +// runs there, while a token feature the server lacks still skips. +function resolveServerProfile(): ServerProfile { + return createServerProfile(readBootstrapSync().server); } // `lane` names the describe or test the caller guards with the returned reason. It is what the // closing report prints, so an unmet gate says which coverage went dark rather than adding one // more anonymous digit to vitest's skip count. -export function requireServer(lane: string, required: Partial): string | null { - const failure = checkCapabilities(resolveServerInfo(), mergeCapabilities(required)); +export function requireServer(lane: string, required: readonly FeatureName[]): string | null { + const failure = checkFeatures(required, resolveServerProfile()); if (failure === null) { return null; } @@ -108,35 +94,26 @@ export function requireOAuthServer(lane: string): string | null { return OAUTH_UNSUPPORTED_REASON; } -// True only when the server version is known AND below `minVersion` — the exact condition under -// which a non-baseline command's preflight raises a CapabilityError (exit 2) rather than warning -// and proceeding on an unknown version. Lets a suite assert the gate fires on the sub-version -// stacks the matrix boots, inverse to the `requireServer` skip the happy-path suite uses. It logs -// nothing: the branch it selects against is the complementary one, not coverage that went missing. -export function serverVersionBelow(minVersion: number): boolean { - const { version } = resolveServerInfo(); - return version !== null && version.major < minVersion; +// Whether the connected server has a feature — the exact question the CLI's preflight and the +// client's `require()` ask, so a suite can select the branch its stack takes. It logs nothing: a +// suite reading it asserts one of two exact outcomes rather than skipping coverage. +export function serverHas(feature: FeatureName): boolean { + return resolveServerProfile().features[feature]; } -// Metabase answers a structurally invalid MBQL 5 definition with a 500 through v61 and a 400 from -// v62 on. A suite asserting "the server, not the client-side validator, rejected this" pins the one -// status its own stack sends, so a 500 where a 400 belongs is still a failure. -const MBQL_REJECTION_STATUS_VERSION = 62; - +// A suite asserting "the server, not the client-side validator, rejected this" pins the one status +// its own stack sends, so a 500 where a 400 belongs is still a failure. export function serverRejectedMessage(): string { - return serverVersionBelow(MBQL_REJECTION_STATUS_VERSION) - ? "Metabase returned 500." - : "Metabase returned 400."; + return serverHas("invalidMbqlIsBadRequest") ? "Metabase returned 400." : "Metabase returned 500."; } -// From v62 a query the server cannot normalize — a database id that is not an integer, say — is -// refused with one message for the whole query before any field-level schema check runs; through -// v61 the schema check runs first and names the field it rejected. -const QUERY_NORMALIZATION_VERSION = 62; +// A query the server cannot normalize — a database id that is not an integer, say — is refused with +// one message for the whole query where normalization runs before the field-level schema check, and +// with the field the schema check rejected where it does not. const QUERY_NORMALIZATION_MESSAGE = "Invalid query: missing or invalid Database ID (:database)"; export function invalidDatabaseRejection(fieldLevelMessage: string): string { - return serverVersionBelow(QUERY_NORMALIZATION_VERSION) - ? fieldLevelMessage - : QUERY_NORMALIZATION_MESSAGE; + return serverHas("queryNormalizedBeforeValidation") + ? QUERY_NORMALIZATION_MESSAGE + : fieldLevelMessage; } diff --git a/tests/e2e/setting.e2e.test.ts b/tests/e2e/setting.e2e.test.ts index 36e80ed0..16622a3c 100644 --- a/tests/e2e/setting.e2e.test.ts +++ b/tests/e2e/setting.e2e.test.ts @@ -18,8 +18,8 @@ import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { requireServer } from "./server-gate"; const remoteSyncSkip = requireServer( - "setting › get --json on a string-valued setting wraps the bare server response", - { minVersion: 60, tokenFeature: "remote_sync" }, + "setting › get --json on a keyword-typed EE setting (remote-sync-type) wraps the bare server response", + ["remoteSync"], ); const MUTABLE_KEY = "enable-public-sharing"; @@ -55,8 +55,8 @@ describe("setting e2e", () => { }; } - async function writeMutableKey(value: unknown): Promise { - await adminClient.requestRaw(`/api/setting/${MUTABLE_KEY}`, { + async function writeSetting(key: string, value: unknown): Promise { + await adminClient.requestRaw(`/api/setting/${key}`, { method: "PUT", body: { value }, expectContentType: "binary", @@ -64,7 +64,7 @@ describe("setting e2e", () => { } it("list returns a parseable envelope including a stable admin-visible setting", async () => { - await writeMutableKey(false); + await writeSetting(MUTABLE_KEY, false); const result = await runCli({ args: ["setting", "list", "--json", "--max-bytes", "0"], @@ -148,7 +148,7 @@ describe("setting e2e", () => { }); it("set null deletes the override; get returns null again", async () => { - await writeMutableKey(false); + await writeSetting(MUTABLE_KEY, false); const setResult = await runCli({ args: ["setting", "set", MUTABLE_KEY, "null", "--json"], @@ -236,16 +236,12 @@ describe("setting e2e", () => { }); it.skipIf(remoteSyncSkip !== null)( - "get --json on a string-valued setting wraps the bare server response", + "get --json on a keyword-typed EE setting (remote-sync-type) wraps the bare server response", async () => { const STRING_KEY = "remote-sync-type"; const TARGET = "read-write"; try { - await adminClient.requestRaw(`/api/setting/${STRING_KEY}`, { - method: "PUT", - body: { value: TARGET }, - expectContentType: "binary", - }); + await writeSetting(STRING_KEY, TARGET); const result = await runCli({ args: ["setting", "get", STRING_KEY, "--json"], @@ -259,15 +255,28 @@ describe("setting e2e", () => { value: TARGET, }); } finally { - await adminClient.requestRaw(`/api/setting/${STRING_KEY}`, { - method: "PUT", - body: { value: null }, - expectContentType: "binary", - }); + await writeSetting(STRING_KEY, null); } }, ); + it("get --json on a keyword-typed setting (session-cookie-samesite) returns the bare name as a string", async () => { + const KEYWORD_KEY = "session-cookie-samesite"; + await writeSetting(KEYWORD_KEY, "strict"); + + const result = await runCli({ + args: ["setting", "get", KEYWORD_KEY, "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SettingValue)).toEqual({ + key: KEYWORD_KEY, + value: "strict", + }); + }); + it("get --json on a text/plain string setting (admin-email) returns the wrapped string", async () => { const result = await runCli({ args: ["setting", "get", "admin-email", "--json"], diff --git a/tests/e2e/setup/bootstrap.ts b/tests/e2e/setup/bootstrap.ts index 14c762dd..1d90cebf 100644 --- a/tests/e2e/setup/bootstrap.ts +++ b/tests/e2e/setup/bootstrap.ts @@ -13,6 +13,7 @@ import { backoffDelay, DEFAULT_MAX_RETRIES, runWithRetries } from "@metabase/cli import { parseJsonResult } from "@metabase/client/json"; import { pollUntil } from "@metabase/client/poll"; import { probeServer, type ServerInfo } from "@metabase/client/version/probe"; +import { createServerProfile } from "@metabase/client/version/profile"; import { USER_AGENT } from "../../../packages/cli/src/core/user-agent"; import { @@ -63,10 +64,7 @@ const ORDERS_BY_STATUS_SQL = "SELECT status, COUNT(*) AS n FROM orders GROUP BY const ORDERS_OVERVIEW_DASHBOARD_NAME = "Orders Overview"; const ORDERS_OVERVIEW_DASHBOARD_DESCRIPTION = "E2E seeded dashboard with one orders dashcard."; const LIMITED_GROUP_NAME = "E2E Limited"; -const LIBRARY_FEATURE = "library"; -const LIBRARY_MIN_VERSION = 59; const TRANSFORMS_ENABLED_SETTING = "transforms-enabled"; -const TRANSFORMS_MIN_VERSION = 59; const TRANSFORMS_LOCKED_STATUSES: ReadonlySet = new Set([402, 403]); const BASE_URL = resolveE2EBaseUrl(); @@ -361,9 +359,6 @@ async function findSeedResidue(sessionId: string): Promise { return residue; } -// Mirrors tests/e2e/server-gate.ts: a null (unparseable head/dev) version counts as the latest, so -// the library round-trip seeds and runs on head images; real sub-59 images parse to a major below -// the floor and skip seeding, since their `/api/ee/library` endpoints don't exist yet. // A reused snapshot must already contain everything the current seed produces for this server. A // library-capable server whose stored snapshot has no library Data collection cannot satisfy the // library suite (every test resets to a state that never had it), so refuse it with an actionable @@ -377,14 +372,11 @@ function assertSnapshotMatchesSeed(existing: E2EBootstrap): void { } function libraryReady(server: ServerInfo): boolean { - if (server.tokenFeatures?.[LIBRARY_FEATURE] !== true) { - return false; - } - return server.version === null || server.version.major >= LIBRARY_MIN_VERSION; + return createServerProfile(server).features.library; } function transformsReady(server: ServerInfo): boolean { - return server.version === null || server.version.major >= TRANSFORMS_MIN_VERSION; + return createServerProfile(server).features.transforms; } // Only the transform suites turn on the opt-in, so a stack that will not take it costs those suites diff --git a/tests/e2e/skills.e2e.test.ts b/tests/e2e/skills.e2e.test.ts index d19537a3..3acec933 100644 --- a/tests/e2e/skills.e2e.test.ts +++ b/tests/e2e/skills.e2e.test.ts @@ -6,6 +6,7 @@ import { SkillGetEnvelope } from "../../packages/cli/src/commands/skills/get"; import { SkillListEnvelope } from "../../packages/cli/src/commands/skills/list"; import { SkillPathListEnvelope } from "../../packages/cli/src/commands/skills/path"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { seedProbedProfile, seedProbedProfileAt, UNREACHABLE, versionAt } from "./seed-profile"; const BUNDLED_VISIBLE_NAMES = [ "core", @@ -21,6 +22,35 @@ const BUNDLED_VISIBLE_NAMES = [ "visualization", ] as const; +const UNFILTERED_NOTE = + 'Skills are unfiltered: profile "default" has no cached server probe (run `mb auth list` to record one).'; + +const TRANSFORM_UNAVAILABLE_ON_58 = { + name: "transform", + failure: { + reason: "version-too-old", + detail: + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", + feature: "transforms", + since: 59, + tokenFeature: null, + serverVersion: "v0.58.0", + }, +}; + +const GIT_SYNC_UNAVAILABLE_ON_58 = { + name: "git-sync", + failure: { + reason: "version-too-old", + detail: + "This operation requires Metabase v60+ (this server is v0.58.0). Upgrade Metabase to use it.", + feature: "remoteSync", + since: 60, + tokenFeature: "remote_sync", + serverVersion: "v0.58.0", + }, +}; + describe("skills e2e", () => { const tempDirs: string[] = []; @@ -49,6 +79,136 @@ describe("skills e2e", () => { } }); + it("list reports `unavailable: null` and keeps stdout clean when there is no cached probe", async () => { + const result = await runCli({ + args: ["skills", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SkillListEnvelope).unavailable).toBeNull(); + expect(result.stderr).toBe(""); + }); + + it("list in text mode notes that nothing was filtered when there is no cached probe", async () => { + const result = await runCli({ + args: ["skills", "list", "--format", "text"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout.startsWith("core\n")).toBe(true); + expect(result.stderr).toBe(UNFILTERED_NOTE); + }); + + it("list against a v58 profile omits the feature-bound skills and reports them under `unavailable`", async () => { + const configHome = await makeIsolatedConfigHome(); + await seedProbedProfile(configHome, 58); + + const result = await runCli({ args: ["skills", "list", "--json"], configHome }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, SkillListEnvelope); + expect(envelope.data.map((s) => s.name)).toEqual( + BUNDLED_VISIBLE_NAMES.filter((name) => name !== "git-sync" && name !== "transform"), + ); + expect(envelope.unavailable).toEqual([GIT_SYNC_UNAVAILABLE_ON_58, TRANSFORM_UNAVAILABLE_ON_58]); + expect(result.stderr).toBe(""); + }); + + it("list --all against a v58 profile lists every skill and reports `unavailable: null`", async () => { + const configHome = await makeIsolatedConfigHome(); + await seedProbedProfile(configHome, 58); + + const result = await runCli({ args: ["skills", "list", "--all", "--json"], configHome }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, SkillListEnvelope); + expect(envelope.data.map((s) => s.name)).toEqual([...BUNDLED_VISIBLE_NAMES]); + expect(envelope.unavailable).toBeNull(); + }); + + it("list in text mode against a v58 profile names each skipped skill on stderr", async () => { + const configHome = await makeIsolatedConfigHome(); + await seedProbedProfile(configHome, 58); + + const result = await runCli({ args: ["skills", "list", "--format", "text"], configHome }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain("transform\n"); + expect(result.stderr).toBe( + [ + `Skipped skill "git-sync": ${GIT_SYNC_UNAVAILABLE_ON_58.failure.detail} Pass --all to print it anyway.`, + `Skipped skill "transform": ${TRANSFORM_UNAVAILABLE_ON_58.failure.detail} Pass --all to print it anyway.`, + ].join("\n"), + ); + }); + + it("get transform against a v58 profile withholds the body, and --all prints it", async () => { + const configHome = await makeIsolatedConfigHome(); + await seedProbedProfile(configHome, 58); + + const withheld = await runCli({ args: ["skills", "get", "transform", "--json"], configHome }); + expect(withheld.exitCode, withheld.stderr).toBe(0); + expect(parseJson(withheld.stdout, SkillGetEnvelope)).toEqual({ + data: [], + returned: 0, + offset: 0, + total: 0, + has_more: false, + next_offset: null, + unavailable: [TRANSFORM_UNAVAILABLE_ON_58], + }); + + const printed = await runCli({ + args: ["skills", "get", "transform", "--all", "--json", "--max-bytes", "0"], + configHome, + }); + expect(printed.exitCode, printed.stderr).toBe(0); + const envelope = parseJson(printed.stdout, SkillGetEnvelope); + expect(envelope.unavailable).toBeNull(); + expect(envelope.data.map((s) => s.name)).toEqual(["transform"]); + }); + + it("get core resolves its sections against the cached server: an OSS v58 loses the library bullet, an EE v63 keeps it without markers", async () => { + const oss58 = await makeIsolatedConfigHome(); + await seedProbedProfile(oss58, 58); + const ee63 = await makeIsolatedConfigHome(); + await seedProbedProfileAt(ee63, UNREACHABLE, versionAt(63), { + library: true, + remote_sync: true, + content_translation: true, + }); + + const onOss58 = await runCli({ args: ["skills", "get", "core", "--json"], configHome: oss58 }); + const onEe63 = await runCli({ args: ["skills", "get", "core", "--json"], configHome: ee63 }); + const unfiltered = await runCli({ + args: ["skills", "get", "core", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + for (const result of [onOss58, onEe63, unfiltered]) { + expect(result.exitCode, result.stderr).toBe(0); + } + const bodyOf = (stdout: string): string => { + const item = parseJson(stdout, SkillGetEnvelope).data[0]; + assert(item !== undefined, "expected the core skill in the envelope"); + return item.body; + }; + const oss58Body = bodyOf(onOss58.stdout); + const ee63Body = bodyOf(onEe63.stdout); + const unfilteredBody = bodyOf(unfiltered.stdout); + + expect(oss58Body).not.toContain("**library.**"); + expect(oss58Body).not.toContain("**transform.**"); + expect(oss58Body).not.toContain(""); + expect(unfilteredBody).toContain(""); + }); + it("list hides the metabase-cli discovery stub", async () => { const result = await runCli({ args: ["skills", "list", "--json"], @@ -129,6 +289,7 @@ describe("skills e2e", () => { has_more: true, next_offset: null, truncated: { reason: "max_bytes", bytes: fullBytes }, + unavailable: null, }); expect(result.stderr).toBe( `… cut at ${fullBytes} bytes; a skill body is indivisible — pass --max-bytes 0 to print it whole, or \`mb skills path \` to read it from disk`, diff --git a/tests/e2e/transform-job.e2e.test.ts b/tests/e2e/transform-job.e2e.test.ts index fdebb1fc..96a01166 100644 --- a/tests/e2e/transform-job.e2e.test.ts +++ b/tests/e2e/transform-job.e2e.test.ts @@ -14,49 +14,47 @@ import { TransformJobTransformsEnvelope } from "../../packages/cli/src/commands/ import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; +import { E2E_BUILTIN_TRANSFORM_JOBS } from "./seed/ids"; import { SEEDED } from "./seed/seeded"; -import { requireServer } from "./server-gate"; +import { requireServer, serverHas } from "./server-gate"; const VALID_CRON = "0 0 0 * * ?"; const SECOND_CRON = "0 0 6 * * ?"; const JOB_NAME = "e2e_job"; const FIRST_USER_JOB_ID = 5; -const HOURLY_JOB_ID = 1; -const DAILY_JOB_ID = 2; +const HOURLY_JOB_ID = E2E_BUILTIN_TRANSFORM_JOBS.HOURLY; +const DAILY_JOB_ID = E2E_BUILTIN_TRANSFORM_JOBS.DAILY; const DAILY_TAG_ID = 2; const JOB_TRANSFORM_NAME = "e2e_job_transform"; const FIRST_JOB_RUN_ID = 1; const RUN_STARTED_MESSAGE = "Job run started"; -// Every shipped release answers POST /run with an opaque `stub--` string; the -// real run id — and the null that reports nothing was started — arrived on the v64 line. -const NUMERIC_RUN_ID_MIN_VERSION = 64; -const numericRunIdSkip = requireServer("transform-job › job_run_id", { - minVersion: NUMERIC_RUN_ID_MIN_VERSION, -}); -const returnsStubRunId = numericRunIdSkip !== null; +// One generation answers every run request with an opaque stub, so it reports a start it cannot +// number and cannot tell when nothing ran; the other names the run, or reports that nothing started. +const reportsRunId = serverHas("transformJobRunIdIsNumeric"); -function stubRunIdPrefix(jobId: number): string { - return `stub-${jobId}-`; -} +const STARTED_RUN: TransformJobRunResultJson = { + message: RUN_STARTED_MESSAGE, + started: true, + run_id: reportsRunId ? FIRST_JOB_RUN_ID : null, +}; -// The stub run id carries a wall-clock suffix, so trim it back to its deterministic prefix and let -// the envelope be asserted whole. -function withoutStubTimestamp( - result: TransformJobRunResultJson, - jobId: number, -): TransformJobRunResultJson { - if (typeof result.job_run_id !== "string") { - return result; - } - return { - ...result, - job_run_id: result.job_run_id.slice(0, stubRunIdPrefix(jobId).length), - }; +const NOTHING_TO_RUN: TransformJobRunResultJson = { + message: RUN_STARTED_MESSAGE, + started: !reportsRunId, + run_id: null, +}; + +function startedRunText(jobId: number): string { + return reportsRunId + ? `Started transform job ${jobId} as run ${FIRST_JOB_RUN_ID}.` + : `Started transform job ${jobId}.`; } -function expectedRunId(jobId: number): string | null { - return returnsStubRunId ? stubRunIdPrefix(jobId) : null; +function nothingToRunText(jobId: number): string { + return reportsRunId + ? `Transform job ${jobId} was not started (already running, or it resolves to no transforms).` + : `Started transform job ${jobId}.`; } interface JobTransformNativeQuery { @@ -87,6 +85,7 @@ const JOB_TRANSFORM_BODY: JobTransformBody = { tag_ids: [DAILY_TAG_ID], }; +// One generation provisions the output table's row at creation, so its link is live before any run. const JOB_TRANSFORM_COMPACT = { id: 1, name: JOB_TRANSFORM_NAME, @@ -99,6 +98,7 @@ const JOB_TRANSFORM_COMPACT = { schema: "public", name: JOB_TRANSFORM_NAME, }, + target_table_id: serverHas("transformTargetTableLinkedOnCreate") ? expect.any(Number) : null, } as const; const BUILT_IN_JOBS = [ @@ -169,8 +169,8 @@ function withoutActive(job: TransformJobCompact): Omit { let bootstrap: E2EBootstrap; @@ -352,65 +352,60 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { expect(result.exitCode, result.stderr).toBe(0); } - it("run triggers a manual job run", async () => { + it("run starts a job that resolves to transforms", async () => { + await createJobTransform(); + const result = await runCli({ - args: ["transform-job", "run", String(HOURLY_JOB_ID), "--json"], + args: ["transform-job", "run", String(DAILY_JOB_ID), "--json"], configHome: await makeIsolatedConfigHome(), env: authEnv(), }); expect(result.exitCode, result.stderr).toBe(0); - expect( - withoutStubTimestamp(parseJson(result.stdout, TransformJobRunResult), HOURLY_JOB_ID), - ).toEqual({ - message: RUN_STARTED_MESSAGE, - job_run_id: expectedRunId(HOURLY_JOB_ID), - }); + expect(parseJson(result.stdout, TransformJobRunResult)).toEqual(STARTED_RUN); }); - it("run --force-refresh triggers a manual job run", async () => { + it("run --force-refresh starts a job that resolves to transforms", async () => { + await createJobTransform(); + const result = await runCli({ - args: ["transform-job", "run", String(HOURLY_JOB_ID), "--force-refresh", "--json"], + args: ["transform-job", "run", String(DAILY_JOB_ID), "--force-refresh", "--json"], configHome: await makeIsolatedConfigHome(), env: authEnv(), }); expect(result.exitCode, result.stderr).toBe(0); - expect( - withoutStubTimestamp(parseJson(result.stdout, TransformJobRunResult), HOURLY_JOB_ID), - ).toEqual({ - message: RUN_STARTED_MESSAGE, - job_run_id: expectedRunId(HOURLY_JOB_ID), + expect(parseJson(result.stdout, TransformJobRunResult)).toEqual(STARTED_RUN); + }); + + it("run reports a job that resolves to no transforms", async () => { + const result = await runCli({ + args: ["transform-job", "run", String(HOURLY_JOB_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, TransformJobRunResult)).toEqual(NOTHING_TO_RUN); }); - // Only the v64 line reports whether anything actually started; every earlier release answers with - // the stub id whether or not the job resolved to a transform, so there is nothing to distinguish. - describe.skipIf(numericRunIdSkip !== null)("job_run_id", () => { - it("run returns the numeric run id when the job resolves to transforms", async () => { - await createJobTransform(); + it("run in text names the run it started", async () => { + await createJobTransform(); - const result = await runCli({ - args: ["transform-job", "run", String(DAILY_JOB_ID), "--json"], - configHome: await makeIsolatedConfigHome(), - env: authEnv(), - }); - expect(result.exitCode, result.stderr).toBe(0); - expect(parseJson(result.stdout, TransformJobRunResult)).toEqual({ - message: RUN_STARTED_MESSAGE, - job_run_id: FIRST_JOB_RUN_ID, - }); + const result = await runCli({ + args: ["transform-job", "run", String(DAILY_JOB_ID), "--format", "text"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toBe(startedRunText(DAILY_JOB_ID)); + }); - it("run says the job was not started when it resolves to no transforms", async () => { - const result = await runCli({ - args: ["transform-job", "run", String(HOURLY_JOB_ID), "--format", "text"], - configHome: await makeIsolatedConfigHome(), - env: authEnv(), - }); - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain( - `Transform job ${HOURLY_JOB_ID} was not started (already running, or it resolves to no transforms).`, - ); + it("run in text says whether a job with no transforms started", async () => { + const result = await runCli({ + args: ["transform-job", "run", String(HOURLY_JOB_ID), "--format", "text"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toBe(nothingToRunText(HOURLY_JOB_ID)); }); it("run with a non-integer id fails fast with ConfigError", async () => { diff --git a/tests/e2e/transform-tag.e2e.test.ts b/tests/e2e/transform-tag.e2e.test.ts index b99cb227..d9453a23 100644 --- a/tests/e2e/transform-tag.e2e.test.ts +++ b/tests/e2e/transform-tag.e2e.test.ts @@ -27,7 +27,7 @@ const USER_TAG_COMPACT = { built_in_type: null, } as const; -const skipReason = requireServer("transform-tag › transform-tag e2e", { minVersion: 59 }); +const skipReason = requireServer("transform-tag › transform-tag e2e", ["transforms"]); describe.skipIf(skipReason !== null)("transform-tag e2e", () => { let bootstrap: E2EBootstrap; diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index 846369ad..d37e6da1 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -23,7 +23,7 @@ import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; -import { invalidDatabaseRejection, requireServer, serverVersionBelow } from "./server-gate"; +import { invalidDatabaseRejection, requireServer, serverHas } from "./server-gate"; const FIRST_TRANSFORM_ID = 1; const TRANSFORM_NAME = "e2e_transform"; @@ -75,6 +75,11 @@ const TRANSFORM_BODY: TransformBody = { }, }; +// One generation provisions the output table's row at creation, so its link is live before any run. +const CREATED_TARGET_TABLE_ID = serverHas("transformTargetTableLinkedOnCreate") + ? expect.any(Number) + : null; + const TRANSFORM_COMPACT = { id: FIRST_TRANSFORM_ID, name: TRANSFORM_NAME, @@ -87,9 +92,10 @@ const TRANSFORM_COMPACT = { name: TRANSFORM_TARGET_TABLE, }, target_db_id: SEEDED.warehouseDbId, + target_table_id: CREATED_TARGET_TABLE_ID, } as const; -const skipReason = requireServer("transform › transform e2e", { minVersion: 59 }); +const skipReason = requireServer("transform › transform e2e", ["transforms"]); describe.skipIf(skipReason !== null)("transform e2e", () => { let bootstrap: E2EBootstrap; @@ -256,11 +262,40 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { const parsed = parseJson(result.stdout, TransformRunResult); expect(parsed.message).toBe("Transform run started"); expect(parsed.final?.status).toBe("succeeded"); - assert( - parsed.target_table_id !== null && parsed.target_table_id !== undefined, - `expected --sync to register a target table; got ${JSON.stringify(parsed.target_table_id)}`, - ); - expect(parsed.target_table_id).toBeGreaterThan(0); + expect(parsed.target_table_id).toEqual(expect.any(Number)); + }); + + it("get reports the table a run registered as target_table_id", async () => { + await createSeedTransform(); + + const runResult = await runCli({ + args: [ + "transform", + "run", + String(FIRST_TRANSFORM_ID), + "--sync", + "--interval", + "1000", + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + timeoutMs: 60_000, + }); + expect(runResult.exitCode, runResult.stderr).toBe(0); + const registered = parseJson(runResult.stdout, TransformRunResult).target_table_id; + expect(registered).toEqual(expect.any(Number)); + + const getResult = await runCli({ + args: ["transform", "get", String(FIRST_TRANSFORM_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(getResult.exitCode, getResult.stderr).toBe(0); + expect(parseJson(getResult.stdout, TransformCompact)).toEqual({ + ...TRANSFORM_COMPACT, + target_table_id: registered, + }); }); it("run --wait --json on a failing transform exits 1 with a stderr summary that does not duplicate final.message", async () => { @@ -332,11 +367,22 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { await createSeedTransform(); const runResult = await runCli({ - args: ["transform", "run", String(FIRST_TRANSFORM_ID), "--wait", "--json"], + args: [ + "transform", + "run", + String(FIRST_TRANSFORM_ID), + "--sync", + "--interval", + "1000", + "--json", + ], configHome: await makeIsolatedConfigHome(), env: authEnv(), + timeoutMs: 60_000, }); expect(runResult.exitCode, runResult.stderr).toBe(0); + const registered = parseJson(runResult.stdout, TransformRunResult).target_table_id; + expect(registered).toEqual(expect.any(Number)); const dropResult = await runCli({ args: ["transform", "delete-table", String(FIRST_TRANSFORM_ID), "--yes", "--json"], @@ -350,13 +396,18 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { id: FIRST_TRANSFORM_ID, }); + // A server with the column keeps naming the dropped table's row; one that hydrates the link + // only ever names an active table. const getResult = await runCli({ args: ["transform", "get", String(FIRST_TRANSFORM_ID), "--json"], configHome: await makeIsolatedConfigHome(), env: authEnv(), }); expect(getResult.exitCode, getResult.stderr).toBe(0); - expect(parseJson(getResult.stdout, TransformCompact)).toEqual(TRANSFORM_COMPACT); + expect(parseJson(getResult.stdout, TransformCompact)).toEqual({ + ...TRANSFORM_COMPACT, + target_table_id: serverHas("transformTargetTableId") ? registered : null, + }); }); it("create with body missing required fields fails on Zod validation", async () => { @@ -924,10 +975,8 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { }); }); -const GATE_MIN_VERSION = 59; - -describe.skipIf(!serverVersionBelow(GATE_MIN_VERSION))( - "transform capability gate against a sub-v59 server", +describe.skipIf(serverHas("transforms"))( + "transform capability gate against a server without transforms", () => { let bootstrap: E2EBootstrap; const tempDirs: string[] = []; @@ -973,7 +1022,26 @@ describe.skipIf(!serverVersionBelow(GATE_MIN_VERSION))( expect(result.exitCode).toBe(2); expect(cliErrorMessage(result.stderr)).toBe( - `This operation requires Metabase v${GATE_MIN_VERSION}+ (this server is ${serverTag}). Upgrade Metabase to use it.\n` + + `This operation requires Metabase v59+ (this server is ${serverTag}). Upgrade Metabase to use it.\n` + + "Or install an `@metabase/cli` release that targets this server.", + ); + expect(result.stdout).toBe(""); + }); + + it("transform list without a cached probe asks the server once and refuses the same way", async () => { + const serverTag = bootstrap.server.version?.tag; + assert(serverTag !== undefined, "gate block requires a known cached server version"); + const configHome = await makeIsolatedConfigHome(); + + const result = await runCli({ + args: ["transform", "list", "--json"], + configHome, + env: { MB_URL: bootstrap.baseUrl, MB_API_KEY: bootstrap.adminApiKey }, + }); + + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toBe( + `This operation requires Metabase v59+ (this server is ${serverTag}). Upgrade Metabase to use it.\n` + "Or install an `@metabase/cli` release that targets this server.", ); expect(result.stdout).toBe(""); diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index 86e7a06c..b42c09f1 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -1,50 +1,30 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { writeProbeResult, writeProfile } from "../../packages/cli/src/core/auth/storage"; +import { parseJson } from "@metabase/client/json"; +import { KNOWN_RANGE } from "@metabase/client/version/profile"; -import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; - -const UNREACHABLE_URL = "http://127.0.0.1:1"; - -function restoreEnv(key: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } -} - -async function withSeedEnv(configHome: string, seed: () => Promise): Promise { - const prevXdg = process.env["XDG_CONFIG_HOME"]; - const prevKeyring = process.env["MB_CLI_DISABLE_KEYRING"]; - process.env["XDG_CONFIG_HOME"] = configHome; - process.env["MB_CLI_DISABLE_KEYRING"] = "1"; - try { - await seed(); - } finally { - restoreEnv("XDG_CONFIG_HOME", prevXdg); - restoreEnv("MB_CLI_DISABLE_KEYRING", prevKeyring); - } -} +import { AuthStatus } from "../../packages/cli/src/commands/auth/status"; +import { CardListEnvelope } from "../../packages/cli/src/commands/card/list"; +import { summarizeServer } from "../../packages/cli/src/core/auth/server-summary"; -async function seedProfile(configHome: string): Promise { - await withSeedEnv(configHome, async () => { - await writeProfile({ url: UNREACHABLE_URL, apiKey: "secret-key" }, "default"); - }); -} - -async function seedProbedProfile(configHome: string, major: number): Promise { - await withSeedEnv(configHome, async () => { - await writeProfile({ url: UNREACHABLE_URL, apiKey: "secret-key" }, "default"); - await writeProbeResult("default", { - user: { id: 1, name: "Tester", isAdmin: true }, - server: { - version: { tag: `v0.${major}.0`, major, patch: 0 }, - tokenFeatures: null, - }, - }); - }); -} +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cliErrorMessage } from "./cli-error"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { + SEED_USER, + seedProbedProfile, + seedProbedProfileAt, + seedProfile, + type SeedTarget, + versionAt, +} from "./seed-profile"; +import { E2E_BUILTIN_TRANSFORM_JOBS } from "./seed/ids"; +import { requireServer } from "./server-gate"; + +const BEYOND_KNOWN = KNOWN_RANGE.max + 5; + +const NEWER_NOTICE = `Metabase v0.${BEYOND_KNOWN}.0 is newer than this CLI supports (up to v${KNOWN_RANGE.max}); commands run as if it were v${KNOWN_RANGE.max}. Run \`mb upgrade\` for a newer CLI.`; +const UNKNOWN_NOTICE = `Could not parse the Metabase version; assuming the newest supported (v${KNOWN_RANGE.max}).`; describe("version preflight enforcement e2e", () => { const tempDirs: string[] = []; @@ -59,7 +39,7 @@ describe("version preflight enforcement e2e", () => { return dir; } - it("refuses a command whose minVersion exceeds the cached server version (exit 2)", async () => { + it("refuses a command whose feature the cached server version predates (exit 2)", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 58); @@ -82,15 +62,16 @@ describe("version preflight enforcement e2e", () => { expect(result.stderr).toContain("Could not reach Metabase"); }); - it("warns but proceeds when a gated command runs without a cached probe", async () => { + it("probes the server itself when a gated command runs without a cached probe and fails on the network layer", async () => { const configHome = await makeIsolatedConfigHome(); await seedProfile(configHome); const result = await runCli({ args: ["measure", "list"], configHome }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Could not detect Metabase server version"); - expect(result.stderr).toContain("Could not reach Metabase"); + expect(cliErrorMessage(result.stderr)).toBe("Could not reach Metabase: fetch failed"); + expect(result.stderr).not.toContain("Could not detect Metabase server version"); + expect(result.stdout).toBe(""); }); it("refuses a token-gated command when the cached server lacks the premium feature (exit 2)", async () => { @@ -120,3 +101,103 @@ describe("version preflight enforcement e2e", () => { expect(result.stderr).toContain("Could not reach Metabase"); }); }); + +describe("version skew notices e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function liveTarget(): SeedTarget { + return { url: bootstrap.baseUrl, apiKey: bootstrap.adminApiKey }; + } + + it("prints exactly one newer-server notice on stderr and succeeds when the cached probe is above the known range", async () => { + const configHome = await makeIsolatedConfigHome(); + await seedProbedProfileAt(configHome, liveTarget(), versionAt(BEYOND_KNOWN)); + + const result = await runCli({ args: ["card", "list", "--limit", "1", "--json"], configHome }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(NEWER_NOTICE); + expect(parseJson(result.stdout, CardListEnvelope).returned).toBe(1); + }); + + it("prints exactly one unknown-version notice on stderr and succeeds when the cached probe carries no parseable version", async () => { + const configHome = await makeIsolatedConfigHome(); + await seedProbedProfileAt(configHome, liveTarget(), null); + + const result = await runCli({ args: ["card", "list", "--limit", "1", "--json"], configHome }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(UNKNOWN_NOTICE); + expect(parseJson(result.stdout, CardListEnvelope).returned).toBe(1); + }); + + it("prints no notice when the cached probe is inside the known range", async () => { + const configHome = await makeIsolatedConfigHome(); + await seedProbedProfileAt(configHome, liveTarget(), versionAt(KNOWN_RANGE.max)); + + const result = await runCli({ args: ["card", "list", "--limit", "1", "--json"], configHome }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + }); + + // A cached v59 profile reads a job run as the stub-string generation; a head server answers with + // the numeric one, so the parse fails against the stale cache and the CLI must notice the server + // moved. Head reports no parseable tag, which is what the refreshed profile then records. + const reprobeSkipReason = requireServer("version › re-probe on a shape error", [ + "transformJobRunIdIsNumeric", + ]); + + describe.skipIf(reprobeSkipReason !== null)("re-probe on a shape error", () => { + it("re-probes once, refreshes the stale profile, and appends the change to the error", async () => { + const configHome = await makeIsolatedConfigHome(); + await seedProbedProfileAt(configHome, liveTarget(), versionAt(59)); + + const result = await runCli({ + args: ["transform-job", "run", String(E2E_BUILTIN_TRANSFORM_JOBS.DAILY), "--json"], + configHome, + }); + + expect(result.exitCode).toBe(1); + expect(cliErrorMessage(result.stderr)).toBe( + "On Metabase v0.59.0 the response shape was unexpected:\n" + + " job_run_id: Invalid input: expected string, received null\n" + + "The server's version changed since the last probe (was v0.59.0, now an unparseable version); the profile was refreshed — retry the command.", + ); + + const status = await runCli({ args: ["auth", "status", "--json"], configHome }); + expect(status.exitCode, status.stderr).toBe(0); + const payload = parseJson(status.stdout, AuthStatus); + expect(payload).toEqual({ + profile: "default", + present: true, + url: bootstrap.baseUrl, + method: "apiKey", + user: SEED_USER, + ...summarizeServer({ + version: null, + date: bootstrap.server.date, + hash: bootstrap.server.hash, + tokenFeatures: bootstrap.server.tokenFeatures, + }), + lastProbedAt: payload.lastProbedAt, + lastFailure: null, + }); + }); + }); +}); From 0a87f487e6232c2306324425e9a281e861ac3a4f Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 16 Sep 2026 18:40:42 -0400 Subject: [PATCH 02/22] fixes --- .claude/skills/add-resource-command/SKILL.md | 2 +- CLAUDE.md | 2 +- README.md | 4 +- docs/architecture.md | 2 +- packages/cli/skill-data/transform/SKILL.md | 4 +- packages/cli/src/commands/auth/list.test.ts | 153 ++++--- packages/cli/src/commands/auth/status.test.ts | 13 +- .../cli/src/commands/requires-guard.test.ts | 55 ++- packages/cli/src/commands/runtime.test.ts | 215 +++++++--- packages/cli/src/commands/runtime.ts | 58 ++- packages/cli/src/commands/skills/get.test.ts | 2 +- packages/cli/src/commands/skills/list.test.ts | 26 +- packages/cli/src/commands/skills/list.ts | 5 +- packages/cli/src/commands/skills/path.ts | 9 +- .../cli/src/commands/transform-job/run.ts | 3 + .../cli/src/core/auth/cached-server.test.ts | 15 + packages/cli/src/core/auth/cached-server.ts | 27 +- .../cli/src/core/auth/server-summary.test.ts | 6 +- packages/cli/src/core/auth/server-summary.ts | 10 +- packages/cli/src/core/auth/storage.test.ts | 148 +++++-- packages/cli/src/core/config.test.ts | 17 +- packages/cli/src/core/skills.test.ts | 88 ++-- packages/cli/src/core/skills.ts | 31 +- packages/cli/src/core/temp-skills-dir.ts | 8 +- packages/cli/src/output/render.test.ts | 27 +- packages/cli/src/output/skill-list.test.ts | 2 +- packages/cli/src/output/skill-list.ts | 5 +- packages/cli/src/output/types.ts | 5 +- packages/cli/src/runtime/command-help.test.ts | 82 +++- packages/client/README.md | 8 +- .../client/src/domain/transform-job.test.ts | 35 +- packages/client/src/domain/transform-job.ts | 28 +- packages/client/src/domain/transform.test.ts | 13 +- packages/client/src/domain/transform.ts | 8 +- packages/client/src/errors.test.ts | 2 +- packages/client/src/http/errors.test.ts | 2 +- packages/client/src/http/transport.test.ts | 112 ++++- packages/client/src/http/transport.ts | 51 ++- packages/client/src/json.test.ts | 12 +- packages/client/src/json.ts | 12 +- .../client/src/resources/optional-parsed.ts | 9 +- .../src/resources/transform-job.test.ts | 11 +- .../client/src/resources/transform-job.ts | 28 +- packages/client/src/resources/transform.ts | 1 - .../src/version/capability-summary.test.ts | 2 +- .../version/major-comparison-guard.test.ts | 22 +- packages/client/src/version/profile.test.ts | 2 +- packages/client/src/version/profile.ts | 8 +- .../src/version/requirement-check.test.ts | 2 +- .../client/src/version/requirements.test.ts | 71 +++- tests/e2e/auth.e2e.test.ts | 389 +++++++++++++----- tests/e2e/bootstrap-data.ts | 4 +- tests/e2e/collection.e2e.test.ts | 63 ++- tests/e2e/eid-translation.e2e.test.ts | 6 +- tests/e2e/git-sync.e2e.test.ts | 16 +- tests/e2e/library.e2e.test.ts | 4 +- tests/e2e/measure.e2e.test.ts | 20 +- tests/e2e/run-cli.e2e.test.ts | 4 +- tests/e2e/server-gate.ts | 5 +- tests/e2e/setting.e2e.test.ts | 2 +- tests/e2e/setup/bootstrap.ts | 27 +- tests/e2e/skills.e2e.test.ts | 294 ++++++++----- tests/e2e/transform-job.e2e.test.ts | 91 ++-- tests/e2e/transform-tag.e2e.test.ts | 16 +- tests/e2e/transform.e2e.test.ts | 116 ++++-- tests/e2e/version.e2e.test.ts | 71 ++-- 66 files changed, 1749 insertions(+), 842 deletions(-) diff --git a/.claude/skills/add-resource-command/SKILL.md b/.claude/skills/add-resource-command/SKILL.md index 1eebd7dd..3264c962 100644 --- a/.claude/skills/add-resource-command/SKILL.md +++ b/.claude/skills/add-resource-command/SKILL.md @@ -94,7 +94,7 @@ Forbidden: Establish the fact first: read the endpoint on each release branch of the reference Metabase checkout (`origin/release-x.58.x` … the newest, and `master`), or `curl` the matrix stacks, and write down which majors answer which shape. Then: -1. Add a rule to `FEATURE_RULES` in `packages/client/src/version/features.ts`, named after the behaviour (`transformTargetTableId`, `libraryChildrenCarryType`) and never after a version. `{ since }` for a shape that arrived, `{ since, until }` for one only older servers have, `tokenFeature` when a premium feature gates it. `features.test.ts` fails a rule that is constant across `KNOWN_RANGE`, and `major-comparison-guard.test.ts` fails a `.major` comparison anywhere else — the rule is the only place the major is read. +1. Add a rule to `FEATURE_RULES` in `packages/client/src/version/features.ts`, named after the behaviour (`transformTargetTableId`, `libraryChildrenCarryType`) and never after a version. `{ since }` for a shape that arrived, `{ since, until }` for one only older servers have, `tokenFeature` when a premium feature gates it. `features.test.ts` fails a version-gated rule that is constant across `KNOWN_RANGE.min … max + 1` (the head slot included, so a rule written against head is legal), and `major-comparison-guard.test.ts` fails a `.major` comparison anywhere else — the rule is the only place the major is read. 2. The canonical `` is the **newest** supported server's shape. A field an older server cannot report is `.nullable()` on the canonical, `null` meaning "this server cannot say". 3. Each older generation gets a module-private `WireV` schema (N is the first major answering that shape) that parses exactly what that server sends, and a converter to the canonical shape. A converter may add fields but never maps two distinguishable wire states onto one canonical state, and it strips the wire fields it owns so the canonical JSON is the same on every server. 4. Export a reader, `Schema(features: Features): z.ZodType`, that returns the canonical schema on the newest generation and `wire.transform(converter)` on the others. The resource hands it to `transport.requestParsed`, so a mismatch is the transport's `ResponseShapeError` naming the server tag. diff --git a/CLAUDE.md b/CLAUDE.md index 903e477f..6c59b31f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ Every resource lives in `packages/client/src/domain/.ts` and - Pin closed enums with `z.enum([...])` where the backend enumerates the values, so a new server value fails loudly instead of passing as an untyped string. - `.optional()` on a domain field means every supported server may omit it. Absence or a different shape on some majors is never an optional: it is a module-private `WireV` schema for that generation plus a converter to the canonical shape, selected by a feature and handed to the transport as a reader (`Schema(features): z.ZodType`), and the canonical field is `nullable`, where `null` means "this server cannot say". - Canonical is the newest supported server's shape. A converter may add fields but never collapses two distinguishable wire states into one canonical state, and it strips the wire fields it owns so the canonical JSON is identical on every server. -- Only `version/features.ts` compares a Metabase major (`major-comparison-guard.test.ts` enforces it; `profile.ts` and `tag.ts` place and parse). Everything else branches on a named feature from `FEATURE_RULES`. A feature name describes a behaviour (`transformTargetTableId`), never a version, and every version-gated rule must be non-constant across `KNOWN_RANGE` (`features.test.ts`), so a rule that stops flipping is deleted with its wire variant and converter. +- Only `version/features.ts` compares a Metabase major (`major-comparison-guard.test.ts` enforces it; `profile.ts` and `tag.ts` place and parse). Everything else branches on a named feature from `FEATURE_RULES`. A feature name describes a behaviour (`transformTargetTableId`), never a version, and every version-gated rule must be non-constant across `KNOWN_RANGE.min … max + 1` (`features.test.ts`), so a rule that stops flipping is deleted with its wire variant and converter. - Every resource method declares its features in `version/requirements.ts` (strictest first) and opens with `await transport.require(".")`, which refuses with `CapabilityError` before any request; `requirements.test.ts` asserts the literal set equals the table. A method that reads a version-selected shape then reads `(await transport.server()).features` and parses with the reader. - Presentation is the CLI's: the terminal binding is `View` in `packages/cli/src/output/views/.ts`. Never inline a column list in a command; never put a `ColumnDef` on the client surface. - Resource methods (`packages/client/src/resources/.ts`) are the only layer naming an `/api/` path. Path params positional, then params, then options; params use Metabase's own field names verbatim with no mapping layer; transport concerns (`signal`, `timeoutMs`, `retries`) only in the trailing options; wire envelopes stay module-private; methods return domain values, and a non-paginated list returns `ListResult`; every string path param goes through `encodeURIComponent`; every method carries the endpoint's description as a doc comment. diff --git a/README.md b/README.md index 51d48106..dc5c286c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Command-line client for Metabase. Logs in to an instance in your browser (OAuth, ## Supported Metabase versions -The CLI is built against Metabase majors **58 through 63** (the client's `KNOWN_RANGE`), the latest patch of each; a newer server, or a head build whose version tag does not parse, runs as the newest known major with one stderr notice per run, and an older one is refused command by command with the version it needs. +The CLI is built against Metabase majors **58 through 63** (the client's `KNOWN_RANGE`), the latest patch of each; a newer server, or a head build whose version tag does not parse, runs as a head build past the newest known major — every shape the client knows head answers with, and one stderr notice per run — and an older one is refused command by command with the version it needs. Every command declares the client methods it calls, and each method names the server features it needs — a feature is a minimum major version, a premium token feature, or both. The server version and token features are detected and cached when you run `mb auth login` (or `mb auth list`). For a command whose methods need a feature, a preflight check runs before the first request and refuses with an actionable message (exit code `2`) when: @@ -13,7 +13,7 @@ Every command declares the client methods it calls, and each method names the se Plain OSS commands against a v0.58+ server (the majority) carry no elevated requirement and skip the preflight entirely. When a gated command runs without a cached probe, the CLI asks the server for its version once and decides on the answer; a server that cannot be reached fails the command with that network error. 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 switch off the client's own check too, so every request goes to the wire and the server answers for itself — footguns, only for servers you know are patched. -`mb auth status --json` reports the window as `knownRange` and where the server sits as `skew`. A server above the window is treated as the newest known major — its additions pass through, and one stderr notice per run points at `mb upgrade`; a server whose version tag does not parse (head builds) is treated the same way with its own notice. A response the CLI cannot parse under a cached profile triggers one fresh probe: if the server's version or premium features changed since the cache was written, the profile is refreshed and the error says so — retry the command. +`mb auth status --json` reports the window as `knownRange` and where the server sits as `skew`. A server above the window is read as a head build past the newest known major — its additions pass through, and one stderr notice per run points at `mb upgrade`; a server whose version tag does not parse (head builds) is treated the same way with its own notice. A response the CLI cannot parse, or a refusal it issues, under a cached profile triggers one fresh probe: if the server's version or premium features changed since the cache was written, the profile is refreshed and the error says so — retry the command. ## Install diff --git a/docs/architecture.md b/docs/architecture.md index 9f9e2f65..381522b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,7 +68,7 @@ The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packa Two things that look like version drift are not, and must not become switches: a setting's value encoding (a string-typed setting answers bare `text/plain`, everything else `application/json`, and a keyword's bare name under that label is the one body that lies) is dispatched on the value's runtime type by middlewares unchanged since v58, so `parseJsonOrPlain` is a correct sniff; and a resource-missing 404 is `text/plain` `Not found.` on every generation while a route-missing 404 is a JSON string literal, dispatched on which handler produced it, so `isRouteMissingResponse` is a correct sniff too. -**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run pointing at `mb upgrade`; an unparseable tag gets its own notice. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. A `ResponseShapeError` raised under a cached profile earns one re-probe (`refreshProfileOnShapeError` in `commands/runtime.ts`): a server whose version tag or granted token features changed since the cache was written has its record rewritten and the error says so; the command is not retried, because its request may have been a write. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). +**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run pointing at `mb upgrade`; an unparseable tag gets its own notice. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. A `ResponseShapeError` or a `CapabilityError` raised under a cached profile earns one re-probe (`refreshProfileOnStaleError` in `commands/runtime.ts`): a server whose version tag or granted token features changed since the cache was written — an upgrade that changed a shape, or an upgrade or new licence that clears a floor the refusal still names — has its record rewritten and the error says so; the command is not retried, because its request may have been a write. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). **Skills carry `requires`.** Skill prose describes the newest behaviour with no version branches — logic executed by a model is untestable and drifts. A skill declares `requires: [, …]` in its frontmatter and fences a passage between `` and ``; `mb skills list` / `mb skills get` resolve both against the profile's cached probe (`core/auth/cached-server.ts`, no request), leave out a skill the server cannot use, strip an unmet passage, and report the skipped skills as `unavailable: [{ name, failure }]` with the same `RequirementFailure` a refused command carries. A marker must sit on its own line and never inside a Markdown table, because the formatter splits a table around an HTML comment. diff --git a/packages/cli/skill-data/transform/SKILL.md b/packages/cli/skill-data/transform/SKILL.md index f746e216..72d41190 100644 --- a/packages/cli/skill-data/transform/SKILL.md +++ b/packages/cli/skill-data/transform/SKILL.md @@ -209,4 +209,6 @@ mb transform-job set-active false --profile --json # disable every job a -`transform-job run` is fire-and-forget — it returns `{message, started, run_id}` immediately (`run_id` is `null` when the server does not number job runs), with no per-job-run polling (no `--wait`). Most ad-hoc agent work is one-off `transform run`, not job authoring. +Every job row carries `active`; it is `null` on a server that cannot switch jobs off, where every job runs on schedule. + +`transform-job run` is fire-and-forget — it returns `{message, started, run_id}` immediately (both `null` when the server does not number job runs: the request was accepted and whether a run started is unsaid), with no per-job-run polling (no `--wait`). Most ad-hoc agent work is one-off `transform run`, not job authoring. diff --git a/packages/cli/src/commands/auth/list.test.ts b/packages/cli/src/commands/auth/list.test.ts index 9a2f06a2..b0d5c19d 100644 --- a/packages/cli/src/commands/auth/list.test.ts +++ b/packages/cli/src/commands/auth/list.test.ts @@ -1,11 +1,12 @@ import { runCommand } from "citty"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ZodType } from "zod"; +import type { z, ZodType } from "zod"; import type { Credential } from "@metabase/client/auth/credential"; import { parseJson } from "@metabase/client/json"; import type { ServerInfo } from "@metabase/client/version/probe"; import { createServerProfile, KNOWN_RANGE } from "@metabase/client/version/profile"; +import type { ProfileRecord } from "../../core/auth/profile-record"; import type { Verification } from "../../core/auth/verify"; const hoisted = vi.hoisted(() => { @@ -68,6 +69,11 @@ function captureStderr(): string[] { return captured; } +type AuthProfileEntry = z.infer["data"][number]; + +const PROBED_AT = "2026-03-04T05:06:07.000Z"; +const LATER = "2026-03-04T06:00:00.000Z"; + function successServer(): ServerInfo { return { version: { tag: "v0.58.7", major: 58, patch: 7 }, @@ -85,6 +91,43 @@ function successVerify(): Verification { }; } +function okEntry(profile: string, url: string): AuthProfileEntry { + return { + profile, + url, + method: "apiKey", + authenticated: true, + status: "ok", + user: { id: 1, name: "Tester", isAdmin: true }, + version: { tag: "v0.58.7", major: 58, patch: 7 }, + edition: "oss", + skew: "supported", + knownRange: KNOWN_RANGE, + tokenFeatures: null, + features: createServerProfile(successServer()).features, + lastProbedAt: PROBED_AT, + lastFailure: null, + }; +} + +function probedRecord(name: string, url: string): ProfileRecord { + return { + name, + url, + apiKey: null, + oauth: null, + lastProbe: { + at: PROBED_AT, + version: { tag: "v0.58.7", major: 58, patch: 7 }, + date: null, + hash: null, + tokenFeatures: null, + user: { id: 1, name: "Tester", isAdmin: true }, + }, + lastFailure: null, + }; +} + describe("auth list command", () => { let home: TempConfigHome; @@ -93,9 +136,12 @@ describe("auth list command", () => { hoisted.verify.results.clear(); hoisted.verify.probed.length = 0; home = setupTempConfigHome(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(PROBED_AT)); }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); home.cleanup(); }); @@ -122,22 +168,21 @@ describe("auth list command", () => { const capture = captureStdout(); await runCommand(authListCommand, { rawArgs: ["--json"] }); - const envelope = capture.parse(AuthProfileListEnvelope); - expect(envelope.returned).toBe(2); - expect(envelope.data.map((entry) => entry.profile)).toEqual(["staging", "prod"]); - expect(envelope.data.every((entry) => entry.status === "ok")).toBe(true); // The subpath survives (instances hosted under a path stay distinguishable); query is dropped. - expect(envelope.data[0]?.url).toBe("https://staging.example.com/path"); - expect(envelope.data[0]?.version).toEqual({ - tag: "v0.58.7", - major: 58, - patch: 7, + expect(capture.parse(AuthProfileListEnvelope)).toEqual({ + data: [ + okEntry("staging", "https://staging.example.com/path"), + okEntry("prod", "https://prod.example.com"), + ], + returned: 2, + offset: 0, + total: 2, + has_more: false, + next_offset: null, }); - expect(envelope.data[0]?.user).toEqual({ id: 1, name: "Tester", isAdmin: true }); - - const staging = await readProfileRecord("staging"); - expect(staging?.lastProbe?.version?.tag).toBe("v0.58.7"); - expect(staging?.lastFailure).toBeNull(); + expect(await readProfileRecord("staging")).toEqual( + probedRecord("staging", "https://staging.example.com/path?x=1"), + ); }); it("renders Auth failed status, footer line, and persists lastFailure on a 401 response", async () => { @@ -154,15 +199,32 @@ describe("auth list command", () => { const stderr = captureStderr(); await runCommand(authListCommand, { rawArgs: ["--json"] }); - const envelope = capture.parse(AuthProfileListEnvelope); - expect(envelope.data).toHaveLength(1); - const record = await readProfileRecord("revoked_profile"); - expect(envelope.data[0]?.status).toBe("auth-failed"); - expect(envelope.data[0]?.lastFailure).toEqual(record?.lastFailure); - expect(record?.lastFailure).toEqual({ - at: envelope.data[0]?.lastFailure?.at, - kind: "auth", - reason: "Invalid or unauthorized API key", + const failure = { at: PROBED_AT, kind: "auth", reason: "Invalid or unauthorized API key" }; + expect(capture.parse(AuthProfileListEnvelope).data).toEqual([ + { + profile: "revoked_profile", + url: "https://m.example.com", + method: "apiKey", + authenticated: false, + status: "auth-failed", + user: null, + version: null, + edition: null, + skew: null, + knownRange: KNOWN_RANGE, + tokenFeatures: null, + features: null, + lastProbedAt: null, + lastFailure: failure, + }, + ]); + expect(await readProfileRecord("revoked_profile")).toEqual({ + name: "revoked_profile", + url: "https://m.example.com", + apiKey: null, + oauth: null, + lastProbe: null, + lastFailure: failure, }); expect(stderr.join("")).toContain( @@ -210,26 +272,8 @@ describe("auth list command", () => { const capture = captureStdout(); await runCommand(authListCommand, { rawArgs: ["--json", "--limit", "1"] }); - const envelope = capture.parse(AuthProfileListEnvelope); - expect(envelope).toEqual({ - data: [ - { - profile: "staging", - url: "https://staging.example.com", - method: "apiKey", - authenticated: true, - status: "ok", - user: { id: 1, name: "Tester", isAdmin: true }, - version: { tag: "v0.58.7", major: 58, patch: 7 }, - edition: "oss", - skew: "supported", - knownRange: KNOWN_RANGE, - tokenFeatures: null, - features: createServerProfile(successServer()).features, - lastProbedAt: envelope.data[0]?.lastProbedAt, - lastFailure: null, - }, - ], + expect(capture.parse(AuthProfileListEnvelope)).toEqual({ + data: [okEntry("staging", "https://staging.example.com")], returned: 1, offset: 0, limit: 1, @@ -240,13 +284,16 @@ describe("auth list command", () => { }); it("preserves the previous lastProbe and apiKey on a failed refresh", async () => { + captureStdout(); hoisted.verify.results.set("good", successVerify()); await writeProfile({ url: "https://m.example.com", apiKey: "good" }, "stable"); await runCommand(authListCommand, { rawArgs: ["--json"] }); - const before = await readProfileRecord("stable"); - expect(before?.lastProbe).not.toBeNull(); + expect(await readProfileRecord("stable")).toEqual( + probedRecord("stable", "https://m.example.com"), + ); + vi.setSystemTime(new Date(LATER)); hoisted.verify.results.set("good", { ok: false, which: "server", @@ -254,11 +301,15 @@ describe("auth list command", () => { message: "Could not reach Metabase: getaddrinfo ENOTFOUND", }); await runCommand(authListCommand, { rawArgs: ["--json"] }); - const after = await readProfileRecord("stable"); - expect(after?.lastProbe).toEqual(before?.lastProbe); - expect(after?.url).toBe(before?.url); - expect(after?.apiKey).toBe(before?.apiKey); - expect(after?.lastFailure?.kind).toBe("network"); + expect(await readProfileRecord("stable")).toEqual({ + ...probedRecord("stable", "https://m.example.com"), + lastFailure: { + at: LATER, + kind: "network", + reason: "Could not reach Metabase: getaddrinfo ENOTFOUND", + }, + }); + expect(hoisted.store.get("metabase-cli:profile:stable:apiKey")).toBe("good"); }); }); diff --git a/packages/cli/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts index b37ea688..d3d9eeca 100644 --- a/packages/cli/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -41,15 +41,20 @@ function captureStdout(): CapturedStdout { }; } +const PROBED_AT = "2026-03-04T05:06:07.000Z"; + describe("auth status command", () => { let home: TempConfigHome; beforeEach(() => { hoisted.store.clear(); home = setupTempConfigHome(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(PROBED_AT)); }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); home.cleanup(); }); @@ -116,7 +121,7 @@ describe("auth status command", () => { hash: null, tokenFeatures: null, }; - const probe = await writeProbeResult("default", { + await writeProbeResult("default", { user: { id: 42, name: "Alice", isAdmin: true }, server, }); @@ -135,7 +140,7 @@ describe("auth status command", () => { knownRange: KNOWN_RANGE, tokenFeatures: null, features: createServerProfile(server).features, - lastProbedAt: probe?.at ?? null, + lastProbedAt: PROBED_AT, lastFailure: null, }); }); @@ -149,7 +154,7 @@ describe("auth status command", () => { hash: null, tokenFeatures: { library: true }, }; - const probe = await writeProbeResult("default", { + await writeProbeResult("default", { user: { id: 42, name: "Alice", isAdmin: true }, server, }); @@ -168,7 +173,7 @@ describe("auth status command", () => { knownRange: KNOWN_RANGE, tokenFeatures: { library: true }, features: createServerProfile(server).features, - lastProbedAt: probe?.at ?? null, + lastProbedAt: PROBED_AT, lastFailure: null, }); }); diff --git a/packages/cli/src/commands/requires-guard.test.ts b/packages/cli/src/commands/requires-guard.test.ts index 25ca328a..d12fbe38 100644 --- a/packages/cli/src/commands/requires-guard.test.ts +++ b/packages/cli/src/commands/requires-guard.test.ts @@ -4,14 +4,18 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { isMethodKey, type MethodKey } from "@metabase/client/version/requirements"; +import { errorMessage } from "@metabase/client/errors"; +import { summarizeCapabilities } from "@metabase/client/version/capability-summary"; +import { + isMethodKey, + type MethodKey, + methodRequirements, +} from "@metabase/client/version/requirements"; const HERE = dirname(fileURLToPath(import.meta.url)); const CLI_SRC = resolve(HERE, ".."); -// A call on a client namespace, wherever the client came from: `client.card.list(`, -// `mb.gitSync.branch(`. Filtering the match through the requirements table drops everything that -// merely looks like one. +// Any `x.ns.method(` call; filtering through the requirements table drops what merely looks like one. const METHOD_CALL = /\b[A-Za-z_$][\w$]*\.([A-Za-z]+)\.([A-Za-z]+)\(/g; const REQUIRES_DECLARATION = /^\s*requires: (null|\[[^\]]*\]),?$/m; const STRING_LITERAL = /"([^"]+)"/g; @@ -36,6 +40,17 @@ function readSource(file: string): string { return readFileSync(resolve(CLI_SRC, file), "utf8"); } +// Every non-command source file that reaches a client method, whether or not `CLIENT_HELPERS` names it. +function clientReachingHelpers(): string[] { + return readdirSync(CLI_SRC, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".ts")) + .filter((entry) => !entry.name.endsWith(".test.ts")) + .map((entry) => relative(CLI_SRC, resolve(entry.parentPath, entry.name)).split(sep).join("/")) + .filter((file) => !readSource(file).includes("defineMetabaseCommand({")) + .filter((file) => methodsCalledIn(readSource(file)).length > 0) + .toSorted(); +} + function methodsCalledIn(source: string): MethodKey[] { return [...source.matchAll(METHOD_CALL)] .map((match) => `${match[1]}.${match[2]}`) @@ -69,11 +84,22 @@ function declarationOf(file: string): Declaration { return { file, requires: requires.toSorted() }; } +interface OnlineDeclaration { + readonly file: string; + readonly requires: readonly string[]; +} + +function onlineOnly(declarations: readonly Declaration[]): OnlineDeclaration[] { + return declarations.flatMap(({ file, requires }) => + requires === null ? [] : [{ file, requires }], + ); +} + describe("every command declares exactly the client methods it reaches", () => { const declarations = commandFiles().map(declarationOf); it("declares the methods called in its body and in the helpers it hands the client to", () => { - const online = declarations.filter((declaration) => declaration.requires !== null); + const online = onlineOnly(declarations); const declared = Object.fromEntries(online.map((entry) => [entry.file, entry.requires])); const reached = Object.fromEntries( online.map((entry) => [entry.file, methodsReachedBy(entry.file)]), @@ -89,6 +115,25 @@ describe("every command declares exactly the client methods it reaches", () => { expect(reached).toEqual(Object.fromEntries(offline.map((entry) => [entry.file, []]))); }); + // `defineMetabaseCommand` summarizes the declared methods the moment the module loads, and the + // summary admits one premium feature; a command reaching two would refuse to load at all. + it("reaches methods whose features summarize to one server floor and at most one premium feature", () => { + const refused = onlineOnly(declarations).flatMap((declaration) => { + const features = declaration.requires.filter(isMethodKey).flatMap(methodRequirements); + try { + summarizeCapabilities(features); + return []; + } catch (error) { + return [{ file: declaration.file, reason: errorMessage(error) }]; + } + }); + expect(refused).toEqual([]); + }); + + it("names every non-command file that reaches a client method", () => { + expect(clientReachingHelpers()).toEqual(Object.values(CLIENT_HELPERS).toSorted()); + }); + it("names only helpers that still exist and still call a client method", () => { const reached = Object.fromEntries( Object.values(CLIENT_HELPERS).map((file) => [file, methodsCalledIn(readSource(file))]), diff --git a/packages/cli/src/commands/runtime.test.ts b/packages/cli/src/commands/runtime.test.ts index ee96c032..c69cbe0e 100644 --- a/packages/cli/src/commands/runtime.test.ts +++ b/packages/cli/src/commands/runtime.test.ts @@ -1,11 +1,14 @@ import { runCommand } from "citty"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { parseJson } from "@metabase/client/json"; import { captureFetch, jsonResponse } from "@metabase/client/testing/fetch-capture"; import type { ServerInfo } from "@metabase/client/version/probe"; import { createServerProfile, KNOWN_RANGE } from "@metabase/client/version/profile"; -import { setupTempConfigHome, type TempConfigHome } from "../core/auth/temp-config-home"; +import type { ProfileLastProbe, ProfileRecord } from "../core/auth/profile-record"; +import { probeAt, setupTempConfigHome, type TempConfigHome } from "../core/auth/temp-config-home"; const hoisted = vi.hoisted(() => ({ store: new Map(), @@ -22,17 +25,22 @@ const { connectionFlags, listFlags, outputFlags, profileFlag } = await import(". const { readProfileRecord, writeProbeResult, writeProfile } = await import("../core/auth/storage"); const BEYOND_KNOWN = KNOWN_RANGE.max + 5; +const PROBED_AT = "2026-03-04T05:06:07.000Z"; +const REPROBED_AT = "2026-03-04T06:00:00.000Z"; -const NEWER_NOTICE = `Metabase v0.${BEYOND_KNOWN}.0 is newer than this CLI supports (up to v${KNOWN_RANGE.max}); commands run as if it were v${KNOWN_RANGE.max}. Run \`mb upgrade\` for a newer CLI.\n`; -const UNKNOWN_NOTICE = `Could not parse the Metabase version; assuming the newest supported (v${KNOWN_RANGE.max}).\n`; +const NEWER_NOTICE = `Metabase v0.${BEYOND_KNOWN}.0 is newer than this CLI supports (up to v${KNOWN_RANGE.max}); commands run as if it were a head build past v${KNOWN_RANGE.max}. Run \`mb upgrade\` for a newer CLI.\n`; +const UNKNOWN_NOTICE = `Could not parse the Metabase version; assuming a head build past v${KNOWN_RANGE.max}.\n`; function shapeErrorEnvelope(message: string): unknown { return { ok: false, error: { category: "response-shape", message, exitCode: 1 } }; } +function capabilityErrorEnvelope(message: string): unknown { + return { ok: false, error: { category: "capability", message, exitCode: 2 } }; +} + function errorEnvelopeOf(stderr: string[]): unknown { - const parsed: unknown = JSON.parse(stderr.join("")); - return parsed; + return parseJson(stderr.join(""), z.unknown(), { source: "stderr" }); } async function seedProbedProfile(name: string, info: ServerInfo): Promise { @@ -43,15 +51,21 @@ async function seedProbedProfile(name: string, info: ServerInfo): Promise }); } -function fakeServerInfo(major: number): ServerInfo { +function reprobedRecord(probe: ProfileLastProbe): ProfileRecord { return { - version: { tag: `v0.${major}.0`, major, patch: 0 }, - date: null, - hash: null, - tokenFeatures: null, + name: "default", + url: "https://m.example.com", + apiKey: null, + oauth: null, + lastProbe: { ...probe, at: REPROBED_AT, user: { id: 1, name: "Tester", isAdmin: true } }, + lastFailure: null, }; } +function sameServerProbe(major: number): Response { + return jsonResponse({ version: { tag: `v0.${major}.0` }, "token-features": {} }); +} + function captureStderr(): string[] { const captured: string[] = []; vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { @@ -75,9 +89,13 @@ describe("defineMetabaseCommand", () => { delete process.env[SKIP_PREFLIGHT_ENV]; previousExitCode = process.exitCode; process.exitCode = 0; + vi.stubGlobal("fetch", captureFetch([]).fetch); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(PROBED_AT)); }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); home.cleanup(); @@ -135,16 +153,22 @@ describe("defineMetabaseCommand", () => { }); it("does not call resolveConfig when the run handler never calls getClient", async () => { + const ran = vi.fn(); const cmd = defineMetabaseCommand({ meta: { name: "no-client", description: "does not need the client" }, requires: [], args: {}, run() { - return; + ran(); }, }); + const stderr = captureStderr(); - await expect(runCommand(cmd, { rawArgs: [] })).resolves.toBeDefined(); + await runCommand(cmd, { rawArgs: [] }); + + expect(ran).toHaveBeenCalledOnce(); + expect(stderr.join("")).toBe(""); + expect(process.exitCode).toBe(0); }); it("returns the same client instance across multiple getClient() calls", async () => { @@ -177,7 +201,7 @@ describe("defineMetabaseCommand", () => { await runCommand(cmd, { rawArgs: [] }); - const parsed: unknown = JSON.parse(stderr.join("")); + const parsed = errorEnvelopeOf(stderr); expect(parsed).toEqual({ ok: false, error: { @@ -204,7 +228,7 @@ describe("defineMetabaseCommand", () => { await runCommand(cmd, { rawArgs: ["--json", "--limit", "0"] }); - const parsed: unknown = JSON.parse(stderr.join("")); + const parsed = errorEnvelopeOf(stderr); expect(parsed).toEqual({ ok: false, error: { @@ -230,7 +254,7 @@ describe("defineMetabaseCommand", () => { await runCommand(cmd, { rawArgs: ["--json", "--max-bytes", "abc"] }); - const parsed: unknown = JSON.parse(stderr.join("")); + const parsed = errorEnvelopeOf(stderr); expect(parsed).toEqual({ ok: false, error: { @@ -242,7 +266,7 @@ describe("defineMetabaseCommand", () => { expect(process.exitCode).toBe(2); }); - it("reports an unresolvable --format as plain text, there being no format to serialize into", async () => { + it("reports an unresolvable --format as plain text", async () => { const cmd = defineMetabaseCommand({ meta: { name: "misformatted", description: "bad format" }, requires: [], @@ -260,7 +284,8 @@ describe("defineMetabaseCommand", () => { }); it("refuses with CapabilityError exit code 2 before run does any work when the cached server lacks a required feature", async () => { - await seedProbedProfile("default", fakeServerInfo(58)); + await seedProbedProfile("default", probeAt(58)); + vi.stubGlobal("fetch", captureFetch([sameServerProbe(58)]).fetch); const ran = vi.fn(); const cmd = defineMetabaseCommand({ @@ -284,7 +309,8 @@ describe("defineMetabaseCommand", () => { }); it("refuses with CapabilityError exit code 2 when the required premium token-feature is absent", async () => { - await seedProbedProfile("default", fakeServerInfo(61)); + await seedProbedProfile("default", probeAt(61)); + vi.stubGlobal("fetch", captureFetch([sameServerProbe(61)]).fetch); const ran = vi.fn(); const cmd = defineMetabaseCommand({ @@ -307,8 +333,9 @@ describe("defineMetabaseCommand", () => { expect(ran).not.toHaveBeenCalled(); }); - it("checks every declared method, so a second method's feature refuses when the first is satisfied", async () => { - await seedProbedProfile("default", fakeServerInfo(59)); + it("refuses on a second declared method's feature when the first is satisfied", async () => { + await seedProbedProfile("default", probeAt(59)); + vi.stubGlobal("fetch", captureFetch([sameServerProbe(59)]).fetch); const ran = vi.fn(); const cmd = defineMetabaseCommand({ @@ -400,8 +427,8 @@ describe("defineMetabaseCommand", () => { expect(process.exitCode).toBe(0); }); - it("hands the client the profile derived from the cached probe, so it never probes for itself", async () => { - const info = fakeServerInfo(61); + it("hands the client the cached probe's profile and never probes", async () => { + const info = probeAt(61); await seedProbedProfile("default", info); const seen = vi.fn(); @@ -486,7 +513,7 @@ describe("defineMetabaseCommand", () => { expect(ran).not.toHaveBeenCalled(); }); - it("proceeds when the cached probe carries a version the client could not parse, reading it as the newest known and saying so once", async () => { + it("reads an unparseable cached version as the newest known, says so once, and proceeds", async () => { await seedProbedProfile("default", { version: null, date: null, @@ -514,7 +541,7 @@ describe("defineMetabaseCommand", () => { }); it("prints one newer-server notice when the cached probe is above the known range, even for a baseline command", async () => { - await seedProbedProfile("default", fakeServerInfo(BEYOND_KNOWN)); + await seedProbedProfile("default", probeAt(BEYOND_KNOWN)); const cmd = defineMetabaseCommand({ meta: { name: "baseline-on-newer", description: "baseline on a newer server" }, @@ -534,7 +561,7 @@ describe("defineMetabaseCommand", () => { }); it("prints no notice when the cached probe is inside the known range", async () => { - await seedProbedProfile("default", fakeServerInfo(KNOWN_RANGE.max)); + await seedProbedProfile("default", probeAt(KNOWN_RANGE.max)); const cmd = defineMetabaseCommand({ meta: { name: "supported", description: "supported server" }, @@ -581,7 +608,7 @@ describe("defineMetabaseCommand", () => { }); it("ignores the cached probe when --url points the profile at another server", async () => { - await seedProbedProfile("default", fakeServerInfo(58)); + await seedProbedProfile("default", probeAt(58)); const capture = captureFetch([ jsonResponse({ version: { tag: "v0.61.0" }, "token-features": {} }), jsonResponse([]), @@ -626,8 +653,8 @@ describe("defineMetabaseCommand", () => { } it("re-probes once, writes a changed server back to the profile, and appends the change to the error", async () => { - await seedProbedProfile("default", fakeServerInfo(59)); - const before = await readProfileRecord("default"); + await seedProbedProfile("default", probeAt(59)); + vi.setSystemTime(new Date(REPROBED_AT)); const capture = captureFetch([ jsonResponse({}), jsonResponse({ @@ -650,23 +677,24 @@ describe("defineMetabaseCommand", () => { ), ); expect(process.exitCode).toBe(1); - const after = await readProfileRecord("default"); - expect(after?.lastProbe).toEqual({ - at: after?.lastProbe?.at, - version: { tag: "v0.63.4", major: 63, patch: 4 }, - date: "2026-09-01", - hash: "abc1234", - tokenFeatures: { library: true }, - user: before?.lastProbe?.user, - }); - expect(after?.lastProbe?.at).not.toBe(before?.lastProbe?.at); + expect(await readProfileRecord("default")).toEqual( + reprobedRecord({ + at: REPROBED_AT, + version: { tag: "v0.63.4", major: 63, patch: 4 }, + date: "2026-09-01", + hash: "abc1234", + tokenFeatures: { library: true }, + user: { id: 1, name: "Tester", isAdmin: true }, + }), + ); }); it("names changed premium features when the version is the same", async () => { await seedProbedProfile("default", { - ...fakeServerInfo(59), + ...probeAt(59), tokenFeatures: { library: false }, }); + vi.setSystemTime(new Date(REPROBED_AT)); const capture = captureFetch([ jsonResponse({}), jsonResponse({ version: { tag: "v0.59.0" }, "token-features": { library: true } }), @@ -681,19 +709,20 @@ describe("defineMetabaseCommand", () => { `${SHAPE_LEAD}\nThe server's premium features changed since the last probe; the profile was refreshed — retry the command.`, ), ); - const after = await readProfileRecord("default"); - expect(after?.lastProbe).toEqual({ - at: after?.lastProbe?.at, - version: { tag: "v0.59.0", major: 59, patch: 0 }, - date: null, - hash: null, - tokenFeatures: { library: true }, - user: { id: 1, name: "Tester", isAdmin: true }, - }); + expect(await readProfileRecord("default")).toEqual( + reprobedRecord({ + at: REPROBED_AT, + version: { tag: "v0.59.0", major: 59, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { library: true }, + user: { id: 1, name: "Tester", isAdmin: true }, + }), + ); }); it("reports the error unchanged and leaves the profile alone when the fresh probe agrees with the cache", async () => { - await seedProbedProfile("default", fakeServerInfo(59)); + await seedProbedProfile("default", probeAt(59)); const before = await readProfileRecord("default"); const capture = captureFetch([ jsonResponse({}), @@ -713,7 +742,7 @@ describe("defineMetabaseCommand", () => { }); it("reports the error unchanged when the re-probe itself fails", async () => { - await seedProbedProfile("default", fakeServerInfo(59)); + await seedProbedProfile("default", probeAt(59)); const before = await readProfileRecord("default"); const capture = captureFetch([jsonResponse({}), new TypeError("fetch failed")]); vi.stubGlobal("fetch", capture.fetch); @@ -741,16 +770,11 @@ describe("defineMetabaseCommand", () => { "https://m.example.com/api/session/properties", "https://m.example.com/api/measure", ]); - expect(errorEnvelopeOf(stderr)).toEqual( - shapeErrorEnvelope( - "Metabase returned unexpected response shape:\n" + - " Invalid input: expected array, received object", - ), - ); + expect(errorEnvelopeOf(stderr)).toEqual(shapeErrorEnvelope(SHAPE_LEAD)); }); it("re-probes on a shape error even when the preflight was skipped", async () => { - await seedProbedProfile("default", fakeServerInfo(58)); + await seedProbedProfile("default", probeAt(58)); const capture = captureFetch([ jsonResponse({}), jsonResponse({ version: { tag: "v0.63.0" }, "token-features": {} }), @@ -779,8 +803,76 @@ describe("defineMetabaseCommand", () => { }); }); + describe("re-probe on a refusal", () => { + const ACTIVATION_REFUSAL = + "This operation requires Metabase v61+ (this server is v0.58.0). Upgrade Metabase to use it."; + const DOWNGRADE_REMEDY = "Or install an `@metabase/cli` release that targets this server."; + + function activationCommand() { + return defineMetabaseCommand({ + meta: { name: "needs-activation", description: "wants job activation" }, + args: {}, + requires: ["transformJob.setActive"], + async run({ getClient }) { + await getClient(); + }, + }); + } + + it("re-probes once, writes an upgraded server back, and tells the user to retry", async () => { + await seedProbedProfile("default", probeAt(58)); + vi.setSystemTime(new Date(REPROBED_AT)); + const capture = captureFetch([ + jsonResponse({ version: { tag: "v0.61.3" }, "token-features": {} }), + ]); + vi.stubGlobal("fetch", capture.fetch); + const stderr = captureStderr(); + + await runCommand(activationCommand(), { rawArgs: [] }); + + expect(capture.calls.map((call) => call.url)).toEqual([ + "https://m.example.com/api/session/properties", + ]); + expect(errorEnvelopeOf(stderr)).toEqual( + capabilityErrorEnvelope( + `${ACTIVATION_REFUSAL}\n` + + "The server's version changed since the last probe (was v0.58.0, now v0.61.3); the profile was refreshed — retry the command.\n" + + DOWNGRADE_REMEDY, + ), + ); + expect(process.exitCode).toBe(2); + expect(await readProfileRecord("default")).toEqual( + reprobedRecord({ + at: REPROBED_AT, + version: { tag: "v0.61.3", major: 61, patch: 3 }, + date: null, + hash: null, + tokenFeatures: {}, + user: { id: 1, name: "Tester", isAdmin: true }, + }), + ); + }); + + it("reports the refusal unchanged and leaves the profile alone when the server has not changed", async () => { + await seedProbedProfile("default", probeAt(58)); + const before = await readProfileRecord("default"); + const capture = captureFetch([ + jsonResponse({ version: { tag: "v0.58.0" }, "token-features": {} }), + ]); + vi.stubGlobal("fetch", capture.fetch); + const stderr = captureStderr(); + + await runCommand(activationCommand(), { rawArgs: [] }); + + expect(errorEnvelopeOf(stderr)).toEqual( + capabilityErrorEnvelope(`${ACTIVATION_REFUSAL}\n${DOWNGRADE_REMEDY}`), + ); + expect(await readProfileRecord("default")).toEqual(before); + }); + }); + it("bypasses the preflight check when --skip-preflight is passed", async () => { - await seedProbedProfile("default", fakeServerInfo(58)); + await seedProbedProfile("default", probeAt(58)); const ran = vi.fn(); const cmd = defineMetabaseCommand({ @@ -798,7 +890,8 @@ describe("defineMetabaseCommand", () => { }); it("lets the client refuse a gated method the command's own declaration does not cover", async () => { - await seedProbedProfile("default", fakeServerInfo(58)); + await seedProbedProfile("default", probeAt(58)); + vi.stubGlobal("fetch", captureFetch([sameServerProbe(58)]).fetch); const stderr = captureStderr(); const ran = vi.fn(); @@ -823,7 +916,7 @@ describe("defineMetabaseCommand", () => { }); it("switches the client's own check off too when --skip-preflight is passed", async () => { - await seedProbedProfile("default", fakeServerInfo(58)); + await seedProbedProfile("default", probeAt(58)); const capture = captureFetch([jsonResponse([])]); vi.stubGlobal("fetch", capture.fetch); @@ -845,7 +938,7 @@ describe("defineMetabaseCommand", () => { }); it("bypasses the preflight check when MB_CLI_SKIP_PREFLIGHT=1 is set", async () => { - await seedProbedProfile("default", fakeServerInfo(58)); + await seedProbedProfile("default", probeAt(58)); process.env[SKIP_PREFLIGHT_ENV] = "1"; const ran = vi.fn(); diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts index 44c5707c..727c9517 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -4,7 +4,6 @@ import type { ZodType } from "zod"; import type { MetabaseClient } from "@metabase/client/client"; import { MetabaseError, ResponseShapeError } from "@metabase/client/errors"; -import { normalizeUrl } from "@metabase/client/url"; import { summarizeCapabilities } from "@metabase/client/version/capability-summary"; import type { FeatureName } from "@metabase/client/version/features"; import { CapabilityError } from "@metabase/client/version/preflight-error"; @@ -15,10 +14,10 @@ import { type MethodKey, methodRequirements } from "@metabase/client/version/req import type { ProfileLastProbe } from "../core/auth/profile-record"; import { serverChangeNote, skewNotice } from "../core/auth/server-summary"; +import { readCachedProbe } from "../core/auth/cached-server"; import { consumeKeyringDowngradeWarning, consumeLegacyStorageWarning, - readProfileRecord, writeProbeResult, } from "../core/auth/storage"; import { @@ -64,8 +63,7 @@ interface MetabaseCommandDef { skills?: readonly SkillPointer[]; inputSchema?: ZodType; outputSchema?: ZodType; - // The client methods `run` calls; `null` for a command that never reaches a server. The features - // those methods need are what the preflight checks and what `help --json` reports. + // `null` for a command that never reaches a server. requires: readonly MethodKey[] | null; run: (context: MetabaseCommandContext) => Promise | void; } @@ -101,7 +99,7 @@ export function defineMetabaseCommand( const rawGetClient = async (): Promise => { if (cachedClient === null) { const resolved = await getResolvedConfig(); - const probe = await loadCachedProbe(resolved); + const probe = await readCachedProbe(resolved.profile, resolved.url); const server = probe === null ? null : createServerProfile(probe); const { createClient } = await import("@metabase/client/client"); cachedClient = createClient( @@ -139,7 +137,7 @@ export function defineMetabaseCommand( getResolvedConfig, }); } catch (error) { - throw await refreshProfileOnShapeError(error, cachedServer); + throw await refreshProfileOnStaleError(error, cachedServer); } finally { emitPendingWarnings(); } @@ -179,21 +177,6 @@ function emitPendingWarnings(): void { } } -// The record's probe is the server the client was built on, and only when the record's URL is the -// one the command is talking to: `--url` or the environment can point the same profile name at -// another server, whose shape the cached probe says nothing about. Without a cached probe the -// client is left to ask the server itself on first need. -async function loadCachedProbe(resolved: ResolvedConfig): Promise { - const record = await readProfileRecord(resolved.profile); - if (record === null || record.lastProbe === null) { - return null; - } - if (normalizeUrl(record.url) !== resolved.url) { - return null; - } - return record.lastProbe; -} - type SkewNotifier = (profile: ServerProfile) => void; // The notice is about the server, not the command, so the first profile a command resolves — @@ -216,10 +199,8 @@ type PreflightEnforcer = (client: MetabaseClient) => Promise; const NO_OP_ENFORCER: PreflightEnforcer = async () => {}; -// Anticipates the refusal the client's own `require()` would raise on the first gated call, so a -// command fails before it has done any work. `client.server()` is the cached profile when the -// profile record carries a probe and one live probe otherwise; a probe that fails ends the command -// here with that error, as the client's check would moments later. +// Raises the refusal the client's own `require()` would raise on the first gated call, so a +// command fails before it has done any work. function createPreflightEnforcer( features: readonly FeatureName[] | null, skip: boolean, @@ -250,22 +231,29 @@ interface CachedServer { probe: ProfileLastProbe; } -// A shape error under a cached profile may mean the server was upgraded since the probe and the -// wire schema was chosen for the wrong generation. One fresh probe settles it: a changed server is -// written back so the next run reads the right shape, and the error says so. The command is not -// retried — its request may have been a write. -async function refreshProfileOnShapeError( +// A shape error or a refusal under a cached profile may mean the server changed since the probe. +// One fresh probe settles it: a changed server is written back and the error says so. The command +// is not retried — its request may have been a write. +async function refreshProfileOnStaleError( error: unknown, cached: CachedServer | null, ): Promise { - if (!(error instanceof ResponseShapeError) || cached === null) { + if (cached === null) { return error; } - const note = await refreshChangedProbe(cached); - if (note === null) { - return error; + if (error instanceof ResponseShapeError) { + const note = await refreshChangedProbe(cached); + return note === null + ? error + : new ResponseShapeError(`${error.message}\n${note}`, error.developerDetail); + } + if (error instanceof CapabilityError) { + const note = await refreshChangedProbe(cached); + return note === null + ? error + : new CapabilityError({ ...error.developerDetail, detail: `${error.message}\n${note}` }); } - return new ResponseShapeError(`${error.message}\n${note}`, error.developerDetail); + return error; } async function refreshChangedProbe(cached: CachedServer): Promise { diff --git a/packages/cli/src/commands/skills/get.test.ts b/packages/cli/src/commands/skills/get.test.ts index e7f2c7a5..9f4c2c58 100644 --- a/packages/cli/src/commands/skills/get.test.ts +++ b/packages/cli/src/commands/skills/get.test.ts @@ -117,7 +117,7 @@ describe("skills get command", () => { expect(stdout.chunks.join("")).toBe(`${GAMMA_SKILL_MD.trimEnd()}\n`); expect(stderr.chunks.join("")).toBe( - 'Skills are unfiltered: profile "default" has no cached server probe (run `mb auth list` to record one).\n', + 'Skills are unfiltered: profile "default" has no cached server probe (run `mb auth login` to record one).\n', ); }); diff --git a/packages/cli/src/commands/skills/list.test.ts b/packages/cli/src/commands/skills/list.test.ts index 32eacb2b..33791932 100644 --- a/packages/cli/src/commands/skills/list.test.ts +++ b/packages/cli/src/commands/skills/list.test.ts @@ -80,7 +80,7 @@ describe("skills list command", () => { expect(stdout.chunks.join("")).toBe("alpha\n The first skill.\n\n"); expect(stderr.chunks.join("")).toBe( - 'Skills are unfiltered: profile "default" has no cached server probe (run `mb auth list` to record one).\n', + 'Skills are unfiltered: profile "default" has no cached server probe (run `mb auth login` to record one).\n', ); }); @@ -139,6 +139,30 @@ describe("skills list command", () => { ); }); + it("names each skipped skill in a projected text listing too", async () => { + await seedProbedProfile("default", probeAt(58)); + const stdout = capture(process.stdout); + const stderr = capture(process.stderr); + + await runCommand(skillsListCommand, { rawArgs: ["--format", "text", "--fields", "name"] }); + + expect(stdout.chunks.join("")).toBe( + [ + "┌───────┐", + "│ name │", + "├───────┤", + "│ alpha │", + "├───────┤", + "│ beta │", + "└───────┘", + "", + ].join("\n"), + ); + expect(stderr.chunks.join("")).toBe( + 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --all to print it anyway.\n', + ); + }); + it("lists every skill and reports nothing filtered when the server has the features", async () => { await seedProbedProfile("default", probeAt(61)); const stdout = capture(process.stdout); diff --git a/packages/cli/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts index 02dbd342..1f3bcce4 100644 --- a/packages/cli/src/commands/skills/list.ts +++ b/packages/cli/src/commands/skills/list.ts @@ -57,9 +57,12 @@ export default defineMetabaseCommand({ const envelope = windowList(items, ctx.range); if (ctx.format === "json" || ctx.fields !== undefined || ctx.full) { renderListWithExtras(envelope, { unavailable: selection.unavailable }, skillSummaryView, ctx); + } else { + renderSkillList(envelope.data, ctx.maxBytes); + } + if (ctx.format === "json") { return; } - renderSkillList(envelope.data, ctx.maxBytes); for (const notice of skillFilterNotices(selection.unavailable, { profileName, bypassed: args.all === true, diff --git a/packages/cli/src/commands/skills/path.ts b/packages/cli/src/commands/skills/path.ts index 7d3d3a65..014321a5 100644 --- a/packages/cli/src/commands/skills/path.ts +++ b/packages/cli/src/commands/skills/path.ts @@ -2,7 +2,12 @@ import { z } from "zod"; import type { ResourceView } from "../../output/view"; -import { findSkillByName, loadAllSkills, loadVisibleSkills } from "../../core/skills"; +import { + findSkillByName, + loadAllSkills, + loadVisibleSkills, + type SkillInfo, +} from "../../core/skills"; import { renderList } from "../../output/render"; import { listEnvelopeSchema } from "../../output/types"; import { windowList } from "../../output/window"; @@ -52,6 +57,6 @@ export default defineMetabaseCommand({ }, }); -function toSkillPath(info: { name: string; dir: string }): SkillPathJson { +function toSkillPath(info: SkillInfo): SkillPathJson { return { name: info.name, dir: info.dir }; } diff --git a/packages/cli/src/commands/transform-job/run.ts b/packages/cli/src/commands/transform-job/run.ts index c447ea4f..e2f4db16 100644 --- a/packages/cli/src/commands/transform-job/run.ts +++ b/packages/cli/src/commands/transform-job/run.ts @@ -20,6 +20,9 @@ const transformJobRunResultView: ResourceView = { }; function runSummary(id: number, result: TransformJobRunResult): string { + if (result.started === null) { + return `Requested a run of transform job ${id}; this server does not say whether one started.`; + } if (!result.started) { return `Transform job ${id} was not started (already running, or it resolves to no transforms).`; } diff --git a/packages/cli/src/core/auth/cached-server.test.ts b/packages/cli/src/core/auth/cached-server.test.ts index 683037d7..17d5081d 100644 --- a/packages/cli/src/core/auth/cached-server.test.ts +++ b/packages/cli/src/core/auth/cached-server.test.ts @@ -27,6 +27,7 @@ describe("readCachedServerProfile", () => { beforeEach(() => { hoisted.store.clear(); home = setupTempConfigHome(); + delete process.env["MB_URL"]; }); afterEach(() => { @@ -50,4 +51,18 @@ describe("readCachedServerProfile", () => { createServerProfile(probeAt(61, { library: true })), ); }); + + it("ignores the record's probe when the environment points the profile at another server", async () => { + await seedProbedProfile("staging", probeAt(61, { library: true })); + process.env["MB_URL"] = "https://other.example.com"; + + expect(await readCachedServerProfile("staging")).toBeNull(); + }); + + it("keeps the record's probe when the environment names the record's own server", async () => { + await seedProbedProfile("staging", probeAt(61)); + process.env["MB_URL"] = "http://127.0.0.1:1/"; + + expect(await readCachedServerProfile("staging")).toEqual(createServerProfile(probeAt(61))); + }); }); diff --git a/packages/cli/src/core/auth/cached-server.ts b/packages/cli/src/core/auth/cached-server.ts index 75234383..7ab3e3ce 100644 --- a/packages/cli/src/core/auth/cached-server.ts +++ b/packages/cli/src/core/auth/cached-server.ts @@ -1,14 +1,31 @@ +import { normalizeUrl } from "@metabase/client/url"; import { createServerProfile, type ServerProfile } from "@metabase/client/version/profile"; +import { readEnvCredentials } from "../config"; +import type { ProfileLastProbe } from "./profile-record"; import { readProfileRecord } from "./storage"; -// The server a profile last probed, derived at read time, or `null` when the record has no probe -// to derive it from. Nothing here reaches the network, so a caller that only needs to know what -// the server can do keeps working offline. -export async function readCachedServerProfile(profileName: string): Promise { +// The record's probe describes the record's server, and only that one: a URL from a flag or the +// environment can point the same profile name at another host, whose features and shapes the +// cached probe says nothing about. `null` when there is no probe to trust. +export async function readCachedProbe( + profileName: string, + url: string | null, +): Promise { const record = await readProfileRecord(profileName); if (record === null || record.lastProbe === null) { return null; } - return createServerProfile(record.lastProbe); + if (url !== null && normalizeUrl(record.url) !== url) { + return null; + } + return record.lastProbe; +} + +// For a command that takes no `--url` and so can only be pointed elsewhere by the environment. +// Nothing here reaches the network. +export async function readCachedServerProfile(profileName: string): Promise { + const envUrl = readEnvCredentials().url; + const probe = await readCachedProbe(profileName, envUrl === null ? null : normalizeUrl(envUrl)); + return probe === null ? null : createServerProfile(probe); } diff --git a/packages/cli/src/core/auth/server-summary.test.ts b/packages/cli/src/core/auth/server-summary.test.ts index fb74012b..da4c2e09 100644 --- a/packages/cli/src/core/auth/server-summary.test.ts +++ b/packages/cli/src/core/auth/server-summary.test.ts @@ -50,13 +50,13 @@ describe("skewNotice", () => { it("points a newer server at mb upgrade and names the major it is read as", () => { const beyond = KNOWN_RANGE.max + 1; expect(skewNotice(createServerProfile(serverAt(beyond)))).toBe( - `Metabase v0.${beyond}.0 is newer than this CLI supports (up to v${KNOWN_RANGE.max}); commands run as if it were v${KNOWN_RANGE.max}. Run \`mb upgrade\` for a newer CLI.`, + `Metabase v0.${beyond}.0 is newer than this CLI supports (up to v${KNOWN_RANGE.max}); commands run as if it were a head build past v${KNOWN_RANGE.max}. Run \`mb upgrade\` for a newer CLI.`, ); }); - it("says an unparseable version is read as the newest supported", () => { + it("says an unparseable version is read as a head build", () => { expect(skewNotice(createServerProfile(UNPARSEABLE))).toBe( - `Could not parse the Metabase version; assuming the newest supported (v${KNOWN_RANGE.max}).`, + `Could not parse the Metabase version; assuming a head build past v${KNOWN_RANGE.max}.`, ); }); }); diff --git a/packages/cli/src/core/auth/server-summary.ts b/packages/cli/src/core/auth/server-summary.ts index 89bb1f0a..e4150db0 100644 --- a/packages/cli/src/core/auth/server-summary.ts +++ b/packages/cli/src/core/auth/server-summary.ts @@ -16,10 +16,8 @@ const KnownRange = z.object({ max: z.number().int(), }); -// What a probe says about the server, as every auth payload reports it: the raw facts and what -// this CLI derives from them, side by side. Derived when read, never stored — a persisted -// derivation would outlive the CLI that wrote it. Every server field is `null` without a probe; -// `knownRange` is the CLI's own and always present. +// Derived from the probe when read, never stored — a persisted derivation would outlive the CLI +// that wrote it. Every server field is `null` without a probe; `knownRange` is the CLI's own. export const ServerSummary = z.object({ version: ParsedVersion.nullable(), edition: Edition.nullable(), @@ -66,10 +64,10 @@ export function skewNotice(profile: ServerProfile): string | null { return null; } case "newer-than-known": { - return `Metabase ${versionLabel(profile.version)} is newer than this CLI supports (up to v${max}); commands run as if it were v${max}. Run \`mb upgrade\` for a newer CLI.`; + return `Metabase ${versionLabel(profile.version)} is newer than this CLI supports (up to v${max}); commands run as if it were a head build past v${max}. Run \`mb upgrade\` for a newer CLI.`; } case "unknown": { - return `Could not parse the Metabase version; assuming the newest supported (v${max}).`; + return `Could not parse the Metabase version; assuming a head build past v${max}.`; } } } diff --git a/packages/cli/src/core/auth/storage.test.ts b/packages/cli/src/core/auth/storage.test.ts index 613ce62d..79f98fd2 100644 --- a/packages/cli/src/core/auth/storage.test.ts +++ b/packages/cli/src/core/auth/storage.test.ts @@ -1,10 +1,10 @@ -import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parseJson } from "@metabase/client/json"; -import { ProfilesFile } from "./profile-record"; +import { type ProfileRecord, ProfilesFile } from "./profile-record"; import { configDir } from "../paths"; import { setupTempConfigHome, type TempConfigHome } from "./temp-config-home"; @@ -50,6 +50,17 @@ const OAUTH: OAuthCredential = { clientId: "client-1", }; +function defaultRecord(apiKey: string | null): ProfileRecord { + return { + name: "default", + url: "https://m.example.com", + apiKey, + oauth: null, + lastProbe: null, + lastFailure: null, + }; +} + import { join } from "node:path"; function legacyCredentialsPath(): string { @@ -60,6 +71,10 @@ function legacyRejectionsPath(): string { return join(configDir(), "rejections.json"); } +const IS_WINDOWS = process.platform === "win32"; +const PROBED_AT = "2026-03-04T05:06:07.000Z"; +const FAILED_AT = "2026-03-04T06:00:00.000Z"; + describe("profiles (keyring backend)", () => { let home: TempConfigHome; @@ -149,18 +164,12 @@ describe("profiles (keyring backend)", () => { }); it("deletes profiles.json when the last profile is gone", async () => { - if (process.platform === "win32") { - return; - } await writeProfile({ url: "https://m.example.com", apiKey: "k" }, "only"); await clearProfile("only"); - expect(() => statSync(profilesFilePath())).toThrow(/ENOENT/); + expect(existsSync(profilesFilePath())).toBe(false); }); - it("writes profiles.json with 0600 perms", async () => { - if (process.platform === "win32") { - return; - } + it.skipIf(IS_WINDOWS)("writes profiles.json with 0600 perms", async () => { await writeProfile({ url: "https://m.example.com", apiKey: "k" }, "only"); const mode = statSync(profilesFilePath()).mode & 0o777; expect(mode).toBe(0o600); @@ -181,10 +190,7 @@ describe("profiles (file fallback when keyring is broken)", () => { home.cleanup(); }); - it("stores the API key inline in profiles.json with 0600 perms", async () => { - if (process.platform === "win32") { - return; - } + it.skipIf(IS_WINDOWS)("stores the API key inline in profiles.json with 0600 perms", async () => { const location = await writeProfile({ url: "https://m.example.com", apiKey: "secret" }); expect(location).toEqual({ backend: "file", @@ -193,7 +199,7 @@ describe("profiles (file fallback when keyring is broken)", () => { reason: "unavailable", }); const file = parseJson(readFileSync(profilesFilePath(), "utf8"), ProfilesFile); - expect(file.profiles[0]?.apiKey).toBe("secret"); + expect(file).toEqual({ profiles: [defaultRecord("secret")] }); const mode = statSync(profilesFilePath()).mode & 0o777; expect(mode).toBe(0o600); }); @@ -251,12 +257,24 @@ describe("writeProbeResult and writeProbeFailure", () => { hoisted.store.clear(); hoisted.controls.broken = false; home = setupTempConfigHome(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(PROBED_AT)); }); afterEach(() => { + vi.useRealTimers(); home.cleanup(); }); + const ALICE_PROBE = { + at: PROBED_AT, + version: { tag: "v0.58.7", major: 58, patch: 7 }, + date: null, + hash: null, + tokenFeatures: null, + user: { id: 42, name: "Alice", isAdmin: true }, + }; + it("writeProbeResult populates lastProbe and clears lastFailure", async () => { await writeProfile({ url: "https://m.example.com", apiKey: "k" }, "p"); await writeProbeFailure("p", { kind: "auth", reason: "bad" }); @@ -269,13 +287,15 @@ describe("writeProbeResult and writeProbeFailure", () => { tokenFeatures: null, }, }); - expect(probe).not.toBeNull(); - expect(probe?.user).toEqual({ id: 42, name: "Alice", isAdmin: true }); - expect(probe?.version).toEqual({ tag: "v0.58.7", major: 58, patch: 7 }); - - const record = await readProfileRecord("p"); - expect(record?.lastProbe).toEqual(probe); - expect(record?.lastFailure).toBeNull(); + expect(probe).toEqual(ALICE_PROBE); + expect(await readProfileRecord("p")).toEqual({ + name: "p", + url: "https://m.example.com", + apiKey: null, + oauth: null, + lastProbe: ALICE_PROBE, + lastFailure: null, + }); }); it("writeProbeResult returns null and does not create a record when none exists", async () => { @@ -294,8 +314,8 @@ describe("writeProbeResult and writeProbeFailure", () => { it("writeProbeFailure updates lastFailure but leaves apiKey/url/lastProbe untouched", async () => { await writeProfile({ url: "https://m.example.com", apiKey: "k" }, "p"); - const probe = await writeProbeResult("p", { - user: { id: 1, name: "Alice", isAdmin: true }, + await writeProbeResult("p", { + user: { id: 42, name: "Alice", isAdmin: true }, server: { version: { tag: "v0.58.7", major: 58, patch: 7 }, date: null, @@ -303,18 +323,27 @@ describe("writeProbeResult and writeProbeFailure", () => { tokenFeatures: null, }, }); - expect(probe).not.toBeNull(); + vi.setSystemTime(new Date(FAILED_AT)); const failure = await writeProbeFailure("p", { kind: "auth", reason: "Invalid or unauthorized API key", }); - expect(failure).not.toBeNull(); - const after = await readProfileRecord("p"); - expect(after?.url).toBe("https://m.example.com"); - expect(after?.lastProbe).toEqual(probe); - expect(after?.lastFailure).toEqual(failure); + expect(failure).toEqual({ + at: FAILED_AT, + kind: "auth", + reason: "Invalid or unauthorized API key", + }); + expect(await readProfileRecord("p")).toEqual({ + name: "p", + url: "https://m.example.com", + apiKey: null, + oauth: null, + lastProbe: ALICE_PROBE, + lastFailure: { at: FAILED_AT, kind: "auth", reason: "Invalid or unauthorized API key" }, + }); + expect(hoisted.store.get("metabase-cli:profile:p:apiKey")).toBe("k"); }); }); @@ -342,13 +371,17 @@ describe("MB_CLI_DISABLE_KEYRING", () => { }); expect(hoisted.store.size).toBe(0); const file = parseJson(readFileSync(profilesFilePath(), "utf8"), ProfilesFile); - expect(file.profiles[0]?.apiKey).toBe("secret"); + expect(file).toEqual({ profiles: [defaultRecord("secret")] }); }); it("treats values other than '1' as not-disabled", async () => { process.env["MB_CLI_DISABLE_KEYRING"] = "0"; const location = await writeProfile({ url: "https://m.example.com", apiKey: "secret" }); - expect(location.backend).toBe("keyring"); + expect(location).toEqual({ + backend: "keyring", + service: "metabase-cli", + account: "profile:default:apiKey", + }); }); }); @@ -420,8 +453,8 @@ describe("legacy storage detection", () => { await writeProfile({ url: "https://m.example.com", apiKey: "secret" }); - expect(() => statSync(legacyCredentialsPath())).toThrow(/ENOENT/); - expect(() => statSync(legacyRejectionsPath())).toThrow(/ENOENT/); + expect(existsSync(legacyCredentialsPath())).toBe(false); + expect(existsSync(legacyRejectionsPath())).toBe(false); }); }); @@ -468,7 +501,7 @@ describe("OAuth profiles (keyring backend)", () => { url: "https://m.example.com", credential: { kind: "apiKey", apiKey: "k" }, }); - expect((await readProfileRecord())?.oauth).toBeNull(); + expect(await readProfileRecord()).toEqual(defaultRecord(null)); expect(hoisted.store.get("metabase-cli:profile:default:oauthAccess")).toBeUndefined(); expect(hoisted.store.get("metabase-cli:profile:default:oauthRefresh")).toBeUndefined(); }); @@ -480,7 +513,19 @@ describe("OAuth profiles (keyring backend)", () => { url: "https://m.example.com", credential: OAUTH, }); - expect((await readProfileRecord())?.apiKey).toBeNull(); + expect(await readProfileRecord()).toEqual({ + name: "default", + url: "https://m.example.com", + apiKey: null, + oauth: { + accessToken: null, + refreshToken: null, + expiresAt: OAUTH.expiresAt, + clientId: "client-1", + }, + lastProbe: null, + lastFailure: null, + }); expect(hoisted.store.get("metabase-cli:profile:default:apiKey")).toBeUndefined(); }); @@ -501,7 +546,12 @@ describe("OAuth profiles (keyring backend)", () => { }; hoisted.controls.broken = true; // the vault is unavailable during the refresh try { - expect((await writeOAuthProfile("https://m.example.com", rotated)).backend).toBe("file"); + expect(await writeOAuthProfile("https://m.example.com", rotated)).toEqual({ + backend: "file", + path: profilesFilePath(), + account: "profile:default:oauthAccess", + reason: "unavailable", + }); } finally { hoisted.controls.broken = false; } @@ -545,12 +595,24 @@ describe("OAuth profiles (file fallback)", () => { it("inlines the OAuth tokens in profiles.json when the keyring is broken", async () => { const location = await writeOAuthProfile("https://m.example.com", OAUTH); - expect(location.backend).toBe("file"); - expect((await readProfileRecord())?.oauth).toEqual({ - accessToken: "access-1", - refreshToken: "refresh-1", - expiresAt: OAUTH.expiresAt, - clientId: "client-1", + expect(location).toEqual({ + backend: "file", + path: profilesFilePath(), + account: "profile:default:oauthAccess", + reason: "unavailable", + }); + expect(await readProfileRecord()).toEqual({ + name: "default", + url: "https://m.example.com", + apiKey: null, + oauth: { + accessToken: "access-1", + refreshToken: "refresh-1", + expiresAt: OAUTH.expiresAt, + clientId: "client-1", + }, + lastProbe: null, + lastFailure: null, }); expect(await readProfileCredential()).toEqual({ url: "https://m.example.com", diff --git a/packages/cli/src/core/config.test.ts b/packages/cli/src/core/config.test.ts index 9188eeeb..73c441d4 100644 --- a/packages/cli/src/core/config.test.ts +++ b/packages/cli/src/core/config.test.ts @@ -197,14 +197,18 @@ describe("resolveConfig", () => { const error = await resolveConfig({ profile: "missing" }).catch((thrown: unknown) => thrown); expect(error).toBeInstanceOf(ConfigError); assert(error instanceof ConfigError, "expected ConfigError"); - expect(error.message).toContain('Not authenticated for profile "missing"'); + expect(error.message).toBe( + 'Not authenticated for profile "missing". Run `mb auth login`, set MB_URL/MB_API_KEY, or pass --url/--api-key.', + ); }); it("throws ConfigError when nothing is configured", async () => { const error = await resolveConfig({}).catch((thrown: unknown) => thrown); expect(error).toBeInstanceOf(ConfigError); assert(error instanceof ConfigError, "expected ConfigError"); - expect(error.message).toContain("Not authenticated"); + expect(error.message).toBe( + 'Not authenticated for profile "default". Run `mb auth login`, set MB_URL/MB_API_KEY, or pass --url/--api-key.', + ); }); it("does not append the lastFailure hint when stored credentials are still usable", async () => { @@ -233,10 +237,9 @@ describe("resolveConfig", () => { const error = await resolveConfig({ profile: "lost" }).catch((thrown: unknown) => thrown); expect(error).toBeInstanceOf(ConfigError); assert(error instanceof ConfigError, "expected ConfigError"); - expect(error.message).toContain( - 'profile "lost" last verify failed: Invalid or unauthorized API key', + expect(error.message).toBe( + 'Not authenticated for profile "lost". Run `mb auth login`, set MB_URL/MB_API_KEY, or pass --url/--api-key. profile "lost" last verify failed: Invalid or unauthorized API key. Run `mb auth login --profile lost` to update the token.', ); - expect(error.message).toContain("Run `mb auth login --profile lost` to update the token."); }); it("omits the lastFailure hint after a successful re-probe clears the failure", async () => { @@ -259,7 +262,9 @@ describe("resolveConfig", () => { const error = await resolveConfig({ profile: "recovers" }).catch((thrown: unknown) => thrown); expect(error).toBeInstanceOf(ConfigError); assert(error instanceof ConfigError, "expected ConfigError"); - expect(error.message).not.toContain("last verify failed"); + expect(error.message).toBe( + 'Not authenticated for profile "recovers". Run `mb auth login`, set MB_URL/MB_API_KEY, or pass --url/--api-key.', + ); }); }); diff --git a/packages/cli/src/core/skills.test.ts b/packages/cli/src/core/skills.test.ts index 6e0d3902..14960661 100644 --- a/packages/cli/src/core/skills.test.ts +++ b/packages/cli/src/core/skills.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import * as fc from "fast-check"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { ConfigError } from "@metabase/client/errors"; +import { ConfigError, ValidationError } from "@metabase/client/errors"; import { FEATURE_NAMES } from "@metabase/client/version/features"; import type { ServerInfo } from "@metabase/client/version/probe"; import { createServerProfile, type ServerProfile } from "@metabase/client/version/profile"; @@ -37,29 +37,43 @@ function profileAt( } describe("parseFrontmatter", () => { + const SOURCE = "skills/x/SKILL.md"; + it("parses a minimal frontmatter block", () => { expect( - parseFrontmatter("---\nname: test-skill\ndescription: A test skill.\n---\n\nBody."), + parseFrontmatter("---\nname: test-skill\ndescription: A test skill.\n---\n\nBody.", SOURCE), ).toEqual({ name: "test-skill", description: "A test skill.", hidden: false, requires: [] }); }); - it("returns null when there is no frontmatter delimiter", () => { - expect(parseFrontmatter("# A skill\n\nNo frontmatter here.")).toBeNull(); + it("throws ConfigError naming the file when there is no frontmatter delimiter", () => { + expect(() => parseFrontmatter("# A skill\n\nNo frontmatter here.", SOURCE)).toThrow( + new ConfigError("skills/x/SKILL.md: missing frontmatter"), + ); }); - it("returns null when the frontmatter is unterminated", () => { - expect(parseFrontmatter("---\nname: foo\ndescription: bar\n")).toBeNull(); + it("throws ConfigError naming the file when the frontmatter is unterminated", () => { + expect(() => parseFrontmatter("---\nname: foo\ndescription: bar\n", SOURCE)).toThrow( + new ConfigError("skills/x/SKILL.md: unterminated frontmatter"), + ); }); - it("returns null when name is missing or empty", () => { - expect(parseFrontmatter("---\ndescription: no name\n---\n")).toBeNull(); - expect(parseFrontmatter("---\nname:\ndescription: blank name\n---\n")).toBeNull(); + it("throws ValidationError naming the file when name is missing or empty", () => { + for (const block of [ + "---\ndescription: no name\n---\n", + "---\nname:\ndescription: blank\n---\n", + ]) { + expect(() => parseFrontmatter(block, SOURCE)).toThrow(ValidationError); + expect(() => parseFrontmatter(block, SOURCE)).toThrow( + "skills/x/SKILL.md: value did not match expected schema", + ); + } }); it("joins multi-line YAML description continuations", () => { expect( parseFrontmatter( "---\nname: multi\ndescription: First sentence.\n Second line.\n Third line.\n---\n", + SOURCE, ), ).toEqual({ name: "multi", @@ -70,19 +84,19 @@ describe("parseFrontmatter", () => { }); it("parses hidden: true as hidden, missing or false as visible", () => { - expect(parseFrontmatter("---\nname: a\ndescription: x\nhidden: true\n---\n")).toEqual({ + expect(parseFrontmatter("---\nname: a\ndescription: x\nhidden: true\n---\n", SOURCE)).toEqual({ name: "a", description: "x", hidden: true, requires: [], }); - expect(parseFrontmatter("---\nname: b\ndescription: x\nhidden: false\n---\n")).toEqual({ + expect(parseFrontmatter("---\nname: b\ndescription: x\nhidden: false\n---\n", SOURCE)).toEqual({ name: "b", description: "x", hidden: false, requires: [], }); - expect(parseFrontmatter("---\nname: c\ndescription: x\n---\n")).toEqual({ + expect(parseFrontmatter("---\nname: c\ndescription: x\n---\n", SOURCE)).toEqual({ name: "c", description: "x", hidden: false, @@ -90,8 +104,10 @@ describe("parseFrontmatter", () => { }); }); - it("returns null on malformed YAML", () => { - expect(parseFrontmatter("---\n:::not yaml\n---\n")).toBeNull(); + it("throws ConfigError naming the file on malformed YAML", () => { + const unclosed = "---\nname: [unclosed\n---\n"; + expect(() => parseFrontmatter(unclosed, SOURCE)).toThrow(ConfigError); + expect(() => parseFrontmatter(unclosed, SOURCE)).toThrow("skills/x/SKILL.md: invalid YAML: "); }); }); @@ -188,11 +204,8 @@ describe("discoverSkills", () => { ]); }); - it("skips directories without a SKILL.md and directories whose SKILL.md has no frontmatter", () => { + it("skips a directory without a SKILL.md", () => { mkdirSync(join(temp.skillData, "empty")); - const noFmDir = join(temp.skillData, "no-frontmatter"); - mkdirSync(noFmDir); - writeFileSync(join(noFmDir, "SKILL.md"), "# Plain markdown, no YAML.\n", "utf8"); writeSkill(temp.skillData, "real", { name: "real", description: "Real." }, "real body"); expect(discoverSkills([temp.skillData])).toEqual([ @@ -206,6 +219,16 @@ describe("discoverSkills", () => { ]); }); + it("throws ConfigError naming the file whose SKILL.md has no frontmatter rather than skipping it", () => { + const noFmDir = join(temp.skillData, "no-frontmatter"); + mkdirSync(noFmDir); + writeFileSync(join(noFmDir, "SKILL.md"), "# Plain markdown, no YAML.\n", "utf8"); + + expect(() => discoverSkills([temp.skillData])).toThrow( + new ConfigError(`${join(noFmDir, "SKILL.md")}: missing frontmatter`), + ); + }); + it("returns an empty list when no skill directories exist", () => { expect(discoverSkills([join(temp.root, "missing-1"), join(temp.root, "missing-2")])).toEqual( [], @@ -278,7 +301,9 @@ describe("resolveSections", () => { const withLibraryOnly = profileAt(61, { library: true }).features; const withBoth = profileAt(61, { library: true, remote_sync: true }).features; - expect(resolveSections(TEXT, withLibraryOnly, "skill x")).not.toContain("Publish it."); + expect(resolveSections(TEXT, withLibraryOnly, "skill x")).toBe( + ["Intro.", "", "## Transforms", "", "Run one.", "", "Outro."].join("\n"), + ); expect(resolveSections(TEXT, withBoth, "skill x")).toBe( ["Intro.", "", "## Transforms", "", "Run one.", "", "Publish it.", "", "Outro."].join("\n"), ); @@ -598,10 +623,27 @@ describe("the shipped skills", () => { ); expect(bound).toEqual({ "git-sync": ["remoteSync"], transform: ["transforms"] }); - for (const skill of all) { - const content = readSkillContent(skill, { includeExtras: true, profile: profileAt(58) }); - expect(content.body).not.toContain("` … `` pair marks text only a server with every -// named feature can use. With features in hand the markers are resolved: a met section keeps its -// text and loses its markers, an unmet one goes entirely, and a blank line that only separated a -// removed line from a blank one goes with it. Without features the text is returned as written, -// markers included, so a reader still sees what each section needs. The markers are validated -// either way, so a typo fails on every read rather than only against some server. +// Without features the markers stay in the text, so a reader still sees what each section needs. +// They are validated either way, so a typo fails on every read rather than only against some server. export function resolveSections(text: string, features: Features | null, where: string): string { const out: string[] = []; let open: OpenSection | null = null; @@ -366,8 +360,7 @@ export function resolveSections(text: string, features: Features | null, where: return out.join("\n"); } -// Keeps the marker when nothing is being resolved; otherwise leaves it out and says whether the -// line after it, if blank, would now only double a blank line already kept. +// Whether a blank line after a dropped marker would only double a blank line already kept. function dropMarker(out: string[], marker: string, features: Features | null): boolean { if (features === null) { out.push(marker); diff --git a/packages/cli/src/core/temp-skills-dir.ts b/packages/cli/src/core/temp-skills-dir.ts index 71c12605..b9ac79a1 100644 --- a/packages/cli/src/core/temp-skills-dir.ts +++ b/packages/cli/src/core/temp-skills-dir.ts @@ -18,16 +18,12 @@ interface SkillFixture { const GAMMA_FRONTMATTER = "---\nname: gamma\ndescription: The transform skill.\nrequires: [transforms]\n---\n\n"; -// The gamma SKILL.md as written, and as `readSkillContent` resolves it against a server with and -// without the section's feature. export const GAMMA_SKILL_MD = `${GAMMA_FRONTMATTER}Gamma instructions.\n\nGamma on activation.\n\n`; export const GAMMA_WITH_ACTIVATION = `${GAMMA_FRONTMATTER}Gamma instructions.\nGamma on activation.\n`; export const GAMMA_WITHOUT_ACTIVATION = `${GAMMA_FRONTMATTER}Gamma instructions.\n`; -// Two skills is the smallest collection that can show a window dropping one, and a third one bound -// to a feature is the smallest that can show the profile filter at work. Pointing MB_SKILLS_DIR -// here keeps an assertion over rendered output independent of whatever skill-data/ happens to -// ship. +// Pointing MB_SKILLS_DIR here keeps an assertion over rendered output independent of whatever +// skill-data/ happens to ship. const FIXTURES: readonly SkillFixture[] = [ { name: "alpha", description: "The first skill.", body: "Alpha instructions.\n" }, { name: "beta", description: "The second skill.", body: "Beta instructions.\n" }, diff --git a/packages/cli/src/output/render.test.ts b/packages/cli/src/output/render.test.ts index 3c2564d1..e86f2e44 100644 --- a/packages/cli/src/output/render.test.ts +++ b/packages/cli/src/output/render.test.ts @@ -393,10 +393,16 @@ describe("renderList — text format", () => { FULL_RANGE, ); renderList(envelope, cardView, { ...baseOpts, format: "text" }); - expect(streams.stdout).toContain("ID"); - expect(streams.stdout).toContain("Name"); - expect(streams.stdout).toContain("Sales"); - expect(streams.stdout).toContain("Ops"); + expect(streams.stdout).toBe( + `┌────┬───────┐ +│ ID │ Name │ +├────┼───────┤ +│ 1 │ Sales │ +├────┼───────┤ +│ 2 │ Ops │ +└────┴───────┘ +`, + ); }); it("emits a single '(no results)' line when empty", () => { @@ -465,7 +471,9 @@ describe("renderList — text format", () => { const projectedItems = items.map(({ id, name }) => ({ id, name })); const expectedCapped = capListEnvelope(windowList(projectedItems, FULL_RANGE), 500); assert(expectedCapped.truncated !== undefined, "fixture should produce truncation"); - expect(streams.stdout).toContain("ID"); + expect(streams.stdout).toBe( + renderTable(items.slice(0, expectedCapped.returned), cardView.tableColumns) + "\n", + ); expect(streams.stderr).toBe( `… cut at ${expectedCapped.truncated.bytes} bytes; continue with --offset ${expectedCapped.next_offset}, narrow the selection or raise --max-bytes\n`, ); @@ -536,13 +544,16 @@ describe("renderList — --fields path errors", () => { it("enriches an envelope-relative `data.` path with the item-relative hint (json)", () => { const { message } = renderListError({ ...baseOpts, fields: ["data.id"] }); - expect(message).toContain("relative to each item in `data`"); - expect(message).toContain("use `id` instead of `data.id`"); + expect(message).toBe( + 'unknown field path: "data.id" — on list commands --fields paths are relative to each item in `data`, not the envelope. Drop the `data.` prefix (e.g. use `id` instead of `data.id`).', + ); }); it("enriches a `data.` path in text mode too", () => { const { message } = renderListError({ ...baseOpts, format: "text", fields: ["data.name"] }); - expect(message).toContain("use `name` instead of `data.name`"); + expect(message).toBe( + 'unknown field path: "data.name" — on list commands --fields paths are relative to each item in `data`, not the envelope. Drop the `data.` prefix (e.g. use `name` instead of `data.name`).', + ); }); it("leaves an ordinary unknown-path error unchanged", () => { diff --git a/packages/cli/src/output/skill-list.test.ts b/packages/cli/src/output/skill-list.test.ts index 389f8a7f..963b8573 100644 --- a/packages/cli/src/output/skill-list.test.ts +++ b/packages/cli/src/output/skill-list.test.ts @@ -96,7 +96,7 @@ describe("skillFilterNotices", () => { it("names the profile that had no probe to filter by", () => { expect(skillFilterNotices(null, { profileName: "staging", bypassed: false })).toEqual([ - 'Skills are unfiltered: profile "staging" has no cached server probe (run `mb auth list` to record one).', + 'Skills are unfiltered: profile "staging" has no cached server probe (run `mb auth login` to record one).', ]); }); diff --git a/packages/cli/src/output/skill-list.ts b/packages/cli/src/output/skill-list.ts index 8ccc3e88..f1006c91 100644 --- a/packages/cli/src/output/skill-list.ts +++ b/packages/cli/src/output/skill-list.ts @@ -35,8 +35,7 @@ interface SkillFilterContext { bypassed: boolean; } -// What the text rendering says beyond the rows: that nothing was filtered because there was no -// server to filter by, or which skills were and why. `--all` asked for the lot and gets no note. +// `--all` asked for the lot and gets no note. export function skillFilterNotices( unavailable: readonly UnavailableSkill[] | null, context: SkillFilterContext, @@ -46,7 +45,7 @@ export function skillFilterNotices( } if (unavailable === null) { return [ - `Skills are unfiltered: profile "${context.profileName}" has no cached server probe (run \`mb auth list\` to record one).`, + `Skills are unfiltered: profile "${context.profileName}" has no cached server probe (run \`mb auth login\` to record one).`, ]; } return unavailable.map( diff --git a/packages/cli/src/output/types.ts b/packages/cli/src/output/types.ts index cf9f256d..3b061d05 100644 --- a/packages/cli/src/output/types.ts +++ b/packages/cli/src/output/types.ts @@ -30,9 +30,8 @@ export function listEnvelopeSchema(item: ZodType): ZodType return z.object(listEnvelopeShape(item)); } -// A list whose envelope carries fields beyond the window — what `renderListWithExtras` emits -// between the metadata and `data`. The shape's fit to `ListEnvelope` is what `listEnvelopeSchema` -// asserts; this one is left to inference so the extras keep their own types. +// The return type is left to inference so the extras keep their own types; the fit to +// `ListEnvelope` is what `listEnvelopeSchema` asserts. export function listEnvelopeSchemaWithExtras( item: ZodType, extras: X, diff --git a/packages/cli/src/runtime/command-help.test.ts b/packages/cli/src/runtime/command-help.test.ts index 58b482da..3295f8c3 100644 --- a/packages/cli/src/runtime/command-help.test.ts +++ b/packages/cli/src/runtime/command-help.test.ts @@ -449,6 +449,42 @@ const ALL_COMMANDS = [ "skills path", ]; +const BODY_COMMANDS = [ + "table update", + "field update", + "card create", + "card update", + "dashboard create", + "dashboard update", + "dashboard update-dashcard", + "subscription create", + "subscription update", + "alert create", + "alert update", + "collection create", + "document create", + "document update", + "transform create", + "transform update", + "transform-job create", + "transform-job update", + "transform-tag create", + "transform-tag update", + "setup", + "snippet create", + "snippet update", + "segment create", + "segment update", + "measure create", + "measure update", + "timeline create", + "timeline update", + "timeline-event create", + "timeline-event update", + "eid", + "query", +]; + function requiresOf(method: MethodKey): CommandHelpEntry["requires"] { return { methods: [method], features: Array.from(methodRequirements(method)) }; } @@ -472,25 +508,37 @@ describe("command tree contract", () => { }); it("every leaf declares a description", async () => { - for (const entry of await allEntries()) { - expect(entry.description, `missing description for ${entry.command}`).not.toBeNull(); - } + const entries = await allEntries(); + const undescribed = entries + .filter((entry) => entry.description === null) + .map((entry) => entry.command); + expect(entries.length).toBe(ALL_COMMANDS.length); + expect(undescribed).toEqual([]); }); it("every leaf declares examples and an output schema", async () => { - for (const entry of await allEntries()) { - expect(entry.examples.length, `missing examples for ${entry.command}`).toBeGreaterThan(0); - expect(entry.outputSchema, `missing outputSchema for ${entry.command}`).not.toBeNull(); - } + const entries = await allEntries(); + const withoutExamples = entries + .filter((entry) => entry.examples.length === 0) + .map((entry) => entry.command); + const withoutOutputSchema = entries + .filter((entry) => entry.outputSchema === null) + .map((entry) => entry.command); + expect(entries.length).toBe(ALL_COMMANDS.length); + expect({ withoutExamples, withoutOutputSchema }).toEqual({ + withoutExamples: [], + withoutOutputSchema: [], + }); }); it("declares an input schema on every command that accepts a JSON body", async () => { - for (const entry of await allEntries()) { - const acceptsBody = entry.args.some((arg) => arg.name === "body"); - if (acceptsBody) { - expect(entry.inputSchema, `missing inputSchema for ${entry.command}`).not.toBeNull(); - } - } + const entries = await allEntries(); + const acceptingBody = entries.filter((entry) => entry.args.some((arg) => arg.name === "body")); + const withoutInputSchema = acceptingBody + .filter((entry) => entry.inputSchema === null) + .map((entry) => entry.command); + expect(acceptingBody.map((entry) => entry.command)).toEqual(BODY_COMMANDS); + expect(withoutInputSchema).toEqual([]); }); it("reports the measure methods and the feature they need, and card list as baseline", async () => { @@ -508,8 +556,12 @@ describe("command tree contract", () => { "measure archive": { methods: ["measure.archive"], features: ["measures"] }, }); - const cardList = entries.find((entry) => entry.command === "card list"); - expect(cardList?.requires).toEqual({ methods: ["card.list"], features: [] }); + const cardRequires = Object.fromEntries( + entries + .filter((entry) => entry.command === "card list") + .map((entry) => [entry.command, entry.requires]), + ); + expect(cardRequires).toEqual({ "card list": { methods: ["card.list"], features: [] } }); }); it("reports the premium feature behind every content translation command", async () => { diff --git a/packages/client/README.md b/packages/client/README.md index 45e054ee..3363d925 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -308,7 +308,7 @@ try { A method's return type is the shape the newest supported server answers. Where an older server answers differently, the method parses that server's exact wire shape and converts it forward, so `mb.transform.get(5)` carries `target_table_id` on every server (a hydrated `table` on the oldest generations, the column on the rest), `mb.transformJob.run(2)` answers `{ message, started, run_id }` whether the server reports an opaque stub or a numeric run id, and each of `mb.library.get()`'s `effective_children` is `{ id, name, description, type, is_remote_synced }`. A field the server cannot report is `null`; the profile's `features` say why. A response that fails its generation's schema is a `ResponseShapeError` naming the server version and the fields that were off — never a silent fallback to another shape. -A server newer than the window, or a head build whose tag does not parse, is treated as the newest known major with `skew` set to `"newer-than-known"` or `"unknown"`; its additions pass through the loose schemas, and only a token feature can refuse it. The client never takes a version from the caller: a profile comes from a probe, so two consumers of one server cannot disagree about what it is. +A server newer than the window, or a head build whose tag does not parse, is placed one past the newest known major — the slot a rule written against head describes — with `skew` set to `"newer-than-known"` or `"unknown"`; its additions pass through the loose schemas, and only a token feature can refuse it. The client never takes a version from the caller: a profile comes from a probe, so two consumers of one server cannot disagree about what it is. ### Endpoints the client does not model @@ -412,15 +412,15 @@ revocation endpoint. Types: `Credential` (the `ApiKeyCredential | OAuthCredentia `probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo` — the parsed version tag, the build date and hash, and the token-feature map, each `null` when the server does not report it. `ParsedVersion` is the `{ tag, major, patch }` schema a probed version tag parses to; `editionFromTag(tag)` reads the `Edition` (`"oss"` | `"ee"`) Metabase stamps into the tag's semver major, `null` when the tag carries none. -`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` inside `KNOWN_RANGE` (`{ min, max }`, the majors the client is built against), `"newer-than-known"` above it, `"unknown"` when the tag did not parse. A newer or unparseable server is evaluated as `KNOWN_RANGE.max + 1`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile passed as `ClientOptions.server`, or probes once on first call and shares the result; `getServerTag` defaults to that profile's tag. There is no option to assume a version. +`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` at or below `KNOWN_RANGE.max` (`KNOWN_RANGE` is `{ min, max }`, the majors the client is built against; an older server keeps its real major and is refused feature by feature rather than flagged), `"newer-than-known"` above it, `"unknown"` when the tag did not parse. A newer or unparseable server is evaluated as `KNOWN_RANGE.max + 1`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile passed as `ClientOptions.server`, or probes once on first call and shares the result; `getServerTag` defaults to the tag of whichever profile the client holds — the one passed in, or the one a probe has settled on — so a shape error names the server whenever the client has met it. There is no option to assume a version. `Features` is a record of named booleans over `FEATURE_RULES`, the one table that relates a behaviour to the majors and token feature it needs (`FeatureRule`: `{ since, until?, tokenFeature? }`); `evaluateFeatures(effectiveMajor, tokenFeatures)` evaluates it, `FEATURE_NAMES` lists the keys, and `isFeatureName` narrows a string to one. Names describe behaviour (`transformTargetTableId`, `libraryChildrenCarryType`), never a version. Consumers branch on a feature, never on a major — `features.ts` is the only module that compares one. -`METHOD_REQUIREMENTS` names, per client method (`"transform.get"`, `"gitSync.branches"` — the `MethodKey` as the method is reached on the client), the features it needs, strictest first; `methodRequirements(key)` reads one entry, `METHOD_KEYS` lists the keys, and `isMethodKey` narrows a string to one. Every resource method starts by asking `transport.require(key)`, which throws a `CapabilityError` before any request leaves when the connected profile lacks a feature — a version gate reads `This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.`, a token gate `This operation requires the 'remote_sync' premium feature (not enabled on this server).` — and the error's `developerDetail` is a `RequirementFailure` carrying `reason`, `detail`, `feature`, `since`, `tokenFeature` and `serverVersion` — a Zod schema as well as a type (with `RequirementReason` for its `reason` enum), so a consumer that reports the refusal in its own output can describe it. A method that needs nothing never consults the server; a newer or unparseable server is placed at the newest known major, so only a token can refuse it. `ClientOptions.enforceRequirements: false` sends every method to the wire and leaves the server to answer for itself; a method whose wire shape is chosen by generation still reads the profile. `checkRequirements(key, profile)` is the check itself, `checkFeatures(features, profile)` the same check over a bare feature list, `featureGap(profile, feature)` says which half of a rule the profile fails (`FeatureGap`: `{ kind: "version" }` or `{ kind: "token", tokenFeature }`), and `ruleGap` is the same question asked of a bare rule. A consumer that anticipates the refusal — a CLI preflight, say — throws the same `CapabilityError` from the same `RequirementFailure`. +`METHOD_REQUIREMENTS` names, per client method (`"transform.get"`, `"gitSync.branches"` — the `MethodKey` as the method is reached on the client), the features it needs, strictest first; `methodRequirements(key)` reads one entry, `METHOD_KEYS` lists the keys, and `isMethodKey` narrows a string to one. Every resource method starts by asking `transport.require(key)`, which throws a `CapabilityError` before any request leaves when the connected profile lacks a feature — a version gate reads `This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.`, a token gate `This operation requires the 'remote_sync' premium feature (not enabled on this server).` — and the error's `developerDetail` is a `RequirementFailure` carrying `reason`, `detail`, `feature`, `since`, `tokenFeature` and `serverVersion` — a Zod schema as well as a type (with `RequirementReason` for its `reason` enum), so a consumer that reports the refusal in its own output can describe it. A method that needs nothing never consults the server; a newer or unparseable server is placed past the newest known major, so only a token can refuse it. `ClientOptions.enforceRequirements: false` sends every method to the wire and leaves the server to answer for itself; a method whose wire shape is chosen by generation still reads the profile. `checkRequirements(key, profile)` is the check itself, `checkFeatures(features, profile)` the same check over a bare feature list, `featureGap(profile, feature)` says which half of a rule the profile fails (`FeatureGap`: `{ kind: "version" }` or `{ kind: "token", tokenFeature }`), and `ruleGap` is the same question asked of a bare rule. A consumer that anticipates the refusal — a CLI preflight, say — throws the same `CapabilityError` from the same `RequirementFailure`. `summarizeCapabilities(features)` collapses a feature list into a `CapabilitySummary` — `{ minVersion, tokenFeature? }`, the oldest server that has every feature and the one premium feature it must grant — for a consumer that wants a single floor rather than named switches. -A resource whose wire shape differs between generations exports from its `@metabase/client/domain/` module, beside the canonical schema, a reader that picks the exact schema for a `Features` and converts forward: `transformDetailSchema(features)` and `transformRowSchema(features)` read a `Transform` (the older generations' hydrated `table` on the detail endpoint, and no link on the rest, become `target_table_id`), `transformJobRunResultSchema(features)` reads a `TransformJobRunResult` (`{ message, started, run_id }` — a stub id is `started: true, run_id: null`; a numeric or null id is `started: id !== null, run_id: id`), and `libraryWireSchema(features)` with `toLibrary(wire, listing)` reads a `Library` whose `LibraryChild` entries carry `type` from the wire where the generation sends it and `is_remote_synced` from the collection listing on every generation (`LibraryCollectionInfo`, `LibraryListing`). Each `null` in a canonical field means "this server cannot say". `ResponseShapeError.developerDetail.serverSkew` carries the profile's `skew`, and a `newer-than-known` server is named as such in the message's lead line. +A resource whose wire shape differs between generations exports from its `@metabase/client/domain/` module, beside the canonical schema, a reader that picks the exact schema for a `Features` and converts forward: `transformDetailSchema(features)` and `transformRowSchema(features)` read a `Transform` (the older generations' hydrated `table` on the detail endpoint, and no link on the rest, become `target_table_id`), `transformJobSchema(features)` reads a `TransformJob` whose `active` is `null` on a generation that cannot switch jobs off, `transformJobRunResultSchema(features)` reads a `TransformJobRunResult` (`{ message, started, run_id }` — a stub id is `started: null, run_id: null`, the request accepted and the outcome unsaid; a numeric or null id is `started: id !== null, run_id: id`), and `libraryWireSchema(features)` with `toLibrary(wire, listing)` reads a `Library` whose `LibraryChild` entries carry `type` from the wire where the generation sends it and `is_remote_synced` from the collection listing on every generation (`LibraryCollectionInfo`, `LibraryListing`). Each `null` in a canonical field means "this server cannot say". `ResponseShapeError.developerDetail.serverSkew` carries the profile's `skew`, and a `newer-than-known` server is named as such in the message's lead line. Types: `ServerInfo`, `ServerProfile`, `Skew`, `Edition`, `FeatureName`, `FeatureRule`, `Features`, `FeatureGap`, `MethodKey`, `RequirementFailure`, `RequirementReason`, `CapabilitySummary`. diff --git a/packages/client/src/domain/transform-job.test.ts b/packages/client/src/domain/transform-job.test.ts index 855ea6b8..103d8c45 100644 --- a/packages/client/src/domain/transform-job.test.ts +++ b/packages/client/src/domain/transform-job.test.ts @@ -2,21 +2,50 @@ import { describe, expect, it } from "vitest"; import { evaluateFeatures } from "../version/features"; -import { transformJobRunResultSchema } from "./transform-job"; +import { transformJobRunResultSchema, transformJobSchema } from "./transform-job"; +const ALWAYS_ACTIVE_SERVER = evaluateFeatures(58, null); const STUB_RUN_ID_SERVER = evaluateFeatures(61, null); const NUMERIC_RUN_ID_SERVER = evaluateFeatures(64, null); const RUN_STARTED_MESSAGE = "Job run started"; +const JOB_WIRE = { + id: 3, + name: "Nightly", + description: null, + schedule: "0 0 0 * * ?", + ui_display_type: "cron/raw", + entity_id: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +describe("transformJobSchema", () => { + it("reads a job from a server that cannot switch jobs off with active null", () => { + expect(transformJobSchema(ALWAYS_ACTIVE_SERVER).parse(JOB_WIRE)).toEqual({ + ...JOB_WIRE, + active: null, + }); + }); + + it("requires active from a server that can switch jobs off", () => { + expect(transformJobSchema(STUB_RUN_ID_SERVER).safeParse(JOB_WIRE).success).toBe(false); + expect(transformJobSchema(STUB_RUN_ID_SERVER).parse({ ...JOB_WIRE, active: false })).toEqual({ + ...JOB_WIRE, + active: false, + }); + }); +}); + describe("transformJobRunResultSchema", () => { - it("reads an opaque stub id as a start whose run id the server cannot say", () => { + it("reads an opaque stub id as an accepted request whose outcome the server cannot say", () => { expect( transformJobRunResultSchema(STUB_RUN_ID_SERVER).parse({ message: RUN_STARTED_MESSAGE, job_run_id: "stub-3-1767225600000", }), - ).toEqual({ message: RUN_STARTED_MESSAGE, started: true, run_id: null }); + ).toEqual({ message: RUN_STARTED_MESSAGE, started: null, run_id: null }); }); it("reads a numeric run id as a start with that id", () => { diff --git a/packages/client/src/domain/transform-job.ts b/packages/client/src/domain/transform-job.ts index f69013c4..1426cace 100644 --- a/packages/client/src/domain/transform-job.ts +++ b/packages/client/src/domain/transform-job.ts @@ -27,14 +27,13 @@ const JobNextRun = z }) .loose(); -export const TransformJob = z +const TransformJobBase = z .object({ id: z.number().int(), name: z.string(), description: z.string().nullable(), schedule: z.string(), ui_display_type: CronUiDisplayType, - active: z.boolean().optional(), entity_id: z.string().nullable(), created_at: z.string(), updated_at: z.string(), @@ -44,8 +43,22 @@ export const TransformJob = z next_run: JobNextRun.nullable().optional(), }) .loose(); + +// `active` is null on a server whose jobs cannot be switched off: every job there runs on schedule. +export const TransformJob = TransformJobBase.extend({ + active: z.boolean().nullable(), +}); export type TransformJob = z.infer; +function alwaysActive(wire: z.infer): TransformJob { + return { ...wire, active: null }; +} + +/** The shape every transform-job endpoint answers on a server with `features`, read as `TransformJob`. */ +export function transformJobSchema(features: Features): z.ZodType { + return features.transformJobActivation ? TransformJob : TransformJobBase.transform(alwaysActive); +} + export const TransformJobCompact = TransformJob.pick({ id: true, name: true, @@ -80,13 +93,12 @@ export const TransformJobUpdateInput = z .loose(); export type TransformJobUpdateInput = z.infer; -// One generation of servers answers `job_run_id` as an opaque stub string that only says a run was -// started; the other answers the run's numeric id, or null when nothing was started (the job is -// already running, or it resolves to no transforms). `started` keeps the first generation's one bit -// and `run_id` the second's, so neither answer is folded into the other. +// One generation of servers answers `job_run_id` as an opaque stub string whatever happened; the +// other answers the run's numeric id, or null when nothing was started. `started` and `run_id` are +// both null for the first, so neither answer is folded into the other. export const TransformJobRunResult = z.object({ message: z.string(), - started: z.boolean(), + started: z.boolean().nullable(), run_id: z.number().int().positive().nullable(), }); export type TransformJobRunResult = z.infer; @@ -102,7 +114,7 @@ const TransformJobRunWireV64 = z.object({ }); function fromStubRunId(wire: z.infer): TransformJobRunResult { - return { message: wire.message, started: true, run_id: null }; + return { message: wire.message, started: null, run_id: null }; } function fromNumericRunId(wire: z.infer): TransformJobRunResult { diff --git a/packages/client/src/domain/transform.test.ts b/packages/client/src/domain/transform.test.ts index f43760f6..bfc9a662 100644 --- a/packages/client/src/domain/transform.test.ts +++ b/packages/client/src/domain/transform.test.ts @@ -51,19 +51,20 @@ const ROW = { }; describe("transformDetailSchema", () => { - it("reads the hydrated table's id into target_table_id and drops the table", () => { - const detail = transformDetailSchema(HYDRATED_TABLE_SERVER).parse({ + it("reads the hydrated table's id into target_table_id and keeps the table every detail carries", () => { + const table = { id: 42, name: "daily_orders", schema: "public" }; + + expect(transformDetailSchema(HYDRATED_TABLE_SERVER).parse({ ...ROW, table })).toEqual({ ...ROW, - table: { id: 42, name: "daily_orders", schema: "public" }, + table, + target_table_id: 42, }); - - expect(detail).toEqual({ ...ROW, target_table_id: 42 }); - expect("table" in detail).toBe(false); }); it("reads a hydrated null table as no target table yet", () => { expect(transformDetailSchema(HYDRATED_TABLE_SERVER).parse({ ...ROW, table: null })).toEqual({ ...ROW, + table: null, target_table_id: null, }); }); diff --git a/packages/client/src/domain/transform.ts b/packages/client/src/domain/transform.ts index 347819ee..3b8e71a6 100644 --- a/packages/client/src/domain/transform.ts +++ b/packages/client/src/domain/transform.ts @@ -205,15 +205,15 @@ export const Transform = TransformBase.extend({ }); export type Transform = z.infer; -// Before the column existed, `GET /api/transform/{id}` hydrated the registered table instead, and -// every other transform endpoint carried no link at all. +// A server without the column links the output table only on the detail, as the hydrated `table` +// every generation's detail carries; its other transform endpoints carry no link at all. The +// hydrated table stays in place so the detail reads the same on every server. const TransformWireV59Detail = TransformBase.extend({ table: z.object({ id: z.number().int() }).loose().nullable(), }); function fromHydratedTable(wire: z.infer): Transform { - const { table, ...rest } = wire; - return { ...rest, target_table_id: table === null ? null : table.id }; + return { ...wire, target_table_id: wire.table === null ? null : wire.table.id }; } function withoutLink(row: z.infer): Transform { diff --git a/packages/client/src/errors.test.ts b/packages/client/src/errors.test.ts index 3a5f7fea..186f42f5 100644 --- a/packages/client/src/errors.test.ts +++ b/packages/client/src/errors.test.ts @@ -39,7 +39,7 @@ describe("toMetabaseError", () => { assert(!zodError.success, "expected zod failure"); const result = toMetabaseError(zodError.error); expect(result).toBeInstanceOf(ConfigError); - expect(result.userMessage).toContain("name"); + expect(result.userMessage).toBe("name: Invalid input: expected string, received number"); }); it("wraps a generic Error as UnknownError and preserves the message", () => { diff --git a/packages/client/src/http/errors.test.ts b/packages/client/src/http/errors.test.ts index ff6b7c41..3d55bf67 100644 --- a/packages/client/src/http/errors.test.ts +++ b/packages/client/src/http/errors.test.ts @@ -402,7 +402,7 @@ describe("HttpError 404 messages", () => { rawBody: JSON.stringify({ message: "Not found." }), serverTag: "v0.58.7", }); - expect(error.message).not.toContain("mb doctor"); + expect(error.message).toBe("Not found: GET /api/database/9999."); }); }); diff --git a/packages/client/src/http/transport.test.ts b/packages/client/src/http/transport.test.ts index cb0d5a46..7d5c1449 100644 --- a/packages/client/src/http/transport.test.ts +++ b/packages/client/src/http/transport.test.ts @@ -15,6 +15,7 @@ import { import { HttpError } from "./errors"; import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; +import { SessionProperties } from "../domain/session-properties"; import { PROBE_PATH } from "../version/probe"; import { createServerProfile, KNOWN_RANGE } from "../version/profile"; @@ -468,9 +469,11 @@ describe("createTransport.server", () => { }); const pending = client.server(); - controller.abort(); + controller.abort(new Error("operator interrupt")); - await expect(pending).rejects.toBeInstanceOf(AbortError); + const error = await pending.catch((caught: unknown) => caught); + assert(error instanceof AbortError, "expected AbortError"); + expect(error.message).toBe("operator interrupt"); }); it("names the supplied profile's version in a shape error without a getServerTag", async () => { @@ -504,6 +507,61 @@ describe("createTransport.server", () => { ); }); + it("names the server a lazy probe settled on in a later shape error", async () => { + const body = { id: "not-a-number", email: "a@b.com" }; + const expectedIssues = PingResponse.safeParse(body).error?.issues; + assert(expectedIssues !== undefined, "expected zod failure for fixture body"); + const fakeFetch = captureFetch([jsonResponse(PROBE_BODY), jsonResponse(body)]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + await client.server(); + + const error = await client + .requestParsed(PingResponse, "/api/user/current") + .catch((caught: unknown) => caught); + + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + expect(error.developerDetail).toEqual({ + kind: "zod", + method: "GET", + url: "https://m.example.com/api/user/current", + status: 200, + zodIssues: expectedIssues, + serverTag: "v1.61.2", + serverSkew: "supported", + }); + expect(fakeFetch.calls.map((call) => call.url)).toEqual([ + `https://m.example.com${PROBE_PATH}`, + "https://m.example.com/api/user/current", + ]); + }); + + it("leaves a shape error inside the probe itself unnamed rather than waiting on that probe", async () => { + const body = { version: "not-an-object" }; + const expectedIssues = SessionProperties.safeParse(body).error?.issues; + assert(expectedIssues !== undefined, "expected zod failure for fixture body"); + const fakeFetch = captureFetch([jsonResponse(body)]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client.server().catch((caught: unknown) => caught); + + assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); + expect(error.developerDetail).toEqual({ + kind: "zod", + method: "GET", + url: `https://m.example.com${PROBE_PATH}`, + status: 200, + zodIssues: expectedIssues, + serverTag: null, + serverSkew: null, + }); + }); + it("says a supplied profile above the known range is newer than the client supports in a shape error", async () => { const beyond = KNOWN_RANGE.max + 2; const fakeFetch = captureFetch([jsonResponse({ id: "not-a-number", email: "a@b.com" })]); @@ -729,18 +787,17 @@ describe("createTransport credential encoding", () => { it("keeps the credential out of the message, so the error does not undo redaction", () => { const apiKey = "mb_secret_material_…"; - let thrown: unknown = null; - try { + expect(() => createTransport( { url: "https://m.example.com", credential: { kind: "apiKey", apiKey } }, { userAgent: TEST_USER_AGENT }, - ); - } catch (error) { - thrown = error; - } - assert(thrown instanceof ConfigError); - expect(thrown.message).toContain('Credential field "apiKey" has a character at index 19'); - expect(thrown.message).not.toContain("mb_secret_material_"); + ), + ).toThrowError( + new ConfigError( + 'Credential field "apiKey" has a character at index 19 (U+2026) that cannot be sent in an HTTP header. ' + + "Header values are limited to Latin-1; a smart quote, ellipsis, or dash substituted during copy-paste is the usual cause.", + ), + ); }); it("accepts a key at the top of the Latin-1 range", () => { @@ -771,7 +828,9 @@ describe("createTransport base URL", () => { await client.requestParsed(PingResponse, "/api/user/current"); - expect(fakeFetch.calls[0]?.url).toBe("https://m.example.com/api/user/current"); + expect(fakeFetch.calls.map((call) => call.url)).toEqual([ + "https://m.example.com/api/user/current", + ]); }); }); @@ -783,7 +842,7 @@ describe("createTransport user-agent", () => { await client.requestParsed(PingResponse, "/api/user/current"); - expect(fakeFetch.calls[0]?.headers["user-agent"]).toBe(otherCaller); + expect(fakeFetch.calls.map((call) => call.headers["user-agent"])).toEqual([otherCaller]); }); }); @@ -1227,9 +1286,9 @@ describe("createTransport query encoding", () => { }, }); - expect(fakeFetch.calls[0]?.url).toBe( + expect(fakeFetch.calls.map((call) => call.url)).toEqual([ "https://m.example.com/api/search?models=card&models=dashboard&q=x", - ); + ]); }); }); @@ -1256,8 +1315,9 @@ describe("createTransport OAuth bearer auth", () => { { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch }, ); await client.requestParsed(PingResponse, "/api/user/current"); - expect(fakeFetch.calls[0]?.headers["authorization"]).toBe("Bearer acc-1"); - expect(fakeFetch.calls[0]?.headers["x-api-key"]).toBeUndefined(); + expect(fakeFetch.calls.map((call) => call.headers)).toEqual([ + { accept: "application/json", authorization: "Bearer acc-1", "user-agent": TEST_USER_AGENT }, + ]); }); it("refreshes on 401 and replays the request with the new access token", async () => { @@ -1281,8 +1341,10 @@ describe("createTransport OAuth bearer auth", () => { const result = await client.requestParsed(PingResponse, "/api/user/current", { retries: 0 }); expect(result).toEqual({ id: 1, email: "a@b.c" }); expect(refreshCalls).toBe(1); - expect(fakeFetch.calls[0]?.headers["authorization"]).toBe("Bearer acc-1"); - expect(fakeFetch.calls[1]?.headers["authorization"]).toBe("Bearer acc-2"); + expect(fakeFetch.calls.map((call) => call.headers["authorization"])).toEqual([ + "Bearer acc-1", + "Bearer acc-2", + ]); }); it("gives up after a single refresh when the replay still 401s", async () => { @@ -1340,8 +1402,12 @@ describe("createTransport OAuth bearer auth", () => { { id: 1, email: "a@b.c" }, ]); expect(refreshCalls).toBe(1); - expect(fakeFetch.calls[2]?.headers["authorization"]).toBe("Bearer acc-2"); - expect(fakeFetch.calls[3]?.headers["authorization"]).toBe("Bearer acc-2"); + expect(fakeFetch.calls.map((call) => call.headers["authorization"])).toEqual([ + "Bearer acc-1", + "Bearer acc-1", + "Bearer acc-2", + "Bearer acc-2", + ]); }); it("refreshes again on a later request when the token expires a second time", async () => { @@ -1418,7 +1484,9 @@ describe("createTransport subpath base URLs", () => { { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch }, ); await client.requestParsed(PingResponse, "/api/user/current"); - expect(fakeFetch.calls[0]?.url).toBe("https://my.org.com/metabase/api/user/current"); + expect(fakeFetch.calls.map((call) => call.url)).toEqual([ + "https://my.org.com/metabase/api/user/current", + ]); }); }); diff --git a/packages/client/src/http/transport.ts b/packages/client/src/http/transport.ts index b2bf6167..197389ac 100644 --- a/packages/client/src/http/transport.ts +++ b/packages/client/src/http/transport.ts @@ -20,7 +20,7 @@ import { import { HttpError, isRetryableStatus } from "./errors"; import { buildNetworkError, isConnectionClosed } from "./network-error"; -import { NO_SERVER_TAG, parseJsonResponse } from "./response-shape"; +import { parseJsonResponse } from "./response-shape"; import { backoffDelay, DEFAULT_MAX_RETRIES, runWithRetries, type RetryOutcome } from "./retry"; import type { RedactionContext } from "./sanitize"; @@ -104,8 +104,8 @@ export interface ClientOptions { // A profile the caller already holds — from its own cache, or from a probe it ran to verify the // credential — so the client never asks the server what the caller can tell it. server?: ServerProfile; - // Names the server in a shape error. Defaults to the tag of `server`; a caller that resolves - // the tag some other way passes its own. + // Names the server in a shape error. Defaults to the tag of `server`, or of the profile a probe + // has settled on; a caller that resolves the tag some other way passes its own. getServerTag?: ServerTagResolver; refreshCredential?: CredentialRefresher; // Cancels every request this client makes; composed with the per-request `signal` and the @@ -120,8 +120,11 @@ export function createTransport(config: ClientCredentials, options: ClientOption const baseUrl = normalizeUrl(config.url); assertCredentialHeaderSafe(config.credential); const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); - const getServerTag = options.getServerTag ?? tagResolverFor(options.server); - const serverSkew: Skew | null = options.server === undefined ? null : options.server.skew; + // The server every error names: the profile handed in, or else the one a probe has settled on. A + // pending probe is never awaited here — a shape error inside the probe would wait on its own parse. + let settledProfile: ServerProfile | null = options.server ?? null; + const getServerTag = options.getServerTag ?? (async () => tagOf(settledProfile)); + const serverSkew = (): Skew | null => (settledProfile === null ? null : settledProfile.skew); const refreshCredential = options.refreshCredential; let credential = config.credential; const knownSecrets = new Set(credentialSecrets(credential)); @@ -170,16 +173,11 @@ export function createTransport(config: ClientCredentials, options: ClientOption }); } catch (error) { throwIfAborted(prepared.cancelSignal); - // A dropped connection or an elapsed timeout says nothing about whether the request landed: - // the bytes may never have left the client, or the server may already have committed the - // write and lost only the response. Metabase offers no idempotency-key framework to tell a - // replay from a first delivery, so a resend is only safe for a method safe to repeat — the - // same question, and the same answer, as the status gate below. A caller who knows the - // endpoint tolerates a resend opts back in with `idempotent: true`. - // - // A pooled connection reaped by the peer between requests is the one failure that reports the - // socket's age rather than the server's health, so it earns an attempt on a fresh socket even - // when the caller opted out of retries entirely. + // A dropped connection or a timeout says nothing about whether a write landed, and Metabase + // has no idempotency keys to tell a replay from a first delivery, so only a method safe to + // repeat is resent (`idempotent: true` opts a write in). A pooled connection the peer reaped + // between requests reports the socket's age, not the server's health, so it earns one attempt + // on a fresh socket even when the caller opted out of retries. const isStaleSocketFirstTry = attempt === 0 && isConnectionClosed(error); if (prepared.idempotent && (hasRetriesLeft || isStaleSocketFirstTry)) { return { kind: "retry", delayMs: backoffDelay({ attempt }) }; @@ -195,9 +193,8 @@ export function createTransport(config: ClientCredentials, options: ClientOption throw buildNetworkError(error, prepared.method, prepared.url); } - // Metabase offers no idempotency-key framework, so the server cannot recognise a replayed - // write as the same request: retrying a POST whose response was lost double-creates. A status - // code is only grounds for another attempt when the method is safe to repeat. + // Retrying a POST whose response was lost double-creates: a status code is only grounds for + // another attempt when the method is safe to repeat. const canRetryStatus = hasRetriesLeft && prepared.idempotent; if (!response.ok && isRetryableStatus(response.status) && canRetryStatus) { const retryAfter = response.headers.get("Retry-After"); @@ -241,8 +238,6 @@ export function createTransport(config: ClientCredentials, options: ClientOption } } - // On a 401 with an OAuth credential, attempt a single token refresh and replay the request with - // the new access token. API-key credentials and non-401 errors propagate unchanged. async function executeWithAuthRefresh( path: string, opts: TransportRequestOptions, @@ -304,7 +299,10 @@ export function createTransport(config: ClientCredentials, options: ClientOption // failure for the life of the client. function server(): Promise { if (serverProfile === null) { - const probe = probeServer(transport).then(createServerProfile); + const probe = probeServer(transport).then((info) => { + settledProfile = createServerProfile(info); + return settledProfile; + }); probe.catch(() => { serverProfile = null; }); @@ -342,7 +340,7 @@ export function createTransport(config: ClientCredentials, options: ClientOption url: prepared.url, status: response.status, getServerTag, - serverSkew, + serverSkew: serverSkew(), }); }, async requestStream(path, opts) { @@ -363,12 +361,11 @@ export function createTransport(config: ClientCredentials, options: ClientOption return transport; } -function tagResolverFor(server: ServerProfile | undefined): ServerTagResolver { - if (server === undefined) { - return NO_SERVER_TAG; +function tagOf(profile: ServerProfile | null): string | null { + if (profile === null || profile.version === null) { + return null; } - const tag = server.version === null ? null : server.version.tag; - return async () => tag; + return profile.version.tag; } function buildUrl( diff --git a/packages/client/src/json.test.ts b/packages/client/src/json.test.ts index 3e79e86a..7f4ef784 100644 --- a/packages/client/src/json.test.ts +++ b/packages/client/src/json.test.ts @@ -32,7 +32,7 @@ describe("parseJson", () => { parseJson('{"id":"not-a-number","name":"x"}', Person, { source: "--body" }), ); assert(error instanceof ValidationError, "expected ValidationError"); - expect(error.message).toContain("--body"); + expect(error.message).toBe("--body: value did not match expected schema"); expect(error.developerDetail).toEqual({ source: "--body", zodIssues: [ @@ -76,7 +76,7 @@ describe("parseJsonResult", () => { const result = parseJsonResult('{"id":"x","name":"y"}', Person, { source: "--body" }); assert(!result.ok, "expected failure"); assert(result.error instanceof ValidationError, "expected ValidationError"); - expect(result.error.message).toContain("--body"); + expect(result.error.message).toBe("--body: value did not match expected schema"); }); it("treats an empty source string as a source, prefixing it rather than falling back to ", () => { @@ -163,13 +163,7 @@ describe("parseJson property tests", () => { it("property: any string that JSON.parse rejects produces a ConfigError with the source prefix", () => { fc.assert( fc.property(fc.string(), (input) => { - let isValidJson = true; - try { - JSON.parse(input); - } catch { - isValidJson = false; - } - fc.pre(!isValidJson); + fc.pre(!isParseableJson(input)); const result = parseJsonResult(input, z.unknown(), { source: "fixture" }); assert(!result.ok, "expected failure"); assert(result.error instanceof ConfigError, "expected ConfigError"); diff --git a/packages/client/src/json.ts b/packages/client/src/json.ts index 375ab006..b6da8892 100644 --- a/packages/client/src/json.ts +++ b/packages/client/src/json.ts @@ -48,13 +48,11 @@ export function parseJsonResult( return { ok: true, value: parsed.data }; } -// Metabase labels every non-string body `application/json` but only JSON-encodes collections; a -// number or boolean is streamed as its literal, which happens to be JSON, while a keyword is -// streamed as its bare name, which is not. A `text/plain` body is a string whatever it looks like -// (a string-typed setting holding "123" stays "123"). So the header decides between text and -// JSON, and within JSON the body decides: try JSON.parse first, and on parse failure wrap the body -// as a JSON string literal so the schema can validate the shape. A caller that expected an object -// then sees a ValidationError carrying the actual body in `developerDetail.zodIssues`. +// Metabase labels every non-string body `application/json` but only JSON-encodes collections: a +// number or boolean streams as its literal (valid JSON), a keyword as its bare name (not). A +// `text/plain` body is a string whatever it looks like ("123" stays "123"). So the header decides +// text vs JSON, and a JSON body that fails to parse is wrapped as a JSON string literal so the +// schema still validates the shape. export function parseJsonOrPlain( text: string, contentType: string | null, diff --git a/packages/client/src/resources/optional-parsed.ts b/packages/client/src/resources/optional-parsed.ts index f6cdf92f..7d05f342 100644 --- a/packages/client/src/resources/optional-parsed.ts +++ b/packages/client/src/resources/optional-parsed.ts @@ -5,12 +5,9 @@ import { parseJsonOrPlain } from "../json"; const NO_CONTENT_STATUS = 204; -// Endpoints that answer 204 for "the value is unset" rather than 404, and whose value is encoded by -// its runtime type rather than by a response schema: a string is served bare as text/plain, -// everything else as application/json — a real JSON document for a collection, number or boolean, -// but the bare name for a keyword. Every supported server does this the same way; the sniff in -// `parseJsonOrPlain` is the only reading that fits all of them. `null` is the unset value, not an -// error. +// Endpoints that answer 204 for "the value is unset" rather than 404, with the value encoded by its +// runtime type (bare text for a string, JSON otherwise) on every supported server. `null` is the +// unset value, not an error. export async function fetchOptionalParsed( client: Transport, path: string, diff --git a/packages/client/src/resources/transform-job.test.ts b/packages/client/src/resources/transform-job.test.ts index 2600f9b9..dd6aff5f 100644 --- a/packages/client/src/resources/transform-job.test.ts +++ b/packages/client/src/resources/transform-job.test.ts @@ -9,7 +9,7 @@ import { jsonResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; -import { createServerProfile, type ServerProfile } from "../version/profile"; +import { createServerProfile, KNOWN_RANGE, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { url: "https://mb.example.com/metabase", @@ -24,6 +24,7 @@ const JOB = { description: null, schedule: SCHEDULE, ui_display_type: "cron/raw", + active: true, entity_id: null, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", @@ -73,9 +74,11 @@ const SERVER = createServerProfile({ tokenFeatures: null, }); -// The first generation answering a job run with the run's numeric id, or null for no run. +// A head build past the known window, the generation answering a job run with the run's numeric +// id, or null for no run. +const NUMERIC_RUN_ID_MAJOR = KNOWN_RANGE.max + 1; const NUMERIC_RUN_ID_SERVER = createServerProfile({ - version: { tag: "v0.64.0", major: 64, patch: 0 }, + version: { tag: `v0.${NUMERIC_RUN_ID_MAJOR}.0`, major: NUMERIC_RUN_ID_MAJOR, patch: 0 }, date: null, hash: null, tokenFeatures: null, @@ -212,7 +215,7 @@ describe("transform-job resource wire requests", () => { expect(error).toBeInstanceOf(ResponseShapeError); assert(error instanceof ResponseShapeError, "expected ResponseShapeError"); expect(error.userMessage).toBe( - "On Metabase v0.64.0 (newer than this client supports, up to v63) the response shape was unexpected:\n" + + `On Metabase v0.${NUMERIC_RUN_ID_MAJOR}.0 (newer than this client supports, up to v${KNOWN_RANGE.max}) the response shape was unexpected:\n` + " job_run_id: Invalid input: expected number, received string", ); }); diff --git a/packages/client/src/resources/transform-job.ts b/packages/client/src/resources/transform-job.ts index f7dca28f..ee54ae82 100644 --- a/packages/client/src/resources/transform-job.ts +++ b/packages/client/src/resources/transform-job.ts @@ -2,20 +2,17 @@ import { z } from "zod"; import { type Transform, transformRowSchema } from "../domain/transform"; import { - TransformJob, + type TransformJob, TransformJobActiveResult, type TransformJobCreateInput, type TransformJobRunResult, transformJobRunResultSchema, + transformJobSchema, type TransformJobUpdateInput, } from "../domain/transform-job"; import type { RequestOptions, Transport } from "../http/transport"; import type { ListResult } from "../list"; -// `GET /api/transform-job` answers a bare array rather than a `{ data, total }` envelope, so the -// count a caller reads off `ListResult` is the array's own length and the server reports none. -const TransformJobApiList = z.array(TransformJob); - export interface TransformJobRunParams { /** Re-run the whole plan, including dependencies that are already fresh. */ run_all?: boolean | undefined; @@ -26,16 +23,23 @@ export function transformJobResource(transport: Transport) { /** List every transform job the caller can see. */ async function list(options: RequestOptions = {}): Promise> { await transport.require("transformJob.list"); - const data = await transport.requestParsed(TransformJobApiList, "/api/transform-job", { - ...options, - }); + const { features } = await transport.server(); + // A bare array rather than a `{ data, total }` envelope, so the server reports no count. + const data = await transport.requestParsed( + z.array(transformJobSchema(features)), + "/api/transform-job", + { ...options }, + ); return { data, total: null }; } /** Get one transform job by id. */ async function get(id: number, options: RequestOptions = {}): Promise { await transport.require("transformJob.get"); - return transport.requestParsed(TransformJob, `/api/transform-job/${id}`, { ...options }); + const { features } = await transport.server(); + return transport.requestParsed(transformJobSchema(features), `/api/transform-job/${id}`, { + ...options, + }); } /** Create a transform job — a schedule plus the tags it runs — from a full body. */ @@ -44,7 +48,8 @@ export function transformJobResource(transport: Transport) { options: RequestOptions = {}, ): Promise { await transport.require("transformJob.create"); - return transport.requestParsed(TransformJob, "/api/transform-job", { + const { features } = await transport.server(); + return transport.requestParsed(transformJobSchema(features), "/api/transform-job", { ...options, method: "POST", body: params, @@ -58,7 +63,8 @@ export function transformJobResource(transport: Transport) { options: RequestOptions = {}, ): Promise { await transport.require("transformJob.update"); - return transport.requestParsed(TransformJob, `/api/transform-job/${id}`, { + const { features } = await transport.server(); + return transport.requestParsed(transformJobSchema(features), `/api/transform-job/${id}`, { ...options, method: "PUT", body: params, diff --git a/packages/client/src/resources/transform.ts b/packages/client/src/resources/transform.ts index f9ab7278..0c02fd80 100644 --- a/packages/client/src/resources/transform.ts +++ b/packages/client/src/resources/transform.ts @@ -50,7 +50,6 @@ export type TransformRunPageOptions = Omit; // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformResource(transport: Transport) { - // The wire shape is the server's generation's to decide, so every read goes through the profile. async function requestTransformList( path: string, opts: TransportRequestOptions, diff --git a/packages/client/src/version/capability-summary.test.ts b/packages/client/src/version/capability-summary.test.ts index 9f86dff2..96fce0c0 100644 --- a/packages/client/src/version/capability-summary.test.ts +++ b/packages/client/src/version/capability-summary.test.ts @@ -25,7 +25,7 @@ describe("summarizeCapabilities", () => { }); }); - it("refuses a list that needs two premium features, which one field cannot carry", () => { + it("refuses a list that needs two premium features", () => { expect(() => summarizeCapabilities(["library", "remoteSync"])).toThrow( "a capability summary names one premium feature, got 2: library, remote_sync", ); diff --git a/packages/client/src/version/major-comparison-guard.test.ts b/packages/client/src/version/major-comparison-guard.test.ts index bb45f337..46111799 100644 --- a/packages/client/src/version/major-comparison-guard.test.ts +++ b/packages/client/src/version/major-comparison-guard.test.ts @@ -6,9 +6,15 @@ import { describe, expect, it } from "vitest"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, "..", "..", "..", ".."); -const SOURCE_ROOTS = ["packages/client/src", "packages/cli/src"]; +const PACKAGE_ROOTS = ["packages/client/src", "packages/cli/src"]; +// The e2e tier reads a served `minVersion` back from `help --json`; it declares none of its own. +const SOURCE_ROOTS = [...PACKAGE_ROOTS, "tests/e2e"]; -const MAJOR_COMPARISON = /\.major\s*(<=|>=|===|!==|<|>)/; +// A parsed major on either side of a comparison, reached by property or already destructured. +const COMPARISON = "(<=|>=|===|!==|<|>)"; +const MAJOR_COMPARISON = new RegExp( + `\\bmajor\\s*${COMPARISON}|${COMPARISON}\\s*[\\w$.]*\\bmajor\\b`, +); const MIN_VERSION = /\bminVersion\b/; const GUARD = "packages/client/src/version/major-comparison-guard.test.ts"; @@ -26,8 +32,8 @@ const EVALUATORS: ReadonlySet = new Set([ // want one number; nothing outside it may declare one. const VERSION_DIR = "packages/client/src/version/"; -function sourceFiles(): string[] { - return SOURCE_ROOTS.flatMap((root) => +function sourceFiles(roots: readonly string[]): string[] { + return roots.flatMap((root) => readdirSync(resolve(REPO_ROOT, root), { recursive: true, withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith(".ts")) .map((entry) => @@ -53,21 +59,21 @@ function mayDeclareMinVersion(file: string): boolean { describe("Metabase majors are compared in one place", () => { it("no source outside the version evaluators compares a parsed major", () => { - const hits = sourceFiles() + const hits = sourceFiles(SOURCE_ROOTS) .filter((file) => !mayCompareMajors(file)) .flatMap((file) => offendingLines(file, MAJOR_COMPARISON)); expect(hits).toEqual([]); }); it("no source outside the version layer names a minVersion", () => { - const hits = sourceFiles() + const hits = sourceFiles(PACKAGE_ROOTS) .filter((file) => !mayDeclareMinVersion(file)) .flatMap((file) => offendingLines(file, MIN_VERSION)); expect(hits).toEqual([]); }); - it("every allowlisted file still exists, so a deleted file leaves no stale exemption", () => { - const existing = new Set(sourceFiles()); + it("every allowlisted file still exists", () => { + const existing = new Set(sourceFiles(SOURCE_ROOTS)); const stale = [...EVALUATORS].filter((file) => !existing.has(file)); expect(stale).toEqual([]); }); diff --git a/packages/client/src/version/profile.test.ts b/packages/client/src/version/profile.test.ts index 144b7052..00e78e68 100644 --- a/packages/client/src/version/profile.test.ts +++ b/packages/client/src/version/profile.test.ts @@ -60,7 +60,7 @@ describe("createServerProfile", () => { }); }); - it("keeps a major below the window at its real major, still supported, so each feature refuses by name", () => { + it("keeps a major below the window at its real major, still supported", () => { const info = released("v0.57.2", 57, null); expect(createServerProfile(info)).toEqual({ version: { tag: "v0.57.2", major: 57, patch: 2 }, diff --git a/packages/client/src/version/profile.ts b/packages/client/src/version/profile.ts index 1a25f0fe..cc7831f2 100644 --- a/packages/client/src/version/profile.ts +++ b/packages/client/src/version/profile.ts @@ -68,11 +68,9 @@ function place(version: ParsedVersion | null): Placement { return { effectiveMajor: version.major, skew: "supported" }; } -// The tag is the authoritative signal. Head images and dev checkouts report `vUNKNOWN`, and -// `/api/session/properties` carries no other edition field — OSS sends the same all-false -// `token-features` map an unlicensed EE does — so for them the edition is whatever the token -// proves: a granted premium feature means EE, and an instance granting none behaves as OSS for -// everything this client can do. +// A released tag is authoritative. A dev build's tag never parses, and `/api/session/properties` +// carries no other edition field (OSS sends the same all-false `token-features` map an unlicensed +// EE does), so there the edition is whatever the token proves. function detectEdition(info: ServerInfo): Edition { const fromTag = info.version === null ? null : editionFromTag(info.version.tag); if (fromTag !== null) { diff --git a/packages/client/src/version/requirement-check.test.ts b/packages/client/src/version/requirement-check.test.ts index 0776f445..2a333fdc 100644 --- a/packages/client/src/version/requirement-check.test.ts +++ b/packages/client/src/version/requirement-check.test.ts @@ -82,7 +82,7 @@ describe("checkRequirements", () => { }); }); - it("places an unparseable tag at the newest known major, so only a token can refuse it", () => { + it("places an unparseable tag past the newest known major, so only a token can refuse it", () => { const head = createServerProfile({ version: null, date: "2026-09-16", diff --git a/packages/client/src/version/requirements.test.ts b/packages/client/src/version/requirements.test.ts index 5885eef1..8c27abdc 100644 --- a/packages/client/src/version/requirements.test.ts +++ b/packages/client/src/version/requirements.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { createClient } from "../client"; +import { errorMessage } from "../errors"; import type { ClientCredentials, Transport } from "../http/transport"; import { cardResource } from "../resources/card"; import { collectionResource } from "../resources/collection"; @@ -98,21 +99,65 @@ function namespaceOf(fileName: string): string { interface RequiredLiteral { readonly file: string; readonly key: string; + // The function whose body holds the call, named as the resource exposes it. + readonly method: string; +} + +const REQUIRE_CALL = /transport\.require\("([^"]+)"\)/g; +const FUNCTION_DECLARATION = /function\*? ([A-Za-z]+)\(/g; +const RETURNED_OBJECT = /return \{([^}]*)\}/g; +const ALIASED_ENTRY = /([A-Za-z]+): ([A-Za-z]+)/g; + +// `{ delete: remove, import: importFromRemote }`: the name a caller reaches a method by, for a +// function whose own name is a reserved word. Only a declared function counts as aliased, so a +// returned data object (`{ data: rows, total: null }`) contributes nothing. +function exposedNamesOf(source: string): ReadonlyMap { + const declaredFunctions = new Set( + [...source.matchAll(FUNCTION_DECLARATION)].flatMap((match) => + match[1] === undefined ? [] : [match[1]], + ), + ); + const entries = [...source.matchAll(RETURNED_OBJECT)].flatMap((object) => + object[1] === undefined ? [] : Array.from(object[1].matchAll(ALIASED_ENTRY)), + ); + return new Map( + entries.flatMap(([, exposed, declared]) => + exposed !== undefined && declared !== undefined && declaredFunctions.has(declared) + ? [[declared, exposed]] + : [], + ), + ); +} + +function enclosingFunctionAt(source: string, index: number): string { + let enclosing: string | null = null; + for (const match of source.matchAll(FUNCTION_DECLARATION)) { + if (match.index > index) { + break; + } + enclosing = match[1] ?? null; + } + if (enclosing === null) { + throw new Error(`no function declared before offset ${index}`); + } + return enclosing; } function requiredLiterals(): RequiredLiteral[] { return readdirSync(RESOURCES_DIR) .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) - .flatMap((file) => - [ - ...readFileSync(resolve(RESOURCES_DIR, file), "utf8").matchAll( - /transport\.require\("([^"]+)"\)/g, - ), - ] - .map((match) => match[1]) - .filter((key): key is string => key !== undefined) - .map((key) => ({ file, key })), - ); + .flatMap((file) => { + const source = readFileSync(resolve(RESOURCES_DIR, file), "utf8"); + const exposed = exposedNamesOf(source); + return [...source.matchAll(REQUIRE_CALL)].flatMap((match) => { + const key = match[1]; + if (key === undefined) { + return []; + } + const declared = enclosingFunctionAt(source, match.index); + return [{ file, key, method: exposed.get(declared) ?? declared }]; + }); + }); } // One call per resource, with the wire request the fake refuses once the method reaches it — the @@ -272,7 +317,7 @@ async function messageOf(pending: Promise): Promise { try { await pending; } catch (error) { - return error instanceof Error ? error.message : String(error); + return errorMessage(error); } return "resolved"; } @@ -284,7 +329,9 @@ describe("METHOD_REQUIREMENTS", () => { it("is required by every resource method under the method's own key, and by nothing else", () => { const literals = requiredLiterals(); - const foreign = literals.filter(({ file, key }) => !key.startsWith(`${namespaceOf(file)}.`)); + const foreign = literals.filter( + ({ file, key, method }) => key !== `${namespaceOf(file)}.${method}`, + ); expect(foreign).toEqual([]); expect(literals.map(({ key }) => key).toSorted()).toEqual(TABLE_KEYS); }); diff --git a/tests/e2e/auth.e2e.test.ts b/tests/e2e/auth.e2e.test.ts index 0a6d85ce..36942f8f 100644 --- a/tests/e2e/auth.e2e.test.ts +++ b/tests/e2e/auth.e2e.test.ts @@ -1,42 +1,129 @@ import { promises as fs } from "node:fs"; import { join } from "node:path"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import type { z } from "zod"; +import { isFileNotFoundError } from "@metabase/client/errors"; import { parseJson } from "@metabase/client/json"; import { LoginResult } from "../../packages/cli/src/commands/auth/login"; import { AuthProfileListEnvelope } from "../../packages/cli/src/commands/auth/list"; import { LogoutResult } from "../../packages/cli/src/commands/auth/logout"; import { AuthStatus } from "../../packages/cli/src/commands/auth/status"; -import { ProfilesFile } from "../../packages/cli/src/core/auth/profile-record"; import { - type ServerSummary, - summarizeServer, -} from "../../packages/cli/src/core/auth/server-summary"; + type ProbedUser, + type ProfileLastFailure, + type ProfileLastProbe, + type ProfileRecord, + ProfilesFile, +} from "../../packages/cli/src/core/auth/profile-record"; +import { summarizeServer } from "../../packages/cli/src/core/auth/server-summary"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; +type AuthProfileEntry = z.infer["data"][number]; + const BAD_API_KEY = "mb_definitely_not_valid_key_aaaaaaaaaa"; +const UNREACHABLE_URL = "https://127.0.0.1:1/__nonexistent__"; +// Port 1 is on fetch's blocked-port list, so the failure carries no syscall code to hint from. +const UNREACHABLE_REASON = "Could not reach Metabase: fetch failed"; function profilesPath(configHome: string): string { return join(configHome, "metabase-cli", "profiles.json"); } -function serverFields(payload: ServerSummary): ServerSummary { +async function readProfilesJson(configHome: string): Promise { + const raw = await fs.readFile(profilesPath(configHome), "utf8"); + return parseJson(raw, ProfilesFile, { source: profilesPath(configHome) }); +} + +// The one record a fresh config home holds after a single login. +function onlyRecord(file: ProfilesFile): ProfileRecord { + const [record, ...rest] = file.profiles; + if (record === undefined || rest.length > 0) { + throw new Error(`expected exactly one profile record, got ${file.profiles.length}`); + } + return record; +} + +function onlyEntry(envelope: z.infer): AuthProfileEntry { + const [entry, ...rest] = envelope.data; + if (entry === undefined || rest.length > 0) { + throw new Error(`expected exactly one listed profile, got ${envelope.data.length}`); + } + return entry; +} + +function probeOf(record: ProfileRecord): ProfileLastProbe { + if (record.lastProbe === null) { + throw new Error(`profile "${record.name}" holds no probe`); + } + return record.lastProbe; +} + +function failureOf(record: ProfileRecord): ProfileLastFailure { + if (record.lastFailure === null) { + throw new Error(`profile "${record.name}" holds no failure`); + } + return record.lastFailure; +} + +function userOf(payload: { user: ProbedUser | null }): ProbedUser { + if (payload.user === null) { + throw new Error("expected a probed user"); + } + return payload.user; +} + +function timestampOf(value: string | null): string { + if (value === null) { + throw new Error("expected a probe timestamp"); + } + return value; +} + +// The record `auth login` leaves for the admin key: the key inline (the harness disables the +// keyring) and the bootstrap's own server facts. The probe's timestamp is the one value only the +// CLI knows, so the caller reads it off the record it is about to compare. +function probedRecord( + name: string, + bootstrap: E2EBootstrap, + user: ProbedUser, + probedAt: string, +): ProfileRecord { return { - version: payload.version, - edition: payload.edition, - skew: payload.skew, - knownRange: payload.knownRange, - tokenFeatures: payload.tokenFeatures, - features: payload.features, + name, + url: bootstrap.baseUrl, + apiKey: bootstrap.adminApiKey, + oauth: null, + lastProbe: { + at: probedAt, + version: bootstrap.server.version, + date: bootstrap.server.date, + hash: bootstrap.server.hash, + tokenFeatures: bootstrap.server.tokenFeatures, + user, + }, + lastFailure: null, }; } -async function readProfilesJson(configHome: string): Promise { - const raw = await fs.readFile(profilesPath(configHome), "utf8"); - return parseJson(raw, ProfilesFile, { source: profilesPath(configHome) }); +function invalidKeyReason(bootstrap: E2EBootstrap): string { + return `Invalid or unauthorized API key (host: ${new URL(bootstrap.baseUrl).host}).`; +} + +function absentStatus(profile: string): z.infer { + return { + profile, + present: false, + url: null, + method: null, + user: null, + ...summarizeServer(null), + lastProbedAt: null, + lastFailure: null, + }; } describe("auth e2e", () => { @@ -77,42 +164,61 @@ describe("auth e2e", () => { expect(login.stderr).not.toContain(bootstrap.adminApiKey); const loginPayload = parseJson(login.stdout, LoginResult); - expect(loginPayload.profile).toBe("default"); - expect(loginPayload.url).toBe(bootstrap.baseUrl); - expect(loginPayload.authenticated).toBe(true); - expect(loginPayload.user?.id).toBeGreaterThan(0); - expect(loginPayload.user?.name).not.toBe(""); - // Head/nightly builds report an unparseable version tag → version null; - // released builds report a v-tag. - if (loginPayload.version !== null) { - expect(loginPayload.version.tag.startsWith("v")).toBe(true); - } - const expectedServer = summarizeServer(bootstrap.server); - expect(serverFields(loginPayload)).toEqual(expectedServer); - - const fileAfterLogin = await readProfilesJson(configHome); - expect(fileAfterLogin.profiles).toHaveLength(1); - const stored = fileAfterLogin.profiles[0]; - expect(stored?.name).toBe("default"); - expect(stored?.url).toBe(bootstrap.baseUrl); - expect(stored?.lastProbe?.version?.tag).toBe(loginPayload.version?.tag); - expect(stored?.lastFailure).toBeNull(); + // The API key's own user is minted by the bootstrap and named after the key, so only its role + // is pinned here; the same user must then come back from every read of the profile. + const adminUser = userOf(loginPayload); + expect(adminUser.isAdmin).toBe(true); + expect(loginPayload).toEqual({ + profile: "default", + url: bootstrap.baseUrl, + authenticated: true, + user: adminUser, + ...summarizeServer(bootstrap.server), + }); + + const stored = onlyRecord(await readProfilesJson(configHome)); + const probedAt = probeOf(stored).at; + expect(stored).toEqual(probedRecord("default", bootstrap, adminUser, probedAt)); const status = await runCli({ args: ["auth", "status", "--json"], configHome }); expect(status.exitCode, status.stderr).toBe(0); expect(status.stdout).not.toContain(bootstrap.adminApiKey); - const statusPayload = parseJson(status.stdout, AuthStatus); - expect(statusPayload.profile).toBe("default"); - expect(statusPayload.present).toBe(true); - expect(statusPayload.url).toBe(bootstrap.baseUrl); - expect(statusPayload.user?.id).toBe(loginPayload.user?.id); - expect(serverFields(statusPayload)).toEqual(expectedServer); - expect(statusPayload.lastFailure).toBeNull(); + expect(parseJson(status.stdout, AuthStatus)).toEqual({ + profile: "default", + present: true, + url: bootstrap.baseUrl, + method: "apiKey", + user: adminUser, + ...summarizeServer(bootstrap.server), + lastProbedAt: probedAt, + lastFailure: null, + }); const list = await runCli({ args: ["auth", "list", "--json"], configHome }); expect(list.exitCode, list.stderr).toBe(0); - const listed = parseJson(list.stdout, AuthProfileListEnvelope).data.map(serverFields); - expect(listed).toEqual([expectedServer]); + const envelope = parseJson(list.stdout, AuthProfileListEnvelope); + const reprobedAt = timestampOf(onlyEntry(envelope).lastProbedAt); + expect(envelope).toEqual({ + data: [ + { + profile: "default", + url: bootstrap.baseUrl, + method: "apiKey", + authenticated: true, + status: "ok", + user: adminUser, + ...summarizeServer(bootstrap.server), + lastProbedAt: reprobedAt, + lastFailure: null, + }, + ], + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + }); + expect(reprobedAt >= probedAt).toBe(true); }); it("first-time login with an invalid api key fails verification and leaves profiles.json untouched", async () => { @@ -134,22 +240,27 @@ describe("auth e2e", () => { }); expect(login.exitCode).toBe(2); - expect(login.stderr).toContain("verification failed"); - expect(login.stderr).toContain("Invalid or unauthorized API key"); - expect(cliErrorMessage(login.stderr)).toContain( - 'credentials were not saved for profile "first_attempt"', + expect(cliErrorMessage(login.stderr)).toBe( + `verification failed (current user, ${bootstrap.baseUrl}/api/user/current): ${invalidKeyReason(bootstrap)} — credentials were not saved for profile "first_attempt"`, ); - await expect(fs.access(profilesPath(configHome))).rejects.toThrow(); + await expect(fs.access(profilesPath(configHome))).rejects.toSatisfy(isFileNotFoundError); const status = await runCli({ args: ["auth", "status", "--profile", "first_attempt", "--json"], configHome, }); expect(status.exitCode, status.stderr).toBe(0); - const statusPayload = parseJson(status.stdout, AuthStatus); - expect(statusPayload.profile).toBe("first_attempt"); - expect(statusPayload.present).toBe(false); + expect(parseJson(status.stdout, AuthStatus)).toEqual({ + profile: "first_attempt", + present: false, + url: null, + method: null, + user: null, + ...summarizeServer(null), + lastProbedAt: null, + lastFailure: null, + }); }); it("re-login failure preserves prior lastProbe/url/apiKey but writes lastFailure", async () => { @@ -170,10 +281,11 @@ describe("auth e2e", () => { configHome, }); expect(first.exitCode, first.stderr).toBe(0); - const before = await readProfilesJson(configHome); - const beforeStable = before.profiles.find((entry) => entry.name === "stable"); - expect(beforeStable?.lastProbe).not.toBeNull(); - expect(beforeStable?.lastFailure).toBeNull(); + const adminUser = userOf(parseJson(first.stdout, LoginResult)); + const beforeStable = onlyRecord(await readProfilesJson(configHome)); + expect(beforeStable).toEqual( + probedRecord("stable", bootstrap, adminUser, probeOf(beforeStable).at), + ); const second = await runCli({ args: [ @@ -191,13 +303,15 @@ describe("auth e2e", () => { }); expect(second.exitCode).toBe(2); - const after = await readProfilesJson(configHome); - const afterStable = after.profiles.find((entry) => entry.name === "stable"); - expect(afterStable?.url).toBe(beforeStable?.url); - expect(afterStable?.apiKey).toBe(beforeStable?.apiKey); - expect(afterStable?.lastProbe).toEqual(beforeStable?.lastProbe); - expect(afterStable?.lastFailure?.kind).toBe("auth"); - expect(afterStable?.lastFailure?.reason).toContain("Invalid or unauthorized API key"); + const afterStable = onlyRecord(await readProfilesJson(configHome)); + expect(afterStable).toEqual({ + ...beforeStable, + lastFailure: { + at: failureOf(afterStable).at, + kind: "auth", + reason: invalidKeyReason(bootstrap), + }, + }); }); it("auth list refreshes a stored profile and writes the new lastProbe to disk", async () => { @@ -220,29 +334,52 @@ describe("auth e2e", () => { }); expect(login.exitCode, login.stderr).toBe(0); - const before = await readProfilesJson(configHome); - expect(before.profiles[0]?.lastProbe).toBeNull(); + expect(onlyRecord(await readProfilesJson(configHome))).toEqual({ + name: "refreshable", + url: bootstrap.baseUrl, + apiKey: bootstrap.adminApiKey, + oauth: null, + lastProbe: null, + lastFailure: null, + }); const list = await runCli({ args: ["auth", "list", "--json"], configHome }); expect(list.exitCode, list.stderr).toBe(0); const envelope = parseJson(list.stdout, AuthProfileListEnvelope); - expect(envelope.returned).toBe(1); - expect(envelope.data[0]?.status).toBe("ok"); - const versionTag = envelope.data[0]?.version?.tag; - if (versionTag !== undefined) { - expect(versionTag.startsWith("v")).toBe(true); - } - - const after = await readProfilesJson(configHome); - expect(after.profiles[0]?.lastProbe).not.toBeNull(); - expect(after.profiles[0]?.lastProbe?.version?.tag).toBe(envelope.data[0]?.version?.tag); + const entry = onlyEntry(envelope); + const adminUser = userOf(entry); + const probedAt = timestampOf(entry.lastProbedAt); + expect(envelope).toEqual({ + data: [ + { + profile: "refreshable", + url: bootstrap.baseUrl, + method: "apiKey", + authenticated: true, + status: "ok", + user: adminUser, + ...summarizeServer(bootstrap.server), + lastProbedAt: probedAt, + lastFailure: null, + }, + ], + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + }); + + expect(onlyRecord(await readProfilesJson(configHome))).toEqual( + probedRecord("refreshable", bootstrap, adminUser, probedAt), + ); }); it("auth list against an unreachable URL surfaces the failure but keeps cached lastProbe", async () => { const configHome = await makeIsolatedConfigHome(); - await runCli({ + const login = await runCli({ args: [ "auth", "login", @@ -256,30 +393,42 @@ describe("auth e2e", () => { ], configHome, }); - const before = await readProfilesJson(configHome); - const beforeProbe = before.profiles[0]?.lastProbe; - expect(beforeProbe).not.toBeNull(); - - const path = profilesPath(configHome); - const raw = await fs.readFile(path, "utf8"); - const file = parseJson(raw, ProfilesFile, { source: path }); - const brokenProfiles = file.profiles.map((entry) => { - const copy = { ...entry }; - copy.url = "https://127.0.0.1:1/__nonexistent__"; - return copy; - }); - const broken: ProfilesFile = { ...file, profiles: brokenProfiles }; - await fs.writeFile(path, JSON.stringify(broken, null, 2) + "\n"); + expect(login.exitCode, login.stderr).toBe(0); + const adminUser = userOf(parseJson(login.stdout, LoginResult)); + const file = await readProfilesJson(configHome); + const before = onlyRecord(file); + const beforeProbe = probeOf(before); + expect(before).toEqual(probedRecord("stable", bootstrap, adminUser, beforeProbe.at)); + + const broken: ProfilesFile = { ...file, profiles: [{ ...before, url: UNREACHABLE_URL }] }; + await fs.writeFile(profilesPath(configHome), JSON.stringify(broken, null, 2) + "\n"); const list = await runCli({ args: ["auth", "list", "--json"], configHome }); expect(list.exitCode, list.stderr).toBe(0); - const envelope = parseJson(list.stdout, AuthProfileListEnvelope); - expect(envelope.data[0]?.status).toBe("network-error"); - - const after = await readProfilesJson(configHome); - expect(after.profiles[0]?.lastProbe).toEqual(beforeProbe); - expect(after.profiles[0]?.lastFailure).not.toBeNull(); + const after = onlyRecord(await readProfilesJson(configHome)); + const failure = { at: failureOf(after).at, kind: "network", reason: UNREACHABLE_REASON }; + expect(parseJson(list.stdout, AuthProfileListEnvelope)).toEqual({ + data: [ + { + profile: "stable", + url: UNREACHABLE_URL, + method: "apiKey", + authenticated: false, + status: "network-error", + user: adminUser, + ...summarizeServer(bootstrap.server), + lastProbedAt: beforeProbe.at, + lastFailure: failure, + }, + ], + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + }); + expect(after).toEqual({ ...before, url: UNREACHABLE_URL, lastFailure: failure }); }); it("a successful re-login clears a prior lastFailure for the same profile", async () => { @@ -300,6 +449,9 @@ describe("auth e2e", () => { configHome, }); expect(first.exitCode, first.stderr).toBe(0); + const adminUser = userOf(parseJson(first.stdout, LoginResult)); + const initial = onlyRecord(await readProfilesJson(configHome)); + expect(initial).toEqual(probedRecord("recovers", bootstrap, adminUser, probeOf(initial).at)); const failed = await runCli({ args: [ @@ -316,8 +468,15 @@ describe("auth e2e", () => { configHome, }); expect(failed.exitCode).toBe(2); - const afterFailure = await readProfilesJson(configHome); - expect(afterFailure.profiles[0]?.lastFailure).not.toBeNull(); + const afterFailure = onlyRecord(await readProfilesJson(configHome)); + expect(afterFailure).toEqual({ + ...initial, + lastFailure: { + at: failureOf(afterFailure).at, + kind: "auth", + reason: invalidKeyReason(bootstrap), + }, + }); const recovered = await runCli({ args: [ @@ -335,8 +494,9 @@ describe("auth e2e", () => { }); expect(recovered.exitCode, recovered.stderr).toBe(0); - const after = await readProfilesJson(configHome); - expect(after.profiles[0]?.lastFailure).toBeNull(); + const after = onlyRecord(await readProfilesJson(configHome)); + expect(after).toEqual(probedRecord("recovers", bootstrap, adminUser, probeOf(after).at)); + expect(probeOf(after).at >= probeOf(initial).at).toBe(true); }); it("logout clears stored credentials and status reflects the cleared profile", async () => { @@ -367,10 +527,7 @@ describe("auth e2e", () => { const status = await runCli({ args: ["auth", "status", "--json"], configHome }); expect(status.exitCode, status.stderr).toBe(0); - const statusPayload = parseJson(status.stdout, AuthStatus); - expect(statusPayload.profile).toBe("default"); - expect(statusPayload.present).toBe(false); - expect(statusPayload.url).toBeNull(); + expect(parseJson(status.stdout, AuthStatus)).toEqual(absentStatus("default")); }); it("logout reports cleared:false when no credentials are stored for the profile", async () => { @@ -405,14 +562,18 @@ describe("auth e2e", () => { expect(login.exitCode, login.stderr).toBe(0); const payload = parseJson(login.stdout, LoginResult); - expect(payload.profile).toBe("env_routed"); - expect(payload.url).toBe(bootstrap.baseUrl); - expect(payload.authenticated).toBe(true); + const adminUser = userOf(payload); + expect(payload).toEqual({ + profile: "env_routed", + url: bootstrap.baseUrl, + authenticated: true, + user: adminUser, + ...summarizeServer(bootstrap.server), + }); const defaultStatus = await runCli({ args: ["auth", "status", "--json"], configHome }); expect(defaultStatus.exitCode, defaultStatus.stderr).toBe(0); - const defaultPayload = parseJson(defaultStatus.stdout, AuthStatus); - expect(defaultPayload.present).toBe(false); + expect(parseJson(defaultStatus.stdout, AuthStatus)).toEqual(absentStatus("default")); const envStatus = await runCli({ args: ["auth", "status", "--json"], @@ -421,8 +582,16 @@ describe("auth e2e", () => { }); expect(envStatus.exitCode, envStatus.stderr).toBe(0); const envPayload = parseJson(envStatus.stdout, AuthStatus); - expect(envPayload.profile).toBe("env_routed"); - expect(envPayload.present).toBe(true); + expect(envPayload).toEqual({ + profile: "env_routed", + present: true, + url: bootstrap.baseUrl, + method: "apiKey", + user: adminUser, + ...summarizeServer(bootstrap.server), + lastProbedAt: timestampOf(envPayload.lastProbedAt), + lastFailure: null, + }); }); it("logout proceeds without --yes when stdin is not a TTY (non-interactive auto-confirm)", async () => { diff --git a/tests/e2e/bootstrap-data.ts b/tests/e2e/bootstrap-data.ts index 92000b5f..2b690590 100644 --- a/tests/e2e/bootstrap-data.ts +++ b/tests/e2e/bootstrap-data.ts @@ -47,9 +47,7 @@ export const ServerIdentity = z.object({ date: z.string().nullable().default(null), hash: z.string().nullable().default(null), tokenFeatures: TokenFeatures.nullable(), - // Whether the server supports full-API OAuth login (Metabase v63+, full-access scope advertised - // in discovery); probed live during bootstrap. Defaults false for bootstrap files written before - // this field existed — re-run `bun run e2e:bootstrap` after deleting the stale file to refresh it. + // Whether the server supports full-API OAuth login (full-access scope advertised in discovery). oauthSupported: z.boolean().default(false), }); export type ServerIdentity = z.infer; diff --git a/tests/e2e/collection.e2e.test.ts b/tests/e2e/collection.e2e.test.ts index 0d4ed879..2ff33f1f 100644 --- a/tests/e2e/collection.e2e.test.ts +++ b/tests/e2e/collection.e2e.test.ts @@ -1,6 +1,11 @@ import { afterEach, assert, beforeAll, describe, expect, it } from "vitest"; -import { Collection, CollectionCompact } from "@metabase/client/domain/collection"; +import { + COLLECTION_ITEM_FILTER_MODELS, + COLLECTION_PINNED_STATES, + Collection, + CollectionCompact, +} from "@metabase/client/domain/collection"; import { parseJson } from "@metabase/client/json"; import { CollectionItemListEnvelope } from "../../packages/cli/src/commands/collection/items"; @@ -282,7 +287,7 @@ describe("collection e2e", () => { }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Not found: GET /api/collection/9999999."); + expect(cliErrorMessage(result.stderr)).toBe("Not found: GET /api/collection/9999999."); }); it("items lists the seeded card and dashboard inside the default collection", async () => { @@ -294,25 +299,33 @@ describe("collection e2e", () => { expect(result.exitCode, result.stderr).toBe(0); const envelope = parseJson(result.stdout, CollectionItemListEnvelope); - expect(envelope.data.find((item) => item.model === "dashboard")).toEqual({ - id: SEEDED.ordersDashboardId, - model: "dashboard", - name: "Orders Overview", - description: "E2E seeded dashboard with one orders dashcard.", - archived: false, - collection_id: SEEDED.defaultCollectionId, - }); - expect(envelope.data.find((item) => item.model === "card")).toEqual({ - id: SEEDED.ordersCardId, - model: "card", - name: "Orders by status", - description: null, - archived: false, - collection_id: SEEDED.defaultCollectionId, - }); - expect({ returned: envelope.returned, total: envelope.total }).toEqual({ + expect({ + ...envelope, + data: [...envelope.data].toSorted((left, right) => left.model.localeCompare(right.model)), + }).toEqual({ + data: [ + { + id: SEEDED.ordersCardId, + model: "card", + name: "Orders by status", + description: null, + archived: false, + collection_id: SEEDED.defaultCollectionId, + }, + { + id: SEEDED.ordersDashboardId, + model: "dashboard", + name: "Orders Overview", + description: "E2E seeded dashboard with one orders dashcard.", + archived: false, + collection_id: SEEDED.defaultCollectionId, + }, + ], returned: 2, + offset: 0, total: 2, + has_more: false, + next_offset: null, }); }); @@ -416,7 +429,9 @@ describe("collection e2e", () => { }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("invalid --models value: bogus"); + expect(cliErrorMessage(result.stderr)).toBe( + `invalid --models value: bogus (expected one of: ${COLLECTION_ITEM_FILTER_MODELS.join(", ")})`, + ); expect(result.stdout).toBe(""); }); @@ -435,7 +450,9 @@ describe("collection e2e", () => { }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid --pinned-state value: "bogus"'); + expect(cliErrorMessage(result.stderr)).toBe( + `invalid --pinned-state value: "bogus" (expected one of: ${COLLECTION_PINNED_STATES.join(", ")})`, + ); expect(result.stdout).toBe(""); }); @@ -595,7 +612,9 @@ describe("collection e2e", () => { }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(cliErrorMessage(result.stderr)).toBe( + "request body: value did not match expected schema\n /name: Invalid input: expected string, received undefined", + ); expect(result.stdout).toBe(""); }); }); diff --git a/tests/e2e/eid-translation.e2e.test.ts b/tests/e2e/eid-translation.e2e.test.ts index 837d3e09..dc78204c 100644 --- a/tests/e2e/eid-translation.e2e.test.ts +++ b/tests/e2e/eid-translation.e2e.test.ts @@ -1,7 +1,7 @@ import { afterEach, assert, beforeAll, describe, expect, it } from "vitest"; import { Card } from "@metabase/client/domain/card"; -import { EidTranslateResult } from "@metabase/client/domain/eid-translation"; +import { EID_MODELS, EidTranslateResult } from "@metabase/client/domain/eid-translation"; import { parseJson } from "@metabase/client/json"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; @@ -99,7 +99,9 @@ describe("eid e2e", () => { }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid --model: "totally-invalid"'); + expect(cliErrorMessage(result.stderr)).toBe( + `invalid --model: "totally-invalid" (expected one of: ${EID_MODELS.join(", ")})`, + ); }); it.skipIf(transformsSkip !== null)( diff --git a/tests/e2e/git-sync.e2e.test.ts b/tests/e2e/git-sync.e2e.test.ts index 346219ba..b766c165 100644 --- a/tests/e2e/git-sync.e2e.test.ts +++ b/tests/e2e/git-sync.e2e.test.ts @@ -37,7 +37,7 @@ describe("git-sync arg validation e2e (no Metabase contact required)", () => { configHome, }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid timeout: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid timeout: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -48,7 +48,7 @@ describe("git-sync arg validation e2e (no Metabase contact required)", () => { configHome, }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid interval: "xyz" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid interval: "xyz" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -59,7 +59,7 @@ describe("git-sync arg validation e2e (no Metabase contact required)", () => { configHome, }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("invalid new-branch: must not be blank"); + expect(cliErrorMessage(result.stderr)).toBe("invalid new-branch: must not be blank"); expect(result.stdout).toBe(""); }); @@ -70,7 +70,7 @@ describe("git-sync arg validation e2e (no Metabase contact required)", () => { configHome, }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("invalid message: must not be blank"); + expect(cliErrorMessage(result.stderr)).toBe("invalid message: must not be blank"); expect(result.stdout).toBe(""); }); @@ -81,7 +81,7 @@ describe("git-sync arg validation e2e (no Metabase contact required)", () => { configHome, }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("invalid name: branch name must not be blank"); + expect(cliErrorMessage(result.stderr)).toBe("invalid name: branch name must not be blank"); expect(result.stdout).toBe(""); }); @@ -92,7 +92,7 @@ describe("git-sync arg validation e2e (no Metabase contact required)", () => { configHome, }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -103,7 +103,7 @@ describe("git-sync arg validation e2e (no Metabase contact required)", () => { configHome, }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("invalid id: 0 (must be ≥ 1)"); + expect(cliErrorMessage(result.stderr)).toBe("invalid id: 0 (must be ≥ 1)"); expect(result.stdout).toBe(""); }); @@ -114,7 +114,7 @@ describe("git-sync arg validation e2e (no Metabase contact required)", () => { configHome, }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("invalid id: -3 (must be ≥ 1)"); + expect(cliErrorMessage(result.stderr)).toBe("invalid id: -3 (must be ≥ 1)"); expect(result.stdout).toBe(""); }); }); diff --git a/tests/e2e/library.e2e.test.ts b/tests/e2e/library.e2e.test.ts index 8f60c7a6..d7c58dd1 100644 --- a/tests/e2e/library.e2e.test.ts +++ b/tests/e2e/library.e2e.test.ts @@ -101,7 +101,7 @@ describe("library e2e", () => { }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid table id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid table id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -125,7 +125,7 @@ describe("library e2e", () => { }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid database id: "x" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid database id: "x" (expected integer)'); expect(result.stdout).toBe(""); }); diff --git a/tests/e2e/measure.e2e.test.ts b/tests/e2e/measure.e2e.test.ts index 562fce24..e5c1a224 100644 --- a/tests/e2e/measure.e2e.test.ts +++ b/tests/e2e/measure.e2e.test.ts @@ -145,7 +145,7 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { ok: false, errors: [{ path: "/stages", message: "must NOT have fewer than 1 items" }], }); - expect(result.stderr).toContain( + expect(cliErrorMessage(result.stderr)).toBe( "measure.definition validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", ); }); @@ -180,7 +180,9 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(cliErrorMessage(result.stderr)).toBe( + "request body: value did not match expected schema\n /table_id: Invalid input: expected number, received undefined\n /definition: Invalid input: expected record, received undefined", + ); expect(result.stdout).toBe(""); }); @@ -205,7 +207,7 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -217,7 +219,7 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Not found: GET /api/measure/9999999."); + expect(cliErrorMessage(result.stderr)).toBe("Not found: GET /api/measure/9999999."); }); it("update renames the measure and the compact view reflects the new name", async () => { @@ -259,7 +261,7 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { ok: false, errors: [{ path: "/stages", message: "must NOT have fewer than 1 items" }], }); - expect(result.stderr).toContain( + expect(cliErrorMessage(result.stderr)).toBe( "measure.definition validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", ); }); @@ -275,7 +277,9 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(cliErrorMessage(result.stderr)).toBe( + "request body: value did not match expected schema\n /revision_message: Invalid input: expected string, received undefined", + ); expect(result.stdout).toBe(""); }); @@ -288,7 +292,7 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -330,7 +334,7 @@ describe.skipIf(skipReason !== null)("measure e2e", () => { }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); }); diff --git a/tests/e2e/run-cli.e2e.test.ts b/tests/e2e/run-cli.e2e.test.ts index ab54e00e..6a646a50 100644 --- a/tests/e2e/run-cli.e2e.test.ts +++ b/tests/e2e/run-cli.e2e.test.ts @@ -72,8 +72,8 @@ describe("runCli env isolation e2e", () => { const result = await runCli({ args: ["db", "list", "--json"], configHome }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain( - `Not authenticated for profile "${DEFAULT_PROFILE}".`, + expect(cliErrorMessage(result.stderr)).toBe( + `Not authenticated for profile "${DEFAULT_PROFILE}". Run \`mb auth login\`, set MB_URL/MB_API_KEY, or pass --url/--api-key.`, ); }); }); diff --git a/tests/e2e/server-gate.ts b/tests/e2e/server-gate.ts index 93a0653e..949786bd 100644 --- a/tests/e2e/server-gate.ts +++ b/tests/e2e/server-gate.ts @@ -61,7 +61,7 @@ function recordGateSkip(lane: string, reason: string): void { } // The same profile the CLI builds from a cached probe, so a gate and the command it guards agree: -// a head build's unparseable tag lands on the newest known major and every version-gated suite +// a head build's unparseable tag lands past the newest known major and every version-gated suite // runs there, while a token feature the server lacks still skips. function resolveServerProfile(): ServerProfile { return createServerProfile(readBootstrapSync().server); @@ -94,8 +94,7 @@ export function requireOAuthServer(lane: string): string | null { return OAUTH_UNSUPPORTED_REASON; } -// Whether the connected server has a feature — the exact question the CLI's preflight and the -// client's `require()` ask, so a suite can select the branch its stack takes. It logs nothing: a +// The exact question the CLI's preflight and the client's `require()` ask. It logs nothing: a // suite reading it asserts one of two exact outcomes rather than skipping coverage. export function serverHas(feature: FeatureName): boolean { return resolveServerProfile().features[feature]; diff --git a/tests/e2e/setting.e2e.test.ts b/tests/e2e/setting.e2e.test.ts index 16622a3c..375f3bb6 100644 --- a/tests/e2e/setting.e2e.test.ts +++ b/tests/e2e/setting.e2e.test.ts @@ -215,7 +215,7 @@ describe("setting e2e", () => { }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( + expect(cliErrorMessage(result.stderr)).toBe( "multiple body sources given (--file, positional); pass exactly one", ); expect(result.stdout).toBe(""); diff --git a/tests/e2e/setup/bootstrap.ts b/tests/e2e/setup/bootstrap.ts index 1d90cebf..702b88db 100644 --- a/tests/e2e/setup/bootstrap.ts +++ b/tests/e2e/setup/bootstrap.ts @@ -21,6 +21,7 @@ import { BOOTSTRAP_FILE_PATH, type E2EBootstrap, type SeededIds, + type ServerIdentity, } from "../bootstrap-data"; import { DEFAULT_E2E_STACK, @@ -108,19 +109,23 @@ function apiKeyClient(apiKey: string): Transport { ); } +async function probeIdentity(client: Transport): Promise { + const probed = await probeServer(client, { retries: DEFAULT_MAX_RETRIES }); + const oauthSupported = (await tryDiscoverMetadata(BASE_URL, USER_AGENT)) !== null; + return { ...probed, oauthSupported }; +} + async function main(): Promise { await waitForReady(BASE_URL, HEALTH_TIMEOUT_MS); const existing = await readStoredBootstrap(); if (existing && (await canReuseExisting(existing.adminApiKey))) { assertSnapshotMatchesSeed(existing); - await reportSnapshotTransforms(apiKeyClient(existing.adminApiKey), existing.server); - // OAuth support depends on the booted image, not on the reused credentials — re-probe it so a - // stale bootstrap file (or an image swap on the same stack) can't pin the wrong answer. - const oauthSupported = (await tryDiscoverMetadata(BASE_URL, USER_AGENT)) !== null; - if (existing.server.oauthSupported !== oauthSupported) { - await writeStoredBootstrap({ ...existing, server: { ...existing.server, oauthSupported } }); - } + // The credentials and seed outlive the image: the app-db volume survives a pull of a newer + // head, so the server block is the booted image's to answer, never the file's. + const server = await probeIdentity(apiKeyClient(existing.adminApiKey)); + await reportSnapshotTransforms(apiKeyClient(existing.adminApiKey), server); + await writeStoredBootstrap({ ...existing, server }); process.stdout.write(`bootstrap: reusing ${BOOTSTRAP_FILE_PATH}\n`); return; } @@ -132,14 +137,12 @@ async function main(): Promise { const client = apiKeyClient(adminApiKey); const apiKeyUser = await client.requestParsed(CurrentUser, "/api/user/current"); - const probed = await probeServer(client, { retries: DEFAULT_MAX_RETRIES }); - if (transformsReady(probed)) { + const server = await probeIdentity(client); + if (transformsReady(server)) { await enableTransforms(client); await reportTransformsUsable(client); } - const seeded = await seedContent(client, libraryReady(probed), adminPersonalCollectionId); - const oauthSupported = (await tryDiscoverMetadata(BASE_URL, USER_AGENT)) !== null; - const server = { ...probed, oauthSupported }; + const seeded = await seedContent(client, libraryReady(server), adminPersonalCollectionId); const limitedGroupId = await createLimitedGroup(client); await revokeDefaultCollectionAccess(client, limitedGroupId, seeded.defaultCollectionId); diff --git a/tests/e2e/skills.e2e.test.ts b/tests/e2e/skills.e2e.test.ts index 3acec933..51d4e940 100644 --- a/tests/e2e/skills.e2e.test.ts +++ b/tests/e2e/skills.e2e.test.ts @@ -1,13 +1,31 @@ -import { afterEach, assert, describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; +import type { z } from "zod"; import { parseJson } from "@metabase/client/json"; import { SkillGetEnvelope } from "../../packages/cli/src/commands/skills/get"; import { SkillListEnvelope } from "../../packages/cli/src/commands/skills/list"; import { SkillPathListEnvelope } from "../../packages/cli/src/commands/skills/path"; +import { + discoverSkills, + type SkillContent, + type SkillInfo, +} from "../../packages/cli/src/core/skills"; +import { fitWithinCap } from "../../packages/cli/src/output/cap"; +import { DEFAULT_MAX_BYTES } from "../../packages/cli/src/output/types"; +import { cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { seedProbedProfile, seedProbedProfileAt, UNREACHABLE, versionAt } from "./seed-profile"; +type SkillGetPayload = z.infer; + +// The shipped skills are the expected values: the binary under test bundles this very directory. +const SKILL_DATA_DIR = resolve(import.meta.dirname, "..", "..", "packages", "cli", "skill-data"); +const BUNDLED_VISIBLE = discoverSkills([SKILL_DATA_DIR]).filter((skill) => !skill.hidden); + const BUNDLED_VISIBLE_NAMES = [ "core", "dashboard", @@ -23,7 +41,57 @@ const BUNDLED_VISIBLE_NAMES = [ ] as const; const UNFILTERED_NOTE = - 'Skills are unfiltered: profile "default" has no cached server probe (run `mb auth list` to record one).'; + 'Skills are unfiltered: profile "default" has no cached server probe (run `mb auth login` to record one).'; +const SKILL_OVERSIZE_HINT = + "a skill body is indivisible — pass --max-bytes 0 to print it whole, or `mb skills path ` to read it from disk"; + +function bundledSkill(name: string): SkillInfo { + const skill = BUNDLED_VISIBLE.find((candidate) => candidate.name === name); + if (skill === undefined) { + throw new Error(`no bundled skill named "${name}" under ${SKILL_DATA_DIR}`); + } + return skill; +} + +function listRow(skill: SkillInfo): z.infer["data"][number] { + return { name: skill.name, description: skill.description }; +} + +// A skill as `skills get` prints it with nothing to resolve against: SKILL.md verbatim, no extras. +function asWritten(skill: SkillInfo): SkillContent { + return { + name: skill.name, + description: skill.description, + body: readFileSync(join(skill.dir, "SKILL.md"), "utf8"), + references: [], + templates: [], + }; +} + +function fullList(rows: readonly SkillInfo[]): z.infer { + return { + data: rows.map(listRow), + returned: rows.length, + offset: 0, + total: rows.length, + has_more: false, + next_offset: null, + unavailable: null, + }; +} + +// The names a text listing prints at column zero; descriptions sit indented beneath them. +function namesInTextListing(stdout: string): string[] { + return stdout.split("\n").filter((line) => line !== "" && !line.startsWith(" ")); +} + +function onlySkill(envelope: SkillGetPayload): SkillContent { + const [item, ...rest] = envelope.data; + if (item === undefined || rest.length > 0) { + throw new Error(`expected exactly one skill in the envelope, got ${envelope.data.length}`); + } + return item; +} const TRANSFORM_UNAVAILABLE_ON_58 = { name: "transform", @@ -64,29 +132,18 @@ describe("skills e2e", () => { return dir; } - it("list returns the eleven bundled non-hidden skills, sorted by name", async () => { - const result = await runCli({ - args: ["skills", "list", "--json"], - configHome: await makeIsolatedConfigHome(), - }); - - expect(result.exitCode, result.stderr).toBe(0); - const envelope = parseJson(result.stdout, SkillListEnvelope); - expect(envelope.data.map((s) => s.name)).toEqual([...BUNDLED_VISIBLE_NAMES]); - expect(envelope.returned).toBe(BUNDLED_VISIBLE_NAMES.length); - for (const item of envelope.data) { - expect(item.description.length).toBeGreaterThan(20); - } + it("ships exactly the eleven visible skills this suite names", () => { + expect(BUNDLED_VISIBLE.map((skill) => skill.name)).toEqual([...BUNDLED_VISIBLE_NAMES]); }); - it("list reports `unavailable: null` and keeps stdout clean when there is no cached probe", async () => { + it("list returns the eleven bundled non-hidden skills, sorted by name, with `unavailable: null` and a clean stderr when there is no cached probe", async () => { const result = await runCli({ args: ["skills", "list", "--json"], configHome: await makeIsolatedConfigHome(), }); expect(result.exitCode, result.stderr).toBe(0); - expect(parseJson(result.stdout, SkillListEnvelope).unavailable).toBeNull(); + expect(parseJson(result.stdout, SkillListEnvelope)).toEqual(fullList(BUNDLED_VISIBLE)); expect(result.stderr).toBe(""); }); @@ -97,7 +154,7 @@ describe("skills e2e", () => { }); expect(result.exitCode).toBe(0); - expect(result.stdout.startsWith("core\n")).toBe(true); + expect(namesInTextListing(result.stdout)).toEqual([...BUNDLED_VISIBLE_NAMES]); expect(result.stderr).toBe(UNFILTERED_NOTE); }); @@ -108,11 +165,13 @@ describe("skills e2e", () => { const result = await runCli({ args: ["skills", "list", "--json"], configHome }); expect(result.exitCode, result.stderr).toBe(0); - const envelope = parseJson(result.stdout, SkillListEnvelope); - expect(envelope.data.map((s) => s.name)).toEqual( - BUNDLED_VISIBLE_NAMES.filter((name) => name !== "git-sync" && name !== "transform"), + const usable = BUNDLED_VISIBLE.filter( + (skill) => skill.name !== "git-sync" && skill.name !== "transform", ); - expect(envelope.unavailable).toEqual([GIT_SYNC_UNAVAILABLE_ON_58, TRANSFORM_UNAVAILABLE_ON_58]); + expect(parseJson(result.stdout, SkillListEnvelope)).toEqual({ + ...fullList(usable), + unavailable: [GIT_SYNC_UNAVAILABLE_ON_58, TRANSFORM_UNAVAILABLE_ON_58], + }); expect(result.stderr).toBe(""); }); @@ -123,9 +182,8 @@ describe("skills e2e", () => { const result = await runCli({ args: ["skills", "list", "--all", "--json"], configHome }); expect(result.exitCode, result.stderr).toBe(0); - const envelope = parseJson(result.stdout, SkillListEnvelope); - expect(envelope.data.map((s) => s.name)).toEqual([...BUNDLED_VISIBLE_NAMES]); - expect(envelope.unavailable).toBeNull(); + expect(parseJson(result.stdout, SkillListEnvelope)).toEqual(fullList(BUNDLED_VISIBLE)); + expect(result.stderr).toBe(""); }); it("list in text mode against a v58 profile names each skipped skill on stderr", async () => { @@ -135,7 +193,9 @@ describe("skills e2e", () => { const result = await runCli({ args: ["skills", "list", "--format", "text"], configHome }); expect(result.exitCode).toBe(0); - expect(result.stdout).not.toContain("transform\n"); + expect(namesInTextListing(result.stdout)).toEqual( + BUNDLED_VISIBLE_NAMES.filter((name) => name !== "git-sync" && name !== "transform"), + ); expect(result.stderr).toBe( [ `Skipped skill "git-sync": ${GIT_SYNC_UNAVAILABLE_ON_58.failure.detail} Pass --all to print it anyway.`, @@ -165,9 +225,15 @@ describe("skills e2e", () => { configHome, }); expect(printed.exitCode, printed.stderr).toBe(0); - const envelope = parseJson(printed.stdout, SkillGetEnvelope); - expect(envelope.unavailable).toBeNull(); - expect(envelope.data.map((s) => s.name)).toEqual(["transform"]); + expect(parseJson(printed.stdout, SkillGetEnvelope)).toEqual({ + data: [asWritten(bundledSkill("transform"))], + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + unavailable: null, + }); }); it("get core resolves its sections against the cached server: an OSS v58 loses the library bullet, an EE v63 keeps it without markers", async () => { @@ -187,17 +253,11 @@ describe("skills e2e", () => { configHome: await makeIsolatedConfigHome(), }); - for (const result of [onOss58, onEe63, unfiltered]) { - expect(result.exitCode, result.stderr).toBe(0); - } - const bodyOf = (stdout: string): string => { - const item = parseJson(stdout, SkillGetEnvelope).data[0]; - assert(item !== undefined, "expected the core skill in the envelope"); - return item.body; - }; - const oss58Body = bodyOf(onOss58.stdout); - const ee63Body = bodyOf(onEe63.stdout); - const unfilteredBody = bodyOf(unfiltered.stdout); + expect(onOss58.exitCode, onOss58.stderr).toBe(0); + expect(onEe63.exitCode, onEe63.stderr).toBe(0); + expect(unfiltered.exitCode, unfiltered.stderr).toBe(0); + const oss58Body = onlySkill(parseJson(onOss58.stdout, SkillGetEnvelope)).body; + const ee63Body = onlySkill(parseJson(onEe63.stdout, SkillGetEnvelope)).body; expect(oss58Body).not.toContain("**library.**"); expect(oss58Body).not.toContain("**transform.**"); @@ -205,19 +265,9 @@ describe("skills e2e", () => { expect(ee63Body).toContain("**library.**"); expect(ee63Body).toContain("**transform.**"); expect(ee63Body).not.toContain(""); - expect(unfilteredBody).toContain(""); - }); - - it("list hides the metabase-cli discovery stub", async () => { - const result = await runCli({ - args: ["skills", "list", "--json"], - configHome: await makeIsolatedConfigHome(), - }); - - expect(result.exitCode).toBe(0); - const envelope = parseJson(result.stdout, SkillListEnvelope); - expect(envelope.data.map((s) => s.name)).not.toContain("metabase-cli"); + expect(onlySkill(parseJson(unfiltered.stdout, SkillGetEnvelope))).toEqual( + asWritten(bundledSkill("core")), + ); }); it("get core returns the SKILL.md body with frontmatter intact and no references unless --full", async () => { @@ -227,17 +277,15 @@ describe("skills e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - const envelope = parseJson(result.stdout, SkillGetEnvelope); - expect(envelope.returned).toBe(1); - expect(envelope.data).toEqual([ - { - name: "core", - description: expect.stringContaining("Foundations for driving Metabase from the terminal"), - body: expect.stringMatching(/^---\nname: core\n[\s\S]*Top-level command groups/), - references: [], - templates: [], - }, - ]); + expect(parseJson(result.stdout, SkillGetEnvelope)).toEqual({ + data: [asWritten(bundledSkill("core"))], + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + unavailable: null, + }); }); it("get --all returns every non-hidden skill (with --max-bytes 0 to opt out of the list cap)", async () => { @@ -247,9 +295,16 @@ describe("skills e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - const envelope = parseJson(result.stdout, SkillGetEnvelope); - expect(envelope.data.map((s) => s.name)).toEqual([...BUNDLED_VISIBLE_NAMES]); - expect(envelope.truncated).toBeUndefined(); + expect(parseJson(result.stdout, SkillGetEnvelope)).toEqual({ + data: BUNDLED_VISIBLE.map(asWritten), + returned: BUNDLED_VISIBLE.length, + offset: 0, + total: BUNDLED_VISIBLE.length, + has_more: false, + next_offset: null, + unavailable: null, + }); + expect(result.stderr).toBe(""); }); it("get --all under the default byte cap truncates the trailing skills and surfaces a truncation notice", async () => { @@ -259,11 +314,29 @@ describe("skills e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - const envelope = parseJson(result.stdout, SkillGetEnvelope); - expect(envelope.total).toBe(BUNDLED_VISIBLE_NAMES.length); - expect(envelope.returned).toBeLessThan(BUNDLED_VISIBLE_NAMES.length); - expect(envelope.truncated?.reason).toBe("max_bytes"); - expect(result.stderr).toContain("cut at"); + // The uncapped answer is what the cap measured; the leading rows that fit it are the window. + const uncapped = await runCli({ + args: ["skills", "get", "--all", "--json", "--max-bytes", "0"], + configHome: await makeIsolatedConfigHome(), + }); + const whole = parseJson(uncapped.stdout, SkillGetEnvelope); + const fit = fitWithinCap(whole, DEFAULT_MAX_BYTES); + expect(fit.fullBytes).toBe(Buffer.byteLength(uncapped.stdout.trimEnd(), "utf8")); + expect(fit.cut).toBe(true); + expect(fit.count > 0 && fit.count < BUNDLED_VISIBLE.length).toBe(true); + expect(parseJson(result.stdout, SkillGetEnvelope)).toEqual({ + data: BUNDLED_VISIBLE.slice(0, fit.count).map(asWritten), + returned: fit.count, + offset: 0, + total: BUNDLED_VISIBLE.length, + has_more: true, + next_offset: fit.count, + truncated: { reason: "max_bytes", bytes: fit.fullBytes }, + unavailable: null, + }); + expect(result.stderr).toBe( + `… cut at ${fit.fullBytes} bytes; continue with --offset ${fit.count}, ${SKILL_OVERSIZE_HINT}`, + ); }); it("get answers a cap too small for even one skill with an empty window and no resumption point", async () => { @@ -291,36 +364,32 @@ describe("skills e2e", () => { truncated: { reason: "max_bytes", bytes: fullBytes }, unavailable: null, }); - expect(result.stderr).toBe( - `… cut at ${fullBytes} bytes; a skill body is indivisible — pass --max-bytes 0 to print it whole, or \`mb skills path \` to read it from disk`, - ); + expect(result.stderr).toBe(`… cut at ${fullBytes} bytes; ${SKILL_OVERSIZE_HINT}`); }); it("get --all walking next_offset under the default cap terminates and yields every skill once", async () => { const configHome = await makeIsolatedConfigHome(); - const names: string[] = []; - let offset = 0; + const pages: SkillGetPayload[] = []; + let offset: number | null = 0; - for (let iteration = 0; iteration < BUNDLED_VISIBLE_NAMES.length; iteration += 1) { + // Every page carries at least one skill, so the walk is over within one page per skill. + while (offset !== null && pages.length < BUNDLED_VISIBLE.length) { const result = await runCli({ args: ["skills", "get", "--all", "--json", "--offset", String(offset)], configHome, }); expect(result.exitCode, result.stderr).toBe(0); - const envelope = parseJson(result.stdout, SkillGetEnvelope); - expect(envelope.returned).toBeGreaterThan(0); - names.push(...envelope.data.map((skill) => skill.name)); - if (!envelope.has_more) { - expect(names).toEqual([...BUNDLED_VISIBLE_NAMES]); - return; - } - const next = envelope.next_offset; - assert(next !== null && next !== undefined, "has_more must come with a next_offset"); - expect(next).toBe(offset + envelope.returned); - offset = next; + const page = parseJson(result.stdout, SkillGetEnvelope); + pages.push(page); + offset = page.has_more && typeof page.next_offset === "number" ? page.next_offset : null; } - throw new Error(`walk did not terminate; collected ${names.length} skills`); + expect(pages.flatMap((page) => page.data)).toEqual(BUNDLED_VISIBLE.map(asWritten)); + expect(pages.map((page) => page.has_more)).toEqual([...pages.slice(1).map(() => true), false]); + expect(pages.map((page) => page.next_offset)).toEqual([ + ...pages.slice(1).map((page) => page.offset), + null, + ]); }); it("get accepts comma-separated names", async () => { @@ -330,8 +399,15 @@ describe("skills e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - const envelope = parseJson(result.stdout, SkillGetEnvelope); - expect(envelope.data.map((s) => s.name)).toEqual(["git-sync", "transform"]); + expect(parseJson(result.stdout, SkillGetEnvelope)).toEqual({ + data: [asWritten(bundledSkill("git-sync")), asWritten(bundledSkill("transform"))], + returned: 2, + offset: 0, + total: 2, + has_more: false, + next_offset: null, + unavailable: null, + }); }); it("get rejects an unknown skill name with exit 2 and a ConfigError message listing available names", async () => { @@ -341,7 +417,7 @@ describe("skills e2e", () => { }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( + expect(cliErrorMessage(result.stderr)).toBe( `unknown skill name(s): does-not-exist (available: ${BUNDLED_VISIBLE_NAMES.join(", ")})`, ); }); @@ -353,7 +429,9 @@ describe("skills e2e", () => { }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("provide a skill name (comma-separated for multiple) or --all"); + expect(cliErrorMessage(result.stderr)).toBe( + "provide a skill name (comma-separated for multiple) or --all", + ); }); it("path with no name lists every non-hidden skill's directory", async () => { @@ -363,11 +441,14 @@ describe("skills e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - const envelope = parseJson(result.stdout, SkillPathListEnvelope); - expect(envelope.data.map((s) => s.name)).toEqual([...BUNDLED_VISIBLE_NAMES]); - for (const item of envelope.data) { - expect(item.dir.endsWith(`/skill-data/${item.name}`)).toBe(true); - } + expect(parseJson(result.stdout, SkillPathListEnvelope)).toEqual({ + data: BUNDLED_VISIBLE.map((skill) => ({ name: skill.name, dir: skill.dir })), + returned: BUNDLED_VISIBLE.length, + offset: 0, + total: BUNDLED_VISIBLE.length, + has_more: false, + next_offset: null, + }); }); it("path returns a single-item envelope", async () => { @@ -377,12 +458,13 @@ describe("skills e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - const envelope = parseJson(result.stdout, SkillPathListEnvelope); - expect(envelope.returned).toBe(1); - expect(envelope.data).toHaveLength(1); - const item = envelope.data[0]; - assert(item !== undefined, "expected one item in the envelope"); - expect(item.name).toBe("core"); - expect(item.dir.endsWith("/skill-data/core")).toBe(true); + expect(parseJson(result.stdout, SkillPathListEnvelope)).toEqual({ + data: [{ name: "core", dir: bundledSkill("core").dir }], + returned: 1, + offset: 0, + total: 1, + has_more: false, + next_offset: null, + }); }); }); diff --git a/tests/e2e/transform-job.e2e.test.ts b/tests/e2e/transform-job.e2e.test.ts index 96a01166..a7e95f58 100644 --- a/tests/e2e/transform-job.e2e.test.ts +++ b/tests/e2e/transform-job.e2e.test.ts @@ -29,32 +29,36 @@ const JOB_TRANSFORM_NAME = "e2e_job_transform"; const FIRST_JOB_RUN_ID = 1; const RUN_STARTED_MESSAGE = "Job run started"; -// One generation answers every run request with an opaque stub, so it reports a start it cannot -// number and cannot tell when nothing ran; the other names the run, or reports that nothing started. +// One generation answers every run request with an opaque stub, so it can neither number a run +// nor say whether one started; the other names the run, or reports that nothing started. const reportsRunId = serverHas("transformJobRunIdIsNumeric"); const STARTED_RUN: TransformJobRunResultJson = { message: RUN_STARTED_MESSAGE, - started: true, + started: reportsRunId ? true : null, run_id: reportsRunId ? FIRST_JOB_RUN_ID : null, }; const NOTHING_TO_RUN: TransformJobRunResultJson = { message: RUN_STARTED_MESSAGE, - started: !reportsRunId, + started: reportsRunId ? false : null, run_id: null, }; +function requestedRunText(jobId: number): string { + return `Requested a run of transform job ${jobId}; this server does not say whether one started.`; +} + function startedRunText(jobId: number): string { return reportsRunId ? `Started transform job ${jobId} as run ${FIRST_JOB_RUN_ID}.` - : `Started transform job ${jobId}.`; + : requestedRunText(jobId); } function nothingToRunText(jobId: number): string { return reportsRunId ? `Transform job ${jobId} was not started (already running, or it resolves to no transforms).` - : `Started transform job ${jobId}.`; + : requestedRunText(jobId); } interface JobTransformNativeQuery { @@ -101,8 +105,11 @@ const JOB_TRANSFORM_COMPACT = { target_table_id: serverHas("transformTargetTableLinkedOnCreate") ? expect.any(Number) : null, } as const; +const ACTIVE = { active: serverHas("transformJobActivation") ? true : null }; + const BUILT_IN_JOBS = [ { + ...ACTIVE, id: 1, name: "Hourly job", description: "Executes transforms tagged with 'hourly' every hour", @@ -111,6 +118,7 @@ const BUILT_IN_JOBS = [ built_in_type: "hourly", }, { + ...ACTIVE, id: 2, name: "Daily job", description: "Executes transforms tagged with 'daily' once per day", @@ -119,6 +127,7 @@ const BUILT_IN_JOBS = [ built_in_type: "daily", }, { + ...ACTIVE, id: 3, name: "Weekly job", description: "Executes transforms tagged with 'weekly' once per week", @@ -127,6 +136,7 @@ const BUILT_IN_JOBS = [ built_in_type: "weekly", }, { + ...ACTIVE, id: 4, name: "Monthly job", description: "Executes transforms tagged with 'monthly' once per month", @@ -137,6 +147,7 @@ const BUILT_IN_JOBS = [ ] as const; const USER_JOB_COMPACT = { + ...ACTIVE, id: FIRST_USER_JOB_ID, name: JOB_NAME, description: null, @@ -156,19 +167,6 @@ const JOB_BODY: JobBody = { schedule: VALID_CRON, }; -// `active` is absent before v0.61 (added by the "disable jobs" migration), so strip it -// before comparing — the field is asserted nowhere in this suite. -function withoutActive(job: TransformJobCompact): Omit { - return { - id: job.id, - name: job.name, - description: job.description, - schedule: job.schedule, - ui_display_type: job.ui_display_type, - built_in_type: job.built_in_type, - }; -} - const skipReason = requireServer("transform-job › transform-job e2e", ["transforms"]); const setActiveSkipReason = requireServer("transform-job › set-active", ["transformJobActivation"]); @@ -206,7 +204,7 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); const created = parseJson(result.stdout, TransformJobCompact); - expect(withoutActive(created)).toEqual(USER_JOB_COMPACT); + expect(created).toEqual(USER_JOB_COMPACT); return created; } @@ -221,13 +219,16 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { expect(result.exitCode, result.stderr).toBe(0); const envelope = parseJson(result.stdout, TransformJobListEnvelope); - const byId = [...envelope.data] - .toSorted((left, right) => left.id - right.id) - .map(withoutActive); - expect(byId).toEqual([...BUILT_IN_JOBS, USER_JOB_COMPACT]); - expect({ returned: envelope.returned, total: envelope.total }).toEqual({ + expect({ + ...envelope, + data: [...envelope.data].toSorted((left, right) => left.id - right.id), + }).toEqual({ + data: [...BUILT_IN_JOBS, USER_JOB_COMPACT], returned: BUILT_IN_JOBS.length + 1, + offset: 0, total: BUILT_IN_JOBS.length + 1, + has_more: false, + next_offset: null, }); }); @@ -241,7 +242,7 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - expect(withoutActive(parseJson(result.stdout, TransformJobCompact))).toEqual(USER_JOB_COMPACT); + expect(parseJson(result.stdout, TransformJobCompact)).toEqual(USER_JOB_COMPACT); }); it("update changes the schedule and the change is visible via get", async () => { @@ -254,7 +255,7 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(updateResult.exitCode, updateResult.stderr).toBe(0); - expect(withoutActive(parseJson(updateResult.stdout, TransformJobCompact))).toEqual({ + expect(parseJson(updateResult.stdout, TransformJobCompact)).toEqual({ ...USER_JOB_COMPACT, schedule: SECOND_CRON, }); @@ -265,7 +266,7 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(getResult.exitCode, getResult.stderr).toBe(0); - expect(withoutActive(parseJson(getResult.stdout, TransformJobCompact))).toEqual({ + expect(parseJson(getResult.stdout, TransformJobCompact)).toEqual({ ...USER_JOB_COMPACT, schedule: SECOND_CRON, }); @@ -292,7 +293,9 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(getResult.exitCode).toBe(1); - expect(getResult.stderr).toContain(`Not found: GET /api/transform-job/${FIRST_USER_JOB_ID}.`); + expect(cliErrorMessage(getResult.stderr)).toBe( + `Not found: GET /api/transform-job/${FIRST_USER_JOB_ID}.`, + ); }); it("create with body missing required schedule fails on Zod validation", async () => { @@ -303,7 +306,9 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(cliErrorMessage(result.stderr)).toBe( + "request body: value did not match expected schema\n /schedule: Invalid input: expected string, received undefined", + ); expect(result.stdout).toBe(""); }); @@ -314,7 +319,7 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -325,7 +330,7 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Not found: GET /api/transform-job/9999999."); + expect(cliErrorMessage(result.stderr)).toBe("Not found: GET /api/transform-job/9999999."); }); it("delete without --yes refuses in non-TTY and exits 2 (explicit confirmation required)", async () => { @@ -336,7 +341,7 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( + expect(cliErrorMessage(result.stderr)).toBe( `refusing to delete ${FIRST_USER_JOB_ID} without confirmation — pass --yes to proceed non-interactively`, ); expect(result.stdout).toBe(""); @@ -415,7 +420,7 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -426,7 +431,7 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Not found: POST /api/transform-job/9999999/run."); + expect(cliErrorMessage(result.stderr)).toBe("Not found: POST /api/transform-job/9999999/run."); }); it("transforms lists the transforms a job resolves to by tag", async () => { @@ -472,7 +477,7 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -483,7 +488,9 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Not found: GET /api/transform-job/9999999/transforms."); + expect(cliErrorMessage(result.stderr)).toBe( + "Not found: GET /api/transform-job/9999999/transforms.", + ); }); describe.skipIf(setActiveSkipReason !== null)("set-active", () => { @@ -505,10 +512,12 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(listResult.exitCode, listResult.stderr).toBe(0); - const actives = parseJson(listResult.stdout, TransformJobListEnvelope).data.map( - (job) => job.active, + const listed = parseJson(listResult.stdout, TransformJobListEnvelope); + // Deactivating is an update, and the server's first update to a built-in job drops + // `built_in_type` so the name and description stop being re-translated. + expect([...listed.data].toSorted((left, right) => left.id - right.id)).toEqual( + BUILT_IN_JOBS.map((job) => Object.assign({}, job, { active: false, built_in_type: null })), ); - expect(actives).toEqual(Array.from({ length: BUILT_IN_JOBS.length }, () => false)); }); it("set-active true is a no-op when every job is already active", async () => { @@ -531,7 +540,7 @@ describe.skipIf(skipReason !== null)("transform-job e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain( + expect(cliErrorMessage(result.stderr)).toBe( 'invalid active: "maybe" (expected one of: true, false)', ); expect(result.stdout).toBe(""); diff --git a/tests/e2e/transform-tag.e2e.test.ts b/tests/e2e/transform-tag.e2e.test.ts index d9453a23..1bbe8190 100644 --- a/tests/e2e/transform-tag.e2e.test.ts +++ b/tests/e2e/transform-tag.e2e.test.ts @@ -107,8 +107,10 @@ describe.skipIf(skipReason !== null)("transform-tag e2e", () => { }); expect(listResult.exitCode, listResult.stderr).toBe(0); const envelope = parseJson(listResult.stdout, TransformTagListEnvelope); - const userTag = envelope.data.find((tag) => tag.id === FIRST_USER_TAG_ID); - expect(userTag).toEqual({ ...USER_TAG_COMPACT, name: RENAMED }); + expect([...envelope.data].toSorted((left, right) => left.id - right.id)).toEqual([ + ...BUILT_IN_TAGS, + { ...USER_TAG_COMPACT, name: RENAMED }, + ]); }); it("delete --yes removes the tag; subsequent list omits it", async () => { @@ -145,7 +147,9 @@ describe.skipIf(skipReason !== null)("transform-tag e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(cliErrorMessage(result.stderr)).toBe( + "request body: value did not match expected schema\n /name: Invalid input: expected string, received undefined", + ); expect(result.stdout).toBe(""); }); @@ -157,7 +161,7 @@ describe.skipIf(skipReason !== null)("transform-tag e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -169,7 +173,7 @@ describe.skipIf(skipReason !== null)("transform-tag e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Not found: PUT /api/transform-tag/9999999."); + expect(cliErrorMessage(result.stderr)).toBe("Not found: PUT /api/transform-tag/9999999."); }); it("delete without --yes refuses in non-TTY and exits 2 (explicit confirmation required)", async () => { @@ -182,7 +186,7 @@ describe.skipIf(skipReason !== null)("transform-tag e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( + expect(cliErrorMessage(result.stderr)).toBe( `refusing to delete ${FIRST_USER_TAG_ID} without confirmation — pass --yes to proceed non-interactively`, ); expect(result.stdout).toBe(""); diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index d37e6da1..1f37d43c 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -41,6 +41,27 @@ interface TransformBody { target: { type: "table"; database: number; schema: string; name: string }; } +function runIdOf(result: TransformRunResult): number { + if (result.run_id === null) { + throw new Error(`no run started: ${result.message}`); + } + return result.run_id; +} + +function finalRunOf(result: TransformRunResult): TransformRun { + if (result.final === null) { + throw new Error("expected the final run to be reported after waiting"); + } + return result.final; +} + +function registeredTableOf(result: TransformRunResult): number { + if (typeof result.target_table_id !== "number") { + throw new Error("expected the run to register its output table"); + } + return result.target_table_id; +} + async function waitForRunComplete(client: Transport, runId: number): Promise { await pollUntil( async () => client.requestParsed(TransformRun, `/api/transform/run/${runId}`), @@ -222,7 +243,9 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(getResult.exitCode).toBe(1); - expect(getResult.stderr).toContain(`Not found: GET /api/transform/${FIRST_TRANSFORM_ID}.`); + expect(cliErrorMessage(getResult.stderr)).toBe( + `Not found: GET /api/transform/${FIRST_TRANSFORM_ID}.`, + ); }); it("run --wait polls until the run reaches a terminal status and renders the final state", async () => { @@ -235,9 +258,14 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); const parsed = parseJson(result.stdout, TransformRunResult); + const runId = runIdOf(parsed); + const finalRun = finalRunOf(parsed); expect(parsed.message).toBe("Transform run started"); - expect(parsed.run_id).not.toBeNull(); - expect(parsed.final?.status).toBe("succeeded"); + expect({ + id: finalRun.id, + transform_id: finalRun.transform_id, + status: finalRun.status, + }).toEqual({ id: runId, transform_id: FIRST_TRANSFORM_ID, status: "succeeded" }); }); it("run --sync waits for the run's output table to register and surfaces target_table_id", async () => { @@ -260,9 +288,17 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { expect(result.exitCode, result.stderr).toBe(0); const parsed = parseJson(result.stdout, TransformRunResult); + const finalRun = finalRunOf(parsed); expect(parsed.message).toBe("Transform run started"); - expect(parsed.final?.status).toBe("succeeded"); - expect(parsed.target_table_id).toEqual(expect.any(Number)); + expect({ + id: finalRun.id, + status: finalRun.status, + target_table_id: parsed.target_table_id, + }).toEqual({ + id: runIdOf(parsed), + status: "succeeded", + target_table_id: registeredTableOf(parsed), + }); }); it("get reports the table a run registered as target_table_id", async () => { @@ -283,8 +319,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { timeoutMs: 60_000, }); expect(runResult.exitCode, runResult.stderr).toBe(0); - const registered = parseJson(runResult.stdout, TransformRunResult).target_table_id; - expect(registered).toEqual(expect.any(Number)); + const registered = registeredTableOf(parseJson(runResult.stdout, TransformRunResult)); const getResult = await runCli({ args: ["transform", "get", String(FIRST_TRANSFORM_ID), "--json"], @@ -335,14 +370,11 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { expect(runResult.exitCode).toBe(1); const parsed = parseJson(runResult.stdout, TransformRunResult); - const finalRun = parsed.final; - assert(finalRun !== null, "expected final run to be populated when --wait is set"); - const failureDetail = finalRun.message; - assert(failureDetail !== null, "expected failed run to carry a message"); + const finalRun = finalRunOf(parsed); + assert(finalRun.message !== null, "expected failed run to carry a message"); expect(finalRun.status).toBe("failed"); - expect(runResult.stderr).toContain(`transform run ${parsed.run_id} failed`); - expect(runResult.stderr).not.toContain(failureDetail); + expect(cliErrorMessage(runResult.stderr)).toBe(`transform run ${runIdOf(parsed)} failed`); }); it("run returns a run_id for the created transform", async () => { @@ -355,12 +387,13 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); const parsed = parseJson(result.stdout, TransformRunResult); - expect(parsed.message).toBe("Transform run started"); - expect(parsed.run_id).not.toBeNull(); + expect(parsed).toEqual({ + message: "Transform run started", + run_id: runIdOf(parsed), + final: null, + }); - if (parsed.run_id !== null) { - await waitForRunComplete(adminClient, parsed.run_id); - } + await waitForRunComplete(adminClient, runIdOf(parsed)); }); it("delete-table drops the output table while keeping the transform record", async () => { @@ -381,8 +414,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { timeoutMs: 60_000, }); expect(runResult.exitCode, runResult.stderr).toBe(0); - const registered = parseJson(runResult.stdout, TransformRunResult).target_table_id; - expect(registered).toEqual(expect.any(Number)); + const registered = registeredTableOf(parseJson(runResult.stdout, TransformRunResult)); const dropResult = await runCli({ args: ["transform", "delete-table", String(FIRST_TRANSFORM_ID), "--yes", "--json"], @@ -418,7 +450,9 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(cliErrorMessage(result.stderr)).toBe( + "request body: value did not match expected schema\n /source: Invalid input: expected object, received undefined\n /target: Invalid input: expected object, received undefined", + ); expect(result.stdout).toBe(""); }); @@ -429,7 +463,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -440,7 +474,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Not found: GET /api/transform/9999999."); + expect(cliErrorMessage(result.stderr)).toBe("Not found: GET /api/transform/9999999."); }); it("create with invalid MBQL 5 source.query fails pre-flight before sending", async () => { @@ -477,7 +511,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { ok: false, errors: [{ path: "/database", message: "must be integer" }], }); - expect(result.stderr).toContain( + expect(cliErrorMessage(result.stderr)).toBe( "transform.source.query validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", ); }); @@ -505,7 +539,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { ok: false, errors: [{ path: "/database", message: "must be integer" }], }); - expect(result.stderr).toContain( + expect(cliErrorMessage(result.stderr)).toBe( "transform.source.query validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", ); }); @@ -579,7 +613,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid run id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid run id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -590,7 +624,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Not found: GET /api/transform/run/9999999."); + expect(cliErrorMessage(result.stderr)).toBe("Not found: GET /api/transform/run/9999999."); }); it("runs lists the recently-completed run for the seeded transform", async () => { @@ -657,9 +691,10 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { }), ), ); - for (const runResult of runResults) { - expect(runResult.exitCode, runResult.stderr).toBe(0); - } + expect( + runResults.map((runResult) => runResult.exitCode), + runResults.map((runResult) => runResult.stderr).join("\n"), + ).toEqual([0, 0]); const result = await runCli({ args: ["transform", "runs", "--transform-id", String(FIRST_TRANSFORM_ID), "--json"], @@ -774,9 +809,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain( - 'invalid --transform-id: "abc" (expected integer)', - ); + expect(cliErrorMessage(result.stderr)).toBe('invalid --transform-id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -846,7 +879,9 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain(`Not found: POST /api/transform/${FIRST_TRANSFORM_ID}/cancel.`); + expect(cliErrorMessage(result.stderr)).toBe( + `Not found: POST /api/transform/${FIRST_TRANSFORM_ID}/cancel.`, + ); }); it("cancel with non-integer id fails fast with ConfigError", async () => { @@ -856,7 +891,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -868,7 +903,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( + expect(cliErrorMessage(result.stderr)).toBe( `refusing to delete ${FIRST_TRANSFORM_ID} without confirmation — pass --yes to proceed non-interactively`, ); expect(result.stdout).toBe(""); @@ -900,8 +935,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(transformResult.exitCode, transformResult.stderr).toBe(0); - const created = parseJson(transformResult.stdout, TransformCompact); - expect(created).toEqual({ ...TRANSFORM_COMPACT, id: created.id }); + expect(parseJson(transformResult.stdout, TransformCompact)).toEqual(TRANSFORM_COMPACT); }); it("create into a default-namespace collection fails (exit 2) with the --namespace transforms hint", async () => { @@ -960,7 +994,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(cliErrorMessage(result.stderr)).toBe('invalid id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); @@ -971,7 +1005,9 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { env: authEnv(), }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Not found: GET /api/transform/9999999/dependencies."); + expect(cliErrorMessage(result.stderr)).toBe( + "Not found: GET /api/transform/9999999/dependencies.", + ); }); }); diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index b42c09f1..2e6a4674 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -8,7 +8,7 @@ import { CardListEnvelope } from "../../packages/cli/src/commands/card/list"; import { summarizeServer } from "../../packages/cli/src/core/auth/server-summary"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; -import { cliErrorMessage } from "./cli-error"; +import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { SEED_USER, @@ -23,8 +23,13 @@ import { requireServer } from "./server-gate"; const BEYOND_KNOWN = KNOWN_RANGE.max + 5; -const NEWER_NOTICE = `Metabase v0.${BEYOND_KNOWN}.0 is newer than this CLI supports (up to v${KNOWN_RANGE.max}); commands run as if it were v${KNOWN_RANGE.max}. Run \`mb upgrade\` for a newer CLI.`; -const UNKNOWN_NOTICE = `Could not parse the Metabase version; assuming the newest supported (v${KNOWN_RANGE.max}).`; +const DOWNGRADE_REMEDY = "Or install an `@metabase/cli` release that targets this server."; +const MEASURES_REFUSAL = + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it."; +const UNREACHABLE_MESSAGE = "Could not reach Metabase: fetch failed"; + +const NEWER_NOTICE = `Metabase v0.${BEYOND_KNOWN}.0 is newer than this CLI supports (up to v${KNOWN_RANGE.max}); commands run as if it were a head build past v${KNOWN_RANGE.max}. Run \`mb upgrade\` for a newer CLI.`; +const UNKNOWN_NOTICE = `Could not parse the Metabase version; assuming a head build past v${KNOWN_RANGE.max}.`; describe("version preflight enforcement e2e", () => { const tempDirs: string[] = []; @@ -46,9 +51,9 @@ describe("version preflight enforcement e2e", () => { const result = await runCli({ args: ["measure", "list"], configHome }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( - "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", - ); + expect(cliErrorCategory(result.stderr)).toBe("capability"); + expect(cliErrorMessage(result.stderr)).toBe(`${MEASURES_REFUSAL}\n${DOWNGRADE_REMEDY}`); + expect(result.stdout).toBe(""); }); it("bypasses the refusal and reaches the network layer when --skip-preflight is passed", async () => { @@ -58,8 +63,9 @@ describe("version preflight enforcement e2e", () => { const result = await runCli({ args: ["measure", "list", "--skip-preflight"], configHome }); expect(result.exitCode).toBe(1); - expect(result.stderr).not.toContain("This operation requires Metabase"); - expect(result.stderr).toContain("Could not reach Metabase"); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_MESSAGE); + expect(result.stdout).toBe(""); }); it("probes the server itself when a gated command runs without a cached probe and fails on the network layer", async () => { @@ -69,8 +75,8 @@ describe("version preflight enforcement e2e", () => { const result = await runCli({ args: ["measure", "list"], configHome }); expect(result.exitCode).toBe(1); - expect(cliErrorMessage(result.stderr)).toBe("Could not reach Metabase: fetch failed"); - expect(result.stderr).not.toContain("Could not detect Metabase server version"); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_MESSAGE); expect(result.stdout).toBe(""); }); @@ -81,9 +87,11 @@ describe("version preflight enforcement e2e", () => { const result = await runCli({ args: ["git-sync", "status"], configHome }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain( + expect(cliErrorCategory(result.stderr)).toBe("capability"); + expect(cliErrorMessage(result.stderr)).toBe( "This operation requires the 'remote_sync' premium feature (not enabled on this server).", ); + expect(result.stdout).toBe(""); }); it("bypasses the refusal via MB_CLI_SKIP_PREFLIGHT=1 and reaches the network layer", async () => { @@ -97,8 +105,9 @@ describe("version preflight enforcement e2e", () => { }); expect(result.exitCode).toBe(1); - expect(result.stderr).not.toContain("This operation requires Metabase"); - expect(result.stderr).toContain("Could not reach Metabase"); + expect(cliErrorCategory(result.stderr)).toBe("network"); + expect(cliErrorMessage(result.stderr)).toBe(UNREACHABLE_MESSAGE); + expect(result.stdout).toBe(""); }); }); @@ -124,6 +133,18 @@ describe("version skew notices e2e", () => { return { url: bootstrap.baseUrl, apiKey: bootstrap.adminApiKey }; } + // `auth status` reads the cached probe without opening a socket, so it reports the profile as + // the disk holds it. + async function lastProbedAt(configHome: string): Promise { + const status = await runCli({ args: ["auth", "status", "--json"], configHome }); + expect(status.exitCode, status.stderr).toBe(0); + const probedAt = parseJson(status.stdout, AuthStatus).lastProbedAt; + if (probedAt === null) { + throw new Error("expected the profile to hold a cached probe"); + } + return probedAt; + } + it("prints exactly one newer-server notice on stderr and succeeds when the cached probe is above the known range", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfileAt(configHome, liveTarget(), versionAt(BEYOND_KNOWN)); @@ -156,9 +177,8 @@ describe("version skew notices e2e", () => { expect(result.stderr).toBe(""); }); - // A cached v59 profile reads a job run as the stub-string generation; a head server answers with - // the numeric one, so the parse fails against the stale cache and the CLI must notice the server - // moved. Head reports no parseable tag, which is what the refreshed profile then records. + // A cached v59 profile reads a job run as the stub-string generation; a server answering with the + // numeric one fails that parse, and the CLI must notice the server moved. const reprobeSkipReason = requireServer("version › re-probe on a shape error", [ "transformJobRunIdIsNumeric", ]); @@ -167,6 +187,7 @@ describe("version skew notices e2e", () => { it("re-probes once, refreshes the stale profile, and appends the change to the error", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfileAt(configHome, liveTarget(), versionAt(59)); + const seededAt = await lastProbedAt(configHome); const result = await runCli({ args: ["transform-job", "run", String(E2E_BUILTIN_TRANSFORM_JOBS.DAILY), "--json"], @@ -174,30 +195,28 @@ describe("version skew notices e2e", () => { }); expect(result.exitCode).toBe(1); + const liveLabel = + bootstrap.server.version === null ? "an unparseable version" : bootstrap.server.version.tag; expect(cliErrorMessage(result.stderr)).toBe( "On Metabase v0.59.0 the response shape was unexpected:\n" + " job_run_id: Invalid input: expected string, received null\n" + - "The server's version changed since the last probe (was v0.59.0, now an unparseable version); the profile was refreshed — retry the command.", + `The server's version changed since the last probe (was v0.59.0, now ${liveLabel}); the profile was refreshed — retry the command.`, ); const status = await runCli({ args: ["auth", "status", "--json"], configHome }); expect(status.exitCode, status.stderr).toBe(0); - const payload = parseJson(status.stdout, AuthStatus); - expect(payload).toEqual({ + const refreshedAt = await lastProbedAt(configHome); + expect(parseJson(status.stdout, AuthStatus)).toEqual({ profile: "default", present: true, url: bootstrap.baseUrl, method: "apiKey", user: SEED_USER, - ...summarizeServer({ - version: null, - date: bootstrap.server.date, - hash: bootstrap.server.hash, - tokenFeatures: bootstrap.server.tokenFeatures, - }), - lastProbedAt: payload.lastProbedAt, + ...summarizeServer(bootstrap.server), + lastProbedAt: refreshedAt, lastFailure: null, }); + expect(refreshedAt > seededAt).toBe(true); }); }); }); From 9ac5f50d5ee759f5dd5cb35b386d483813d7145c Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 16 Sep 2026 22:31:35 -0400 Subject: [PATCH 03/22] fixes --- .github/workflows/e2e.yml | 4 ++ CLAUDE.md | 2 +- bun.lock | 5 +- docs/architecture.md | 6 +- packages/cli/src/commands/auth/list.test.ts | 2 + packages/cli/src/commands/auth/status.test.ts | 2 + packages/cli/src/commands/runtime.test.ts | 4 ++ packages/cli/src/core/auth/profile-record.ts | 7 +- .../cli/src/core/auth/server-summary.test.ts | 9 ++- packages/cli/src/core/auth/storage.test.ts | 4 ++ packages/cli/src/core/auth/storage.ts | 1 + .../cli/src/core/auth/temp-config-home.ts | 1 + packages/cli/src/core/auth/verify.test.ts | 1 + packages/cli/src/core/config.test.ts | 1 + packages/cli/src/core/skills.test.ts | 1 + packages/cli/src/output/error.test.ts | 3 + packages/client/README.md | 9 +-- packages/client/package.json | 3 - packages/client/src/architecture.test.ts | 4 +- packages/client/src/http/transport.test.ts | 43 +++++++++++++ packages/client/src/http/transport.ts | 20 +++--- packages/client/src/resources/card.ts | 14 ++-- packages/client/src/resources/collection.ts | 16 ++--- .../src/resources/content-translation.test.ts | 1 + .../src/resources/content-translation.ts | 4 +- packages/client/src/resources/dashboard.ts | 18 +++--- packages/client/src/resources/database.ts | 12 ++-- packages/client/src/resources/dataset.ts | 2 +- packages/client/src/resources/document.ts | 10 +-- .../client/src/resources/eid-translation.ts | 2 +- packages/client/src/resources/field.ts | 8 +-- .../client/src/resources/git-sync.test.ts | 1 + packages/client/src/resources/git-sync.ts | 30 ++++----- packages/client/src/resources/library.test.ts | 2 + packages/client/src/resources/library.ts | 12 ++-- packages/client/src/resources/measure.test.ts | 1 + packages/client/src/resources/measure.ts | 10 +-- packages/client/src/resources/notification.ts | 12 ++-- packages/client/src/resources/pulse.ts | 10 +-- packages/client/src/resources/search.ts | 2 +- packages/client/src/resources/segment.ts | 10 +-- packages/client/src/resources/setting.ts | 6 +- packages/client/src/resources/setup.ts | 2 +- packages/client/src/resources/snippet.ts | 10 +-- packages/client/src/resources/table.ts | 12 ++-- .../client/src/resources/timeline-event.ts | 10 +-- packages/client/src/resources/timeline.ts | 14 ++-- .../src/resources/transform-job.test.ts | 2 + .../client/src/resources/transform-job.ts | 28 ++++---- .../src/resources/transform-tag.test.ts | 1 + .../client/src/resources/transform-tag.ts | 8 +-- .../client/src/resources/transform.test.ts | 2 + packages/client/src/resources/transform.ts | 28 ++++---- packages/client/src/resources/upload.ts | 2 +- packages/client/src/resources/user.ts | 2 +- packages/client/src/signal.test.ts | 30 ++++++++- packages/client/src/signal.ts | 14 ++++ packages/client/src/testing/deferred.ts | 22 +++++++ .../client/src/testing/fake-client.test.ts | 1 + packages/client/src/version/features.ts | 5 +- .../src/version/matrix-coverage.test.ts | 62 ++++++++++++++++++ packages/client/src/version/probe.test.ts | 3 + packages/client/src/version/probe.ts | 10 ++- packages/client/src/version/profile.test.ts | 31 ++++++++- packages/client/src/version/profile.ts | 13 ++-- .../src/version/requirement-check.test.ts | 3 + .../client/src/version/requirements.test.ts | 3 +- packages/client/src/version/tag.test.ts | 10 +++ packages/client/src/version/tag.ts | 64 ++++++++++--------- scripts/e2e-matrix.ts | 36 +++++------ tests/e2e/auth.e2e.test.ts | 1 + tests/e2e/bootstrap-data.ts | 3 +- tests/e2e/query.e2e.test.ts | 6 +- tests/e2e/seed-profile.ts | 9 ++- tests/e2e/server-gate.ts | 12 +--- tests/e2e/transform.e2e.test.ts | 6 +- 76 files changed, 518 insertions(+), 262 deletions(-) create mode 100644 packages/client/src/testing/deferred.ts create mode 100644 packages/client/src/version/matrix-coverage.test.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a1fb685d..b0e2c410 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -25,6 +25,10 @@ jobs: - ee-60 - oss-61 - ee-61 + - oss-62 + - ee-62 + - oss-63 + - ee-63 - oss-head - ee-head env: diff --git a/CLAUDE.md b/CLAUDE.md index 6c59b31f..8cb82635 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ This file is rules only. Architecture and rationale live in `docs/architecture.m | `packages/cli` | Publishable CLI: `commands/` (shell only), `core/` (pure logic), `output/` (presentation), `runtime/` (platform glue) | | `tests/e2e` | Built-binary tier against a live Metabase | -- Nothing in `packages/client` may import from `packages/cli`, touch `process`, or mutate process-global state. Its dependency budget is `zod` (peer) + `semver` + `node:` builtins. +- Nothing in `packages/client` may import from `packages/cli`, touch `process`, or mutate process-global state. Its dependency budget is `zod` (peer) + `node:` builtins. - Within `packages/client`, only `client.ts` and `resources/` may import from `resources/`. The CLI may `import type` from the resource subpaths the `exports` map publishes. - `JSON.parse` only in `json.ts`. `fetch` only in `http/` (plus `packages/cli/src/core/npm-registry.ts`, which is not a Metabase endpoint). `new URL(` only in `url.ts` and `http/`. Within `packages/client`, a `setTimeout` wait loop only in `poll.ts` and `http/retry.ts`; in `packages/cli`, a hand-rolled one nowhere — wait on `node:timers/promises`. - `process.exit` only in the CLI entry; `process.stdout.write` only in `output/`; `child_process` only in `runtime/process.ts`. `src/output/` must not import the HTTP layer — reach for `@metabase/client/errors`. diff --git a/bun.lock b/bun.lock index 88301e8d..c152264f 100644 --- a/bun.lock +++ b/bun.lock @@ -25,7 +25,7 @@ }, "packages/cli": { "name": "@metabase/cli", - "version": "0.3.0", + "version": "0.3.1", "bin": { "mb": "./dist/cli.mjs", }, @@ -44,9 +44,6 @@ "packages/client": { "name": "@metabase/client", "version": "0.1.0", - "dependencies": { - "semver": "^7.8.0", - }, "peerDependencies": { "zod": "^4.0.0", }, diff --git a/docs/architecture.md b/docs/architecture.md index 381522b9..a8cdf0b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ The CLI consumes the client as `@metabase/client/` and inlines it at build `zod` is a **peer** dependency of the client, not a normal one. The client's public API is Zod schemas, so the consumer's zod instance is part of the contract — shipping our own copy would hand embedders two incompatible `ZodType` identities. -Root devDependencies hoist and are therefore reachable from the client's source tree. The client's dependency budget (`zod` + `semver` + `node:` builtins) is a rule to uphold, not something the resolver rejects. +Root devDependencies hoist and are therefore reachable from the client's source tree. The client's dependency budget (`zod` + `node:` builtins) is a rule to uphold, not something the resolver rejects. The split is internal. To anyone outside the repo this is the CLI, which is why the root `README.md` is the CLI's user-facing command reference rather than a workspace map, and why `packages/client/README.md` — the client's own reference — is reached only from inside `packages/`. npm renders a README only from the package root, so `packages/cli/README.md` is a pointer to the root one: install, `mb --help`, and the link. The reference itself has one home. @@ -56,7 +56,7 @@ Truncation by the output byte cap recomputes `has_more` / `next_offset`, making The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packages/client/src/version/profile.ts`, and only the latest patch of each major: patch releases carry fixes, not API-contract changes, so the e2e matrix boots the latest patch per major and that is the whole contract. Inside the window every difference the client cares about is a named feature; outside it the client degrades in one stated way rather than guessing. -**One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (parsed tag, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's semver major — `v0.` is OSS, `v1.` is EE — and, for a tag that does not parse, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`; a tag that does not parse — head images report `vUNKNOWN` — lands in the same slot as `unknown`. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. +**One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (parsed tag, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's leading number — `v0.` is OSS, `v1.` is EE — and, for a tag that stamps none, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`; a tag that does not parse — head images report `vUNKNOWN` — lands in the same slot as `unknown`. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. **`features.ts` is the only file that compares a major.** `FEATURE_RULES` maps a behaviour name to `{ since, until?, tokenFeature? }`; `evaluateFeatures` turns the placed major and the token map into `Features`, a record of booleans every consumer branches on. Two tests keep the table honest: `major-comparison-guard.test.ts` scans both packages' source for a `.major` comparison or a `minVersion` outside the version layer, and `features.test.ts` asserts every version-gated rule is non-constant across `KNOWN_RANGE.min … max + 1` and that no name carries a version. When a major leaves the window the rules that stopped flipping fail the second test and are deleted with their wire variants — that is what keeps the switch count bounded. Rules exist for three kinds of consumer: resource methods and readers (`transforms`, `transformTargetTableId`, `libraryChildrenCarryType`), e2e suites that pin per-generation server behaviour (`invalidMbqlIsBadRequest`, `collectionItemsTotalOnEmptyPage`), and skill prose (`boxplotDisplay`, `smartLinkMeasureModel`); a rule no client code reads is still a rule, its consumer is the gate or the skill. @@ -74,7 +74,7 @@ Two things that look like version drift are not, and must not become switches: a **Adding a version-conditional feature** is: a rule in `FEATURE_RULES` named after the behaviour, verified against the reference Metabase checkout's release branches (route files `src/metabase/api_routes/routes.clj` and `enterprise/backend/src/metabase_enterprise/api_routes/routes.clj`; token-feature keys in `src/metabase/premium_features/settings.clj`; trust the source over a branch's committed `openapi.json`, which can lag its routes); the method's entry in `METHOD_REQUIREMENTS`; `requires` on the command; and, where a shape differs per generation, the wire variant and converter. The e2e suite gates with `requireServer(lane, [])` where a server may lack the route and branches with `serverHas(feature)` where every stack answers and the answers differ. -**When a new Metabase major ships:** raise `KNOWN_RANGE.max`, add the `oss-` / `ee-` stacks to `scripts/e2e-matrix.ts` and run them, and when a major leaves the window at the other end raise `min` and run the unit tier — `features.test.ts` flags every rule that is now constant, and each one is deleted together with its wire variant, converter, `METHOD_REQUIREMENTS` entries, skill markers and e2e branches. Update the range the root `README.md` states. Nothing else names a major. +**When a new Metabase major ships:** raise `KNOWN_RANGE.max`, add the `oss-` / `ee-` stacks to `scripts/e2e-matrix.ts` and `.github/workflows/e2e.yml` and run them — `matrix-coverage.test.ts` fails until every major in the range has both lanes, pinned to an image of its own major and edition — and when a major leaves the window at the other end raise `min` and run the unit tier — `features.test.ts` flags every rule that is now constant, and each one is deleted together with its wire variant, converter, `METHOD_REQUIREMENTS` entries, skill markers and e2e branches. Update the range the root `README.md` states. Nothing else names a major. ## The e2e stack diff --git a/packages/cli/src/commands/auth/list.test.ts b/packages/cli/src/commands/auth/list.test.ts index b0d5c19d..5742790a 100644 --- a/packages/cli/src/commands/auth/list.test.ts +++ b/packages/cli/src/commands/auth/list.test.ts @@ -76,6 +76,7 @@ const LATER = "2026-03-04T06:00:00.000Z"; function successServer(): ServerInfo { return { + edition: "oss", version: { tag: "v0.58.7", major: 58, patch: 7 }, date: null, hash: null, @@ -118,6 +119,7 @@ function probedRecord(name: string, url: string): ProfileRecord { oauth: null, lastProbe: { at: PROBED_AT, + edition: "oss", version: { tag: "v0.58.7", major: 58, patch: 7 }, date: null, hash: null, diff --git a/packages/cli/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts index d3d9eeca..e8c30131 100644 --- a/packages/cli/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -116,6 +116,7 @@ describe("auth status command", () => { it("surfaces the cached probe and what this CLI derives from it when one exists", async () => { await writeProfile({ url: "https://m.example.com", apiKey: "secret" }); const server: ServerInfo = { + edition: "oss", version: { tag: "v0.58.7", major: 58, patch: 7 }, date: null, hash: null, @@ -149,6 +150,7 @@ describe("auth status command", () => { await writeProfile({ url: "https://m.example.com", apiKey: "secret" }); const beyond = KNOWN_RANGE.max + 5; const server: ServerInfo = { + edition: "ee", version: { tag: `v1.${beyond}.0`, major: beyond, patch: 0 }, date: null, hash: null, diff --git a/packages/cli/src/commands/runtime.test.ts b/packages/cli/src/commands/runtime.test.ts index c69cbe0e..ec6b7d56 100644 --- a/packages/cli/src/commands/runtime.test.ts +++ b/packages/cli/src/commands/runtime.test.ts @@ -515,6 +515,7 @@ describe("defineMetabaseCommand", () => { it("reads an unparseable cached version as the newest known, says so once, and proceeds", async () => { await seedProbedProfile("default", { + edition: null, version: null, date: null, hash: null, @@ -680,6 +681,7 @@ describe("defineMetabaseCommand", () => { expect(await readProfileRecord("default")).toEqual( reprobedRecord({ at: REPROBED_AT, + edition: "oss", version: { tag: "v0.63.4", major: 63, patch: 4 }, date: "2026-09-01", hash: "abc1234", @@ -712,6 +714,7 @@ describe("defineMetabaseCommand", () => { expect(await readProfileRecord("default")).toEqual( reprobedRecord({ at: REPROBED_AT, + edition: "oss", version: { tag: "v0.59.0", major: 59, patch: 0 }, date: null, hash: null, @@ -844,6 +847,7 @@ describe("defineMetabaseCommand", () => { expect(await readProfileRecord("default")).toEqual( reprobedRecord({ at: REPROBED_AT, + edition: "oss", version: { tag: "v0.61.3", major: 61, patch: 3 }, date: null, hash: null, diff --git a/packages/cli/src/core/auth/profile-record.ts b/packages/cli/src/core/auth/profile-record.ts index c5b4f8c5..39025f38 100644 --- a/packages/cli/src/core/auth/profile-record.ts +++ b/packages/cli/src/core/auth/profile-record.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { TokenFeatures } from "@metabase/client/domain/session-properties"; -import { ParsedVersion } from "@metabase/client/version/tag"; +import { Edition, ParsedVersion } from "@metabase/client/version/tag"; export const ProbedUser = z.object({ id: z.number().int(), @@ -11,11 +11,12 @@ export const ProbedUser = z.object({ export type ProbedUser = z.infer; // The raw probe, never anything derived from it: a profile built from a stale derivation would -// outlive the client that wrote it. `date` and `hash` read as `null` off a record written before -// they were kept. +// outlive the client that wrote it. `edition`, `date` and `hash` read as `null` off a record +// written before they were kept. export const ProfileLastProbe = z.object({ at: z.iso.datetime(), version: ParsedVersion.nullable(), + edition: Edition.nullable().default(null), date: z.string().nullable().default(null), hash: z.string().nullable().default(null), tokenFeatures: TokenFeatures.nullable(), diff --git a/packages/cli/src/core/auth/server-summary.test.ts b/packages/cli/src/core/auth/server-summary.test.ts index da4c2e09..fb7bfa2c 100644 --- a/packages/cli/src/core/auth/server-summary.test.ts +++ b/packages/cli/src/core/auth/server-summary.test.ts @@ -7,6 +7,7 @@ import { serverChangeNote, skewNotice, summarizeServer } from "./server-summary" function serverAt(major: number, tokenFeatures: ServerInfo["tokenFeatures"] = null): ServerInfo { return { + edition: "oss", version: { tag: `v0.${major}.0`, major, patch: 0 }, date: null, hash: null, @@ -14,7 +15,13 @@ function serverAt(major: number, tokenFeatures: ServerInfo["tokenFeatures"] = nu }; } -const UNPARSEABLE: ServerInfo = { version: null, date: null, hash: null, tokenFeatures: null }; +const UNPARSEABLE: ServerInfo = { + version: null, + edition: null, + date: null, + hash: null, + tokenFeatures: null, +}; describe("summarizeServer", () => { it("reports every server fact as null without a probe and still names the CLI's own range", () => { diff --git a/packages/cli/src/core/auth/storage.test.ts b/packages/cli/src/core/auth/storage.test.ts index 79f98fd2..5f61f9be 100644 --- a/packages/cli/src/core/auth/storage.test.ts +++ b/packages/cli/src/core/auth/storage.test.ts @@ -269,6 +269,7 @@ describe("writeProbeResult and writeProbeFailure", () => { const ALICE_PROBE = { at: PROBED_AT, version: { tag: "v0.58.7", major: 58, patch: 7 }, + edition: "oss", date: null, hash: null, tokenFeatures: null, @@ -281,6 +282,7 @@ describe("writeProbeResult and writeProbeFailure", () => { const probe = await writeProbeResult("p", { user: { id: 42, name: "Alice", isAdmin: true }, server: { + edition: "oss", version: { tag: "v0.58.7", major: 58, patch: 7 }, date: null, hash: null, @@ -302,6 +304,7 @@ describe("writeProbeResult and writeProbeFailure", () => { const result = await writeProbeResult("ghost", { user: { id: 1, name: "n", isAdmin: false }, server: { + edition: "oss", version: { tag: "v0.58.7", major: 58, patch: 7 }, date: null, hash: null, @@ -317,6 +320,7 @@ describe("writeProbeResult and writeProbeFailure", () => { await writeProbeResult("p", { user: { id: 42, name: "Alice", isAdmin: true }, server: { + edition: "oss", version: { tag: "v0.58.7", major: 58, patch: 7 }, date: null, hash: null, diff --git a/packages/cli/src/core/auth/storage.ts b/packages/cli/src/core/auth/storage.ts index 496a593d..71dfccc4 100644 --- a/packages/cli/src/core/auth/storage.ts +++ b/packages/cli/src/core/auth/storage.ts @@ -451,6 +451,7 @@ export async function writeProbeResult( const probe = ProfileLastProbe.parse({ at: new Date().toISOString(), version: input.server.version, + edition: input.server.edition, date: input.server.date, hash: input.server.hash, tokenFeatures: input.server.tokenFeatures, diff --git a/packages/cli/src/core/auth/temp-config-home.ts b/packages/cli/src/core/auth/temp-config-home.ts index ea4e569d..4cc2d904 100644 --- a/packages/cli/src/core/auth/temp-config-home.ts +++ b/packages/cli/src/core/auth/temp-config-home.ts @@ -46,6 +46,7 @@ export function probeAt( tokenFeatures: ServerInfo["tokenFeatures"] = null, ): ServerInfo { return { + edition: "oss", version: { tag: `v0.${major}.0`, major, patch: 0 }, date: null, hash: null, diff --git a/packages/cli/src/core/auth/verify.test.ts b/packages/cli/src/core/auth/verify.test.ts index 1fa60d43..b174262e 100644 --- a/packages/cli/src/core/auth/verify.test.ts +++ b/packages/cli/src/core/auth/verify.test.ts @@ -83,6 +83,7 @@ describe("verifyAndProbe", () => { user: { id: 1, name: "Admin", isAdmin: true }, server: { version: { tag: "v0.58.0", major: 58, patch: 0 }, + edition: "oss", date: null, hash: null, tokenFeatures: { transforms: true }, diff --git a/packages/cli/src/core/config.test.ts b/packages/cli/src/core/config.test.ts index 73c441d4..42c54984 100644 --- a/packages/cli/src/core/config.test.ts +++ b/packages/cli/src/core/config.test.ts @@ -251,6 +251,7 @@ describe("resolveConfig", () => { await writeProbeResult("recovers", { user: { id: 1, name: "Tester", isAdmin: true }, server: { + edition: "oss", version: { tag: "v0.58.7", major: 58, patch: 7 }, date: null, hash: null, diff --git a/packages/cli/src/core/skills.test.ts b/packages/cli/src/core/skills.test.ts index 14960661..d0b734ec 100644 --- a/packages/cli/src/core/skills.test.ts +++ b/packages/cli/src/core/skills.test.ts @@ -29,6 +29,7 @@ function profileAt( tokenFeatures: ServerInfo["tokenFeatures"] = null, ): ServerProfile { return createServerProfile({ + edition: "oss", version: { tag: `v0.${major}.0`, major, patch: 0 }, date: null, hash: null, diff --git a/packages/cli/src/output/error.test.ts b/packages/cli/src/output/error.test.ts index b6dbdc68..9ed39483 100644 --- a/packages/cli/src/output/error.test.ts +++ b/packages/cli/src/output/error.test.ts @@ -146,6 +146,7 @@ describe("reportError", () => { const failure = checkFeatures( ["transformJobActivation"], createServerProfile({ + edition: "oss", version: { tag: "v0.58.0", major: 58, patch: 0 }, date: null, hash: null, @@ -166,6 +167,7 @@ describe("reportError", () => { const failure = checkFeatures( ["library"], createServerProfile({ + edition: "oss", version: { tag: "v0.61.0", major: 61, patch: 0 }, date: null, hash: null, @@ -184,6 +186,7 @@ describe("reportError", () => { const failure = checkFeatures( ["transformJobActivation"], createServerProfile({ + edition: "oss", version: { tag: "v0.58.0", major: 58, patch: 0 }, date: null, hash: null, diff --git a/packages/client/README.md b/packages/client/README.md index 3363d925..8a3923a4 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -13,10 +13,7 @@ can load and declarations `moduleResolution: node16` can follow, with no `@types ## Dependency budget -Runtime dependencies are `zod` and `semver`. `zod` is a **peer** dependency: every schema on this -surface is a zod schema, so the consumer's zod is part of the contract and a resolver must be told to -reconcile the two rather than left free to add a second copy. A production module under `src/` imports -nothing beyond those two and `node:` builtins; test files may reach the root devDependencies. +The one runtime dependency is `zod`, and it is a **peer** dependency: every schema on this surface is a zod schema, so the consumer's zod is part of the contract and a resolver must be told to reconcile the two rather than left free to add a second copy. A production module under `src/` imports nothing beyond it and `node:` builtins; test files may reach the root devDependencies. The package is also free of process I/O: it does not touch `process` at all — no filesystem access, no subprocesses, no writes to the process streams, no `process.exit`, no signal handlers, no @@ -255,7 +252,7 @@ The client registers no signal handler and reads no process state, so cancellati `AbortSignal` the host owns. Three places take one and they compose: `ClientOptions.signal` cancels every request a client makes, `RequestOptions.signal` cancels a single request, and `PollOptions.signal` stops a wait loop. Each is folded together with the request timeout, so whichever -fires first ends the work. +fires first ends the work. A method's first call may also wait on the client's one shared server probe; the method's `signal` ends that caller's wait, while the probe itself belongs to the client and is cancelled only by `ClientOptions.signal`, so a later call still finds it settled. ```ts import { createClient, isTransformRunTerminal, pollUntil } from "@metabase/client"; @@ -410,7 +407,7 @@ revocation endpoint. Types: `Credential` (the `ApiKeyCredential | OAuthCredentia ### Version and capabilities -`probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo` — the parsed version tag, the build date and hash, and the token-feature map, each `null` when the server does not report it. `ParsedVersion` is the `{ tag, major, patch }` schema a probed version tag parses to; `editionFromTag(tag)` reads the `Edition` (`"oss"` | `"ee"`) Metabase stamps into the tag's semver major, `null` when the tag carries none. +`probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo` — the parsed version tag, the edition the tag stamps, the build date and hash, and the token-feature map, each `null` when the server does not report it. `ParsedVersion` is the `{ tag, major, patch }` schema a probed version tag parses to; `editionFromTag(tag)` reads the `Edition` (`"oss"` | `"ee"`) Metabase stamps into the tag's leading number (`v0.` / `v1.`, a hotfix's fourth number and a `-SNAPSHOT` suffix included), `null` when the tag carries none. `createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` at or below `KNOWN_RANGE.max` (`KNOWN_RANGE` is `{ min, max }`, the majors the client is built against; an older server keeps its real major and is refused feature by feature rather than flagged), `"newer-than-known"` above it, `"unknown"` when the tag did not parse. A newer or unparseable server is evaluated as `KNOWN_RANGE.max + 1`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile passed as `ClientOptions.server`, or probes once on first call and shares the result; `getServerTag` defaults to the tag of whichever profile the client holds — the one passed in, or the one a probe has settled on — so a shape error names the server whenever the client has met it. There is no option to assume a version. diff --git a/packages/client/package.json b/packages/client/package.json index d500b2d3..bd6c0b34 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -56,9 +56,6 @@ "build": "tsdown", "prepack": "tsdown" }, - "dependencies": { - "semver": "^7.8.0" - }, "peerDependencies": { "zod": "^4.0.0" }, diff --git a/packages/client/src/architecture.test.ts b/packages/client/src/architecture.test.ts index be86df2e..dc746ab5 100644 --- a/packages/client/src/architecture.test.ts +++ b/packages/client/src/architecture.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; const SRC_ROOT = resolve(fileURLToPath(import.meta.url), ".."); -const DEPENDENCY_BUDGET = ["zod", "semver"]; +const DEPENDENCY_BUDGET = ["zod"]; interface SourceFile { relPath: string; @@ -188,7 +188,7 @@ describe("layering policy", () => { }); } - it("imports nothing beyond zod, semver and node: builtins", () => { + it("imports nothing beyond zod and node: builtins", () => { const offenders = files.flatMap((file) => bareSpecifiers(file.content) .filter((specifier) => !withinBudget(specifier)) diff --git a/packages/client/src/http/transport.test.ts b/packages/client/src/http/transport.test.ts index 7d5c1449..f5f4b385 100644 --- a/packages/client/src/http/transport.test.ts +++ b/packages/client/src/http/transport.test.ts @@ -13,6 +13,7 @@ import { type Transport, } from "./transport"; import { HttpError } from "./errors"; +import { deferred } from "../testing/deferred"; import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-capture"; import { CapabilityError } from "../version/preflight-error"; import { SessionProperties } from "../domain/session-properties"; @@ -399,6 +400,7 @@ describe("createTransport.server", () => { "token-features": { library: true }, }; const PROBED_PROFILE = createServerProfile({ + edition: "ee", version: { tag: "v1.61.2", major: 61, patch: 2 }, date: "2026-05-19", hash: "0c64e27", @@ -476,6 +478,29 @@ describe("createTransport.server", () => { expect(error.message).toBe("operator interrupt"); }); + it("ends one caller's wait on its own signal while the shared probe settles for the rest", async () => { + const probeResponse = deferred(); + const fakeFetch = captureFetch([() => probeResponse.promise]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + const controller = new AbortController(); + + const abandoned = client.server({ signal: controller.signal }); + const patient = client.server(); + controller.abort(new Error("caller moved on")); + + const error = await abandoned.catch((caught: unknown) => caught); + assert(error instanceof AbortError, "expected AbortError"); + expect(error.message).toBe("caller moved on"); + + probeResponse.resolve(jsonResponse(PROBE_BODY)); + expect(await patient).toEqual(PROBED_PROFILE); + expect(await client.server()).toBe(await patient); + expect(fakeFetch.calls).toHaveLength(1); + }); + it("names the supplied profile's version in a shape error without a getServerTag", async () => { const body = { id: "not-a-number", email: "a@b.com" }; const expectedIssues = PingResponse.safeParse(body).error?.issues; @@ -569,6 +594,7 @@ describe("createTransport.server", () => { userAgent: TEST_USER_AGENT, fetchImpl: fakeFetch.fetch, server: createServerProfile({ + edition: "oss", version: { tag: `v0.${beyond}.0`, major: beyond, patch: 0 }, date: null, hash: null, @@ -592,6 +618,7 @@ describe("createTransport.server", () => { describe("createTransport.require", () => { const OSS_58 = createServerProfile({ + edition: "oss", version: { tag: "v0.58.0", major: 58, patch: 0 }, date: null, hash: null, @@ -636,6 +663,22 @@ describe("createTransport.require", () => { expect(fakeFetch.calls).toEqual([]); }); + it("refuses with an already-aborted signal before probing for a gated method", async () => { + const fakeFetch = captureFetch([]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + }); + + const error = await client + .require("measure.list", { signal: AbortSignal.abort(new Error("already gone")) }) + .catch((caught: unknown) => caught); + + assert(error instanceof AbortError, "expected AbortError"); + expect(error.message).toBe("already gone"); + expect(fakeFetch.calls).toEqual([]); + }); + it("never probes for a method that needs nothing", async () => { const fakeFetch = captureFetch([]); const client = createTransport(CONFIG, { diff --git a/packages/client/src/http/transport.ts b/packages/client/src/http/transport.ts index 197389ac..57d67ff4 100644 --- a/packages/client/src/http/transport.ts +++ b/packages/client/src/http/transport.ts @@ -2,7 +2,7 @@ import type { ZodType } from "zod"; import { errorMessage, NetworkError, TimeoutError } from "../errors"; import { JSON_CONTENT_TYPE } from "../json"; -import { combineAborts, throwIfAborted } from "../signal"; +import { combineAborts, throwIfAborted, untilAborted } from "../signal"; import { normalizeUrl } from "../url"; import { CapabilityError } from "../version/preflight-error"; import { probeServer } from "../version/probe"; @@ -63,13 +63,16 @@ export interface Transport { requestRaw(path: string, opts?: TransportRequestOptions): Promise; requestStream(path: string, opts?: TransportRequestOptions): Promise>; // The profile handed in at construction, or else the one the first call probes and every later - // call shares. - server(): Promise; + // call shares. `signal` ends this caller's wait; the probe itself is the client's and is cancelled + // only by the client's own signal, so a later call still finds it settled. + server(options?: WaitOptions): Promise; // Throws `CapabilityError` when the server lacks a feature the method needs, before any request // leaves. A method that needs nothing resolves without consulting the server. - require(key: MethodKey): Promise; + require(key: MethodKey, options?: WaitOptions): Promise; } +export type WaitOptions = Pick; + export interface ClientCredentials { url: string; credential: Credential; @@ -297,7 +300,8 @@ export function createTransport(config: ClientCredentials, options: ClientOption // A failed probe is not memoized: the next call asks again rather than replaying one transient // failure for the life of the client. - function server(): Promise { + async function server(wait: WaitOptions = {}): Promise { + throwIfAborted(wait.signal); if (serverProfile === null) { const probe = probeServer(transport).then((info) => { settledProfile = createServerProfile(info); @@ -308,16 +312,16 @@ export function createTransport(config: ClientCredentials, options: ClientOption }); serverProfile = probe; } - return serverProfile; + return untilAborted(serverProfile, wait.signal); } const enforceRequirements = options.enforceRequirements ?? true; - async function requireFeatures(key: MethodKey): Promise { + async function requireFeatures(key: MethodKey, wait: WaitOptions = {}): Promise { if (!enforceRequirements || methodRequirements(key).length === 0) { return; } - const failure = checkRequirements(key, await server()); + const failure = checkRequirements(key, await server(wait)); if (failure !== null) { throw new CapabilityError(failure); } diff --git a/packages/client/src/resources/card.ts b/packages/client/src/resources/card.ts index a3f4c3dc..c03cbe10 100644 --- a/packages/client/src/resources/card.ts +++ b/packages/client/src/resources/card.ts @@ -36,7 +36,7 @@ export function cardResource(transport: Transport) { params: CardListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("card.list"); + await transport.require("card.list", options); const data = await transport.requestParsed(CardApiList, "/api/card", { ...options, query: { f: params.f, model_id: params.model_id }, @@ -46,13 +46,13 @@ export function cardResource(transport: Transport) { /** Get one card by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("card.get"); + await transport.require("card.get", options); return transport.requestParsed(Card, `/api/card/${id}`, { ...options }); } /** Create a card — a question, a model, or a metric — from a full card body. */ async function create(params: CardCreateInput, options: RequestOptions = {}): Promise { - await transport.require("card.create"); + await transport.require("card.create", options); return transport.requestParsed(Card, "/api/card", { ...options, method: "POST", @@ -66,7 +66,7 @@ export function cardResource(transport: Transport) { params: CardUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("card.update"); + await transport.require("card.update", options); return transport.requestParsed(Card, `/api/card/${id}`, { ...options, method: "PUT", @@ -76,7 +76,7 @@ export function cardResource(transport: Transport) { /** Archive (soft-delete) a card by id. Metabase models this as an update, not its own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("card.archive"); + await transport.require("card.archive", options); return update(id, { archived: true }, options); } @@ -86,7 +86,7 @@ export function cardResource(transport: Transport) { params: CardQueryParams, options: RequestOptions = {}, ): Promise { - await transport.require("card.query"); + await transport.require("card.query", options); return transport.requestParsed(CardQueryResult, `/api/card/${id}/query`, { ...options, method: "POST", @@ -104,7 +104,7 @@ export function cardResource(transport: Transport) { params: CardExportParams, options: RequestOptions = {}, ): Promise> { - await transport.require("card.exportQuery"); + await transport.require("card.exportQuery", options); const body = new URLSearchParams({ parameters: JSON.stringify(params.parameters), format_rows: String(params.format_rows), diff --git a/packages/client/src/resources/collection.ts b/packages/client/src/resources/collection.ts index adcfb265..63d4ed95 100644 --- a/packages/client/src/resources/collection.ts +++ b/packages/client/src/resources/collection.ts @@ -74,7 +74,7 @@ export function collectionResource(transport: Transport) { params: CollectionListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("collection.list"); + await transport.require("collection.list", options); const data = await transport.requestParsed(CollectionApiList, "/api/collection", { ...options, query: COLLECTION_LIST_QUERY[params.filter ?? DEFAULT_LIST_FILTER], @@ -88,14 +88,14 @@ export function collectionResource(transport: Transport) { * caller resolves both. */ async function listWithLibrary(options: RequestOptions = {}): Promise> { - await transport.require("collection.listWithLibrary"); + await transport.require("collection.listWithLibrary", options); const data = await listCollectionsWithLibrary(transport, Collection, options); return { data, total: null }; } /** Get one collection by id, entity id, or the `root`/`trash` alias. */ async function get(ref: CollectionId, options: RequestOptions = {}): Promise { - await transport.require("collection.get"); + await transport.require("collection.get", options); return transport.requestParsed(Collection, `/api/collection/${refPath(ref)}`, { ...options }); } @@ -104,7 +104,7 @@ export function collectionResource(transport: Transport) { params: CollectionCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("collection.create"); + await transport.require("collection.create", options); return transport.requestParsed(Collection, "/api/collection", { ...options, method: "POST", @@ -118,7 +118,7 @@ export function collectionResource(transport: Transport) { params: CollectionUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("collection.update"); + await transport.require("collection.update", options); return transport.requestParsed(Collection, `/api/collection/${refPath(ref)}`, { ...options, method: "PUT", @@ -128,7 +128,7 @@ export function collectionResource(transport: Transport) { /** Archive (soft-delete) a collection. Metabase models this as an update, not its own endpoint. */ async function archive(ref: CollectionId, options: RequestOptions = {}): Promise { - await transport.require("collection.archive"); + await transport.require("collection.archive", options); return update(ref, { archived: true }, options); } @@ -141,7 +141,7 @@ export function collectionResource(transport: Transport) { params: CollectionItemListParams = {}, options: CollectionItemPageOptions = {}, ): AsyncIterable> { - await transport.require("collection.itemPages"); + await transport.require("collection.itemPages", options); yield* paginatePages(transport, `/api/collection/${refPath(ref)}/items`, CollectionItem, { query: { models: params.models, @@ -157,7 +157,7 @@ export function collectionResource(transport: Transport) { /** Fetch the collection hierarchy as a forest of nested nodes. */ async function tree(options: RequestOptions = {}): Promise> { - await transport.require("collection.tree"); + await transport.require("collection.tree", options); const data = await transport.requestParsed(CollectionTreeApiList, "/api/collection/tree", { ...options, }); diff --git a/packages/client/src/resources/content-translation.test.ts b/packages/client/src/resources/content-translation.test.ts index f7ce8784..92d2099a 100644 --- a/packages/client/src/resources/content-translation.test.ts +++ b/packages/client/src/resources/content-translation.test.ts @@ -34,6 +34,7 @@ const JSON_READ_HEADERS = { // The least server that answers this resource, so a method asking for more than the resource's // own feature is refused here before it reaches the scripted wire. const SERVER = createServerProfile({ + edition: "ee", version: { tag: "v1.58.0", major: 58, patch: 0 }, date: null, hash: null, diff --git a/packages/client/src/resources/content-translation.ts b/packages/client/src/resources/content-translation.ts index 2b714d9c..cfde836b 100644 --- a/packages/client/src/resources/content-translation.ts +++ b/packages/client/src/resources/content-translation.ts @@ -8,7 +8,7 @@ const CONTENT_TRANSLATION_PATH = "/api/ee/content-translation"; export function contentTranslationResource(transport: Transport) { /** Download the complete content translation dictionary as CSV. */ async function download(options: RequestOptions = {}): Promise> { - await transport.require("contentTranslation.download"); + await transport.require("contentTranslation.download", options); return transport.requestStream(`${CONTENT_TRANSLATION_PATH}/csv`, options); } @@ -17,7 +17,7 @@ export function contentTranslationResource(transport: Transport) { file: CsvFile, options: RequestOptions = {}, ): Promise { - await transport.require("contentTranslation.upload"); + await transport.require("contentTranslation.upload", options); const form = buildCsvFormData(file); return transport.requestParsed( ContentTranslationUploadResult, diff --git a/packages/client/src/resources/dashboard.ts b/packages/client/src/resources/dashboard.ts index c3c7ca37..ca496fe2 100644 --- a/packages/client/src/resources/dashboard.ts +++ b/packages/client/src/resources/dashboard.ts @@ -75,7 +75,7 @@ export function dashboardResource(transport: Transport) { params: DashboardListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("dashboard.list"); + await transport.require("dashboard.list", options); const data = await transport.requestParsed(DashboardApiList, "/api/dashboard", { ...options, query: { f: params.f }, @@ -85,7 +85,7 @@ export function dashboardResource(transport: Transport) { /** Get one dashboard by id, with its dashcards, tabs and parameters. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("dashboard.get"); + await transport.require("dashboard.get", options); return transport.requestParsed(DashboardDetail, `/api/dashboard/${id}`, { ...options }); } @@ -97,7 +97,7 @@ export function dashboardResource(transport: Transport) { params: DashboardCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.create"); + await transport.require("dashboard.create", options); return transport.requestParsed(Dashboard, "/api/dashboard", { ...options, method: "POST", @@ -114,7 +114,7 @@ export function dashboardResource(transport: Transport) { params: DashboardUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.update"); + await transport.require("dashboard.update", options); return transport.requestParsed(DashboardDetail, `/api/dashboard/${id}`, { ...options, method: "PUT", @@ -128,7 +128,7 @@ export function dashboardResource(transport: Transport) { * an archive is a state change on the dashboard itself, and its dashcards are not part of it. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("dashboard.archive"); + await transport.require("dashboard.archive", options); return transport.requestParsed(Dashboard, `/api/dashboard/${id}`, { ...options, method: "PUT", @@ -147,7 +147,7 @@ export function dashboardResource(transport: Transport) { params: DashcardPatchInput, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.updateDashcard"); + await transport.require("dashboard.updateDashcard", options); const dashboard = await get(dashboardId, options); const target = dashboard.dashcards.find((dashcard) => dashcard.id === dashcardId); if (target === undefined) { @@ -177,7 +177,7 @@ export function dashboardResource(transport: Transport) { dashcards: ReadonlyArray | null | undefined, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.checkCardReferences"); + await transport.require("dashboard.checkCardReferences", options); const grouped = groupByCardId(collectCardReferences(dashcards)); const checks = await Promise.all( Array.from(grouped.entries()).map( @@ -227,7 +227,7 @@ export function dashboardResource(transport: Transport) { parameterId: string, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.parameterValues"); + await transport.require("dashboard.parameterValues", options); return transport.requestParsed( ParameterValues, `/api/dashboard/${dashboardId}/params/${encodeURIComponent(parameterId)}/values`, @@ -245,7 +245,7 @@ export function dashboardResource(transport: Transport) { query: string, options: RequestOptions = {}, ): Promise { - await transport.require("dashboard.searchParameterValues"); + await transport.require("dashboard.searchParameterValues", options); const parameter = encodeURIComponent(parameterId); return transport.requestParsed( ParameterValues, diff --git a/packages/client/src/resources/database.ts b/packages/client/src/resources/database.ts index 2c54a0f0..1c17eec6 100644 --- a/packages/client/src/resources/database.ts +++ b/packages/client/src/resources/database.ts @@ -52,7 +52,7 @@ export function databaseResource(transport: Transport) { params: DatabaseListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("database.list"); + await transport.require("database.list", options); const response = await transport.requestParsed(DatabaseApiList, "/api/database", { ...options, query: { include: params.include, saved: params.saved }, @@ -66,7 +66,7 @@ export function databaseResource(transport: Transport) { params: DatabaseGetParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("database.get"); + await transport.require("database.get", options); return transport.requestParsed(Database, `/api/database/${id}`, { ...options, query: { include: params.include }, @@ -75,7 +75,7 @@ export function databaseResource(transport: Transport) { /** List the schema names in a database. */ async function schemas(id: number, options: RequestOptions = {}): Promise> { - await transport.require("database.schemas"); + await transport.require("database.schemas", options); const data = await transport.requestParsed( DatabaseApiSchemaList, `/api/database/${id}/schemas`, @@ -90,7 +90,7 @@ export function databaseResource(transport: Transport) { schema: string, options: RequestOptions = {}, ): Promise> { - await transport.require("database.schemaTables"); + await transport.require("database.schemaTables", options); const data = await transport.requestParsed( DatabaseApiSchemaTableList, `/api/database/${id}/schema/${encodeURIComponent(schema)}`, @@ -108,7 +108,7 @@ export function databaseResource(transport: Transport) { params: DatabaseSyncSchemaParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("database.syncSchema"); + await transport.require("database.syncSchema", options); const ack = await transport.requestParsed(DatabaseTaskAck, `/api/database/${id}/sync_schema`, { ...options, method: "POST", @@ -131,7 +131,7 @@ export function databaseResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise { - await transport.require("database.rescanValues"); + await transport.require("database.rescanValues", options); const ack = await transport.requestParsed( DatabaseTaskAck, `/api/database/${id}/rescan_values`, diff --git a/packages/client/src/resources/dataset.ts b/packages/client/src/resources/dataset.ts index 014b44fd..d2e87130 100644 --- a/packages/client/src/resources/dataset.ts +++ b/packages/client/src/resources/dataset.ts @@ -7,7 +7,7 @@ export function datasetResource(transport: Transport) { * MBQL, or native — rather than a reference to a saved one, so nothing here is a card. */ async function query(body: unknown, options: RequestOptions = {}): Promise { - await transport.require("dataset.query"); + await transport.require("dataset.query", options); return transport.requestParsed(CardQueryResult, "/api/dataset", { ...options, method: "POST", diff --git a/packages/client/src/resources/document.ts b/packages/client/src/resources/document.ts index 296dbd2f..e39b5c94 100644 --- a/packages/client/src/resources/document.ts +++ b/packages/client/src/resources/document.ts @@ -11,7 +11,7 @@ const DocumentApiList = z.object({ items: z.array(Document) }).loose(); export function documentResource(transport: Transport) { /** Get existing documents. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("document.list"); + await transport.require("document.list", options); const response = await transport.requestParsed(DocumentApiList, "/api/document", { ...options, }); @@ -20,7 +20,7 @@ export function documentResource(transport: Transport) { /** Return an existing document by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("document.get"); + await transport.require("document.get", options); return transport.requestParsed(Document, `/api/document/${id}`, { ...options }); } @@ -29,7 +29,7 @@ export function documentResource(transport: Transport) { params: DocumentCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("document.create"); + await transport.require("document.create", options); return transport.requestParsed(Document, "/api/document", { ...options, method: "POST", @@ -43,7 +43,7 @@ export function documentResource(transport: Transport) { params: DocumentUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("document.update"); + await transport.require("document.update", options); return transport.requestParsed(Document, `/api/document/${id}`, { ...options, method: "PUT", @@ -53,7 +53,7 @@ export function documentResource(transport: Transport) { /** Archive (soft-delete) a document by id. Metabase models this as an update, not its own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("document.archive"); + await transport.require("document.archive", options); return update(id, { archived: true }, options); } diff --git a/packages/client/src/resources/eid-translation.ts b/packages/client/src/resources/eid-translation.ts index 3b7eda8f..5f5465f2 100644 --- a/packages/client/src/resources/eid-translation.ts +++ b/packages/client/src/resources/eid-translation.ts @@ -11,7 +11,7 @@ export function eidTranslationResource(transport: Transport) { params: EidTranslateInput, options: RequestOptions = {}, ): Promise { - await transport.require("eidTranslation.translate"); + await transport.require("eidTranslation.translate", options); return transport.requestParsed(EidTranslateResult, "/api/eid-translation/translate", { ...options, method: "POST", diff --git a/packages/client/src/resources/field.ts b/packages/client/src/resources/field.ts index 70fd7fcd..de0cb82d 100644 --- a/packages/client/src/resources/field.ts +++ b/packages/client/src/resources/field.ts @@ -13,7 +13,7 @@ const FieldApiSummary = z.tuple([ export function fieldResource(transport: Transport) { /** Get one field by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("field.get"); + await transport.require("field.get", options); return transport.requestParsed(Field, `/api/field/${id}`, { ...options }); } @@ -23,7 +23,7 @@ export function fieldResource(transport: Transport) { params: FieldUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("field.update"); + await transport.require("field.update", options); return transport.requestParsed(Field, `/api/field/${id}`, { ...options, method: "PUT", @@ -33,7 +33,7 @@ export function fieldResource(transport: Transport) { /** Get the row count and the distinct-value count for a field. */ async function summary(id: number, options: RequestOptions = {}): Promise { - await transport.require("field.summary"); + await transport.require("field.summary", options); const [[, count], [, distincts]] = await transport.requestParsed( FieldApiSummary, `/api/field/${id}/summary`, @@ -44,7 +44,7 @@ export function fieldResource(transport: Transport) { /** Get the cached distinct values Metabase holds for a field. */ async function values(id: number, options: RequestOptions = {}): Promise { - await transport.require("field.values"); + await transport.require("field.values", options); return transport.requestParsed(FieldValues, `/api/field/${id}/values`, { ...options }); } diff --git a/packages/client/src/resources/git-sync.test.ts b/packages/client/src/resources/git-sync.test.ts index a9744aba..e6a5c03c 100644 --- a/packages/client/src/resources/git-sync.test.ts +++ b/packages/client/src/resources/git-sync.test.ts @@ -60,6 +60,7 @@ const IMMEDIATE_POLL = { intervalMs: 1, timeoutMs: 1_000 }; // The least server that answers this resource, so a method asking for more than the resource's // own feature is refused here before it reaches the scripted wire. const SERVER = createServerProfile({ + edition: "ee", version: { tag: "v1.60.0", major: 60, patch: 0 }, date: null, hash: null, diff --git a/packages/client/src/resources/git-sync.ts b/packages/client/src/resources/git-sync.ts index 9ea86483..b95ab62d 100644 --- a/packages/client/src/resources/git-sync.ts +++ b/packages/client/src/resources/git-sync.ts @@ -100,13 +100,13 @@ export interface SyncCreateBranchParams { export function gitSyncResource(transport: Transport) { /** Get the running or most recently finished sync task, or null when the server has none. */ async function currentTask(options: RequestOptions = {}): Promise { - await transport.require("gitSync.currentTask"); + await transport.require("gitSync.currentTask", options); return fetchOptionalParsed(transport, "/api/ee/remote-sync/current-task", SyncTask, options); } /** Request cancellation of the running sync task, and answer it in the state that left it. */ async function cancelTask(options: RequestOptions = {}): Promise { - await transport.require("gitSync.cancelTask"); + await transport.require("gitSync.cancelTask", options); return transport.requestParsed(SyncTask, "/api/ee/remote-sync/current-task/cancel", { ...options, method: "POST", @@ -115,7 +115,7 @@ export function gitSyncResource(transport: Transport) { /** Whether Metabase holds content changes the remote has not been told about. */ async function isDirty(options: RequestOptions = {}): Promise { - await transport.require("gitSync.isDirty"); + await transport.require("gitSync.isDirty", options); const flag = await transport.requestParsed(SyncDirtyFlag, "/api/ee/remote-sync/is-dirty", { ...options, }); @@ -124,7 +124,7 @@ export function gitSyncResource(transport: Transport) { /** List the objects whose local state differs from the remote. */ async function dirty(options: RequestOptions = {}): Promise> { - await transport.require("gitSync.dirty"); + await transport.require("gitSync.dirty", options); const response = await transport.requestParsed(SyncDirtyList, "/api/ee/remote-sync/dirty", { ...options, }); @@ -139,7 +139,7 @@ export function gitSyncResource(transport: Transport) { params: SyncRemoteChangesParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.hasRemoteChanges"); + await transport.require("gitSync.hasRemoteChanges", options); return transport.requestParsed(SyncRemoteChanges, "/api/ee/remote-sync/has-remote-changes", { ...options, query: { "force-refresh": params["force-refresh"] }, @@ -155,7 +155,7 @@ export function gitSyncResource(transport: Transport) { params: SyncImportParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.import"); + await transport.require("gitSync.import", options); const started = await transport.requestParsed(SyncImportStarted, "/api/ee/remote-sync/import", { ...options, method: "POST", @@ -176,7 +176,7 @@ export function gitSyncResource(transport: Transport) { params: SyncExportParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.export"); + await transport.require("gitSync.export", options); const started = await transport.requestParsed(SyncExportStarted, "/api/ee/remote-sync/export", { ...options, method: "POST", @@ -200,7 +200,7 @@ export function gitSyncResource(transport: Transport) { params: SyncStashParams, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.stash"); + await transport.require("gitSync.stash", options); const started = await transport.requestParsed(SyncStashStarted, "/api/ee/remote-sync/stash", { ...options, method: "POST", @@ -219,7 +219,7 @@ export function gitSyncResource(transport: Transport) { /** List the branches the configured remote carries. */ async function branches(options: RequestOptions = {}): Promise> { - await transport.require("gitSync.branches"); + await transport.require("gitSync.branches", options); const response = await transport.requestParsed(SyncBranchList, "/api/ee/remote-sync/branches", { ...options, }); @@ -231,7 +231,7 @@ export function gitSyncResource(transport: Transport) { params: SyncCreateBranchParams, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.createBranch"); + await transport.require("gitSync.createBranch", options); return transport.requestParsed(SyncBranchCreated, "/api/ee/remote-sync/create-branch", { ...options, method: "POST", @@ -248,7 +248,7 @@ export function gitSyncResource(transport: Transport) { synced: boolean, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.setCollectionSynced"); + await transport.require("gitSync.setCollectionSynced", options); return transport.requestParsed(SyncSettingsUpdateResult, "/api/ee/remote-sync/settings", { ...options, method: "PUT", @@ -260,14 +260,14 @@ export function gitSyncResource(transport: Transport) { async function syncedCollections( options: RequestOptions = {}, ): Promise> { - await transport.require("gitSync.syncedCollections"); + await transport.require("gitSync.syncedCollections", options); const data = await listCollectionsWithLibrary(transport, SyncScopeCollection, options); return { data: data.filter((entry) => entry.is_remote_synced === true), total: null }; } /** The remote's URL, or null when none is configured or the caller may not read it. */ async function remoteUrl(options: RequestOptions = {}): Promise { - await transport.require("gitSync.remoteUrl"); + await transport.require("gitSync.remoteUrl", options); try { const url = await fetchOptionalParsed( transport, @@ -286,7 +286,7 @@ export function gitSyncResource(transport: Transport) { /** The branch git-sync tracks, or null when none is configured or the caller may not read it. */ async function branch(options: RequestOptions = {}): Promise { - await transport.require("gitSync.branch"); + await transport.require("gitSync.branch", options); return fetchOptionalParsed( transport, "/api/setting/remote-sync-branch", @@ -303,7 +303,7 @@ export function gitSyncResource(transport: Transport) { wait: PollOptions, options: RequestOptions = {}, ): Promise { - await transport.require("gitSync.waitForTask"); + await transport.require("gitSync.waitForTask", options); return settle(wait, options); } diff --git a/packages/client/src/resources/library.test.ts b/packages/client/src/resources/library.test.ts index d87ed3b6..a5e65bbc 100644 --- a/packages/client/src/resources/library.test.ts +++ b/packages/client/src/resources/library.test.ts @@ -106,6 +106,7 @@ const READ_COLLECTIONS_CALL = { // The least server that answers this resource, so a method asking for more than the resource's // own feature is refused here before it reaches the scripted wire. const SERVER = createServerProfile({ + edition: "ee", version: { tag: "v1.59.0", major: 59, patch: 0 }, date: null, hash: null, @@ -114,6 +115,7 @@ const SERVER = createServerProfile({ // The first generation whose `effective_children` carry each child's `type`. const TYPED_CHILDREN_SERVER = createServerProfile({ + edition: "ee", version: { tag: "v1.62.0", major: 62, patch: 0 }, date: null, hash: null, diff --git a/packages/client/src/resources/library.ts b/packages/client/src/resources/library.ts index ecf94dad..f0cb36be 100644 --- a/packages/client/src/resources/library.ts +++ b/packages/client/src/resources/library.ts @@ -34,8 +34,8 @@ export interface LibraryPublishParams extends LibraryTableSelectors { export function libraryResource(transport: Transport) { /** Get the Library root and its child collections, or `null` on an instance that has none. */ async function get(options: RequestOptions = {}): Promise { - await transport.require("library.get"); - const { features } = await transport.server(); + await transport.require("library.get", options); + const { features } = await transport.server(options); const wire = await transport.requestParsed(libraryWireSchema(features), LIBRARY_ROOT_PATH, { ...options, }); @@ -63,7 +63,7 @@ export function libraryResource(transport: Transport) { * back from a refetch, which together make this idempotent. */ async function create(options: RequestOptions = {}): Promise { - await transport.require("library.create"); + await transport.require("library.create", options); const existing = await get(options); if (existing !== null) { return existing; @@ -78,7 +78,7 @@ export function libraryResource(transport: Transport) { /** The id of the Library's Data collection, creating the Library first when it does not exist. */ async function ensureDataCollectionId(options: RequestOptions = {}): Promise { - await transport.require("library.ensureDataCollectionId"); + await transport.require("library.ensureDataCollectionId", options); const library = await create(options); const data = library.effective_children.find((child) => child.type === LIBRARY_DATA_TYPE); if (data === undefined) { @@ -98,7 +98,7 @@ export function libraryResource(transport: Transport) { params: LibraryPublishParams, options: RequestOptions = {}, ): Promise { - await transport.require("library.publishTables"); + await transport.require("library.publishTables", options); const response = await transport.requestParsed(PublishTablesResponse, PUBLISH_TABLES_PATH, { ...options, method: "POST", @@ -115,7 +115,7 @@ export function libraryResource(transport: Transport) { params: LibraryTableSelectors, options: RequestOptions = {}, ): Promise { - await transport.require("library.unpublishTables"); + await transport.require("library.unpublishTables", options); await transport.requestRaw(UNPUBLISH_TABLES_PATH, { ...options, method: "POST", diff --git a/packages/client/src/resources/measure.test.ts b/packages/client/src/resources/measure.test.ts index d7419a2c..29bda923 100644 --- a/packages/client/src/resources/measure.test.ts +++ b/packages/client/src/resources/measure.test.ts @@ -39,6 +39,7 @@ const JSON_READ_HEADERS = { // The least server that answers this resource, so a method asking for more than the resource's // own feature is refused here before it reaches the scripted wire. const SERVER = createServerProfile({ + edition: "oss", version: { tag: "v0.59.0", major: 59, patch: 0 }, date: null, hash: null, diff --git a/packages/client/src/resources/measure.ts b/packages/client/src/resources/measure.ts index 1d89be46..d686a838 100644 --- a/packages/client/src/resources/measure.ts +++ b/packages/client/src/resources/measure.ts @@ -15,14 +15,14 @@ export interface MeasureArchiveParams { export function measureResource(transport: Transport) { /** List measures. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("measure.list"); + await transport.require("measure.list", options); const data = await transport.requestParsed(MeasureApiList, "/api/measure", { ...options }); return { data, total: null }; } /** Get one measure by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("measure.get"); + await transport.require("measure.get", options); return transport.requestParsed(Measure, `/api/measure/${id}`, { ...options }); } @@ -31,7 +31,7 @@ export function measureResource(transport: Transport) { params: MeasureCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("measure.create"); + await transport.require("measure.create", options); return transport.requestParsed(Measure, "/api/measure", { ...options, method: "POST", @@ -48,7 +48,7 @@ export function measureResource(transport: Transport) { params: MeasureUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("measure.update"); + await transport.require("measure.update", options); return transport.requestParsed(Measure, `/api/measure/${id}`, { ...options, method: "PUT", @@ -65,7 +65,7 @@ export function measureResource(transport: Transport) { params: MeasureArchiveParams, options: RequestOptions = {}, ): Promise { - await transport.require("measure.archive"); + await transport.require("measure.archive", options); return update(id, { archived: true, revision_message: params.revision_message }, options); } diff --git a/packages/client/src/resources/notification.ts b/packages/client/src/resources/notification.ts index 620f1ee7..897520b8 100644 --- a/packages/client/src/resources/notification.ts +++ b/packages/client/src/resources/notification.ts @@ -76,7 +76,7 @@ export function notificationResource(transport: Transport) { params: NotificationListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("notification.list"); + await transport.require("notification.list", options); const data = await transport.requestParsed(NotificationApiList, "/api/notification", { ...options, query: { @@ -92,7 +92,7 @@ export function notificationResource(transport: Transport) { /** Get one question alert by id, refusing an id that names a system-event notification. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("notification.get"); + await transport.require("notification.get", options); const notification = await transport.requestParsed(Notification, `/api/notification/${id}`, { ...options, }); @@ -104,7 +104,7 @@ export function notificationResource(transport: Transport) { params: NotificationCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("notification.create"); + await transport.require("notification.create", options); return transport.requestParsed(Notification, "/api/notification", { ...options, method: "POST", @@ -118,7 +118,7 @@ export function notificationResource(transport: Transport) { params: NotificationUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("notification.update"); + await transport.require("notification.update", options); const current = await get(id, options); return transport.requestParsed(Notification, `/api/notification/${id}`, { ...options, @@ -129,7 +129,7 @@ export function notificationResource(transport: Transport) { /** Archive a question alert by id, stopping every delivery. Metabase models this as an update. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("notification.archive"); + await transport.require("notification.archive", options); return update(id, { active: false }, options); } @@ -138,7 +138,7 @@ export function notificationResource(transport: Transport) { * body. */ async function send(id: number, options: RequestOptions = {}): Promise { - await transport.require("notification.send"); + await transport.require("notification.send", options); await get(id, options); await transport.requestRaw(`/api/notification/${id}/send`, { ...options, diff --git a/packages/client/src/resources/pulse.ts b/packages/client/src/resources/pulse.ts index 962fbb9b..fde3445c 100644 --- a/packages/client/src/resources/pulse.ts +++ b/packages/client/src/resources/pulse.ts @@ -34,7 +34,7 @@ export function pulseResource(transport: Transport) { params: PulseListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("pulse.list"); + await transport.require("pulse.list", options); const data = await transport.requestParsed(PulseApiList, "/api/pulse", { ...options, query: { dashboard_id: params.dashboard_id, archived: params.archived }, @@ -44,13 +44,13 @@ export function pulseResource(transport: Transport) { /** Get one dashboard subscription by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("pulse.get"); + await transport.require("pulse.get", options); return transport.requestParsed(Pulse, `/api/pulse/${id}`, { ...options }); } /** Create a dashboard subscription — its cards and its delivery channels — from a full body. */ async function create(params: PulseCreateInput, options: RequestOptions = {}): Promise { - await transport.require("pulse.create"); + await transport.require("pulse.create", options); return transport.requestParsed(Pulse, "/api/pulse", { ...options, method: "POST", @@ -64,7 +64,7 @@ export function pulseResource(transport: Transport) { params: PulseUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("pulse.update"); + await transport.require("pulse.update", options); const current = await get(id, options); return transport.requestParsed(Pulse, `/api/pulse/${id}`, { ...options, @@ -78,7 +78,7 @@ export function pulseResource(transport: Transport) { * update, and disables each of the subscription's channels as a side effect. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("pulse.archive"); + await transport.require("pulse.archive", options); return update(id, { archived: true }, options); } diff --git a/packages/client/src/resources/search.ts b/packages/client/src/resources/search.ts index dfb78ea7..bf16bb74 100644 --- a/packages/client/src/resources/search.ts +++ b/packages/client/src/resources/search.ts @@ -39,7 +39,7 @@ export function searchResource(transport: Transport) { params: SearchParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("search.query"); + await transport.require("search.query", options); const response = await transport.requestParsed(SearchApiResponse, "/api/search", { ...options, query: { diff --git a/packages/client/src/resources/segment.ts b/packages/client/src/resources/segment.ts index 6f185631..adf3899e 100644 --- a/packages/client/src/resources/segment.ts +++ b/packages/client/src/resources/segment.ts @@ -16,14 +16,14 @@ export interface SegmentArchiveParams { export function segmentResource(transport: Transport) { /** List every segment the caller can see. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("segment.list"); + await transport.require("segment.list", options); const data = await transport.requestParsed(SegmentApiList, "/api/segment", { ...options }); return { data, total: null }; } /** Get one segment by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("segment.get"); + await transport.require("segment.get", options); return transport.requestParsed(Segment, `/api/segment/${id}`, { ...options }); } @@ -32,7 +32,7 @@ export function segmentResource(transport: Transport) { params: SegmentCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("segment.create"); + await transport.require("segment.create", options); return transport.requestParsed(Segment, "/api/segment", { ...options, method: "POST", @@ -49,7 +49,7 @@ export function segmentResource(transport: Transport) { params: SegmentUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("segment.update"); + await transport.require("segment.update", options); return transport.requestParsed(Segment, `/api/segment/${id}`, { ...options, method: "PUT", @@ -66,7 +66,7 @@ export function segmentResource(transport: Transport) { params: SegmentArchiveParams, options: RequestOptions = {}, ): Promise { - await transport.require("segment.archive"); + await transport.require("segment.archive", options); return update(id, { archived: true, revision_message: params.revision_message }, options); } diff --git a/packages/client/src/resources/setting.ts b/packages/client/src/resources/setting.ts index 5529bd40..68224fea 100644 --- a/packages/client/src/resources/setting.ts +++ b/packages/client/src/resources/setting.ts @@ -24,7 +24,7 @@ function rethrowSettingError(error: unknown, key: string): never { export function settingResource(transport: Transport) { /** Get all settings and their values, with descriptions and environment-variable names. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("setting.list"); + await transport.require("setting.list", options); const data = await transport.requestParsed(SettingApiList, "/api/setting", { ...options }); return { data, total: null }; } @@ -34,7 +34,7 @@ export function settingResource(transport: Transport) { * reaches the caller as `null`; a string-typed setting answers bare text rather than JSON. */ async function get(key: string, options: RequestOptions = {}): Promise { - await transport.require("setting.get"); + await transport.require("setting.get", options); const path = `/api/setting/${encodeURIComponent(key)}`; return fetchOptionalParsed(transport, path, z.unknown(), options).catch((error: unknown) => rethrowSettingError(error, key), @@ -43,7 +43,7 @@ export function settingResource(transport: Transport) { /** Set a single setting's value by key. The endpoint answers 204 with no body. */ async function set(key: string, value: unknown, options: RequestOptions = {}): Promise { - await transport.require("setting.set"); + await transport.require("setting.set", options); await transport .requestRaw(`/api/setting/${encodeURIComponent(key)}`, { ...options, diff --git a/packages/client/src/resources/setup.ts b/packages/client/src/resources/setup.ts index 46051a5b..b4d34b19 100644 --- a/packages/client/src/resources/setup.ts +++ b/packages/client/src/resources/setup.ts @@ -7,7 +7,7 @@ export function setupResource(transport: Transport) { * applying the instance preferences. The setup token is single-use. */ async function create(params: SetupInput, options: RequestOptions = {}): Promise { - await transport.require("setup.create"); + await transport.require("setup.create", options); return transport.requestParsed(SetupResult, "/api/setup", { ...options, method: "POST", diff --git a/packages/client/src/resources/snippet.ts b/packages/client/src/resources/snippet.ts index 7251bef6..a1da6f1c 100644 --- a/packages/client/src/resources/snippet.ts +++ b/packages/client/src/resources/snippet.ts @@ -18,7 +18,7 @@ export function snippetResource(transport: Transport) { params: SnippetListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("snippet.list"); + await transport.require("snippet.list", options); const data = await transport.requestParsed(SnippetApiList, "/api/native-query-snippet", { ...options, query: { archived: params.archived }, @@ -28,7 +28,7 @@ export function snippetResource(transport: Transport) { /** Get one native query snippet by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("snippet.get"); + await transport.require("snippet.get", options); return transport.requestParsed(Snippet, `/api/native-query-snippet/${id}`, { ...options }); } @@ -37,7 +37,7 @@ export function snippetResource(transport: Transport) { params: SnippetCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("snippet.create"); + await transport.require("snippet.create", options); return transport.requestParsed(Snippet, "/api/native-query-snippet", { ...options, method: "POST", @@ -51,7 +51,7 @@ export function snippetResource(transport: Transport) { params: SnippetUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("snippet.update"); + await transport.require("snippet.update", options); return transport.requestParsed(Snippet, `/api/native-query-snippet/${id}`, { ...options, method: "PUT", @@ -64,7 +64,7 @@ export function snippetResource(transport: Transport) { * own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("snippet.archive"); + await transport.require("snippet.archive", options); return update(id, { archived: true }, options); } diff --git a/packages/client/src/resources/table.ts b/packages/client/src/resources/table.ts index 6696a4b4..4740614e 100644 --- a/packages/client/src/resources/table.ts +++ b/packages/client/src/resources/table.ts @@ -19,14 +19,14 @@ const UPLOAD_UPDATE_PATHS: Record = { export function tableResource(transport: Transport) { /** List every table the caller can see, across all databases. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("table.list"); + await transport.require("table.list", options); const data = await transport.requestParsed(TableApiList, "/api/table", { ...options }); return { data, total: null }; } /** Get one table by id, without its fields. */ async function get(id: number, options: RequestOptions = {}): Promise
{ - await transport.require("table.get"); + await transport.require("table.get", options); return transport.requestParsed(Table, `/api/table/${id}`, { ...options }); } @@ -36,7 +36,7 @@ export function tableResource(transport: Transport) { params: TableUpdateInput, options: RequestOptions = {}, ): Promise
{ - await transport.require("table.update"); + await transport.require("table.update", options); return transport.requestParsed(Table, `/api/table/${id}`, { ...options, method: "PUT", @@ -49,7 +49,7 @@ export function tableResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise { - await transport.require("table.queryMetadata"); + await transport.require("table.queryMetadata", options); return transport.requestParsed(TableQueryMetadata, `/api/table/${id}/query_metadata`, { ...options, }); @@ -81,7 +81,7 @@ export function tableResource(transport: Transport) { file: CsvFile, options: RequestOptions = {}, ): Promise { - await transport.require("table.appendCsv"); + await transport.require("table.appendCsv", options); return updateFromCsv(id, "append", file, options); } @@ -96,7 +96,7 @@ export function tableResource(transport: Transport) { file: CsvFile, options: RequestOptions = {}, ): Promise { - await transport.require("table.replaceCsv"); + await transport.require("table.replaceCsv", options); return updateFromCsv(id, "replace", file, options); } diff --git a/packages/client/src/resources/timeline-event.ts b/packages/client/src/resources/timeline-event.ts index 2f859f55..aaf41ef4 100644 --- a/packages/client/src/resources/timeline-event.ts +++ b/packages/client/src/resources/timeline-event.ts @@ -8,7 +8,7 @@ import type { RequestOptions, Transport } from "../http/transport"; export function timelineEventResource(transport: Transport) { /** Get one timeline event by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("timelineEvent.get"); + await transport.require("timelineEvent.get", options); return transport.requestParsed(TimelineEvent, `/api/timeline-event/${id}`, { ...options }); } @@ -17,7 +17,7 @@ export function timelineEventResource(transport: Transport) { params: TimelineEventCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("timelineEvent.create"); + await transport.require("timelineEvent.create", options); return transport.requestParsed(TimelineEvent, "/api/timeline-event", { ...options, method: "POST", @@ -34,7 +34,7 @@ export function timelineEventResource(transport: Transport) { params: TimelineEventUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("timelineEvent.update"); + await transport.require("timelineEvent.update", options); return transport.requestParsed(TimelineEvent, `/api/timeline-event/${id}`, { ...options, method: "PUT", @@ -47,13 +47,13 @@ export function timelineEventResource(transport: Transport) { * endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("timelineEvent.archive"); + await transport.require("timelineEvent.archive", options); return update(id, { archived: true }, options); } /** Permanently delete a timeline event by id. The server answers with no body. */ async function remove(id: number, options: RequestOptions = {}): Promise { - await transport.require("timelineEvent.delete"); + await transport.require("timelineEvent.delete", options); await transport.requestRaw(`/api/timeline-event/${id}`, { ...options, method: "DELETE", diff --git a/packages/client/src/resources/timeline.ts b/packages/client/src/resources/timeline.ts index fb9e63ef..c5e991cb 100644 --- a/packages/client/src/resources/timeline.ts +++ b/packages/client/src/resources/timeline.ts @@ -31,7 +31,7 @@ export function timelineResource(transport: Transport) { params: TimelineListParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("timeline.list"); + await transport.require("timeline.list", options); const data = await transport.requestParsed(TimelineApiList, "/api/timeline", { ...options, query: { archived: params.archived }, @@ -41,7 +41,7 @@ export function timelineResource(transport: Transport) { /** Get one timeline by id, without its events. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("timeline.get"); + await transport.require("timeline.get", options); return transport.requestParsed(Timeline, `/api/timeline/${id}`, { ...options }); } @@ -54,7 +54,7 @@ export function timelineResource(transport: Transport) { params: TimelineEventsParams = {}, options: RequestOptions = {}, ): Promise> { - await transport.require("timeline.events"); + await transport.require("timeline.events", options); const timeline = await transport.requestParsed(TimelineWithEvents, `/api/timeline/${id}`, { ...options, query: { include: "events", archived: params.archived }, @@ -67,7 +67,7 @@ export function timelineResource(transport: Transport) { params: TimelineCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("timeline.create"); + await transport.require("timeline.create", options); return transport.requestParsed(Timeline, "/api/timeline", { ...options, method: "POST", @@ -84,7 +84,7 @@ export function timelineResource(transport: Transport) { params: TimelineUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("timeline.update"); + await transport.require("timeline.update", options); return transport.requestParsed(Timeline, `/api/timeline/${id}`, { ...options, method: "PUT", @@ -97,13 +97,13 @@ export function timelineResource(transport: Transport) { * not its own endpoint. */ async function archive(id: number, options: RequestOptions = {}): Promise { - await transport.require("timeline.archive"); + await transport.require("timeline.archive", options); return update(id, { archived: true }, options); } /** Permanently delete a timeline and all its events by id. The server answers with no body. */ async function remove(id: number, options: RequestOptions = {}): Promise { - await transport.require("timeline.delete"); + await transport.require("timeline.delete", options); await transport.requestRaw(`/api/timeline/${id}`, { ...options, method: "DELETE", diff --git a/packages/client/src/resources/transform-job.test.ts b/packages/client/src/resources/transform-job.test.ts index dd6aff5f..9a73af4e 100644 --- a/packages/client/src/resources/transform-job.test.ts +++ b/packages/client/src/resources/transform-job.test.ts @@ -68,6 +68,7 @@ const BINARY_READ_HEADERS = { // own feature is refused here before it reaches the scripted wire. It is also of the generation // that answers a job run with an opaque stub id. const SERVER = createServerProfile({ + edition: "oss", version: { tag: "v0.61.0", major: 61, patch: 0 }, date: null, hash: null, @@ -78,6 +79,7 @@ const SERVER = createServerProfile({ // id, or null for no run. const NUMERIC_RUN_ID_MAJOR = KNOWN_RANGE.max + 1; const NUMERIC_RUN_ID_SERVER = createServerProfile({ + edition: "oss", version: { tag: `v0.${NUMERIC_RUN_ID_MAJOR}.0`, major: NUMERIC_RUN_ID_MAJOR, patch: 0 }, date: null, hash: null, diff --git a/packages/client/src/resources/transform-job.ts b/packages/client/src/resources/transform-job.ts index ee54ae82..3fe7cdbd 100644 --- a/packages/client/src/resources/transform-job.ts +++ b/packages/client/src/resources/transform-job.ts @@ -22,8 +22,8 @@ export interface TransformJobRunParams { export function transformJobResource(transport: Transport) { /** List every transform job the caller can see. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("transformJob.list"); - const { features } = await transport.server(); + await transport.require("transformJob.list", options); + const { features } = await transport.server(options); // A bare array rather than a `{ data, total }` envelope, so the server reports no count. const data = await transport.requestParsed( z.array(transformJobSchema(features)), @@ -35,8 +35,8 @@ export function transformJobResource(transport: Transport) { /** Get one transform job by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("transformJob.get"); - const { features } = await transport.server(); + await transport.require("transformJob.get", options); + const { features } = await transport.server(options); return transport.requestParsed(transformJobSchema(features), `/api/transform-job/${id}`, { ...options, }); @@ -47,8 +47,8 @@ export function transformJobResource(transport: Transport) { params: TransformJobCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformJob.create"); - const { features } = await transport.server(); + await transport.require("transformJob.create", options); + const { features } = await transport.server(options); return transport.requestParsed(transformJobSchema(features), "/api/transform-job", { ...options, method: "POST", @@ -62,8 +62,8 @@ export function transformJobResource(transport: Transport) { params: TransformJobUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformJob.update"); - const { features } = await transport.server(); + await transport.require("transformJob.update", options); + const { features } = await transport.server(options); return transport.requestParsed(transformJobSchema(features), `/api/transform-job/${id}`, { ...options, method: "PUT", @@ -73,7 +73,7 @@ export function transformJobResource(transport: Transport) { /** Delete a transform job by id, leaving the transforms it ran untouched. */ async function remove(id: number, options: RequestOptions = {}): Promise { - await transport.require("transformJob.delete"); + await transport.require("transformJob.delete", options); await transport.requestRaw(`/api/transform-job/${id}`, { ...options, method: "DELETE", @@ -90,8 +90,8 @@ export function transformJobResource(transport: Transport) { params: TransformJobRunParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("transformJob.run"); - const { features } = await transport.server(); + await transport.require("transformJob.run", options); + const { features } = await transport.server(options); return transport.requestParsed( transformJobRunResultSchema(features), `/api/transform-job/${id}/run`, @@ -104,8 +104,8 @@ export function transformJobResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise> { - await transport.require("transformJob.transforms"); - const { features } = await transport.server(); + await transport.require("transformJob.transforms", options); + const { features } = await transport.server(options); const data = await transport.requestParsed( z.array(transformRowSchema(features)), `/api/transform-job/${id}/transforms`, @@ -122,7 +122,7 @@ export function transformJobResource(transport: Transport) { active: boolean, options: RequestOptions = {}, ): Promise { - await transport.require("transformJob.setActive"); + await transport.require("transformJob.setActive", options); return transport.requestParsed(TransformJobActiveResult, "/api/transform-job/active", { ...options, method: "PUT", diff --git a/packages/client/src/resources/transform-tag.test.ts b/packages/client/src/resources/transform-tag.test.ts index d06c19d4..a1c2323b 100644 --- a/packages/client/src/resources/transform-tag.test.ts +++ b/packages/client/src/resources/transform-tag.test.ts @@ -44,6 +44,7 @@ const BINARY_READ_HEADERS = { // The least server that answers this resource, so a method asking for more than the resource's // own feature is refused here before it reaches the scripted wire. const SERVER = createServerProfile({ + edition: "oss", version: { tag: "v0.59.0", major: 59, patch: 0 }, date: null, hash: null, diff --git a/packages/client/src/resources/transform-tag.ts b/packages/client/src/resources/transform-tag.ts index b3531560..b73e13fa 100644 --- a/packages/client/src/resources/transform-tag.ts +++ b/packages/client/src/resources/transform-tag.ts @@ -16,7 +16,7 @@ const TransformTagApiList = z.array(TransformTag); export function transformTagResource(transport: Transport) { /** List every transform tag the caller can see, built-in tags included. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("transformTag.list"); + await transport.require("transformTag.list", options); const data = await transport.requestParsed(TransformTagApiList, "/api/transform-tag", { ...options, }); @@ -28,7 +28,7 @@ export function transformTagResource(transport: Transport) { params: TransformTagCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformTag.create"); + await transport.require("transformTag.create", options); return transport.requestParsed(TransformTag, "/api/transform-tag", { ...options, method: "POST", @@ -42,7 +42,7 @@ export function transformTagResource(transport: Transport) { params: TransformTagUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transformTag.update"); + await transport.require("transformTag.update", options); return transport.requestParsed(TransformTag, `/api/transform-tag/${id}`, { ...options, method: "PUT", @@ -52,7 +52,7 @@ export function transformTagResource(transport: Transport) { /** Delete a transform tag by id, detaching it from every transform and job carrying it. */ async function remove(id: number, options: RequestOptions = {}): Promise { - await transport.require("transformTag.delete"); + await transport.require("transformTag.delete", options); await transport.requestRaw(`/api/transform-tag/${id}`, { ...options, method: "DELETE", diff --git a/packages/client/src/resources/transform.test.ts b/packages/client/src/resources/transform.test.ts index 3e0b6b2a..ca3a66f2 100644 --- a/packages/client/src/resources/transform.test.ts +++ b/packages/client/src/resources/transform.test.ts @@ -101,6 +101,7 @@ const EXPIRING_POLL = { intervalMs: 1, timeoutMs: 5 }; // own feature is refused here before it reaches the scripted wire. It is also the generation that // links the output table only on the detail, as a hydrated `table`. const SERVER = createServerProfile({ + edition: "oss", version: { tag: "v0.59.0", major: 59, patch: 0 }, date: null, hash: null, @@ -109,6 +110,7 @@ const SERVER = createServerProfile({ // The first generation carrying `target_table_id` as a column on every transform endpoint. const TABLE_ID_COLUMN_SERVER = createServerProfile({ + edition: "oss", version: { tag: "v0.61.0", major: 61, patch: 0 }, date: null, hash: null, diff --git a/packages/client/src/resources/transform.ts b/packages/client/src/resources/transform.ts index 0c02fd80..46aae0f6 100644 --- a/packages/client/src/resources/transform.ts +++ b/packages/client/src/resources/transform.ts @@ -54,26 +54,26 @@ export function transformResource(transport: Transport) { path: string, opts: TransportRequestOptions, ): Promise> { - const { features } = await transport.server(); + const { features } = await transport.server(opts); const data = await transport.requestParsed(transformListSchema(features), path, opts); return { data, total: null }; } async function requestTransform(path: string, opts: TransportRequestOptions): Promise { - const { features } = await transport.server(); + const { features } = await transport.server(opts); return transport.requestParsed(transformRowSchema(features), path, opts); } /** List every transform the caller can see. */ async function list(options: RequestOptions = {}): Promise> { - await transport.require("transform.list"); + await transport.require("transform.list", options); return requestTransformList("/api/transform", { ...options }); } /** Get one transform by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - await transport.require("transform.get"); - const { features } = await transport.server(); + await transport.require("transform.get", options); + const { features } = await transport.server(options); return transport.requestParsed(transformDetailSchema(features), `/api/transform/${id}`, { ...options, }); @@ -84,7 +84,7 @@ export function transformResource(transport: Transport) { params: TransformCreateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transform.create"); + await transport.require("transform.create", options); return requestTransform("/api/transform", { ...options, method: "POST", body: params }); } @@ -94,13 +94,13 @@ export function transformResource(transport: Transport) { params: TransformUpdateInput, options: RequestOptions = {}, ): Promise { - await transport.require("transform.update"); + await transport.require("transform.update", options); return requestTransform(`/api/transform/${id}`, { ...options, method: "PUT", body: params }); } /** Delete a transform by id, leaving any table it already materialized in place. */ async function remove(id: number, options: RequestOptions = {}): Promise { - await transport.require("transform.delete"); + await transport.require("transform.delete", options); await transport.requestRaw(`/api/transform/${id}`, { ...options, method: "DELETE", @@ -110,7 +110,7 @@ export function transformResource(transport: Transport) { /** Drop a transform's materialized output table, keeping the transform definition. */ async function deleteTable(id: number, options: RequestOptions = {}): Promise { - await transport.require("transform.deleteTable"); + await transport.require("transform.deleteTable", options); await transport.requestRaw(`/api/transform/${id}/table`, { ...options, method: "DELETE", @@ -123,13 +123,13 @@ export function transformResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise> { - await transport.require("transform.dependencies"); + await transport.require("transform.dependencies", options); return requestTransformList(`/api/transform/${id}/dependencies`, { ...options }); } /** Request cancellation of a transform's current run. */ async function cancel(id: number, options: RequestOptions = {}): Promise { - await transport.require("transform.cancel"); + await transport.require("transform.cancel", options); await transport.requestRaw(`/api/transform/${id}/cancel`, { ...options, method: "POST", @@ -139,7 +139,7 @@ export function transformResource(transport: Transport) { /** Get one transform run by run id — not by the id of the transform that produced it. */ async function getRun(runId: number, options: RequestOptions = {}): Promise { - await transport.require("transform.getRun"); + await transport.require("transform.getRun", options); return transport.requestParsed(TransformRun, `/api/transform/run/${runId}`, { ...options }); } @@ -148,7 +148,7 @@ export function transformResource(transport: Transport) { params: TransformRunPageParams = {}, options: TransformRunPageOptions = {}, ): AsyncIterable> { - await transport.require("transform.runPages"); + await transport.require("transform.runPages", options); yield* paginatePages(transport, "/api/transform/run", TransformRun, { query: { "transform-ids": params["transform-ids"] }, ...(options.offset !== undefined && { offset: options.offset }), @@ -168,7 +168,7 @@ export function transformResource(transport: Transport) { params: TransformRunParams = {}, options: RequestOptions = {}, ): Promise { - await transport.require("transform.run"); + await transport.require("transform.run", options); const kickoff = await transport.requestParsed(TransformRunKickoff, `/api/transform/${id}/run`, { ...options, method: "POST", diff --git a/packages/client/src/resources/upload.ts b/packages/client/src/resources/upload.ts index 0dc6c053..7958fce0 100644 --- a/packages/client/src/resources/upload.ts +++ b/packages/client/src/resources/upload.ts @@ -22,7 +22,7 @@ export function uploadResource(transport: Transport) { params: UploadCsvParams, options: RequestOptions = {}, ): Promise { - await transport.require("upload.createFromCsv"); + await transport.require("upload.createFromCsv", options); const form = buildCsvFormData(file); form.append("collection_id", params.collection_id); const response = await transport.requestRaw("/api/upload/csv", { diff --git a/packages/client/src/resources/user.ts b/packages/client/src/resources/user.ts index 63f62284..1a660714 100644 --- a/packages/client/src/resources/user.ts +++ b/packages/client/src/resources/user.ts @@ -4,7 +4,7 @@ import type { RequestOptions, Transport } from "../http/transport"; export function userResource(transport: Transport) { /** Get the user the request's credentials authenticate as. */ async function current(options: RequestOptions = {}): Promise { - await transport.require("user.current"); + await transport.require("user.current", options); return transport.requestParsed(CurrentUser, "/api/user/current", { ...options }); } diff --git a/packages/client/src/signal.test.ts b/packages/client/src/signal.test.ts index ec234f22..5c69bd13 100644 --- a/packages/client/src/signal.test.ts +++ b/packages/client/src/signal.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { AbortError } from "./errors"; -import { abortReason, combineAborts, throwIfAborted } from "./signal"; +import { abortReason, combineAborts, throwIfAborted, untilAborted } from "./signal"; +import { deferred } from "./testing/deferred"; describe("combineAborts", () => { it("aborts as soon as one composed source does, carrying that source's reason", () => { @@ -69,3 +70,28 @@ describe("abortReason", () => { expect(wrapped.category).toBe("abort"); }); }); + +describe("untilAborted", () => { + it("rejects with the signal's reason and leaves the promise to settle on its own", async () => { + const { promise, resolve } = deferred(); + const controller = new AbortController(); + + const waited = untilAborted(promise, controller.signal); + controller.abort(new Error("moved on")); + + const error = await waited.catch((caught: unknown) => caught); + assert(error instanceof AbortError, "expected AbortError"); + expect(error.message).toBe("moved on"); + resolve("done"); + expect(await promise).toBe("done"); + }); + + it("resolves with the promise's value when the signal stays live", async () => { + expect(await untilAborted(Promise.resolve("done"), new AbortController().signal)).toBe("done"); + }); + + it("returns the promise itself when there is no signal", async () => { + const promise = Promise.resolve("done"); + expect(untilAborted(promise, undefined)).toBe(promise); + }); +}); diff --git a/packages/client/src/signal.ts b/packages/client/src/signal.ts index 75429f49..0f3e9666 100644 --- a/packages/client/src/signal.ts +++ b/packages/client/src/signal.ts @@ -22,3 +22,17 @@ export function abortReason(signal: AbortSignal): MetabaseError { } return new AbortError("aborted"); } + +// Settles as `promise` does, or rejects with the signal's reason first. The promise keeps running: +// this abandons a wait, never the work behind it. +export function untilAborted(promise: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) { + return promise; + } + throwIfAborted(signal); + return new Promise((resolve, reject) => { + const onAbort = (): void => reject(abortReason(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); + }); +} diff --git a/packages/client/src/testing/deferred.ts b/packages/client/src/testing/deferred.ts new file mode 100644 index 00000000..84c41ee9 --- /dev/null +++ b/packages/client/src/testing/deferred.ts @@ -0,0 +1,22 @@ +export interface Deferred { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} + +// A promise settled from outside, so a test can hold a response back until it has observed what +// happens while it is pending. +export function deferred(): Deferred { + let settle: ((value: T) => void) | null = null; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { + promise, + resolve: (value) => { + if (settle === null) { + throw new Error("deferred: executor did not run"); + } + settle(value); + }, + }; +} diff --git a/packages/client/src/testing/fake-client.test.ts b/packages/client/src/testing/fake-client.test.ts index 1c1d3bf5..50c0558e 100644 --- a/packages/client/src/testing/fake-client.test.ts +++ b/packages/client/src/testing/fake-client.test.ts @@ -110,6 +110,7 @@ describe("createFakeClient", () => { it("hands back the planned server profile", async () => { const server = createServerProfile({ + edition: "oss", version: { tag: "v0.61.2", major: 61, patch: 2 }, date: null, hash: null, diff --git a/packages/client/src/version/features.ts b/packages/client/src/version/features.ts index 1e96c06c..6aee29df 100644 --- a/packages/client/src/version/features.ts +++ b/packages/client/src/version/features.ts @@ -22,9 +22,8 @@ export const FEATURE_RULES = { transformTargetTableLinkedOnCreate: { since: 61, until: 61 }, transformJobRunIdIsNumeric: { since: 64 }, libraryChildrenCarryType: { since: 62 }, - collectionItemsTotalOnEmptyPage: { since: 62 }, - invalidMbqlIsBadRequest: { since: 62 }, - queryNormalizedBeforeValidation: { since: 62 }, + collectionItemsTotalOnEmptyPage: { since: 64 }, + invalidMbqlIsBadRequest: { since: 59 }, boxplotDisplay: { since: 59 }, nativeTableTemplateTag: { since: 59 }, smartLinkMeasureModel: { since: 60 }, diff --git a/packages/client/src/version/matrix-coverage.test.ts b/packages/client/src/version/matrix-coverage.test.ts new file mode 100644 index 00000000..ff79a7ca --- /dev/null +++ b/packages/client/src/version/matrix-coverage.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { KNOWN_RANGE } from "./profile"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, "..", "..", "..", ".."); + +const MATRIX_STACK = /\{ id: "([a-z]+-(?:\d+|head))", image: "([^"]+)"/g; +const CI_STACK = /^\s+- ([a-z]+-(?:\d+|head))$/gm; + +function group(match: RegExpMatchArray, index: number): string { + const value = match[index]; + if (value === undefined) { + throw new Error(`no capture group ${index} in ${match[0]}`); + } + return value; +} + +function matrixStacks(): Map { + const source = readFileSync(resolve(REPO_ROOT, "scripts/e2e-matrix.ts"), "utf8"); + return new Map( + [...source.matchAll(MATRIX_STACK)].map((match) => [group(match, 1), group(match, 2)]), + ); +} + +function ciStacks(): string[] { + const source = readFileSync(resolve(REPO_ROOT, ".github/workflows/e2e.yml"), "utf8"); + return [...source.matchAll(CI_STACK)].map((match) => group(match, 1)); +} + +function expectedLanes(): string[] { + const majors = Array.from( + { length: KNOWN_RANGE.max - KNOWN_RANGE.min + 1 }, + (_, index) => KNOWN_RANGE.min + index, + ); + return [...majors.map(String), "head"].flatMap((major) => [`oss-${major}`, `ee-${major}`]); +} + +describe("every supported major has an e2e lane", () => { + it("the matrix boots an oss and an ee stack for each major in KNOWN_RANGE, plus head", () => { + expect([...matrixStacks().keys()]).toEqual(expectedLanes()); + }); + + it("CI runs exactly the matrix's lanes", () => { + expect(ciStacks()).toEqual([...matrixStacks().keys()]); + }); + + it("each released lane pins the image of its own major and edition", () => { + const released = [...matrixStacks()].filter(([id]) => !id.endsWith("-head")); + const mismatched = released.filter(([id, image]) => { + const [edition, major] = id.split("-"); + const repo = edition === "ee" ? "metabase/metabase-enterprise" : "metabase/metabase"; + const semverMajor = edition === "ee" ? 1 : 0; + return !image.startsWith(`${repo}:v${semverMajor}.${major}.`); + }); + expect(mismatched).toEqual([]); + }); +}); diff --git a/packages/client/src/version/probe.test.ts b/packages/client/src/version/probe.test.ts index b2e129fd..3b5b9845 100644 --- a/packages/client/src/version/probe.test.ts +++ b/packages/client/src/version/probe.test.ts @@ -24,6 +24,7 @@ describe("probeServer", () => { ); expect(await probeServer(client)).toEqual({ version: { tag: "v1.58.7", major: 58, patch: 7 }, + edition: "ee", date: "2025-12-15", hash: "abc1234", tokenFeatures: { advanced_permissions: true, audit_app: true, embedding: true }, @@ -40,6 +41,7 @@ describe("probeServer", () => { const { client } = createFakeClient(planning({ version: { tag: "v0.58.7" } })); expect(await probeServer(client)).toEqual({ version: { tag: "v0.58.7", major: 58, patch: 7 }, + edition: "oss", date: null, hash: null, tokenFeatures: null, @@ -55,6 +57,7 @@ describe("probeServer", () => { ); expect(await probeServer(client)).toEqual({ version: null, + edition: null, date: null, hash: null, tokenFeatures: { transforms: true }, diff --git a/packages/client/src/version/probe.ts b/packages/client/src/version/probe.ts index 24c30d09..81f0d8e1 100644 --- a/packages/client/src/version/probe.ts +++ b/packages/client/src/version/probe.ts @@ -1,13 +1,16 @@ import { SessionProperties, type TokenFeatures } from "../domain/session-properties"; import type { Transport } from "../http/transport"; -import { tryParseTag, type ParsedVersion } from "./tag"; +import { type Edition, editionFromTag, tryParseTag, type ParsedVersion } from "./tag"; export const PROBE_PATH = "/api/session/properties"; export const PROBE_TIMEOUT_MS = 10_000; +// `edition` is what the tag stamps, and null when the tag stamps nothing (`vUNKNOWN`); a `-SNAPSHOT` +// tag stamps it even though it carries no usable version. export interface ServerInfo { readonly version: ParsedVersion | null; + readonly edition: Edition | null; readonly date: string | null; readonly hash: string | null; readonly tokenFeatures: Readonly | null; @@ -28,9 +31,10 @@ export async function probeServer( timeoutMs: PROBE_TIMEOUT_MS, retries: opts.retries ?? 0, }); - const version = tryParseTag(properties.version.tag); + const { tag } = properties.version; return { - version, + version: tryParseTag(tag), + edition: editionFromTag(tag), date: properties.version.date ?? null, hash: properties.version.hash ?? null, tokenFeatures: properties["token-features"] ?? null, diff --git a/packages/client/src/version/profile.test.ts b/packages/client/src/version/profile.test.ts index 00e78e68..7a57d872 100644 --- a/packages/client/src/version/profile.test.ts +++ b/packages/client/src/version/profile.test.ts @@ -3,12 +3,14 @@ import { describe, expect, it } from "vitest"; import { evaluateFeatures } from "./features"; import type { ServerInfo } from "./probe"; import { createServerProfile, featureGap, KNOWN_RANGE } from "./profile"; +import { editionFromTag } from "./tag"; const HEAD_SLOT = KNOWN_RANGE.max + 1; function released(tag: string, major: number, tokenFeatures: ServerInfo["tokenFeatures"]) { return { version: { tag, major, patch: 2 }, + edition: editionFromTag(tag), date: "2026-05-19", hash: "0c64e27", tokenFeatures, @@ -45,6 +47,7 @@ describe("createServerProfile", () => { it("reads an unparseable tag as the head slot and reports unknown", () => { const info: ServerInfo = { version: null, + edition: null, date: "2026-09-16", hash: "548573f", tokenFeatures: { library: false }, @@ -77,9 +80,21 @@ describe("createServerProfile", () => { expect(createServerProfile(released("v1.61.2", 61, null)).edition).toBe("ee"); }); + it("takes the edition from a snapshot tag, which stamps one without a usable version", () => { + const info: ServerInfo = { + version: null, + edition: "ee", + date: null, + hash: null, + tokenFeatures: { library: false }, + }; + expect(createServerProfile(info).edition).toBe("ee"); + }); + it("takes the edition from a granted premium feature when the tag says nothing", () => { const info: ServerInfo = { version: null, + edition: null, date: null, hash: null, tokenFeatures: { library: true, remote_sync: false }, @@ -88,7 +103,13 @@ describe("createServerProfile", () => { }); it("carries a probe without date, hash or token features as nulls", () => { - const info: ServerInfo = { version: null, date: null, hash: null, tokenFeatures: null }; + const info: ServerInfo = { + version: null, + edition: null, + date: null, + hash: null, + tokenFeatures: null, + }; expect(createServerProfile(info)).toEqual({ version: null, buildDate: null, @@ -119,7 +140,13 @@ describe("featureGap", () => { }); it("places an unparseable tag at the head slot, so only a token can be missing", () => { - const info: ServerInfo = { version: null, date: null, hash: null, tokenFeatures: null }; + const info: ServerInfo = { + version: null, + edition: null, + date: null, + hash: null, + tokenFeatures: null, + }; const profile = createServerProfile(info); expect(featureGap(profile, "transformJobRunIdIsNumeric")).toBeNull(); expect(featureGap(profile, "library")).toEqual({ kind: "token", tokenFeature: "library" }); diff --git a/packages/client/src/version/profile.ts b/packages/client/src/version/profile.ts index cc7831f2..9709d1f9 100644 --- a/packages/client/src/version/profile.ts +++ b/packages/client/src/version/profile.ts @@ -11,7 +11,7 @@ import { ruleGap, } from "./features"; import type { ServerInfo } from "./probe"; -import { Edition, editionFromTag, ParsedVersion } from "./tag"; +import { Edition, ParsedVersion } from "./tag"; // The majors this client was built and tested against. A server above `max` is read as `max + 1`: // its additions pass through the loose schemas, and a shape it changed fails loudly instead of @@ -68,13 +68,12 @@ function place(version: ParsedVersion | null): Placement { return { effectiveMajor: version.major, skew: "supported" }; } -// A released tag is authoritative. A dev build's tag never parses, and `/api/session/properties` -// carries no other edition field (OSS sends the same all-false `token-features` map an unlicensed -// EE does), so there the edition is whatever the token proves. +// The tag is authoritative. Where it stamps no edition, `/api/session/properties` carries no other +// edition field (OSS sends the same all-false `token-features` map an unlicensed EE does), so +// there the edition is whatever the token proves. function detectEdition(info: ServerInfo): Edition { - const fromTag = info.version === null ? null : editionFromTag(info.version.tag); - if (fromTag !== null) { - return fromTag; + if (info.edition !== null) { + return info.edition; } return grantsAnyFeature(info.tokenFeatures) ? "ee" : "oss"; } diff --git a/packages/client/src/version/requirement-check.test.ts b/packages/client/src/version/requirement-check.test.ts index 2a333fdc..870b7fc4 100644 --- a/packages/client/src/version/requirement-check.test.ts +++ b/packages/client/src/version/requirement-check.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; import type { ServerInfo } from "./probe"; +import { editionFromTag } from "./tag"; import { createServerProfile } from "./profile"; import { checkFeatures, checkRequirements } from "./requirement-check"; function profileOf(tag: string, major: number, tokenFeatures: ServerInfo["tokenFeatures"]) { return createServerProfile({ version: { tag, major, patch: 0 }, + edition: editionFromTag(tag), date: null, hash: null, tokenFeatures, @@ -84,6 +86,7 @@ describe("checkRequirements", () => { it("places an unparseable tag past the newest known major, so only a token can refuse it", () => { const head = createServerProfile({ + edition: null, version: null, date: "2026-09-16", hash: "548573f", diff --git a/packages/client/src/version/requirements.test.ts b/packages/client/src/version/requirements.test.ts index 8c27abdc..deaec62a 100644 --- a/packages/client/src/version/requirements.test.ts +++ b/packages/client/src/version/requirements.test.ts @@ -56,6 +56,7 @@ const CREDENTIALS: ClientCredentials = { // A method that picks its wire shape by generation reads the profile between asking and requesting. const SERVER = createServerProfile({ + edition: "oss", version: { tag: "v0.63.0", major: 63, patch: 0 }, date: null, hash: null, @@ -103,7 +104,7 @@ interface RequiredLiteral { readonly method: string; } -const REQUIRE_CALL = /transport\.require\("([^"]+)"\)/g; +const REQUIRE_CALL = /transport\.require\("([^"]+)", options\)/g; const FUNCTION_DECLARATION = /function\*? ([A-Za-z]+)\(/g; const RETURNED_OBJECT = /return \{([^}]*)\}/g; const ALIASED_ENTRY = /([A-Za-z]+): ([A-Za-z]+)/g; diff --git a/packages/client/src/version/tag.test.ts b/packages/client/src/version/tag.test.ts index 7a691649..81b2c1ac 100644 --- a/packages/client/src/version/tag.test.ts +++ b/packages/client/src/version/tag.test.ts @@ -23,12 +23,21 @@ describe("tryParseTag", () => { expect(tryParseTag("1.59.12")).toEqual({ tag: "1.59.12", major: 59, patch: 12 }); }); + it("parses a hotfix tag, whose fourth number semver would refuse", () => { + expect(tryParseTag("v0.62.19.5")).toEqual({ tag: "v0.62.19.5", major: 62, patch: 19 }); + }); + + it("parses a release-candidate tag", () => { + expect(tryParseTag("v1.64.0-RC1")).toEqual({ tag: "v1.64.0-RC1", major: 64, patch: 0 }); + }); + it.each([ ["major prefix outside 0|1", "v2.58.7"], ["wholly malformed", "vLOCAL_DEV"], ["a head/nightly build tag", "vUNKNOWN"], ["a locally built jar tag that would read as v1", "v0.1.0-SNAPSHOT"], ["a snapshot build of a released line", "v0.59.12-SNAPSHOT"], + ["a fifth number", "v0.62.19.5.1"], ])("returns null on %s", (_label, input) => { expect(tryParseTag(input)).toBeNull(); }); @@ -40,6 +49,7 @@ describe("editionFromTag", () => { ["a released EE tag", "v1.61.2", "ee"], ["a locally built OSS jar", "v0.62.0-SNAPSHOT", "oss"], ["a locally built EE jar", "v1.62.0-SNAPSHOT", "ee"], + ["an EE hotfix", "v1.63.16.4", "ee"], ])("reads the edition off %s", (_label, input, expected) => { expect(editionFromTag(input)).toBe(expected); }); diff --git a/packages/client/src/version/tag.ts b/packages/client/src/version/tag.ts index 89b6cab7..9223f8e5 100644 --- a/packages/client/src/version/tag.ts +++ b/packages/client/src/version/tag.ts @@ -1,4 +1,3 @@ -import { parse as parseSemver } from "semver"; import { z } from "zod"; export const ParsedVersion = z.object({ @@ -11,44 +10,51 @@ export type ParsedVersion = z.infer; export const Edition = z.enum(["oss", "ee"]); export type Edition = z.infer; -// Metabase's build stamps the edition into the semver major: an OSS jar is tagged `v0..` -// and an EE jar `v1..`, released and `-SNAPSHOT` alike. A tag that parses to neither -// (`vUNKNOWN`, `vLOCAL_DEV`) says nothing about the edition. -const OSS_SEMVER_MAJOR = 0; -const EE_SEMVER_MAJOR = 1; +// Metabase's build stamps the edition into the leading number: an OSS jar is tagged `v0..` +// and an EE jar `v1..`, released and `-SNAPSHOT` alike, with an optional fourth number on +// a hotfix (`v0.62.19.5`) — so the tag is not semver, and a semver parser refuses a real release. A tag +// that fits neither shape (`vUNKNOWN`, `vLOCAL_DEV`) says nothing about the edition. +const TAG = /^v?([01])\.(\d+)\.(\d+)(?:\.\d+)?(?:-([0-9A-Za-z.-]+))?$/; +const OSS_EDITION_NUMBER = "0"; -// A dev build reports a tag that either fails semver outright ("vUNKNOWN", "vLOCAL_DEV") or parses -// to a number that means nothing — a locally built jar reports "v0.1.0-SNAPSHOT", which would read -// as Metabase v1 and make every version gate fire against a server that actually carries the newest -// features. Metabase itself treats any "-SNAPSHOT" tag as "no version", so we do too. -const DEV_BUILD_SUFFIX = "-SNAPSHOT"; +// A locally built jar reports "v0.1.0-SNAPSHOT", which would read as Metabase v1 and make every version +// gate fire against a server that actually carries the newest features. Metabase itself treats any +// "-SNAPSHOT" tag as "no version", so we do too. +const DEV_BUILD_SUFFIX = "SNAPSHOT"; -export function tryParseTag(tag: string): ParsedVersion | null { - if (tag.endsWith(DEV_BUILD_SUFFIX)) { +interface TagParts { + readonly edition: Edition; + readonly major: number; + readonly patch: number; + readonly prerelease: string | null; +} + +function tagParts(tag: string): TagParts | null { + const match = TAG.exec(tag); + if (match === null) { return null; } - const parsed = parseSemver(tag); - if (parsed === null || editionOfSemverMajor(parsed.major) === null) { + const [, editionNumber, major, patch, prerelease] = match; + if (editionNumber === undefined || major === undefined || patch === undefined) { return null; } return { - tag, - major: parsed.minor, - patch: parsed.patch, + edition: editionNumber === OSS_EDITION_NUMBER ? "oss" : "ee", + major: Number(major), + patch: Number(patch), + prerelease: prerelease ?? null, }; } -export function editionFromTag(tag: string): Edition | null { - const parsed = parseSemver(tag); - return parsed === null ? null : editionOfSemverMajor(parsed.major); +export function tryParseTag(tag: string): ParsedVersion | null { + const parts = tagParts(tag); + if (parts === null || parts.prerelease === DEV_BUILD_SUFFIX) { + return null; + } + return { tag, major: parts.major, patch: parts.patch }; } -function editionOfSemverMajor(semverMajor: number): Edition | null { - if (semverMajor === OSS_SEMVER_MAJOR) { - return "oss"; - } - if (semverMajor === EE_SEMVER_MAJOR) { - return "ee"; - } - return null; +export function editionFromTag(tag: string): Edition | null { + const parts = tagParts(tag); + return parts === null ? null : parts.edition; } diff --git a/scripts/e2e-matrix.ts b/scripts/e2e-matrix.ts index cbbb7460..47402605 100644 --- a/scripts/e2e-matrix.ts +++ b/scripts/e2e-matrix.ts @@ -16,20 +16,20 @@ interface Stack { } const STACKS: readonly Stack[] = [ - { id: "oss-58", image: "metabase/metabase:v0.58.15", port: 13058 }, - { id: "ee-58", image: "metabase/metabase-enterprise:v1.58.15", port: 13158 }, - { id: "oss-59", image: "metabase/metabase:v0.59.12", port: 13059 }, - { id: "ee-59", image: "metabase/metabase-enterprise:v1.59.12", port: 13159 }, - { id: "oss-60", image: "metabase/metabase:v0.60.7", port: 13060 }, - { id: "ee-60", image: "metabase/metabase-enterprise:v1.60.7", port: 13160 }, - { id: "oss-61", image: "metabase/metabase:v0.61.2", port: 13061 }, - { id: "ee-61", image: "metabase/metabase-enterprise:v1.61.2", port: 13161 }, - { id: "oss-head", image: "metabase/metabase-head:latest", port: 13062 }, - { - id: "ee-head", - image: "metabase/metabase-enterprise-head:latest", - port: 13162, - }, + { id: "oss-58", image: "metabase/metabase:v0.58.34", port: 13058 }, + { id: "ee-58", image: "metabase/metabase-enterprise:v1.58.34", port: 13158 }, + { id: "oss-59", image: "metabase/metabase:v0.59.31", port: 13059 }, + { id: "ee-59", image: "metabase/metabase-enterprise:v1.59.31", port: 13159 }, + { id: "oss-60", image: "metabase/metabase:v0.60.27", port: 13060 }, + { id: "ee-60", image: "metabase/metabase-enterprise:v1.60.27", port: 13160 }, + { id: "oss-61", image: "metabase/metabase:v0.61.21", port: 13061 }, + { id: "ee-61", image: "metabase/metabase-enterprise:v1.61.21", port: 13161 }, + { id: "oss-62", image: "metabase/metabase:v0.62.19.5", port: 13062 }, + { id: "ee-62", image: "metabase/metabase-enterprise:v1.62.19.5", port: 13162 }, + { id: "oss-63", image: "metabase/metabase:v0.63.18", port: 13063 }, + { id: "ee-63", image: "metabase/metabase-enterprise:v1.63.18", port: 13163 }, + { id: "oss-head", image: "metabase/metabase-head:latest", port: 13064 }, + { id: "ee-head", image: "metabase/metabase-enterprise-head:latest", port: 13164 }, ]; const DEFAULT_PARALLELISM = 2; @@ -51,22 +51,22 @@ interface StackResult { } function parseArgs(argv: readonly string[]): CliOptions { - let selected: Stack[] = [...STACKS]; + const selected: Stack[] = []; let parallelism = 1; for (const arg of argv) { if (arg.startsWith("--stack=")) { - selected = [resolveStack(arg.slice("--stack=".length))]; + selected.push(resolveStack(arg.slice("--stack=".length))); } else if (arg === "--parallel") { parallelism = DEFAULT_PARALLELISM; } else if (arg.startsWith("--parallel=")) { parallelism = parsePositiveInt(arg.slice("--parallel=".length)); } else { throw new Error( - `Unknown argument: ${arg}. Usage: e2e-matrix [--stack=] [--parallel[=N]]`, + `Unknown argument: ${arg}. Usage: e2e-matrix [--stack= ...] [--parallel[=N]]`, ); } } - return { stacks: selected, parallelism }; + return { stacks: selected.length === 0 ? [...STACKS] : selected, parallelism }; } function resolveStack(id: string): Stack { diff --git a/tests/e2e/auth.e2e.test.ts b/tests/e2e/auth.e2e.test.ts index 36942f8f..26e90635 100644 --- a/tests/e2e/auth.e2e.test.ts +++ b/tests/e2e/auth.e2e.test.ts @@ -100,6 +100,7 @@ function probedRecord( lastProbe: { at: probedAt, version: bootstrap.server.version, + edition: bootstrap.server.edition, date: bootstrap.server.date, hash: bootstrap.server.hash, tokenFeatures: bootstrap.server.tokenFeatures, diff --git a/tests/e2e/bootstrap-data.ts b/tests/e2e/bootstrap-data.ts index 2b690590..b189777f 100644 --- a/tests/e2e/bootstrap-data.ts +++ b/tests/e2e/bootstrap-data.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; import { z } from "zod"; import { isFileNotFoundError } from "@metabase/client/errors"; -import { ParsedVersion } from "@metabase/client/version/tag"; +import { Edition, ParsedVersion } from "@metabase/client/version/tag"; import { TokenFeatures } from "@metabase/client/domain/session-properties"; import { parseJson } from "@metabase/client/json"; @@ -44,6 +44,7 @@ export type SeededIds = z.infer; export const ServerIdentity = z.object({ version: ParsedVersion.nullable(), + edition: Edition.nullable().default(null), date: z.string().nullable().default(null), hash: z.string().nullable().default(null), tokenFeatures: TokenFeatures.nullable(), diff --git a/tests/e2e/query.e2e.test.ts b/tests/e2e/query.e2e.test.ts index a3a95d91..84cd2d4c 100644 --- a/tests/e2e/query.e2e.test.ts +++ b/tests/e2e/query.e2e.test.ts @@ -12,7 +12,7 @@ import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { assertCompactColumns, assertCompletedQuery } from "./card-query"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; -import { invalidDatabaseRejection } from "./server-gate"; +import { QUERY_NORMALIZATION_MESSAGE } from "./server-gate"; import { SEEDED } from "./seed/seeded"; const VALID_QUERY = { @@ -181,9 +181,7 @@ describe("query e2e", () => { }); expect(result.exitCode).toBe(1); - expect(cliErrorMessage(result.stderr)).toContain( - invalidDatabaseRejection('database: should be an integer, received: "My DB"'), - ); + expect(cliErrorMessage(result.stderr)).toBe(QUERY_NORMALIZATION_MESSAGE); expect(result.stdout).toBe(""); }); diff --git a/tests/e2e/seed-profile.ts b/tests/e2e/seed-profile.ts index 27337bec..a33f0662 100644 --- a/tests/e2e/seed-profile.ts +++ b/tests/e2e/seed-profile.ts @@ -1,4 +1,5 @@ import type { ServerInfo } from "@metabase/client/version/probe"; +import { editionFromTag } from "@metabase/client/version/tag"; import { writeProbeResult, writeProfile } from "../../packages/cli/src/core/auth/storage"; @@ -56,7 +57,13 @@ export async function seedProbedProfileAt( await writeProfile(target, "default"); await writeProbeResult("default", { user: SEED_USER, - server: { version, date: null, hash: null, tokenFeatures }, + server: { + version, + edition: version === null ? null : editionFromTag(version.tag), + date: null, + hash: null, + tokenFeatures, + }, }); }); } diff --git a/tests/e2e/server-gate.ts b/tests/e2e/server-gate.ts index 949786bd..3c2b8080 100644 --- a/tests/e2e/server-gate.ts +++ b/tests/e2e/server-gate.ts @@ -107,12 +107,6 @@ export function serverRejectedMessage(): string { } // A query the server cannot normalize — a database id that is not an integer, say — is refused with -// one message for the whole query where normalization runs before the field-level schema check, and -// with the field the schema check rejected where it does not. -const QUERY_NORMALIZATION_MESSAGE = "Invalid query: missing or invalid Database ID (:database)"; - -export function invalidDatabaseRejection(fieldLevelMessage: string): string { - return serverHas("queryNormalizedBeforeValidation") - ? QUERY_NORMALIZATION_MESSAGE - : fieldLevelMessage; -} +// one message for the whole query, before any field-level schema check names the field. +export const QUERY_NORMALIZATION_MESSAGE = + "Invalid query: missing or invalid Database ID (:database)"; diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index 1f37d43c..e7af7b78 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -23,7 +23,7 @@ import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; import { SEEDED } from "./seed/seeded"; -import { invalidDatabaseRejection, requireServer, serverHas } from "./server-gate"; +import { QUERY_NORMALIZATION_MESSAGE, requireServer, serverHas } from "./server-gate"; const FIRST_TRANSFORM_ID = 1; const TRANSFORM_NAME = "e2e_transform"; @@ -569,9 +569,7 @@ describe.skipIf(skipReason !== null)("transform e2e", () => { }); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain( - invalidDatabaseRejection("source.query.lib/metadata: missing required key"), - ); + expect(cliErrorMessage(result.stderr)).toBe(QUERY_NORMALIZATION_MESSAGE); expect(result.stdout).toBe(""); }); From a8188db70d633bb3c94f72756120eb3b4724f28d Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:12:21 -0400 Subject: [PATCH 04/22] Drop the capabilities summary from help JSON --- .claude/skills/add-resource-command/SKILL.md | 2 +- .claude/skills/review/SKILL.md | 6 ++-- README.md | 2 +- docs/architecture.md | 4 +-- packages/cli/src/commands/group.ts | 1 - .../cli/src/commands/requires-guard.test.ts | 23 +------------ packages/cli/src/commands/runtime.ts | 2 -- packages/cli/src/main.ts | 1 - packages/cli/src/output/help.test.ts | 3 -- .../cli/src/runtime/command-augment.test.ts | 7 ---- packages/cli/src/runtime/command-augment.ts | 2 -- packages/cli/src/runtime/command-help.test.ts | 28 +--------------- packages/cli/src/runtime/command-help.ts | 4 --- packages/client/README.md | 6 ++-- packages/client/package.json | 1 - packages/client/src/index.ts | 1 - .../src/version/capability-summary.test.ts | 33 ------------------- .../client/src/version/capability-summary.ts | 29 ---------------- .../version/major-comparison-guard.test.ts | 20 +---------- tests/e2e/help-json.e2e.test.ts | 10 ++---- 20 files changed, 15 insertions(+), 170 deletions(-) delete mode 100644 packages/client/src/version/capability-summary.test.ts delete mode 100644 packages/client/src/version/capability-summary.ts diff --git a/.claude/skills/add-resource-command/SKILL.md b/.claude/skills/add-resource-command/SKILL.md index 3264c962..eb445086 100644 --- a/.claude/skills/add-resource-command/SKILL.md +++ b/.claude/skills/add-resource-command/SKILL.md @@ -351,7 +351,7 @@ renderList(envelope, collectionItemView, ctx); `collectForOutput` pulls only as far as the output byte budget can display, so an unbounded listing over a large collection costs a page or two rather than a full drain the cap then discards. It sizes each request through the `PageRequest` it hands the source — forward `max` and `pageSize` verbatim, or the budget cannot bound the walk. -Every command declares `requires`: the list of `"."` keys its `run` body reaches (plus the methods of any helper it hands the client to), or `null` for a command that never touches a Metabase server. `packages/cli/src/commands/requires-guard.test.ts` reads the body and fails a declaration that differs from the calls in it, so the list is transcribed, not chosen. The preflight derives the features from `METHOD_REQUIREMENTS`, runs once per command against the profile's cached probe (or one live probe), and refuses with the client's own message and exit code 2; a command whose methods need nothing never preflights. `help --json` reports the derived `requires: { methods, features }` and a `capabilities` summary. Nothing on the command names a version. +Every command declares `requires`: the list of `"."` keys its `run` body reaches (plus the methods of any helper it hands the client to), or `null` for a command that never touches a Metabase server. `packages/cli/src/commands/requires-guard.test.ts` reads the body and fails a declaration that differs from the calls in it, so the list is transcribed, not chosen. The preflight derives the features from `METHOD_REQUIREMENTS`, runs once per command against the profile's cached probe (or one live probe), and refuses with the client's own message and exit code 2; a command whose methods need nothing never preflights. `help --json` reports the derived `requires: { methods, features }`. Nothing on the command names a version. The `ListEnvelope` export is **mandatory**. It is consumed by JSON help (`--help --json`, via `outputSchema`) and by the matching e2e test (which imports it back to parse `--json` output). Do **not** redeclare the envelope shape inline anywhere. It is the _CLI's_ envelope — `{ data, returned, offset, limit?, total?, has_more, next_offset?, truncated? }`, declared in `packages/cli/src/output/types.ts` — and has nothing to do with the server's wire envelope, which stayed module-private in Step 2. diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index 8c72e1e5..13ef100f 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -37,7 +37,7 @@ packages/client/src/ auth/ OAuth login flow: credential.ts, pkce.ts, callback-server.ts, oauth-*.ts version/ tag.ts, probe.ts, profile.ts (KNOWN_RANGE, ServerProfile), features.ts (FEATURE_RULES — the only file comparing a Metabase major), requirements.ts (METHOD_REQUIREMENTS), - requirement-check.ts, capability-summary.ts, preflight-error.ts + requirement-check.ts, preflight-error.ts domain/ Metabase API resource Zod schemas + inferred types errors.ts MetabaseError taxonomy, toMetabaseError, isFileNotFoundError, errorMessage list.ts ListResult = { data, total } — what a one-response list method returns @@ -108,9 +108,9 @@ A new or changed method comes with its wire test in `packages/client/src/resourc ## Version contract -`packages/client/src/version/features.ts` is the only production file that compares a Metabase major (`major-comparison-guard.test.ts` scans both packages); `profile.ts` places a version and `tag.ts` parses one. A `.major` comparison, a literal major used as a decision, or a `minVersion` anywhere else is FAIL. Feature names describe a behaviour (`transformTargetTableId`), never a version (`transformV61Shape` is FAIL), and every version-gated rule must flip somewhere inside `KNOWN_RANGE` — `features.test.ts` enforces it, so a diff that raises `KNOWN_RANGE.min` without deleting the rules that went constant is FAIL. +`packages/client/src/version/features.ts` is the only production file that compares a Metabase major (`major-comparison-guard.test.ts` scans both packages); `profile.ts` places a version and `tag.ts` parses one. A `.major` comparison or a literal major used as a decision anywhere else is FAIL. Feature names describe a behaviour (`transformTargetTableId`), never a version (`transformV61Shape` is FAIL), and every version-gated rule must flip somewhere inside `KNOWN_RANGE` — `features.test.ts` enforces it, so a diff that raises `KNOWN_RANGE.min` without deleting the rules that went constant is FAIL. -Every method in `resources/*.ts` opens with `await transport.require(".")` under its own namespace and has a matching `METHOD_REQUIREMENTS` entry, strictest feature first (`requirements.test.ts`). A method without one, or a command under `packages/cli/src/commands/` declaring `requires` that differs from the `client..(` calls in its body (`requires-guard.test.ts`), is FAIL. A `defineMetabaseCommand` declaring `capabilities`, a `minVersion` or a `tokenFeature` is FAIL — the summary is derived from `requires`, never declared. +Every method in `resources/*.ts` opens with `await transport.require(".")` under its own namespace and has a matching `METHOD_REQUIREMENTS` entry, strictest feature first (`requirements.test.ts`). A method without one, or a command under `packages/cli/src/commands/` declaring `requires` that differs from the `client..(` calls in its body (`requires-guard.test.ts`), is FAIL. A `defineMetabaseCommand` declaring a version floor or a token feature is FAIL — the features are derived from `requires`, never declared. Version-driven drift is a `WireV` schema plus a converter, selected by a feature and handed to `requestParsed` through an exported `Schema(features): z.ZodType` reader. The canonical schema is the newest server's shape; a field an older server cannot report is `.nullable()`. FAIL: `.optional()` meaning "an older server omits it"; a converter mapping two distinguishable wire states onto one canonical state; a `z.union` of generation shapes parsed by trial (runtime shape detection); a converter that leaves a wire-only field on the canonical value; a discriminated union of generations as a method's public return type; version prose in `packages/cli/skill-data/` where a `requires:` frontmatter key or a `` … `` fence belongs (markers on their own lines, never inside a table). diff --git a/README.md b/README.md index dc5c286c..bcdf0f5d 100644 --- a/README.md +++ b/README.md @@ -1975,7 +1975,7 @@ The former `METABASE_`-prefixed names (`METABASE_URL`, `METABASE_API_KEY`, `META Every node of the command tree answers `--help --json` with machine-readable help, mirroring what text help shows at that level: -- A leaf command emits its full entry — name, description, `details`, examples, citty args with types/defaults/enums, `requires` (the client methods the command calls and the server features they need), `capabilities` (the same requirements summarized as a minimum server version and, where one is needed, a token feature), and the input and output Zod schemas rendered as JSON Schema (`inputSchema` is the exact validator `readBody` enforces on the JSON body, `null` for commands that take none). +- A leaf command emits its full entry — name, description, `details`, examples, citty args with types/defaults/enums, `requires` (the client methods the command calls and the server features they need), and the input and output Zod schemas rendered as JSON Schema (`inputSchema` is the exact validator `readBody` enforces on the JSON body, `null` for commands that take none). - A command group (and the root) emits `{ description, skills, commands }` — its own sentence (`null` when it declares none), its own agent-skill pointers, and a flat `commands: [{ command, description }]` index of every leaf in its subtree, with full-path names. ```sh diff --git a/docs/architecture.md b/docs/architecture.md index a8cdf0b6..8d707ff7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,11 +58,11 @@ The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packa **One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (parsed tag, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's leading number — `v0.` is OSS, `v1.` is EE — and, for a tag that stamps none, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`; a tag that does not parse — head images report `vUNKNOWN` — lands in the same slot as `unknown`. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. -**`features.ts` is the only file that compares a major.** `FEATURE_RULES` maps a behaviour name to `{ since, until?, tokenFeature? }`; `evaluateFeatures` turns the placed major and the token map into `Features`, a record of booleans every consumer branches on. Two tests keep the table honest: `major-comparison-guard.test.ts` scans both packages' source for a `.major` comparison or a `minVersion` outside the version layer, and `features.test.ts` asserts every version-gated rule is non-constant across `KNOWN_RANGE.min … max + 1` and that no name carries a version. When a major leaves the window the rules that stopped flipping fail the second test and are deleted with their wire variants — that is what keeps the switch count bounded. Rules exist for three kinds of consumer: resource methods and readers (`transforms`, `transformTargetTableId`, `libraryChildrenCarryType`), e2e suites that pin per-generation server behaviour (`invalidMbqlIsBadRequest`, `collectionItemsTotalOnEmptyPage`), and skill prose (`boxplotDisplay`, `smartLinkMeasureModel`); a rule no client code reads is still a rule, its consumer is the gate or the skill. +**`features.ts` is the only file that compares a major.** `FEATURE_RULES` maps a behaviour name to `{ since, until?, tokenFeature? }`; `evaluateFeatures` turns the placed major and the token map into `Features`, a record of booleans every consumer branches on. Two tests keep the table honest: `major-comparison-guard.test.ts` scans both packages' source and the e2e tier for a `.major` comparison outside the version evaluators, and `features.test.ts` asserts every version-gated rule is non-constant across `KNOWN_RANGE.min … max + 1` and that no name carries a version. When a major leaves the window the rules that stopped flipping fail the second test and are deleted with their wire variants — that is what keeps the switch count bounded. Rules exist for three kinds of consumer: resource methods and readers (`transforms`, `transformTargetTableId`, `libraryChildrenCarryType`), e2e suites that pin per-generation server behaviour (`invalidMbqlIsBadRequest`, `collectionItemsTotalOnEmptyPage`), and skill prose (`boxplotDisplay`, `smartLinkMeasureModel`); a rule no client code reads is still a rule, its consumer is the gate or the skill. **Every method declares what it needs and refuses before the wire.** `version/requirements.ts` keys `METHOD_REQUIREMENTS` by `.` exactly as the method is reached on the client, listing the features it needs strictest first (the first unmet one is what the refusal names, and naming the lowest floor sends a user through one upgrade only to be refused again). Each resource method opens with `await transport.require(key)`; a method whose list is empty returns at once and never probes, so a baseline command with no cached probe stays socket-free. Otherwise the transport reads the profile and throws `CapabilityError` carrying a `RequirementFailure` — `reason`, `detail`, `feature`, `since`, `tokenFeature`, `serverVersion` — before any request. `requirements.test.ts` asserts the literal `require("…")` set in `resources/` equals the table. `ClientOptions.enforceRequirements: false` is the escape hatch; the CLI wires `--skip-preflight` / `MB_CLI_SKIP_PREFLIGHT=1` to it, and a version-selected reader then still costs one lazy probe because the wire shape is chosen from the profile. The module imports only `FeatureName`, so the CLI's preflight and `--help` reach it without dragging `domain/` onto the startup chunk. -**The CLI's preflight asks the client.** A command declares `requires: readonly MethodKey[] | null`, the methods its body reaches; `commands/requires-guard.test.ts` reads every command file and asserts the declaration equals the `client..(` calls in it plus the methods of the helpers it hands the client to — verified, not derived at runtime, because runtime derivation would import the resource layer before `--help`. `defineMetabaseCommand` unions the methods' features, and `createPreflightEnforcer` runs once per command: `client.server()` (cached or one live probe), `checkFeatures`, and the same `CapabilityError` (exit 2) the client would raise moments later. A probe that fails ends the command with that network error rather than warning and proceeding, which would only buy a second connection attempt and the same error. `help --json` reports `requires: { methods, features }` beside `capabilities` — `summarizeCapabilities` collapses the features into `{ minVersion, tokenFeature? }`, the one place a floor is built, for consumers that want a single number. +**The CLI's preflight asks the client.** A command declares `requires: readonly MethodKey[] | null`, the methods its body reaches; `commands/requires-guard.test.ts` reads every command file and asserts the declaration equals the `client..(` calls in it plus the methods of the helpers it hands the client to — verified, not derived at runtime, because runtime derivation would import the resource layer before `--help`. `defineMetabaseCommand` unions the methods' features, and `createPreflightEnforcer` runs once per command: `client.server()` (cached or one live probe), `checkFeatures`, and the same `CapabilityError` (exit 2) the client would raise moments later. A probe that fails ends the command with that network error rather than warning and proceeding, which would only buy a second connection attempt and the same error. `help --json` reports `requires: { methods, features }`; a consumer that wants a floor reads `FEATURE_RULES` for the features named, so no second summary can disagree with the check. **Canonical schema, exact wire variants, lossless converters.** The exported domain schema of a resource is the shape the newest supported server returns. Where an older generation answers differently it gets a module-private `WireV` schema (N is the first major answering that shape) and a converter to the canonical shape; the feature switch selects deterministically, the transport parses that one schema, and a mismatch is its `ResponseShapeError` naming the server tag and the expected fields. There is no guessing between shapes. The canonical field is `nullable`, `null` meaning "this server cannot say"; a converter may add fields but never maps two distinguishable wire states onto one (`job_run_id` — an opaque stub on released servers, a number or `null` on head — becomes `{ started, run_id }`, not `run_id` alone); and a converter strips the wire fields it owns so the JSON the CLI prints is identical on every server. Readers are schema factories the resource hands to `requestParsed` — `transformDetailSchema(features)` / `transformRowSchema(features)` in `domain/transform.ts`, `transformJobRunResultSchema(features)` in `domain/transform-job.ts` — so the exact-schema failure is the transport's and no raw `ZodError` escapes. When the converter needs a second request (`domain/library.ts`: `type` rides the wire from one generation on, `is_remote_synced` is on no generation's wire and comes from the collection listing), the reader tags its generation (`libraryWireSchema(features)`) and a separate exported converter (`toLibrary(wire, listing)`) finishes the job. The pattern to copy is in `domain/transform.ts` and `resources/transform.ts`: `TransformBase` for the fields every generation shares, `Transform` = base plus the newest link, `TransformWireV59Detail` for the hydrated older detail, and the resource doing `await transport.require(key)`, `const { features } = await transport.server()`, `transport.requestParsed(reader(features), path, opts)`. diff --git a/packages/cli/src/commands/group.ts b/packages/cli/src/commands/group.ts index 0587d222..b30e716b 100644 --- a/packages/cli/src/commands/group.ts +++ b/packages/cli/src/commands/group.ts @@ -29,7 +29,6 @@ export function defineCommandGroup(def: CommandGroupDef): CommandDef { inputSchema: null, outputSchema: null, requires: null, - capabilities: null, }); return cmd; } diff --git a/packages/cli/src/commands/requires-guard.test.ts b/packages/cli/src/commands/requires-guard.test.ts index d12fbe38..d56bc688 100644 --- a/packages/cli/src/commands/requires-guard.test.ts +++ b/packages/cli/src/commands/requires-guard.test.ts @@ -4,13 +4,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { errorMessage } from "@metabase/client/errors"; -import { summarizeCapabilities } from "@metabase/client/version/capability-summary"; -import { - isMethodKey, - type MethodKey, - methodRequirements, -} from "@metabase/client/version/requirements"; +import { isMethodKey, type MethodKey } from "@metabase/client/version/requirements"; const HERE = dirname(fileURLToPath(import.meta.url)); const CLI_SRC = resolve(HERE, ".."); @@ -115,21 +109,6 @@ describe("every command declares exactly the client methods it reaches", () => { expect(reached).toEqual(Object.fromEntries(offline.map((entry) => [entry.file, []]))); }); - // `defineMetabaseCommand` summarizes the declared methods the moment the module loads, and the - // summary admits one premium feature; a command reaching two would refuse to load at all. - it("reaches methods whose features summarize to one server floor and at most one premium feature", () => { - const refused = onlineOnly(declarations).flatMap((declaration) => { - const features = declaration.requires.filter(isMethodKey).flatMap(methodRequirements); - try { - summarizeCapabilities(features); - return []; - } catch (error) { - return [{ file: declaration.file, reason: errorMessage(error) }]; - } - }); - expect(refused).toEqual([]); - }); - it("names every non-command file that reaches a client method", () => { expect(clientReachingHelpers()).toEqual(Object.values(CLIENT_HELPERS).toSorted()); }); diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts index 727c9517..18ccd364 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -4,7 +4,6 @@ import type { ZodType } from "zod"; import type { MetabaseClient } from "@metabase/client/client"; import { MetabaseError, ResponseShapeError } from "@metabase/client/errors"; -import { summarizeCapabilities } from "@metabase/client/version/capability-summary"; import type { FeatureName } from "@metabase/client/version/features"; import { CapabilityError } from "@metabase/client/version/preflight-error"; import { probeServer, type ServerInfo } from "@metabase/client/version/probe"; @@ -153,7 +152,6 @@ export function defineMetabaseCommand( inputSchema: def.inputSchema ?? null, outputSchema: def.outputSchema ?? null, requires: requirements, - capabilities: requirements === null ? null : summarizeCapabilities(requirements.features), }); return cmd; } diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 86683b55..54f5a4a8 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -59,7 +59,6 @@ setMetabaseAugment(main, { inputSchema: null, outputSchema: null, requires: null, - capabilities: null, }); export default main; diff --git a/packages/cli/src/output/help.test.ts b/packages/cli/src/output/help.test.ts index 33747840..9bbc72c3 100644 --- a/packages/cli/src/output/help.test.ts +++ b/packages/cli/src/output/help.test.ts @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { parseJson } from "@metabase/client/json"; -import { summarizeCapabilities } from "@metabase/client/version/capability-summary"; import { connectionFlags, outputFlags, profileFlag } from "../commands/flags"; import { defineCommandGroup } from "../commands/group"; @@ -341,7 +340,6 @@ describe("showUsage", () => { inputSchema: null, outputSchema: null, requires: null, - capabilities: null, }); const leaf = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, @@ -442,7 +440,6 @@ describe("showUsageJson", () => { additionalProperties: false, }, requires: { methods: ["card.list"], features: [] }, - capabilities: summarizeCapabilities([]), }); }); diff --git a/packages/cli/src/runtime/command-augment.test.ts b/packages/cli/src/runtime/command-augment.test.ts index de61ac78..18959274 100644 --- a/packages/cli/src/runtime/command-augment.test.ts +++ b/packages/cli/src/runtime/command-augment.test.ts @@ -18,7 +18,6 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: z.object({ id: z.number() }), requires: null, - capabilities: null, }; setMetabaseAugment(cmd, augment); expect(getMetabaseAugment(cmd)).toBe(augment); @@ -34,7 +33,6 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: null, requires: null, - capabilities: null, }; const secondAugment: MetabaseAugment = { examples: ["b"], @@ -43,7 +41,6 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: null, requires: null, - capabilities: null, }; setMetabaseAugment(first, firstAugment); setMetabaseAugment(second, secondAugment); @@ -60,7 +57,6 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: null, requires: null, - capabilities: null, }; const replacement: MetabaseAugment = { examples: ["after"], @@ -69,7 +65,6 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: null, requires: null, - capabilities: null, }; setMetabaseAugment(cmd, initial); setMetabaseAugment(cmd, replacement); @@ -85,7 +80,6 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: null, requires: null, - capabilities: null, }; setMetabaseAugment(cmd, augment); const recalled = getMetabaseAugment(cmd); @@ -96,7 +90,6 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { inputSchema: null, outputSchema: null, requires: null, - capabilities: null, }); }); }); diff --git a/packages/cli/src/runtime/command-augment.ts b/packages/cli/src/runtime/command-augment.ts index 0ccbcf43..15140dbe 100644 --- a/packages/cli/src/runtime/command-augment.ts +++ b/packages/cli/src/runtime/command-augment.ts @@ -1,6 +1,5 @@ import type { ZodType } from "zod"; -import type { CapabilitySummary } from "@metabase/client/version/capability-summary"; import type { FeatureName } from "@metabase/client/version/features"; import type { MethodKey } from "@metabase/client/version/requirements"; @@ -21,7 +20,6 @@ export interface MetabaseAugment { inputSchema: ZodType | null; outputSchema: ZodType | null; requires: CommandRequirements | null; - capabilities: CapabilitySummary | null; } const augments = new WeakMap(); diff --git a/packages/cli/src/runtime/command-help.test.ts b/packages/cli/src/runtime/command-help.test.ts index 3295f8c3..26254e3f 100644 --- a/packages/cli/src/runtime/command-help.test.ts +++ b/packages/cli/src/runtime/command-help.test.ts @@ -2,7 +2,6 @@ import { defineCommand } from "citty"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { summarizeCapabilities } from "@metabase/client/version/capability-summary"; import { type MethodKey, methodRequirements } from "@metabase/client/version/requirements"; import { defineMetabaseCommand } from "../commands/runtime"; @@ -176,7 +175,6 @@ describe("buildHelpEntry", () => { additionalProperties: false, }, requires: { methods: [], features: [] }, - capabilities: summarizeCapabilities([]), }); }); @@ -276,7 +274,6 @@ describe("buildHelpEntry", () => { inputSchema: null, outputSchema: null, requires: null, - capabilities: null, }); }); @@ -300,7 +297,6 @@ describe("buildHelpEntry", () => { inputSchema: null, outputSchema: null, requires: null, - capabilities: null, }); }); }); @@ -616,25 +612,6 @@ describe("command tree contract", () => { }); }); - it("summarizes a command's features as the single floor and token its capabilities carry", async () => { - const entries = await allEntries(); - const summaries = Object.fromEntries( - entries - .filter((entry) => - ["card list", "measure list", "transform-job set-active", "library get"].includes( - entry.command, - ), - ) - .map((entry) => [entry.command, entry.capabilities]), - ); - expect(summaries).toEqual({ - "card list": summarizeCapabilities([]), - "measure list": summarizeCapabilities(["measures"]), - "transform-job set-active": summarizeCapabilities(["transformJobActivation", "transforms"]), - "library get": summarizeCapabilities(["library"]), - }); - }); - it("advertises a --limit default on search alone, so the shared flag description stays true", async () => { const entries = await allEntries(); const withDefault = Object.fromEntries( @@ -653,12 +630,9 @@ describe("command tree contract", () => { }); }); - it("reports null requires and capabilities for exactly the commands that never touch a Metabase server", async () => { + it("reports null requires for exactly the commands that never touch a Metabase server", async () => { const entries = await allEntries(); const local = entries.filter((entry) => entry.requires === null).map((entry) => entry.command); - expect( - entries.filter((entry) => entry.capabilities === null).map((entry) => entry.command), - ).toEqual(local); expect(local.toSorted()).toEqual([ "auth status", "skills get", diff --git a/packages/cli/src/runtime/command-help.ts b/packages/cli/src/runtime/command-help.ts index 57144e0f..3676ac0c 100644 --- a/packages/cli/src/runtime/command-help.ts +++ b/packages/cli/src/runtime/command-help.ts @@ -1,7 +1,6 @@ import type { ArgDef, ArgsDef, CommandDef, CommandMeta } from "citty"; import { z } from "zod"; -import { CapabilitySummary } from "@metabase/client/version/capability-summary"; import { FEATURE_NAMES } from "@metabase/client/version/features"; import { METHOD_KEYS } from "@metabase/client/version/requirements"; @@ -44,7 +43,6 @@ export const CommandHelpEntry = z.object({ inputSchema: z.unknown().nullable(), outputSchema: z.unknown().nullable(), requires: CommandHelpRequires.nullable(), - capabilities: CapabilitySummary.nullable(), }); export type CommandHelpEntry = z.infer; @@ -68,7 +66,6 @@ const EMPTY_AUGMENT: MetabaseAugment = { inputSchema: null, outputSchema: null, requires: null, - capabilities: null, }; export async function buildHelpEntry( @@ -87,7 +84,6 @@ export async function buildHelpEntry( inputSchema: augment.inputSchema ? z.toJSONSchema(augment.inputSchema) : null, outputSchema: augment.outputSchema ? z.toJSONSchema(augment.outputSchema) : null, requires: augment.requires, - capabilities: augment.capabilities, }; if (augment.details !== null) { entry.details = augment.details; diff --git a/packages/client/README.md b/packages/client/README.md index 8a3923a4..9b72ca64 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -405,7 +405,7 @@ an `OAuthCredential`. `refreshOAuthCredential` exchanges the rotating refresh to revocation endpoint. Types: `Credential` (the `ApiKeyCredential | OAuthCredential` union), `OAuthLoginInput`, `OAuthLoginDeps`. -### Version and capabilities +### Versions and requirements `probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo` — the parsed version tag, the edition the tag stamps, the build date and hash, and the token-feature map, each `null` when the server does not report it. `ParsedVersion` is the `{ tag, major, patch }` schema a probed version tag parses to; `editionFromTag(tag)` reads the `Edition` (`"oss"` | `"ee"`) Metabase stamps into the tag's leading number (`v0.` / `v1.`, a hotfix's fourth number and a `-SNAPSHOT` suffix included), `null` when the tag carries none. @@ -415,11 +415,9 @@ revocation endpoint. Types: `Credential` (the `ApiKeyCredential | OAuthCredentia `METHOD_REQUIREMENTS` names, per client method (`"transform.get"`, `"gitSync.branches"` — the `MethodKey` as the method is reached on the client), the features it needs, strictest first; `methodRequirements(key)` reads one entry, `METHOD_KEYS` lists the keys, and `isMethodKey` narrows a string to one. Every resource method starts by asking `transport.require(key)`, which throws a `CapabilityError` before any request leaves when the connected profile lacks a feature — a version gate reads `This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.`, a token gate `This operation requires the 'remote_sync' premium feature (not enabled on this server).` — and the error's `developerDetail` is a `RequirementFailure` carrying `reason`, `detail`, `feature`, `since`, `tokenFeature` and `serverVersion` — a Zod schema as well as a type (with `RequirementReason` for its `reason` enum), so a consumer that reports the refusal in its own output can describe it. A method that needs nothing never consults the server; a newer or unparseable server is placed past the newest known major, so only a token can refuse it. `ClientOptions.enforceRequirements: false` sends every method to the wire and leaves the server to answer for itself; a method whose wire shape is chosen by generation still reads the profile. `checkRequirements(key, profile)` is the check itself, `checkFeatures(features, profile)` the same check over a bare feature list, `featureGap(profile, feature)` says which half of a rule the profile fails (`FeatureGap`: `{ kind: "version" }` or `{ kind: "token", tokenFeature }`), and `ruleGap` is the same question asked of a bare rule. A consumer that anticipates the refusal — a CLI preflight, say — throws the same `CapabilityError` from the same `RequirementFailure`. -`summarizeCapabilities(features)` collapses a feature list into a `CapabilitySummary` — `{ minVersion, tokenFeature? }`, the oldest server that has every feature and the one premium feature it must grant — for a consumer that wants a single floor rather than named switches. - A resource whose wire shape differs between generations exports from its `@metabase/client/domain/` module, beside the canonical schema, a reader that picks the exact schema for a `Features` and converts forward: `transformDetailSchema(features)` and `transformRowSchema(features)` read a `Transform` (the older generations' hydrated `table` on the detail endpoint, and no link on the rest, become `target_table_id`), `transformJobSchema(features)` reads a `TransformJob` whose `active` is `null` on a generation that cannot switch jobs off, `transformJobRunResultSchema(features)` reads a `TransformJobRunResult` (`{ message, started, run_id }` — a stub id is `started: null, run_id: null`, the request accepted and the outcome unsaid; a numeric or null id is `started: id !== null, run_id: id`), and `libraryWireSchema(features)` with `toLibrary(wire, listing)` reads a `Library` whose `LibraryChild` entries carry `type` from the wire where the generation sends it and `is_remote_synced` from the collection listing on every generation (`LibraryCollectionInfo`, `LibraryListing`). Each `null` in a canonical field means "this server cannot say". `ResponseShapeError.developerDetail.serverSkew` carries the profile's `skew`, and a `newer-than-known` server is named as such in the message's lead line. -Types: `ServerInfo`, `ServerProfile`, `Skew`, `Edition`, `FeatureName`, `FeatureRule`, `Features`, `FeatureGap`, `MethodKey`, `RequirementFailure`, `RequirementReason`, `CapabilitySummary`. +Types: `ServerInfo`, `ServerProfile`, `Skew`, `Edition`, `FeatureName`, `FeatureRule`, `Features`, `FeatureGap`, `VersionGap`, `TokenGap`, `MethodKey`, `RequirementFailure`, `RequirementReason`. ### Resource schemas diff --git a/packages/client/package.json b/packages/client/package.json index bd6c0b34..1e5b667d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -36,7 +36,6 @@ "./http/oauth": "./dist/http/oauth.js", "./http/retry": "./dist/http/retry.js", "./http/transport": "./dist/http/transport.js", - "./version/capability-summary": "./dist/version/capability-summary.js", "./version/features": "./dist/version/features.js", "./version/profile": "./dist/version/profile.js", "./version/preflight-error": "./dist/version/preflight-error.js", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 1cacb45b..23ef973b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -87,7 +87,6 @@ export { } from "./version/requirements"; export type { MethodKey } from "./version/requirements"; export { checkFeatures, checkRequirements } from "./version/requirement-check"; -export { CapabilitySummary, summarizeCapabilities } from "./version/capability-summary"; export { CapabilityError, RequirementFailure, RequirementReason } from "./version/preflight-error"; export type { Page, PaginateOptions } from "./paginate"; diff --git a/packages/client/src/version/capability-summary.test.ts b/packages/client/src/version/capability-summary.test.ts deleted file mode 100644 index 96fce0c0..00000000 --- a/packages/client/src/version/capability-summary.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { summarizeCapabilities } from "./capability-summary"; -import { KNOWN_RANGE } from "./profile"; - -describe("summarizeCapabilities", () => { - it("reports the oldest known major as the floor of no features at all", () => { - expect(summarizeCapabilities([])).toEqual({ minVersion: KNOWN_RANGE.min }); - }); - - it("reports the highest `since` among the features as the floor", () => { - expect(summarizeCapabilities(["transforms", "transformJobActivation"])).toEqual({ - minVersion: 61, - }); - }); - - it("carries the one premium feature the list needs", () => { - expect(summarizeCapabilities(["library"])).toEqual({ minVersion: 59, tokenFeature: "library" }); - }); - - it("names the same premium feature once when several features share it", () => { - expect(summarizeCapabilities(["library", "library"])).toEqual({ - minVersion: 59, - tokenFeature: "library", - }); - }); - - it("refuses a list that needs two premium features", () => { - expect(() => summarizeCapabilities(["library", "remoteSync"])).toThrow( - "a capability summary names one premium feature, got 2: library, remote_sync", - ); - }); -}); diff --git a/packages/client/src/version/capability-summary.ts b/packages/client/src/version/capability-summary.ts deleted file mode 100644 index ec941762..00000000 --- a/packages/client/src/version/capability-summary.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { z } from "zod"; - -import { FEATURE_RULES, type FeatureName, type FeatureRule } from "./features"; -import { KNOWN_RANGE } from "./profile"; - -// What a set of features amounts to for a consumer that thinks in terms of one server floor and -// one premium feature rather than named switches. -export const CapabilitySummary = z.object({ - minVersion: z.number(), - tokenFeature: z.string().optional(), -}); -export type CapabilitySummary = z.infer; - -export function summarizeCapabilities(features: readonly FeatureName[]): CapabilitySummary { - const rules: FeatureRule[] = features.map((feature) => FEATURE_RULES[feature]); - const minVersion = Math.max(KNOWN_RANGE.min, ...rules.map((rule) => rule.since)); - const tokenFeatures = [ - ...new Set( - rules.flatMap((rule) => (rule.tokenFeature === undefined ? [] : [rule.tokenFeature])), - ), - ]; - if (tokenFeatures.length > 1) { - throw new Error( - `a capability summary names one premium feature, got ${tokenFeatures.length}: ${tokenFeatures.join(", ")}`, - ); - } - const [tokenFeature] = tokenFeatures; - return tokenFeature === undefined ? { minVersion } : { minVersion, tokenFeature }; -} diff --git a/packages/client/src/version/major-comparison-guard.test.ts b/packages/client/src/version/major-comparison-guard.test.ts index 46111799..44101b7e 100644 --- a/packages/client/src/version/major-comparison-guard.test.ts +++ b/packages/client/src/version/major-comparison-guard.test.ts @@ -6,16 +6,13 @@ import { describe, expect, it } from "vitest"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, "..", "..", "..", ".."); -const PACKAGE_ROOTS = ["packages/client/src", "packages/cli/src"]; -// The e2e tier reads a served `minVersion` back from `help --json`; it declares none of its own. -const SOURCE_ROOTS = [...PACKAGE_ROOTS, "tests/e2e"]; +const SOURCE_ROOTS = ["packages/client/src", "packages/cli/src", "tests/e2e"]; // A parsed major on either side of a comparison, reached by property or already destructured. const COMPARISON = "(<=|>=|===|!==|<|>)"; const MAJOR_COMPARISON = new RegExp( `\\bmajor\\s*${COMPARISON}|${COMPARISON}\\s*[\\w$.]*\\bmajor\\b`, ); -const MIN_VERSION = /\bminVersion\b/; const GUARD = "packages/client/src/version/major-comparison-guard.test.ts"; @@ -28,10 +25,6 @@ const EVALUATORS: ReadonlySet = new Set([ "packages/client/src/version/tag.ts", ]); -// A single server floor is a summary the version layer derives from features for consumers that -// want one number; nothing outside it may declare one. -const VERSION_DIR = "packages/client/src/version/"; - function sourceFiles(roots: readonly string[]): string[] { return roots.flatMap((root) => readdirSync(resolve(REPO_ROOT, root), { recursive: true, withFileTypes: true }) @@ -53,10 +46,6 @@ function mayCompareMajors(file: string): boolean { return EVALUATORS.has(file); } -function mayDeclareMinVersion(file: string): boolean { - return file.startsWith(VERSION_DIR); -} - describe("Metabase majors are compared in one place", () => { it("no source outside the version evaluators compares a parsed major", () => { const hits = sourceFiles(SOURCE_ROOTS) @@ -65,13 +54,6 @@ describe("Metabase majors are compared in one place", () => { expect(hits).toEqual([]); }); - it("no source outside the version layer names a minVersion", () => { - const hits = sourceFiles(PACKAGE_ROOTS) - .filter((file) => !mayDeclareMinVersion(file)) - .flatMap((file) => offendingLines(file, MIN_VERSION)); - expect(hits).toEqual([]); - }); - it("every allowlisted file still exists", () => { const existing = new Set(sourceFiles(SOURCE_ROOTS)); const stale = [...EVALUATORS].filter((file) => !existing.has(file)); diff --git a/tests/e2e/help-json.e2e.test.ts b/tests/e2e/help-json.e2e.test.ts index 341a81c5..6ec36bbe 100644 --- a/tests/e2e/help-json.e2e.test.ts +++ b/tests/e2e/help-json.e2e.test.ts @@ -50,7 +50,7 @@ describe("--help --json e2e", () => { expect(index).toEqual(await buildHelpIndex(card, ["card"])); }); - it("reports the client methods a gated command calls, their features, and the floor they summarize to", async () => { + it("reports the client methods a gated command calls and their features", async () => { const result = await runCli({ args: ["transform-job", "set-active", "--help", "--json"], configHome: await makeIsolatedConfigHome(), @@ -63,7 +63,6 @@ describe("--help --json e2e", () => { methods: ["transformJob.setActive"], features: ["transformJobActivation", "transforms"], }); - expect(entry.capabilities).toEqual({ minVersion: 61 }); }); it("reports the premium feature behind a token-gated command", async () => { @@ -76,10 +75,9 @@ describe("--help --json e2e", () => { const entry = parseJson(result.stdout, CommandHelpEntry, { source: "--help --json" }); expect(entry.requires).toEqual({ methods: ["library.get"], features: ["library"] }); - expect(entry.capabilities).toEqual({ minVersion: 59, tokenFeature: "library" }); }); - it("reports a baseline command's methods with no features and the baseline floor", async () => { + it("reports a baseline command's methods with no features", async () => { const result = await runCli({ args: ["card", "list", "--help", "--json"], configHome: await makeIsolatedConfigHome(), @@ -89,10 +87,9 @@ describe("--help --json e2e", () => { const entry = parseJson(result.stdout, CommandHelpEntry, { source: "--help --json" }); expect(entry.requires).toEqual({ methods: ["card.list"], features: [] }); - expect(entry.capabilities).toEqual({ minVersion: 58 }); }); - it("reports null requires and capabilities for a command that never reaches a server", async () => { + it("reports null requires for a command that never reaches a server", async () => { const result = await runCli({ args: ["uuid", "--help", "--json"], configHome: await makeIsolatedConfigHome(), @@ -102,7 +99,6 @@ describe("--help --json e2e", () => { const entry = parseJson(result.stdout, CommandHelpEntry, { source: "--help --json" }); expect(entry.requires).toBeNull(); - expect(entry.capabilities).toBeNull(); }); it("emits the full entry with output schema and examples for a leaf command", async () => { From fbda7ced86b983c2e47c81d3148e8cebf499170f Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:13:12 -0400 Subject: [PATCH 05/22] Give KNOWN_RANGE its own module --- .claude/skills/review/SKILL.md | 2 +- docs/architecture.md | 2 +- packages/cli/src/commands/auth/list.test.ts | 3 ++- packages/cli/src/commands/auth/render.ts | 3 ++- packages/cli/src/commands/auth/status.test.ts | 3 ++- packages/cli/src/commands/runtime.test.ts | 3 ++- packages/cli/src/core/auth/server-summary.test.ts | 3 ++- packages/cli/src/core/auth/server-summary.ts | 8 ++------ packages/client/package.json | 1 + packages/client/src/errors.ts | 3 ++- packages/client/src/http/transport.test.ts | 3 ++- packages/client/src/index.ts | 9 ++------- packages/client/src/resources/transform-job.test.ts | 3 ++- packages/client/src/version/features.test.ts | 2 +- packages/client/src/version/known-range.ts | 5 +++++ packages/client/src/version/matrix-coverage.test.ts | 2 +- packages/client/src/version/profile.test.ts | 3 ++- packages/client/src/version/profile.ts | 7 +------ tests/e2e/version.e2e.test.ts | 2 +- 19 files changed, 34 insertions(+), 33 deletions(-) create mode 100644 packages/client/src/version/known-range.ts diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index 13ef100f..843d3e7e 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -35,7 +35,7 @@ packages/client/src/ response-shape.ts, sanitize.ts, retry.ts, oauth.ts testing/ the shipped test doubles — fake-client.ts, fetch-capture.ts auth/ OAuth login flow: credential.ts, pkce.ts, callback-server.ts, oauth-*.ts - version/ tag.ts, probe.ts, profile.ts (KNOWN_RANGE, ServerProfile), features.ts (FEATURE_RULES — + version/ tag.ts, probe.ts, known-range.ts (KNOWN_RANGE), profile.ts (ServerProfile), features.ts (FEATURE_RULES — the only file comparing a Metabase major), requirements.ts (METHOD_REQUIREMENTS), requirement-check.ts, preflight-error.ts domain/ Metabase API resource Zod schemas + inferred types diff --git a/docs/architecture.md b/docs/architecture.md index 8d707ff7..1fe981dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,7 @@ Truncation by the output byte cap recomputes `has_more` / `next_offset`, making ## Server versions -The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packages/client/src/version/profile.ts`, and only the latest patch of each major: patch releases carry fixes, not API-contract changes, so the e2e matrix boots the latest patch per major and that is the whole contract. Inside the window every difference the client cares about is a named feature; outside it the client degrades in one stated way rather than guessing. +The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packages/client/src/version/known-range.ts`, and only the latest patch of each major: patch releases carry fixes, not API-contract changes, so the e2e matrix boots the latest patch per major and that is the whole contract. Inside the window every difference the client cares about is a named feature; outside it the client degrades in one stated way rather than guessing. **One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (parsed tag, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's leading number — `v0.` is OSS, `v1.` is EE — and, for a tag that stamps none, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`; a tag that does not parse — head images report `vUNKNOWN` — lands in the same slot as `unknown`. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. diff --git a/packages/cli/src/commands/auth/list.test.ts b/packages/cli/src/commands/auth/list.test.ts index 5742790a..2540222e 100644 --- a/packages/cli/src/commands/auth/list.test.ts +++ b/packages/cli/src/commands/auth/list.test.ts @@ -5,7 +5,8 @@ import type { z, ZodType } from "zod"; import type { Credential } from "@metabase/client/auth/credential"; import { parseJson } from "@metabase/client/json"; import type { ServerInfo } from "@metabase/client/version/probe"; -import { createServerProfile, KNOWN_RANGE } from "@metabase/client/version/profile"; +import { KNOWN_RANGE } from "@metabase/client/version/known-range"; +import { createServerProfile } from "@metabase/client/version/profile"; import type { ProfileRecord } from "../../core/auth/profile-record"; import type { Verification } from "../../core/auth/verify"; diff --git a/packages/cli/src/commands/auth/render.ts b/packages/cli/src/commands/auth/render.ts index 65452730..75794ca7 100644 --- a/packages/cli/src/commands/auth/render.ts +++ b/packages/cli/src/commands/auth/render.ts @@ -1,4 +1,5 @@ -import { KNOWN_RANGE, Skew } from "@metabase/client/version/profile"; +import { KNOWN_RANGE } from "@metabase/client/version/known-range"; +import { Skew } from "@metabase/client/version/profile"; import { EMPTY_CELL } from "../../output/table"; diff --git a/packages/cli/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts index e8c30131..9f19c42a 100644 --- a/packages/cli/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -4,7 +4,8 @@ import type { ZodType } from "zod"; import { parseJson } from "@metabase/client/json"; import type { ServerInfo } from "@metabase/client/version/probe"; -import { createServerProfile, KNOWN_RANGE } from "@metabase/client/version/profile"; +import { KNOWN_RANGE } from "@metabase/client/version/known-range"; +import { createServerProfile } from "@metabase/client/version/profile"; const hoisted = vi.hoisted(() => ({ store: new Map(), diff --git a/packages/cli/src/commands/runtime.test.ts b/packages/cli/src/commands/runtime.test.ts index ec6b7d56..ba3f38ee 100644 --- a/packages/cli/src/commands/runtime.test.ts +++ b/packages/cli/src/commands/runtime.test.ts @@ -5,7 +5,8 @@ import { z } from "zod"; import { parseJson } from "@metabase/client/json"; import { captureFetch, jsonResponse } from "@metabase/client/testing/fetch-capture"; import type { ServerInfo } from "@metabase/client/version/probe"; -import { createServerProfile, KNOWN_RANGE } from "@metabase/client/version/profile"; +import { KNOWN_RANGE } from "@metabase/client/version/known-range"; +import { createServerProfile } from "@metabase/client/version/profile"; import type { ProfileLastProbe, ProfileRecord } from "../core/auth/profile-record"; import { probeAt, setupTempConfigHome, type TempConfigHome } from "../core/auth/temp-config-home"; diff --git a/packages/cli/src/core/auth/server-summary.test.ts b/packages/cli/src/core/auth/server-summary.test.ts index fb7bfa2c..86865b63 100644 --- a/packages/cli/src/core/auth/server-summary.test.ts +++ b/packages/cli/src/core/auth/server-summary.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import type { ServerInfo } from "@metabase/client/version/probe"; -import { createServerProfile, KNOWN_RANGE } from "@metabase/client/version/profile"; +import { KNOWN_RANGE } from "@metabase/client/version/known-range"; +import { createServerProfile } from "@metabase/client/version/profile"; import { serverChangeNote, skewNotice, summarizeServer } from "./server-summary"; diff --git a/packages/cli/src/core/auth/server-summary.ts b/packages/cli/src/core/auth/server-summary.ts index e4150db0..0f616160 100644 --- a/packages/cli/src/core/auth/server-summary.ts +++ b/packages/cli/src/core/auth/server-summary.ts @@ -3,12 +3,8 @@ import { z } from "zod"; import { TokenFeatures } from "@metabase/client/domain/session-properties"; import { Features } from "@metabase/client/version/features"; import type { ServerInfo } from "@metabase/client/version/probe"; -import { - createServerProfile, - KNOWN_RANGE, - type ServerProfile, - Skew, -} from "@metabase/client/version/profile"; +import { KNOWN_RANGE } from "@metabase/client/version/known-range"; +import { createServerProfile, type ServerProfile, Skew } from "@metabase/client/version/profile"; import { Edition, ParsedVersion } from "@metabase/client/version/tag"; const KnownRange = z.object({ diff --git a/packages/client/package.json b/packages/client/package.json index 1e5b667d..885d2309 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -37,6 +37,7 @@ "./http/retry": "./dist/http/retry.js", "./http/transport": "./dist/http/transport.js", "./version/features": "./dist/version/features.js", + "./version/known-range": "./dist/version/known-range.js", "./version/profile": "./dist/version/profile.js", "./version/preflight-error": "./dist/version/preflight-error.js", "./version/probe": "./dist/version/probe.js", diff --git a/packages/client/src/errors.ts b/packages/client/src/errors.ts index 4dce43a3..50f66f3a 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -1,7 +1,8 @@ import { core as zodCore, ZodError } from "zod"; import { escapeJsonPointerSegment } from "./json-pointer"; -import { KNOWN_RANGE, type Skew } from "./version/profile"; +import { KNOWN_RANGE } from "./version/known-range"; +import type { Skew } from "./version/profile"; export type ErrorCategory = | "network" diff --git a/packages/client/src/http/transport.test.ts b/packages/client/src/http/transport.test.ts index f5f4b385..bd5ef4b1 100644 --- a/packages/client/src/http/transport.test.ts +++ b/packages/client/src/http/transport.test.ts @@ -18,7 +18,8 @@ import { captureFetch, jsonResponse, TEST_USER_AGENT } from "../testing/fetch-ca import { CapabilityError } from "../version/preflight-error"; import { SessionProperties } from "../domain/session-properties"; import { PROBE_PATH } from "../version/probe"; -import { createServerProfile, KNOWN_RANGE } from "../version/profile"; +import { KNOWN_RANGE } from "../version/known-range"; +import { createServerProfile } from "../version/profile"; const CONFIG: ClientCredentials = { url: "https://m.example.com", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 23ef973b..94bd6483 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -72,13 +72,8 @@ export type { TokenGap, VersionGap, } from "./version/features"; -export { - createServerProfile, - featureGap, - KNOWN_RANGE, - ServerProfile, - Skew, -} from "./version/profile"; +export { KNOWN_RANGE } from "./version/known-range"; +export { createServerProfile, featureGap, ServerProfile, Skew } from "./version/profile"; export { isMethodKey, METHOD_KEYS, diff --git a/packages/client/src/resources/transform-job.test.ts b/packages/client/src/resources/transform-job.test.ts index 9a73af4e..05d5232a 100644 --- a/packages/client/src/resources/transform-job.test.ts +++ b/packages/client/src/resources/transform-job.test.ts @@ -9,7 +9,8 @@ import { jsonResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; -import { createServerProfile, KNOWN_RANGE, type ServerProfile } from "../version/profile"; +import { KNOWN_RANGE } from "../version/known-range"; +import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { url: "https://mb.example.com/metabase", diff --git a/packages/client/src/version/features.test.ts b/packages/client/src/version/features.test.ts index 2372c3da..e833ddd9 100644 --- a/packages/client/src/version/features.test.ts +++ b/packages/client/src/version/features.test.ts @@ -8,7 +8,7 @@ import { type FeatureRule, ruleGap, } from "./features"; -import { KNOWN_RANGE } from "./profile"; +import { KNOWN_RANGE } from "./known-range"; const RULES: ReadonlyArray = Object.values(FEATURE_RULES); diff --git a/packages/client/src/version/known-range.ts b/packages/client/src/version/known-range.ts new file mode 100644 index 00000000..233ea710 --- /dev/null +++ b/packages/client/src/version/known-range.ts @@ -0,0 +1,5 @@ +// The majors this client was built and tested against. A server above `max` is read as `max + 1`: +// its additions pass through the loose schemas, and a shape it changed fails loudly instead of +// being guessed at. A server below `min` keeps its real major so every feature it lacks is refused +// by name. +export const KNOWN_RANGE = { min: 58, max: 63 } as const; diff --git a/packages/client/src/version/matrix-coverage.test.ts b/packages/client/src/version/matrix-coverage.test.ts index ff79a7ca..986c414a 100644 --- a/packages/client/src/version/matrix-coverage.test.ts +++ b/packages/client/src/version/matrix-coverage.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { KNOWN_RANGE } from "./profile"; +import { KNOWN_RANGE } from "./known-range"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, "..", "..", "..", ".."); diff --git a/packages/client/src/version/profile.test.ts b/packages/client/src/version/profile.test.ts index 7a57d872..8c942303 100644 --- a/packages/client/src/version/profile.test.ts +++ b/packages/client/src/version/profile.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest"; import { evaluateFeatures } from "./features"; import type { ServerInfo } from "./probe"; -import { createServerProfile, featureGap, KNOWN_RANGE } from "./profile"; +import { KNOWN_RANGE } from "./known-range"; +import { createServerProfile, featureGap } from "./profile"; import { editionFromTag } from "./tag"; const HEAD_SLOT = KNOWN_RANGE.max + 1; diff --git a/packages/client/src/version/profile.ts b/packages/client/src/version/profile.ts index 9709d1f9..31e1f057 100644 --- a/packages/client/src/version/profile.ts +++ b/packages/client/src/version/profile.ts @@ -10,15 +10,10 @@ import { Features, ruleGap, } from "./features"; +import { KNOWN_RANGE } from "./known-range"; import type { ServerInfo } from "./probe"; import { Edition, ParsedVersion } from "./tag"; -// The majors this client was built and tested against. A server above `max` is read as `max + 1`: -// its additions pass through the loose schemas, and a shape it changed fails loudly instead of -// being guessed at. A server below `min` keeps its real major so every feature it lacks is refused -// by name. -export const KNOWN_RANGE = { min: 58, max: 63 } as const; - export const Skew = z.enum(["supported", "newer-than-known", "unknown"]); export type Skew = z.infer; diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index 2e6a4674..32db87f8 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { parseJson } from "@metabase/client/json"; -import { KNOWN_RANGE } from "@metabase/client/version/profile"; +import { KNOWN_RANGE } from "@metabase/client/version/known-range"; import { AuthStatus } from "../../packages/cli/src/commands/auth/status"; import { CardListEnvelope } from "../../packages/cli/src/commands/card/list"; From a3b117835dc2568f1959401458bcfc49ef90b288 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:14:12 -0400 Subject: [PATCH 06/22] Report a server below the known range as older-than-known --- README.md | 6 +++--- docs/architecture.md | 4 ++-- packages/cli/src/commands/auth/render.ts | 1 + packages/cli/src/core/auth/server-summary.test.ts | 7 +++++++ packages/cli/src/core/auth/server-summary.ts | 5 ++++- packages/client/README.md | 4 ++-- packages/client/src/version/profile.test.ts | 4 ++-- packages/client/src/version/profile.ts | 5 ++++- 8 files changed, 25 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bcdf0f5d..9683ba29 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Command-line client for Metabase. Logs in to an instance in your browser (OAuth, ## Supported Metabase versions -The CLI is built against Metabase majors **58 through 63** (the client's `KNOWN_RANGE`), the latest patch of each; a newer server, or a head build whose version tag does not parse, runs as a head build past the newest known major — every shape the client knows head answers with, and one stderr notice per run — and an older one is refused command by command with the version it needs. +The CLI is built against Metabase majors **58 through 63** (the client's `KNOWN_RANGE`), the latest patch of each; a newer server, or a head build whose version tag does not parse, runs as a head build past the newest known major — every shape the client knows head answers with, and one stderr notice per run — and an older one keeps its real major, gets one stderr notice per run pointing at a Metabase upgrade, and is refused command by command with the version it needs. Every command declares the client methods it calls, and each method names the server features it needs — a feature is a minimum major version, a premium token feature, or both. The server version and token features are detected and cached when you run `mb auth login` (or `mb auth list`). For a command whose methods need a feature, a preflight check runs before the first request and refuses with an actionable message (exit code `2`) when: @@ -13,7 +13,7 @@ Every command declares the client methods it calls, and each method names the se Plain OSS commands against a v0.58+ server (the majority) carry no elevated requirement and skip the preflight entirely. When a gated command runs without a cached probe, the CLI asks the server for its version once and decides on the answer; a server that cannot be reached fails the command with that network error. 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 switch off the client's own check too, so every request goes to the wire and the server answers for itself — footguns, only for servers you know are patched. -`mb auth status --json` reports the window as `knownRange` and where the server sits as `skew`. A server above the window is read as a head build past the newest known major — its additions pass through, and one stderr notice per run points at `mb upgrade`; a server whose version tag does not parse (head builds) is treated the same way with its own notice. A response the CLI cannot parse, or a refusal it issues, under a cached profile triggers one fresh probe: if the server's version or premium features changed since the cache was written, the profile is refreshed and the error says so — retry the command. +`mb auth status --json` reports the window as `knownRange` and where the server sits as `skew`. A server above the window is read as a head build past the newest known major — its additions pass through, and one stderr notice per run points at `mb upgrade`; a server whose version tag does not parse (head builds) is treated the same way with its own notice; a server below the window is `older-than-known`, still evaluated at its own major, with a notice naming the oldest major the CLI supports. A response the CLI cannot parse, or a refusal it issues, under a cached profile triggers one fresh probe: if the server's version or premium features changed since the cache was written, the profile is refreshed and the error says so — retry the command. ## Install @@ -124,7 +124,7 @@ mb auth login --url https://m.example.com < key.txt ### `mb auth status` -Show whether a profile is authenticated. The output includes the auth method (`OAuth` or `API key`) alongside the cached user, role, server version and skew (`supported`, `newer than this CLI knows (vN max)`, or `unknown version`). `--json` adds what the CLI derives from the cached probe: `edition`, `skew`, `knownRange` and the `features` map the preflight checks. +Show whether a profile is authenticated. The output includes the auth method (`OAuth` or `API key`) alongside the cached user, role, server version and skew (`supported`, `older than this CLI supports (vN min)`, `newer than this CLI knows (vN max)`, or `unknown version`). `--json` adds what the CLI derives from the cached probe: `edition`, `skew`, `knownRange` and the `features` map the preflight checks. ```sh mb auth status diff --git a/docs/architecture.md b/docs/architecture.md index 1fe981dc..3c86fbe7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,7 @@ Truncation by the output byte cap recomputes `has_more` / `next_offset`, making The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packages/client/src/version/known-range.ts`, and only the latest patch of each major: patch releases carry fixes, not API-contract changes, so the e2e matrix boots the latest patch per major and that is the whole contract. Inside the window every difference the client cares about is a named feature; outside it the client degrades in one stated way rather than guessing. -**One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (parsed tag, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's leading number — `v0.` is OSS, `v1.` is EE — and, for a tag that stamps none, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`; a tag that does not parse — head images report `vUNKNOWN` — lands in the same slot as `unknown`. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. +**One `ServerProfile`, derived once.** `probeServer` reads `/api/session/properties` into a `ServerInfo` (parsed tag, build date, hash, token-feature map), and `createServerProfile` turns that into the `ServerProfile` the transport carries: the raw facts, `edition` (read from the tag's leading number — `v0.` is OSS, `v1.` is EE — and, for a tag that stamps none, from whether any premium feature is granted, because an unlicensed EE reports the same all-false map OSS does), the derived `features`, and `skew`. A major inside the window is `supported`; one below it keeps its real major as `older-than-known`, so every feature it lacks is refused by name; one above it is placed at `KNOWN_RANGE.max + 1` as `newer-than-known`; a tag that does not parse — head images report `vUNKNOWN` — lands in the same slot as `unknown`. The CLI hands the profile record's cached probe to `createClient` as `ClientOptions.server`, so a command pays no probe; a client without one probes lazily on the first call that needs it and memoizes. Nothing may pass a version in — `createClient` takes a profile or probes, never an `assumeVersion` — so the CLI, the harness and the client can never disagree about which server they are on. **`features.ts` is the only file that compares a major.** `FEATURE_RULES` maps a behaviour name to `{ since, until?, tokenFeature? }`; `evaluateFeatures` turns the placed major and the token map into `Features`, a record of booleans every consumer branches on. Two tests keep the table honest: `major-comparison-guard.test.ts` scans both packages' source and the e2e tier for a `.major` comparison outside the version evaluators, and `features.test.ts` asserts every version-gated rule is non-constant across `KNOWN_RANGE.min … max + 1` and that no name carries a version. When a major leaves the window the rules that stopped flipping fail the second test and are deleted with their wire variants — that is what keeps the switch count bounded. Rules exist for three kinds of consumer: resource methods and readers (`transforms`, `transformTargetTableId`, `libraryChildrenCarryType`), e2e suites that pin per-generation server behaviour (`invalidMbqlIsBadRequest`, `collectionItemsTotalOnEmptyPage`), and skill prose (`boxplotDisplay`, `smartLinkMeasureModel`); a rule no client code reads is still a rule, its consumer is the gate or the skill. @@ -68,7 +68,7 @@ The client is built against a window of Metabase majors, `KNOWN_RANGE` in `packa Two things that look like version drift are not, and must not become switches: a setting's value encoding (a string-typed setting answers bare `text/plain`, everything else `application/json`, and a keyword's bare name under that label is the one body that lies) is dispatched on the value's runtime type by middlewares unchanged since v58, so `parseJsonOrPlain` is a correct sniff; and a resource-missing 404 is `text/plain` `Not found.` on every generation while a route-missing 404 is a JSON string literal, dispatched on which handler produced it, so `isRouteMissingResponse` is a correct sniff too. -**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run pointing at `mb upgrade`; an unparseable tag gets its own notice. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. A `ResponseShapeError` or a `CapabilityError` raised under a cached profile earns one re-probe (`refreshProfileOnStaleError` in `commands/runtime.ts`): a server whose version tag or granted token features changed since the cache was written — an upgrade that changed a shape, or an upgrade or new licence that clears a floor the refusal still names — has its record rewritten and the error says so; the command is not retried, because its request may have been a write. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). +**Skew is reported, never refused.** A server above the window runs in the head slot, `KNOWN_RANGE.max + 1`: its additions pass through `.loose()`, and the CLI prints one stderr notice per run pointing at `mb upgrade`; an unparseable tag gets its own notice, and a server below the window gets one pointing at a Metabase upgrade while its commands are refused one feature at a time. Refusing a newer server would make the CLI unusable against head, which is the build the team lives on. If head has changed a shape the parse fails loudly instead of trying older shapes. A `ResponseShapeError` or a `CapabilityError` raised under a cached profile earns one re-probe (`refreshProfileOnStaleError` in `commands/runtime.ts`): a server whose version tag or granted token features changed since the cache was written — an upgrade that changed a shape, or an upgrade or new licence that clears a floor the refusal still names — has its record rewritten and the error says so; the command is not retried, because its request may have been a write. The cached probe is trusted only when the record's URL is the one the command resolved — `--url` can point a profile name at another host. `auth status` / `auth list` / `auth login` render `skew` beside the version and, in JSON, `edition`, `knownRange` and the `features` map (`core/auth/server-summary.ts`). **Skills carry `requires`.** Skill prose describes the newest behaviour with no version branches — logic executed by a model is untestable and drifts. A skill declares `requires: [, …]` in its frontmatter and fences a passage between `` and ``; `mb skills list` / `mb skills get` resolve both against the profile's cached probe (`core/auth/cached-server.ts`, no request), leave out a skill the server cannot use, strip an unmet passage, and report the skipped skills as `unavailable: [{ name, failure }]` with the same `RequirementFailure` a refused command carries. A marker must sit on its own line and never inside a Markdown table, because the formatter splits a table around an HTML comment. diff --git a/packages/cli/src/commands/auth/render.ts b/packages/cli/src/commands/auth/render.ts index 75794ca7..9982ad53 100644 --- a/packages/cli/src/commands/auth/render.ts +++ b/packages/cli/src/commands/auth/render.ts @@ -5,6 +5,7 @@ import { EMPTY_CELL } from "../../output/table"; const SKEW_LABEL: Readonly> = Object.freeze({ supported: "supported", + "older-than-known": `older than this CLI supports (v${KNOWN_RANGE.min} min)`, "newer-than-known": `newer than this CLI knows (v${KNOWN_RANGE.max} max)`, unknown: "unknown version", }); diff --git a/packages/cli/src/core/auth/server-summary.test.ts b/packages/cli/src/core/auth/server-summary.test.ts index 86865b63..a8fa672e 100644 --- a/packages/cli/src/core/auth/server-summary.test.ts +++ b/packages/cli/src/core/auth/server-summary.test.ts @@ -55,6 +55,13 @@ describe("skewNotice", () => { expect(skewNotice(createServerProfile(serverAt(KNOWN_RANGE.max)))).toBeNull(); }); + it("points an older server at upgrading Metabase and names the oldest major it supports", () => { + const below = KNOWN_RANGE.min - 1; + expect(skewNotice(createServerProfile(serverAt(below)))).toBe( + `Metabase v0.${below}.0 is older than this CLI supports (v${KNOWN_RANGE.min}+); commands needing a newer feature are refused by name. Upgrade Metabase to v${KNOWN_RANGE.min} or later.`, + ); + }); + it("points a newer server at mb upgrade and names the major it is read as", () => { const beyond = KNOWN_RANGE.max + 1; expect(skewNotice(createServerProfile(serverAt(beyond)))).toBe( diff --git a/packages/cli/src/core/auth/server-summary.ts b/packages/cli/src/core/auth/server-summary.ts index 0f616160..97b8e1e4 100644 --- a/packages/cli/src/core/auth/server-summary.ts +++ b/packages/cli/src/core/auth/server-summary.ts @@ -54,11 +54,14 @@ function versionLabel(version: ParsedVersion | null): string { // One line for a server outside the window this CLI was built against, or `null` inside it. export function skewNotice(profile: ServerProfile): string | null { - const max = KNOWN_RANGE.max; + const { min, max } = KNOWN_RANGE; switch (profile.skew) { case "supported": { return null; } + case "older-than-known": { + return `Metabase ${versionLabel(profile.version)} is older than this CLI supports (v${min}+); commands needing a newer feature are refused by name. Upgrade Metabase to v${min} or later.`; + } case "newer-than-known": { return `Metabase ${versionLabel(profile.version)} is newer than this CLI supports (up to v${max}); commands run as if it were a head build past v${max}. Run \`mb upgrade\` for a newer CLI.`; } diff --git a/packages/client/README.md b/packages/client/README.md index 9b72ca64..bdb75d31 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -305,7 +305,7 @@ try { A method's return type is the shape the newest supported server answers. Where an older server answers differently, the method parses that server's exact wire shape and converts it forward, so `mb.transform.get(5)` carries `target_table_id` on every server (a hydrated `table` on the oldest generations, the column on the rest), `mb.transformJob.run(2)` answers `{ message, started, run_id }` whether the server reports an opaque stub or a numeric run id, and each of `mb.library.get()`'s `effective_children` is `{ id, name, description, type, is_remote_synced }`. A field the server cannot report is `null`; the profile's `features` say why. A response that fails its generation's schema is a `ResponseShapeError` naming the server version and the fields that were off — never a silent fallback to another shape. -A server newer than the window, or a head build whose tag does not parse, is placed one past the newest known major — the slot a rule written against head describes — with `skew` set to `"newer-than-known"` or `"unknown"`; its additions pass through the loose schemas, and only a token feature can refuse it. The client never takes a version from the caller: a profile comes from a probe, so two consumers of one server cannot disagree about what it is. +A server newer than the window, or a head build whose tag does not parse, is placed one past the newest known major — the slot a rule written against head describes — with `skew` set to `"newer-than-known"` or `"unknown"`; its additions pass through the loose schemas, and only a token feature can refuse it. A server older than the window is `"older-than-known"`, evaluated at its own major, so each method it lacks refuses by name. The client never takes a version from the caller: a profile comes from a probe, so two consumers of one server cannot disagree about what it is. ### Endpoints the client does not model @@ -409,7 +409,7 @@ revocation endpoint. Types: `Credential` (the `ApiKeyCredential | OAuthCredentia `probeServer(client)` reads `/api/session/properties` and returns a `ServerInfo` — the parsed version tag, the edition the tag stamps, the build date and hash, and the token-feature map, each `null` when the server does not report it. `ParsedVersion` is the `{ tag, major, patch }` schema a probed version tag parses to; `editionFromTag(tag)` reads the `Edition` (`"oss"` | `"ee"`) Metabase stamps into the tag's leading number (`v0.` / `v1.`, a hotfix's fourth number and a `-SNAPSHOT` suffix included), `null` when the tag carries none. -`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` at or below `KNOWN_RANGE.max` (`KNOWN_RANGE` is `{ min, max }`, the majors the client is built against; an older server keeps its real major and is refused feature by feature rather than flagged), `"newer-than-known"` above it, `"unknown"` when the tag did not parse. A newer or unparseable server is evaluated as `KNOWN_RANGE.max + 1`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile passed as `ClientOptions.server`, or probes once on first call and shares the result; `getServerTag` defaults to the tag of whichever profile the client holds — the one passed in, or the one a probe has settled on — so a shape error names the server whenever the client has met it. There is no option to assume a version. +`createServerProfile(info)` turns a `ServerInfo` into the `ServerProfile` the client reasons with: the raw facts (`version`, `buildDate`, `hash`, `edition`, `tokenFeatures`), the derived `features`, and `skew` (`Skew`) — `"supported"` inside `KNOWN_RANGE` (`{ min, max }`, the majors the client is built against), `"older-than-known"` below it (the server keeps its real major and is refused feature by feature), `"newer-than-known"` above it, `"unknown"` when the tag did not parse. A newer or unparseable server is evaluated as `KNOWN_RANGE.max + 1`; the edition of a server whose tag carries none is `"ee"` when any premium feature is granted and `"oss"` otherwise. `client.server()` returns the profile passed as `ClientOptions.server`, or probes once on first call and shares the result; `getServerTag` defaults to the tag of whichever profile the client holds — the one passed in, or the one a probe has settled on — so a shape error names the server whenever the client has met it. There is no option to assume a version. `Features` is a record of named booleans over `FEATURE_RULES`, the one table that relates a behaviour to the majors and token feature it needs (`FeatureRule`: `{ since, until?, tokenFeature? }`); `evaluateFeatures(effectiveMajor, tokenFeatures)` evaluates it, `FEATURE_NAMES` lists the keys, and `isFeatureName` narrows a string to one. Names describe behaviour (`transformTargetTableId`, `libraryChildrenCarryType`), never a version. Consumers branch on a feature, never on a major — `features.ts` is the only module that compares one. diff --git a/packages/client/src/version/profile.test.ts b/packages/client/src/version/profile.test.ts index 8c942303..3f69815b 100644 --- a/packages/client/src/version/profile.test.ts +++ b/packages/client/src/version/profile.test.ts @@ -64,7 +64,7 @@ describe("createServerProfile", () => { }); }); - it("keeps a major below the window at its real major, still supported", () => { + it("keeps a major below the window at its real major and reports older-than-known", () => { const info = released("v0.57.2", 57, null); expect(createServerProfile(info)).toEqual({ version: { tag: "v0.57.2", major: 57, patch: 2 }, @@ -73,7 +73,7 @@ describe("createServerProfile", () => { edition: "oss", tokenFeatures: null, features: evaluateFeatures(57, null), - skew: "supported", + skew: "older-than-known", }); }); diff --git a/packages/client/src/version/profile.ts b/packages/client/src/version/profile.ts index 31e1f057..9e5ba2ff 100644 --- a/packages/client/src/version/profile.ts +++ b/packages/client/src/version/profile.ts @@ -14,7 +14,7 @@ import { KNOWN_RANGE } from "./known-range"; import type { ServerInfo } from "./probe"; import { Edition, ParsedVersion } from "./tag"; -export const Skew = z.enum(["supported", "newer-than-known", "unknown"]); +export const Skew = z.enum(["supported", "older-than-known", "newer-than-known", "unknown"]); export type Skew = z.infer; export const ServerProfile = z.object({ @@ -60,6 +60,9 @@ function place(version: ParsedVersion | null): Placement { if (version.major > KNOWN_RANGE.max) { return { effectiveMajor: KNOWN_RANGE.max + 1, skew: "newer-than-known" }; } + if (version.major < KNOWN_RANGE.min) { + return { effectiveMajor: version.major, skew: "older-than-known" }; + } return { effectiveMajor: version.major, skew: "supported" }; } From 5029c84f6acde4933a8dd836a19f621a7470392a Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:14:27 -0400 Subject: [PATCH 07/22] Keep one seeded-profile helper for both test tiers --- .../cli/src/core/auth/temp-config-home.ts | 39 +++++++++++---- tests/e2e/seed-profile.ts | 48 ++++++------------- tests/e2e/skills.e2e.test.ts | 13 ++--- tests/e2e/version.e2e.test.ts | 23 +++++---- 4 files changed, 62 insertions(+), 61 deletions(-) diff --git a/packages/cli/src/core/auth/temp-config-home.ts b/packages/cli/src/core/auth/temp-config-home.ts index 4cc2d904..0a318902 100644 --- a/packages/cli/src/core/auth/temp-config-home.ts +++ b/packages/cli/src/core/auth/temp-config-home.ts @@ -3,12 +3,22 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ServerInfo } from "@metabase/client/version/probe"; +import { editionFromTag } from "@metabase/client/version/tag"; import type { ProbedUser } from "./profile-record"; import { writeProbeResult, writeProfile } from "./storage"; -const UNREACHABLE_URL = "http://127.0.0.1:1"; -const SEED_USER: ProbedUser = { id: 1, name: "Tester", isAdmin: true }; +// Port 1 is on fetch's blocked-port list, so a test reading the cached probe cannot be quietly +// served by a live server, and the failure carries no syscall code to hint from. +export const UNREACHABLE_URL = "http://127.0.0.1:1"; +export const SEED_USER: ProbedUser = { id: 1, name: "Tester", isAdmin: true }; + +export interface SeedTarget { + url: string; + apiKey: string; +} + +export const UNREACHABLE_TARGET: SeedTarget = { url: UNREACHABLE_URL, apiKey: "secret-key" }; export interface TempConfigHome { path: string; @@ -29,27 +39,36 @@ export function setupTempConfigHome(): TempConfigHome { }; } -// A profile record as `auth login` would have left it: the key stored, the server probed. The URL -// never answers, so a test reading the cached probe cannot be quietly served by a live server. +// A profile record as `auth login` would have left it: the key stored, the server probed. export async function seedProbedProfile( name: string, server: ServerInfo, - user: ProbedUser = SEED_USER, + target: SeedTarget = UNREACHABLE_TARGET, ): Promise { - await writeProfile({ url: UNREACHABLE_URL, apiKey: "secret-key" }, name); - await writeProbeResult(name, { user, server }); + await writeProfile(target, name); + await writeProbeResult(name, { user: SEED_USER, server }); } -// A released OSS server at `major`, as `probeServer` would report it. +// A released server at `major`, as `probeServer` would report it: the edition is what the tag stamps. export function probeAt( major: number, tokenFeatures: ServerInfo["tokenFeatures"] = null, ): ServerInfo { + const tag = `v0.${major}.0`; return { - edition: "oss", - version: { tag: `v0.${major}.0`, major, patch: 0 }, + edition: editionFromTag(tag), + version: { tag, major, patch: 0 }, date: null, hash: null, tokenFeatures, }; } + +// A server whose tag parses to nothing, as `probeServer` reports a head or local build. +export const UNPARSEABLE_PROBE: ServerInfo = { + edition: null, + version: null, + date: null, + hash: null, + tokenFeatures: null, +}; diff --git a/tests/e2e/seed-profile.ts b/tests/e2e/seed-profile.ts index a33f0662..18968508 100644 --- a/tests/e2e/seed-profile.ts +++ b/tests/e2e/seed-profile.ts @@ -1,24 +1,14 @@ import type { ServerInfo } from "@metabase/client/version/probe"; -import { editionFromTag } from "@metabase/client/version/tag"; -import { writeProbeResult, writeProfile } from "../../packages/cli/src/core/auth/storage"; +import { writeProfile } from "../../packages/cli/src/core/auth/storage"; +import { + probeAt, + type SeedTarget, + seedProbedProfile as writeProbedProfile, + UNREACHABLE_TARGET, +} from "../../packages/cli/src/core/auth/temp-config-home"; -// The seeded records are written straight through the CLI's own storage, so the CLI under test -// reads a profile it could have written itself; the port-1 URL never answers, which keeps a test -// on the cached probe honest about opening no socket. -export const UNREACHABLE_URL = "http://127.0.0.1:1"; -export const SEED_USER = { id: 1, name: "Tester", isAdmin: true }; - -export interface SeedTarget { - url: string; - apiKey: string; -} - -export const UNREACHABLE: SeedTarget = { url: UNREACHABLE_URL, apiKey: "secret-key" }; - -export function versionAt(major: number): ServerInfo["version"] { - return { tag: `v0.${major}.0`, major, patch: 0 }; -} +const SEED_PROFILE_NAME = "default"; function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { @@ -28,6 +18,9 @@ function restoreEnv(key: string, value: string | undefined): void { } } +// The CLI's own storage writes the seeded records, so the binary under test reads a profile it +// could have written itself; the storage reads its config home and keyring switch off the +// environment, which is pointed at the test's config home for the write alone. async function withSeedEnv(configHome: string, seed: () => Promise): Promise { const prevXdg = process.env["XDG_CONFIG_HOME"]; const prevKeyring = process.env["MB_CLI_DISABLE_KEYRING"]; @@ -43,31 +36,20 @@ async function withSeedEnv(configHome: string, seed: () => Promise): Promi export async function seedProfile(configHome: string): Promise { await withSeedEnv(configHome, async () => { - await writeProfile(UNREACHABLE, "default"); + await writeProfile(UNREACHABLE_TARGET, SEED_PROFILE_NAME); }); } export async function seedProbedProfileAt( configHome: string, target: SeedTarget, - version: ServerInfo["version"], - tokenFeatures: ServerInfo["tokenFeatures"] = null, + server: ServerInfo, ): Promise { await withSeedEnv(configHome, async () => { - await writeProfile(target, "default"); - await writeProbeResult("default", { - user: SEED_USER, - server: { - version, - edition: version === null ? null : editionFromTag(version.tag), - date: null, - hash: null, - tokenFeatures, - }, - }); + await writeProbedProfile(SEED_PROFILE_NAME, server, target); }); } export async function seedProbedProfile(configHome: string, major: number): Promise { - await seedProbedProfileAt(configHome, UNREACHABLE, versionAt(major)); + await seedProbedProfileAt(configHome, UNREACHABLE_TARGET, probeAt(major)); } diff --git a/tests/e2e/skills.e2e.test.ts b/tests/e2e/skills.e2e.test.ts index 51d4e940..1d0abd84 100644 --- a/tests/e2e/skills.e2e.test.ts +++ b/tests/e2e/skills.e2e.test.ts @@ -9,6 +9,7 @@ import { parseJson } from "@metabase/client/json"; import { SkillGetEnvelope } from "../../packages/cli/src/commands/skills/get"; import { SkillListEnvelope } from "../../packages/cli/src/commands/skills/list"; import { SkillPathListEnvelope } from "../../packages/cli/src/commands/skills/path"; +import { probeAt, UNREACHABLE_TARGET } from "../../packages/cli/src/core/auth/temp-config-home"; import { discoverSkills, type SkillContent, @@ -18,7 +19,7 @@ import { fitWithinCap } from "../../packages/cli/src/output/cap"; import { DEFAULT_MAX_BYTES } from "../../packages/cli/src/output/types"; import { cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { seedProbedProfile, seedProbedProfileAt, UNREACHABLE, versionAt } from "./seed-profile"; +import { seedProbedProfile, seedProbedProfileAt } from "./seed-profile"; type SkillGetPayload = z.infer; @@ -240,11 +241,11 @@ describe("skills e2e", () => { const oss58 = await makeIsolatedConfigHome(); await seedProbedProfile(oss58, 58); const ee63 = await makeIsolatedConfigHome(); - await seedProbedProfileAt(ee63, UNREACHABLE, versionAt(63), { - library: true, - remote_sync: true, - content_translation: true, - }); + await seedProbedProfileAt( + ee63, + UNREACHABLE_TARGET, + probeAt(63, { library: true, remote_sync: true, content_translation: true }), + ); const onOss58 = await runCli({ args: ["skills", "get", "core", "--json"], configHome: oss58 }); const onEe63 = await runCli({ args: ["skills", "get", "core", "--json"], configHome: ee63 }); diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index 2e6a4674..93db6fbe 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -6,18 +6,17 @@ import { KNOWN_RANGE } from "@metabase/client/version/profile"; import { AuthStatus } from "../../packages/cli/src/commands/auth/status"; import { CardListEnvelope } from "../../packages/cli/src/commands/card/list"; import { summarizeServer } from "../../packages/cli/src/core/auth/server-summary"; +import { + probeAt, + SEED_USER, + type SeedTarget, + UNPARSEABLE_PROBE, +} from "../../packages/cli/src/core/auth/temp-config-home"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cliErrorCategory, cliErrorMessage } from "./cli-error"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { - SEED_USER, - seedProbedProfile, - seedProbedProfileAt, - seedProfile, - type SeedTarget, - versionAt, -} from "./seed-profile"; +import { seedProbedProfile, seedProbedProfileAt, seedProfile } from "./seed-profile"; import { E2E_BUILTIN_TRANSFORM_JOBS } from "./seed/ids"; import { requireServer } from "./server-gate"; @@ -147,7 +146,7 @@ describe("version skew notices e2e", () => { it("prints exactly one newer-server notice on stderr and succeeds when the cached probe is above the known range", async () => { const configHome = await makeIsolatedConfigHome(); - await seedProbedProfileAt(configHome, liveTarget(), versionAt(BEYOND_KNOWN)); + await seedProbedProfileAt(configHome, liveTarget(), probeAt(BEYOND_KNOWN)); const result = await runCli({ args: ["card", "list", "--limit", "1", "--json"], configHome }); @@ -158,7 +157,7 @@ describe("version skew notices e2e", () => { it("prints exactly one unknown-version notice on stderr and succeeds when the cached probe carries no parseable version", async () => { const configHome = await makeIsolatedConfigHome(); - await seedProbedProfileAt(configHome, liveTarget(), null); + await seedProbedProfileAt(configHome, liveTarget(), UNPARSEABLE_PROBE); const result = await runCli({ args: ["card", "list", "--limit", "1", "--json"], configHome }); @@ -169,7 +168,7 @@ describe("version skew notices e2e", () => { it("prints no notice when the cached probe is inside the known range", async () => { const configHome = await makeIsolatedConfigHome(); - await seedProbedProfileAt(configHome, liveTarget(), versionAt(KNOWN_RANGE.max)); + await seedProbedProfileAt(configHome, liveTarget(), probeAt(KNOWN_RANGE.max)); const result = await runCli({ args: ["card", "list", "--limit", "1", "--json"], configHome }); @@ -186,7 +185,7 @@ describe("version skew notices e2e", () => { describe.skipIf(reprobeSkipReason !== null)("re-probe on a shape error", () => { it("re-probes once, refreshes the stale profile, and appends the change to the error", async () => { const configHome = await makeIsolatedConfigHome(); - await seedProbedProfileAt(configHome, liveTarget(), versionAt(59)); + await seedProbedProfileAt(configHome, liveTarget(), probeAt(59)); const seededAt = await lastProbedAt(configHome); const result = await runCli({ From 43ff10b586ffbbdab0315bb13e754e06ac4bb1c9 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:14:27 -0400 Subject: [PATCH 08/22] Cover the re-probe on a stale refusal end to end --- tests/e2e/version.e2e.test.ts | 69 +++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 19 deletions(-) diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index 93db6fbe..e2b459e2 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -176,13 +176,36 @@ describe("version skew notices e2e", () => { expect(result.stderr).toBe(""); }); + function versionChangedNote(cachedTag: string): string { + const liveLabel = + bootstrap.server.version === null ? "an unparseable version" : bootstrap.server.version.tag; + return `The server's version changed since the last probe (was ${cachedTag}, now ${liveLabel}); the profile was refreshed — retry the command.`; + } + + async function expectRefreshedProbe(configHome: string, seededAt: string): Promise { + const status = await runCli({ args: ["auth", "status", "--json"], configHome }); + expect(status.exitCode, status.stderr).toBe(0); + const refreshedAt = await lastProbedAt(configHome); + expect(parseJson(status.stdout, AuthStatus)).toEqual({ + profile: "default", + present: true, + url: bootstrap.baseUrl, + method: "apiKey", + user: SEED_USER, + ...summarizeServer(bootstrap.server), + lastProbedAt: refreshedAt, + lastFailure: null, + }); + expect(refreshedAt > seededAt).toBe(true); + } + // A cached v59 profile reads a job run as the stub-string generation; a server answering with the // numeric one fails that parse, and the CLI must notice the server moved. - const reprobeSkipReason = requireServer("version › re-probe on a shape error", [ + const shapeReprobeSkipReason = requireServer("version › re-probe on a shape error", [ "transformJobRunIdIsNumeric", ]); - describe.skipIf(reprobeSkipReason !== null)("re-probe on a shape error", () => { + describe.skipIf(shapeReprobeSkipReason !== null)("re-probe on a shape error", () => { it("re-probes once, refreshes the stale profile, and appends the change to the error", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfileAt(configHome, liveTarget(), probeAt(59)); @@ -194,28 +217,36 @@ describe("version skew notices e2e", () => { }); expect(result.exitCode).toBe(1); - const liveLabel = - bootstrap.server.version === null ? "an unparseable version" : bootstrap.server.version.tag; expect(cliErrorMessage(result.stderr)).toBe( "On Metabase v0.59.0 the response shape was unexpected:\n" + " job_run_id: Invalid input: expected string, received null\n" + - `The server's version changed since the last probe (was v0.59.0, now ${liveLabel}); the profile was refreshed — retry the command.`, + versionChangedNote("v0.59.0"), ); - const status = await runCli({ args: ["auth", "status", "--json"], configHome }); - expect(status.exitCode, status.stderr).toBe(0); - const refreshedAt = await lastProbedAt(configHome); - expect(parseJson(status.stdout, AuthStatus)).toEqual({ - profile: "default", - present: true, - url: bootstrap.baseUrl, - method: "apiKey", - user: SEED_USER, - ...summarizeServer(bootstrap.server), - lastProbedAt: refreshedAt, - lastFailure: null, - }); - expect(refreshedAt > seededAt).toBe(true); + await expectRefreshedProbe(configHome, seededAt); + }); + }); + + // A cached v58 profile refuses measures before any request; the live server clears that floor, + // so the refusal is the stale-probe case the CLI must diagnose without retrying the command. + const refusalReprobeSkipReason = requireServer("version › re-probe on a refusal", ["measures"]); + + describe.skipIf(refusalReprobeSkipReason !== null)("re-probe on a refusal", () => { + it("re-probes once, refreshes the stale profile, and appends the change to the refusal", async () => { + const configHome = await makeIsolatedConfigHome(); + await seedProbedProfileAt(configHome, liveTarget(), probeAt(58)); + const seededAt = await lastProbedAt(configHome); + + const result = await runCli({ args: ["measure", "list", "--json"], configHome }); + + expect(result.exitCode).toBe(2); + expect(cliErrorCategory(result.stderr)).toBe("capability"); + expect(cliErrorMessage(result.stderr)).toBe( + `${MEASURES_REFUSAL}\n${versionChangedNote("v0.58.0")}\n${DOWNGRADE_REMEDY}`, + ); + expect(result.stdout).toBe(""); + + await expectRefreshedProbe(configHome, seededAt); }); }); }); From 5264e69c47c4d74194fa73c9e05c9c6c7fe5fc82 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:14:38 -0400 Subject: [PATCH 09/22] Point the auth e2e at the shared unreachable URL --- tests/e2e/auth.e2e.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/e2e/auth.e2e.test.ts b/tests/e2e/auth.e2e.test.ts index 26e90635..c91d9f6a 100644 --- a/tests/e2e/auth.e2e.test.ts +++ b/tests/e2e/auth.e2e.test.ts @@ -18,6 +18,7 @@ import { ProfilesFile, } from "../../packages/cli/src/core/auth/profile-record"; import { summarizeServer } from "../../packages/cli/src/core/auth/server-summary"; +import { UNREACHABLE_URL } from "../../packages/cli/src/core/auth/temp-config-home"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { cliErrorMessage } from "./cli-error"; @@ -25,8 +26,6 @@ import { cliErrorMessage } from "./cli-error"; type AuthProfileEntry = z.infer["data"][number]; const BAD_API_KEY = "mb_definitely_not_valid_key_aaaaaaaaaa"; -const UNREACHABLE_URL = "https://127.0.0.1:1/__nonexistent__"; -// Port 1 is on fetch's blocked-port list, so the failure carries no syscall code to hint from. const UNREACHABLE_REASON = "Could not reach Metabase: fetch failed"; function profilesPath(configHome: string): string { From 33822c09d205d5446712ea75230990ce136e7545 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:14:46 -0400 Subject: [PATCH 10/22] Name the probed-user payload type in the auth e2e --- tests/e2e/auth.e2e.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/e2e/auth.e2e.test.ts b/tests/e2e/auth.e2e.test.ts index c91d9f6a..daade6f9 100644 --- a/tests/e2e/auth.e2e.test.ts +++ b/tests/e2e/auth.e2e.test.ts @@ -68,7 +68,11 @@ function failureOf(record: ProfileRecord): ProfileLastFailure { return record.lastFailure; } -function userOf(payload: { user: ProbedUser | null }): ProbedUser { +interface ProbedUserPayload { + user: ProbedUser | null; +} + +function userOf(payload: ProbedUserPayload): ProbedUser { if (payload.user === null) { throw new Error("expected a probed user"); } From abc8ecbab73821bd644306bc2246145ac2e3ee82 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:14:46 -0400 Subject: [PATCH 11/22] Probe the booted image before judging a reused snapshot --- tests/e2e/setup/bootstrap.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/e2e/setup/bootstrap.ts b/tests/e2e/setup/bootstrap.ts index 702b88db..25f07f27 100644 --- a/tests/e2e/setup/bootstrap.ts +++ b/tests/e2e/setup/bootstrap.ts @@ -120,12 +120,13 @@ async function main(): Promise { const existing = await readStoredBootstrap(); if (existing && (await canReuseExisting(existing.adminApiKey))) { - assertSnapshotMatchesSeed(existing); // The credentials and seed outlive the image: the app-db volume survives a pull of a newer // head, so the server block is the booted image's to answer, never the file's. const server = await probeIdentity(apiKeyClient(existing.adminApiKey)); + const reused: E2EBootstrap = { ...existing, server }; + assertSnapshotMatchesSeed(reused); await reportSnapshotTransforms(apiKeyClient(existing.adminApiKey), server); - await writeStoredBootstrap({ ...existing, server }); + await writeStoredBootstrap(reused); process.stdout.write(`bootstrap: reusing ${BOOTSTRAP_FILE_PATH}\n`); return; } From a0b909d883e270bf7c26e946ba950c611ff52083 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:14:46 -0400 Subject: [PATCH 12/22] Drop semver from the client budget the skills state --- .claude/skills/add-resource-command/SKILL.md | 2 +- .claude/skills/audit-architecture/SKILL.md | 2 +- .claude/skills/review/SKILL.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude/skills/add-resource-command/SKILL.md b/.claude/skills/add-resource-command/SKILL.md index 3264c962..6b1392ba 100644 --- a/.claude/skills/add-resource-command/SKILL.md +++ b/.claude/skills/add-resource-command/SKILL.md @@ -47,7 +47,7 @@ If the resource genuinely has more verbs (e.g. a ` values` for fetching disti The domain file is client surface, so it lives in `@metabase/client`, not the CLI. Two constraints follow from that: -- **A domain file imports `zod`, sibling `domain/*` files and — only for a reader that selects a wire shape by generation — `import type { Features } from "../version/features"`, nothing else.** Not the rest of the client (`../json`, `../poll`), not `node:` builtins, and never `packages/cli` — a client file that reaches into the CLI inverts the dependency between the two packages. The wider `zod` + `semver` + `node:` budget is the client package's ceiling; `domain/` sits well inside it. +- **A domain file imports `zod`, sibling `domain/*` files and — only for a reader that selects a wire shape by generation — `import type { Features } from "../version/features"`, nothing else.** Not the rest of the client (`../json`, `../poll`), not `node:` builtins, and never `packages/cli` — a client file that reaches into the CLI inverts the dependency between the two packages. The wider `zod` + `node:` budget is the client package's ceiling; `domain/` sits well inside it. - **Nothing CLI-shaped leaks in.** No `@clack/prompts`, no `process.stdout.write`, no `process.exit` — the client never owns presentation or process control. A single file in `packages/client/src/domain/` may host multiple resources (e.g. `domain/user.ts` exports `CurrentUser` / `CurrentUserCompact`). The pair holds **per resource**, not per file. `` is PascalCase. diff --git a/.claude/skills/audit-architecture/SKILL.md b/.claude/skills/audit-architecture/SKILL.md index 0f85c414..02f2c8ac 100644 --- a/.claude/skills/audit-architecture/SKILL.md +++ b/.claude/skills/audit-architecture/SKILL.md @@ -33,7 +33,7 @@ Whole-tree non-deterministic audit. Where `/review` checks the diff, this checks > **Package boundary (hardest rule):** > > - Nothing under `packages/client/src/**` may import from `packages/cli` — not by an `@metabase/cli` specifier, and not by a relative path that resolves outside the client's own src tree (`../../cli/src/...` reads as ordinary navigation but lands in the other package). -> - The client's dependency budget is `zod`, `semver`, and `node:` builtins. Root devDependencies hoist, so any third-party import resolves at runtime — report every production client file importing a package outside that budget. +> - The client's dependency budget is `zod` and `node:` builtins. Root devDependencies hoist, so any third-party import resolves at runtime — report every production client file importing a package outside that budget. > - `packages/cli/src/**` consumes the client by specifier: `@metabase/client/` (e.g. `@metabase/client/domain/card`, `@metabase/client/json`, `@metabase/client/errors`). A relative path from the CLI into `packages/client/src/` is a violation. > > **Within `packages/client/src/`:** diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index 8c72e1e5..473c688c 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -64,7 +64,7 @@ packages/cli/src/ Allowed import direction — the package boundary first, then within each package: - **Nothing in the client may import from the CLI** — not by an `@metabase/cli` specifier, not by a relative path that escapes the client's own src tree. The reverse is expected: the CLI consumes the client as `@metabase/client/`. -- The client's dependency budget is `zod` + `semver` + `node:` builtins. Root devDependencies hoist, so the resolver will not reject a third dependency — a production client file importing anything else is FAIL even though it type-checks. +- The client's dependency budget is `zod` + `node:` builtins. Root devDependencies hoist, so the resolver will not reject a third dependency — a production client file importing anything else is FAIL even though it type-checks. - `domain/*` imports only `zod`, sibling `domain/*` files and — for a reader that selects a wire schema by generation (`Schema(features)`) — `import type { Features } from "../version/features"`. A value import from `version/`, or anything else from elsewhere in either package, is FAIL. - `resources/*` is imported by `client.ts` and by sibling `resources/*` files, and by nothing else. A `domain/`, `http/`, `auth/` or `version/` file importing a resource is FAIL — the direction runs one way, and the permitted importers are an allowlist, so a directory added later is not exempt for having arrived after the rule. - `commands/*` may import `core/*`, `output/*`, `runtime/*`, and any `@metabase/client/*` path. @@ -132,7 +132,7 @@ CLAUDE.md declares these as a **separate policy per package**, applied to produc | `fs`, `fs/promises`, `node:fs`, `node:fs/promises` | nowhere — the client touches no files | | `child_process`, `node:child_process` | nowhere — the client spawns nothing | | a `.ts` extension in an import specifier | nowhere | -| any dependency beyond `zod`, `semver`, `node:` builtins | nowhere | +| any dependency beyond `zod` and `node:` builtins | nowhere | | an import resolving into `packages/cli` | nowhere | ### The CLI — every file under `packages/cli/src/` @@ -211,7 +211,7 @@ For each axis below, output `PASS`, `FAIL`, or `UNKNOWN`. For every `FAIL` and ` 2. **Type strictness** — rules above. 3. **Output discipline** — `console.*`, `process.exit`, `process.std*.write` outside permitted homes. A file under `packages/client/src/` writing to a stream or ending the process is FAIL regardless of path — the client has no permitted home for either. 4. **Boundary discipline** — `JSON.parse`, raw `fetch`/`globalThis.fetch`/third-party HTTP libraries (`got`/`axios`/`node-fetch`/`undici`)/`new URL`, `child_process`, polling `setTimeout` outside permitted homes. Read the per-package table — the CLI has no home for `JSON.parse`, `new URL`, or a wait loop, and delegates all three to the client. - 4a. **Package boundary** — an import in `packages/client/**` that resolves into `packages/cli` (by `@metabase/cli` specifier or by a relative path escaping the client's src tree) is FAIL. A production client file importing anything beyond `zod`, `semver`, and `node:` builtins is FAIL. A CLI file reaching into the client by relative path instead of the `@metabase/client/` specifier is FAIL. `packages/cli/src/output/**` importing `@metabase/client/http/**` is FAIL. Anything outside `client.ts` and `resources/` importing `resources/` is FAIL. + 4a. **Package boundary** — an import in `packages/client/**` that resolves into `packages/cli` (by `@metabase/cli` specifier or by a relative path escaping the client's src tree) is FAIL. A production client file importing anything beyond `zod` and `node:` builtins is FAIL. A CLI file reaching into the client by relative path instead of the `@metabase/client/` specifier is FAIL. `packages/cli/src/output/**` importing `@metabase/client/http/**` is FAIL. Anything outside `client.ts` and `resources/` importing `resources/` is FAIL. 4b. **Request placement** — a file under `packages/cli/src/commands/` that names an `/api/…` path literal, or calls `requestParsed`/`requestRaw`/`requestStream`/`paginatePages`, is FAIL. Quote the path template and name the resource method it belongs on. 5. **Resource contract** — every new or changed resource in `packages/client/src/domain/` exports `` (with `.loose()`) and `Compact` (with `.pick({...}).strip()` — the trailing `.strip()` is mandatory), and its `View` lives CLI-side in `packages/cli/src/output/views/.ts` (a `ResourceView`/`ColumnDef`/`tableColumns` in the domain file is FAIL); naming follows the convention; commands consume `z.infer` rather than `Array>` or inline `as { ... }` casts. Every new or changed method in `packages/client/src/resources/` satisfies all eight conventions above, is composed onto `client.ts`, and returns a domain value rather than the server's envelope. 6. **List windowing** — a list command builds its envelope with one of the three helpers in `packages/cli/src/output/window.ts`, picked by who applied the window: `windowList(data, ctx.range, total)` when the method returned the whole result set as a `ListResult` and the slice is client-side (the common case); `windowServerPage(data, total, ctx.range)` when the endpoint applied `limit`/`offset` itself and reports a count; `collectForOutput(source, view, ctx)` for a genuinely paged endpoint, where `source` is a `PageSource` forwarding the helper's `PageRequest` (`max`, `pageSize`) into the resource's `Pages` method. A source that drops `max` or `pageSize` is FAIL — the byte budget cannot bound the walk. A hand-rolled envelope literal is FAIL — it produces a plausible shape whose `has_more`/`next_offset` no test catches. A list verb missing `...listFlags`, or one that never reads `ctx.range`, is FAIL: the `--limit`/`--offset` an agent needs to page silently do nothing. From 9fc62700b710ef4e44c7fa69689ef4c1a85aff8a Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:15:37 -0400 Subject: [PATCH 13/22] Read an unregistered remote-sync branch setting as no branch --- .../client/src/resources/git-sync.test.ts | 22 +++++++++++++++++ packages/client/src/resources/git-sync.ts | 24 +++++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/client/src/resources/git-sync.test.ts b/packages/client/src/resources/git-sync.test.ts index e6a5c03c..a270f0e1 100644 --- a/packages/client/src/resources/git-sync.test.ts +++ b/packages/client/src/resources/git-sync.test.ts @@ -542,6 +542,28 @@ describe("git-sync resource wire requests", () => { expect(await mb.gitSync.branch()).toBeNull(); }); + it("reports no branch when the caller may not read settings", async () => { + const { mb } = clientOver([jsonResponse({ message: "You don't have permissions" }, 403)]); + + expect(await mb.gitSync.branch()).toBeNull(); + }); + + it("reports no branch when the setting is not registered on the server", async () => { + const { mb } = clientOver([jsonResponse({ message: "Not found." }, 404)]); + + expect(await mb.gitSync.branch()).toBeNull(); + }); + + it("rethrows a branch failure that is neither a permission nor a registration answer", async () => { + const { mb } = clientOver([jsonResponse({ message: "boom" }, 500)]); + + const error = await thrownBy(() => mb.gitSync.branch({ retries: 0 })); + + expect(error).toBeInstanceOf(HttpError); + assert(error instanceof HttpError, "expected HttpError"); + expect(error.message).toBe("boom"); + }); + it("answers the task in the status that ended the wait", async () => { const { mb } = clientOver([jsonResponse(RUNNING_TASK), jsonResponse(SETTLED_TASK)]); diff --git a/packages/client/src/resources/git-sync.ts b/packages/client/src/resources/git-sync.ts index b95ab62d..309014f6 100644 --- a/packages/client/src/resources/git-sync.ts +++ b/packages/client/src/resources/git-sync.ts @@ -284,15 +284,25 @@ export function gitSyncResource(transport: Transport) { } } - /** The branch git-sync tracks, or null when none is configured or the caller may not read it. */ + /** + * The branch git-sync tracks, or null when none is configured, the caller may not read it, or + * the server has no remote-sync module. + */ async function branch(options: RequestOptions = {}): Promise { await transport.require("gitSync.branch", options); - return fetchOptionalParsed( - transport, - "/api/setting/remote-sync-branch", - RemoteSyncSetting, - options, - ); + try { + return await fetchOptionalParsed( + transport, + "/api/setting/remote-sync-branch", + RemoteSyncSetting, + options, + ); + } catch (error) { + if (isRemoteUnreadable(error)) { + return null; + } + throw error; + } } /** From 8eea2a51b406f2703a302aefa319835699c09780 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:15:37 -0400 Subject: [PATCH 14/22] Pin the older-server notice end to end --- tests/e2e/version.e2e.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index 32db87f8..e8af5ca9 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -22,6 +22,7 @@ import { E2E_BUILTIN_TRANSFORM_JOBS } from "./seed/ids"; import { requireServer } from "./server-gate"; const BEYOND_KNOWN = KNOWN_RANGE.max + 5; +const BELOW_KNOWN = KNOWN_RANGE.min - 1; const DOWNGRADE_REMEDY = "Or install an `@metabase/cli` release that targets this server."; const MEASURES_REFUSAL = @@ -30,6 +31,7 @@ const UNREACHABLE_MESSAGE = "Could not reach Metabase: fetch failed"; const NEWER_NOTICE = `Metabase v0.${BEYOND_KNOWN}.0 is newer than this CLI supports (up to v${KNOWN_RANGE.max}); commands run as if it were a head build past v${KNOWN_RANGE.max}. Run \`mb upgrade\` for a newer CLI.`; const UNKNOWN_NOTICE = `Could not parse the Metabase version; assuming a head build past v${KNOWN_RANGE.max}.`; +const OLDER_NOTICE = `Metabase v0.${BELOW_KNOWN}.0 is older than this CLI supports (v${KNOWN_RANGE.min}+); commands needing a newer feature are refused by name. Upgrade Metabase to v${KNOWN_RANGE.min} or later.`; describe("version preflight enforcement e2e", () => { const tempDirs: string[] = []; @@ -167,6 +169,17 @@ describe("version skew notices e2e", () => { expect(parseJson(result.stdout, CardListEnvelope).returned).toBe(1); }); + it("prints exactly one older-server notice on stderr and still runs a baseline command when the cached probe is below the known range", async () => { + const configHome = await makeIsolatedConfigHome(); + await seedProbedProfileAt(configHome, liveTarget(), versionAt(BELOW_KNOWN)); + + const result = await runCli({ args: ["card", "list", "--limit", "1", "--json"], configHome }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toBe(OLDER_NOTICE); + expect(parseJson(result.stdout, CardListEnvelope).returned).toBe(1); + }); + it("prints no notice when the cached probe is inside the known range", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfileAt(configHome, liveTarget(), versionAt(KNOWN_RANGE.max)); From 8bac72330a22851e8c0832626613114481a7b05b Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:15:37 -0400 Subject: [PATCH 15/22] Trim restated tests and say why e2e-only feature rules live in the table --- packages/client/src/domain/transform-job.test.ts | 4 ---- packages/client/src/testing/deferred.ts | 16 ++++++---------- packages/client/src/version/features.test.ts | 4 ---- packages/client/src/version/features.ts | 3 +++ packages/client/src/version/requirements.test.ts | 7 ------- 5 files changed, 9 insertions(+), 25 deletions(-) diff --git a/packages/client/src/domain/transform-job.test.ts b/packages/client/src/domain/transform-job.test.ts index 103d8c45..5c3268d0 100644 --- a/packages/client/src/domain/transform-job.test.ts +++ b/packages/client/src/domain/transform-job.test.ts @@ -31,10 +31,6 @@ describe("transformJobSchema", () => { it("requires active from a server that can switch jobs off", () => { expect(transformJobSchema(STUB_RUN_ID_SERVER).safeParse(JOB_WIRE).success).toBe(false); - expect(transformJobSchema(STUB_RUN_ID_SERVER).parse({ ...JOB_WIRE, active: false })).toEqual({ - ...JOB_WIRE, - active: false, - }); }); }); diff --git a/packages/client/src/testing/deferred.ts b/packages/client/src/testing/deferred.ts index 84c41ee9..8440bb1a 100644 --- a/packages/client/src/testing/deferred.ts +++ b/packages/client/src/testing/deferred.ts @@ -5,18 +5,14 @@ export interface Deferred { // A promise settled from outside, so a test can hold a response back until it has observed what // happens while it is pending. +function unsettled(): never { + throw new Error("deferred: executor did not run"); +} + export function deferred(): Deferred { - let settle: ((value: T) => void) | null = null; + let settle: (value: T) => void = unsettled; const promise = new Promise((resolve) => { settle = resolve; }); - return { - promise, - resolve: (value) => { - if (settle === null) { - throw new Error("deferred: executor did not run"); - } - settle(value); - }, - }; + return { promise, resolve: (value) => settle(value) }; } diff --git a/packages/client/src/version/features.test.ts b/packages/client/src/version/features.test.ts index e833ddd9..90ed77db 100644 --- a/packages/client/src/version/features.test.ts +++ b/packages/client/src/version/features.test.ts @@ -71,10 +71,6 @@ describe("evaluateFeatures", () => { expect(evaluateFeatures(61, { library: true }).library).toBe(true); expect(evaluateFeatures(58, { library: true }).library).toBe(false); }); - - it("answers for every rule in the table and nothing else", () => { - expect(Object.keys(evaluateFeatures(61, null))).toEqual([...FEATURE_NAMES]); - }); }); describe("ruleGap", () => { diff --git a/packages/client/src/version/features.ts b/packages/client/src/version/features.ts index 6aee29df..309c56c9 100644 --- a/packages/client/src/version/features.ts +++ b/packages/client/src/version/features.ts @@ -5,6 +5,9 @@ import type { TokenFeatures } from "../domain/session-properties"; // A rule holds on every major from `since` through `until` (both inclusive) on which the server // also grants `tokenFeature`. Names describe the behaviour a consumer branches on, never the // version it arrived in, so a rule reads the same after the majors around it leave the window. +// The table is the one place a server behaviour is pinned to a generation, so it also holds rules +// only the e2e suite branches on (`invalidMbqlIsBadRequest`, `collectionItemsTotalOnEmptyPage`, +// `transformTargetTableLinkedOnCreate`); a rule with no reader in `resources/` is not an orphan. export interface FeatureRule { readonly since: number; readonly until?: number; diff --git a/packages/client/src/version/requirements.test.ts b/packages/client/src/version/requirements.test.ts index deaec62a..a211e215 100644 --- a/packages/client/src/version/requirements.test.ts +++ b/packages/client/src/version/requirements.test.ts @@ -41,7 +41,6 @@ import { FEATURE_RULES, type FeatureRule } from "./features"; import { createServerProfile } from "./profile"; import { isMethodKey, - METHOD_KEYS, METHOD_REQUIREMENTS, type MethodKey, methodRequirements, @@ -372,12 +371,6 @@ describe("isMethodKey", () => { }); }); -describe("METHOD_KEYS", () => { - it("lists every key of the table in its own order", () => { - expect(METHOD_KEYS).toEqual(Object.keys(METHOD_REQUIREMENTS)); - }); -}); - describe("methodRequirements", () => { it("answers the table's own entry", () => { expect(methodRequirements("transformJob.setActive")).toEqual([ From 2e743548f167fc68cce1596f43c0403239101908 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:23:11 -0400 Subject: [PATCH 16/22] Carry the profile refresh note on a CLI-owned error --- packages/cli/src/commands/runtime.test.ts | 3 +- packages/cli/src/commands/runtime.ts | 18 +++----- packages/cli/src/core/auth/server-summary.ts | 16 +++++++ packages/cli/src/output/error.test.ts | 46 ++++++++++++++++++++ packages/cli/src/output/error.ts | 5 +++ 5 files changed, 73 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/runtime.test.ts b/packages/cli/src/commands/runtime.test.ts index ec6b7d56..4428effd 100644 --- a/packages/cli/src/commands/runtime.test.ts +++ b/packages/cli/src/commands/runtime.test.ts @@ -839,8 +839,7 @@ describe("defineMetabaseCommand", () => { expect(errorEnvelopeOf(stderr)).toEqual( capabilityErrorEnvelope( `${ACTIVATION_REFUSAL}\n` + - "The server's version changed since the last probe (was v0.58.0, now v0.61.3); the profile was refreshed — retry the command.\n" + - DOWNGRADE_REMEDY, + "The server's version changed since the last probe (was v0.58.0, now v0.61.3); the profile was refreshed — retry the command.", ), ); expect(process.exitCode).toBe(2); diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts index 727c9517..d9cb672b 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -13,7 +13,7 @@ import { checkFeatures } from "@metabase/client/version/requirement-check"; import { type MethodKey, methodRequirements } from "@metabase/client/version/requirements"; import type { ProfileLastProbe } from "../core/auth/profile-record"; -import { serverChangeNote, skewNotice } from "../core/auth/server-summary"; +import { ProfileRefreshedError, serverChangeNote, skewNotice } from "../core/auth/server-summary"; import { readCachedProbe } from "../core/auth/cached-server"; import { consumeKeyringDowngradeWarning, @@ -241,19 +241,11 @@ async function refreshProfileOnStaleError( if (cached === null) { return error; } - if (error instanceof ResponseShapeError) { - const note = await refreshChangedProbe(cached); - return note === null - ? error - : new ResponseShapeError(`${error.message}\n${note}`, error.developerDetail); - } - if (error instanceof CapabilityError) { - const note = await refreshChangedProbe(cached); - return note === null - ? error - : new CapabilityError({ ...error.developerDetail, detail: `${error.message}\n${note}` }); + if (!(error instanceof ResponseShapeError) && !(error instanceof CapabilityError)) { + return error; } - return error; + const note = await refreshChangedProbe(cached); + return note === null ? error : new ProfileRefreshedError(error, note); } async function refreshChangedProbe(cached: CachedServer): Promise { diff --git a/packages/cli/src/core/auth/server-summary.ts b/packages/cli/src/core/auth/server-summary.ts index e4150db0..134ca02e 100644 --- a/packages/cli/src/core/auth/server-summary.ts +++ b/packages/cli/src/core/auth/server-summary.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { TokenFeatures } from "@metabase/client/domain/session-properties"; +import { type ErrorCategory, MetabaseError } from "@metabase/client/errors"; import { Features } from "@metabase/client/version/features"; import type { ServerInfo } from "@metabase/client/version/probe"; import { @@ -74,6 +75,21 @@ export function skewNotice(profile: ServerProfile): string | null { const PROFILE_REFRESHED_REMEDY = "the profile was refreshed — retry the command."; +// A refusal or shape error raised under a cached profile that a fresh probe has since rewritten. +// The note is the CLI's own, so it rides on the message and leaves the client's detail as it was. +export class ProfileRefreshedError extends MetabaseError { + readonly category: ErrorCategory; + readonly isRetryable = false; + readonly developerDetail: unknown; + + constructor(cause: MetabaseError, note: string) { + super(`${cause.userMessage}\n${note}`); + this.name = "ProfileRefreshedError"; + this.category = cause.category; + this.developerDetail = cause.developerDetail; + } +} + // What a fresh probe says that the cached one did not, or `null` when the two agree on everything // a feature switch reads: the version tag and the premium features. export function serverChangeNote(cached: ServerInfo, fresh: ServerInfo): string | null { diff --git a/packages/cli/src/output/error.test.ts b/packages/cli/src/output/error.test.ts index 9ed39483..f855a317 100644 --- a/packages/cli/src/output/error.test.ts +++ b/packages/cli/src/output/error.test.ts @@ -16,6 +16,7 @@ import { HttpError } from "@metabase/client/http/errors"; import { CapabilityError } from "@metabase/client/version/preflight-error"; import { createServerProfile } from "@metabase/client/version/profile"; import { checkFeatures } from "@metabase/client/version/requirement-check"; +import { ProfileRefreshedError } from "../core/auth/server-summary"; import { exitCodeFor, reportError } from "./error"; interface CapturedStreams { @@ -163,6 +164,51 @@ describe("reportError", () => { expect(process.exitCode).toBe(2); }); + const REFRESH_NOTE = + "The server's version changed since the last probe (was v0.58.0, now v0.61.3); the profile was refreshed — retry the command."; + + function activationRefusalOn58(): CapabilityError { + const failure = checkFeatures( + ["transformJobActivation"], + createServerProfile({ + edition: "oss", + version: { tag: "v0.58.0", major: 58, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, + }), + ); + assert(failure !== null); + return new CapabilityError(failure); + } + + it("withholds the client downgrade when the profile was refreshed, since the note says to retry", () => { + reportError(new ProfileRefreshedError(activationRefusalOn58(), REFRESH_NOTE)); + expect(streams.stderr).toBe( + "This operation requires Metabase v61+ (this server is v0.58.0). Upgrade Metabase to use it.\n" + + `${REFRESH_NOTE}\n` + + "(rerun with MB_VERBOSE=1 for details)\n", + ); + expect(process.exitCode).toBe(2); + }); + + it("keeps the client's own failure as the detail behind a refreshed-profile refusal", () => { + process.env["MB_VERBOSE"] = "1"; + const refusal = activationRefusalOn58(); + reportError(new ProfileRefreshedError(refusal, REFRESH_NOTE), "json"); + expect(streams.stderr).toBe( + JSON.stringify({ + ok: false, + error: { + category: "capability", + message: `${refusal.message}\n${REFRESH_NOTE}`, + exitCode: 2, + detail: refusal.developerDetail, + }, + }) + "\n", + ); + }); + it("withholds the client downgrade when a premium feature is missing, which no client version supplies", () => { const failure = checkFeatures( ["library"], diff --git a/packages/cli/src/output/error.ts b/packages/cli/src/output/error.ts index 0325cd01..22bfefde 100644 --- a/packages/cli/src/output/error.ts +++ b/packages/cli/src/output/error.ts @@ -2,6 +2,7 @@ import { AbortError, toMetabaseError } from "@metabase/client/errors"; import type { ErrorCategory, MetabaseError } from "@metabase/client/errors"; import type { RequirementFailure } from "@metabase/client/version/preflight-error"; +import { ProfileRefreshedError } from "../core/auth/server-summary"; import { consumeLegacyEnvWarnings, ENV_VERBOSE, readEnv } from "../core/env"; import { warn } from "./notice"; import { isPromptCancel } from "./prompt"; @@ -92,6 +93,10 @@ function isRouteMissing(handled: MetabaseError): boolean { // What the CLI adds to a message the client had to phrase without knowing who would print it. Both // output formats carry them, so an agent reading `--json` gets the same remediation a human does. function remediesFor(handled: MetabaseError): readonly string[] { + // The refresh note already says to retry; a remedy naming another release would contradict it. + if (handled instanceof ProfileRefreshedError) { + return []; + } if (isVersionTooOld(handled.developerDetail)) { return [DOWNGRADE_REMEDY]; } From 1cef1821198cbedfcecc1461d53e9e5fda7c0590 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:23:11 -0400 Subject: [PATCH 17/22] Split skills --all from --unfiltered and say why skills go unfiltered --- README.md | 8 +-- packages/cli/skill-data/core/SKILL.md | 2 +- packages/cli/skills/metabase-cli/SKILL.md | 2 +- packages/cli/src/commands/skills/get.test.ts | 57 +++++++++++++++-- packages/cli/src/commands/skills/get.ts | 27 ++++---- packages/cli/src/commands/skills/list.test.ts | 10 +-- packages/cli/src/commands/skills/list.ts | 16 +++-- .../cli/src/core/auth/cached-server.test.ts | 29 +++++---- packages/cli/src/core/auth/cached-server.ts | 63 ++++++++++++++++--- packages/cli/src/output/skill-list.test.ts | 45 ++++++++++--- packages/cli/src/output/skill-list.ts | 45 ++++++++----- tests/e2e/skills.e2e.test.ts | 17 ++--- 12 files changed, 233 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index dc5c286c..42829d9f 100644 --- a/README.md +++ b/README.md @@ -1918,19 +1918,19 @@ The CLI ships with bundled agent skills (Claude Code / `npx skills add` compatib ```sh mb skills list # bundled skills the profile's server can use (table or JSON) -mb skills list --all # every bundled skill, whatever the server +mb skills list --unfiltered # every bundled skill, whatever the server mb skills get core # print the top-level guide, sections the server cannot use left out mb skills get core --full # include references and templates mb skills get git-sync,transform # comma-separated, multi-skill fetch -mb skills get transform --all # print a skill as written, even one the server cannot use -mb skills get --all --json --max-bytes 0 # every non-hidden skill, structured (default cap truncates) +mb skills get transform --unfiltered # print a skill as written, even one the server cannot use +mb skills get --all --json --max-bytes 0 # every non-hidden skill the server can use, structured (default cap truncates) mb skills path # absolute paths for direct Read mb skills path core # one path ``` `mb skills get` honors the shared `--max-bytes` list cap. With the default 24 576 cap, `--all` will return only the first skill and emit a truncation notice — pass `--max-bytes 0` to dump every skill in one envelope. -Skills describe the newest Metabase plainly and declare what they rely on: a skill's frontmatter carries `requires: [, …]` (names from the client's feature table, e.g. `transforms`, `remoteSync`), and a passage inside a skill or one of its references sits between `` and `` markers, each on its own line. `skills list` and `skills get` read the profile's cached server probe (`--profile` respected; no request is made) and resolve both against it: a skill whose features the server lacks is left out and reported in the JSON envelope's `unavailable` array as `{ name, failure }`, where `failure` is the same `{ reason, detail, feature, since, tokenFeature, serverVersion }` a refused command carries; a met section keeps its text and loses its markers; an unmet one is removed. Text mode reports each skipped skill on stderr. Without a cached probe nothing is filtered, `unavailable` is `null`, the markers are printed as written, and text mode says so on stderr. `--all` bypasses the filter on both commands (with names on `get`, it prints those skills as written). An unknown feature name or an unbalanced marker pair is a `ConfigError` on every read, so a typo fails the gate rather than hiding a skill. +Skills describe the newest Metabase plainly and declare what they rely on: a skill's frontmatter carries `requires: [, …]` (names from the client's feature table, e.g. `transforms`, `remoteSync`), and a passage inside a skill or one of its references sits between `` and `` markers, each on its own line. `skills list` and `skills get` read the profile's cached server probe (`--profile` respected; no request is made) and resolve both against it: a skill whose features the server lacks is left out and reported in the JSON envelope's `unavailable` array as `{ name, failure }`, where `failure` is the same `{ reason, detail, feature, since, tokenFeature, serverVersion }` a refused command carries; a met section keeps its text and loses its markers; an unmet one is removed. Text mode reports each skipped skill on stderr. Without a cached probe nothing is filtered, `unavailable` is `null`, the markers are printed as written, and text mode says why on stderr: no such profile, a profile never probed, or no probe for the URL `MB_URL` points at. `--unfiltered` bypasses the filter on both commands and prints the selected skills as written; on `get`, `--all` selects every non-hidden skill and combines with either. A marker inside a fenced code block is text. An unknown feature name, an unbalanced marker pair, or a marker between table rows is a `ConfigError` on every read, so a typo fails the gate rather than hiding a skill. Bundled skills: diff --git a/packages/cli/skill-data/core/SKILL.md b/packages/cli/skill-data/core/SKILL.md index 9e9002d5..89149246 100644 --- a/packages/cli/skill-data/core/SKILL.md +++ b/packages/cli/skill-data/core/SKILL.md @@ -171,7 +171,7 @@ This file is enough for any single-command task. For anything deeper, load the r - **`data-workflow`** — the guided, end-to-end data workflow: investigate raw data, build clean analysis-ready tables, define reusable segments/measures/metrics, answer questions, build dashboards. **Start here when the user states a goal rather than a single verb** — "make sense of my data", "build a data model", "go from raw data to a dashboard", "be my data analyst", "set up analytics for X". It detects where the data is and routes to the right stage. -If a task spans more than one, load each. `mb skills list` enumerates the skills the profile's server can use, and `mb skills get` prints a skill with the sections that server cannot use left out; `--all` shows everything. +If a task spans more than one, load each. `mb skills list` enumerates the skills the profile's server can use, and `mb skills get` prints a skill with the sections that server cannot use left out; `--unfiltered` shows everything. ## Don't diff --git a/packages/cli/skills/metabase-cli/SKILL.md b/packages/cli/skills/metabase-cli/SKILL.md index 32a904d6..30e6f2d7 100644 --- a/packages/cli/skills/metabase-cli/SKILL.md +++ b/packages/cli/skills/metabase-cli/SKILL.md @@ -17,7 +17,7 @@ Before running any `mb` command, load the workflow content from the CLI: ```bash mb skills get core # auth, flag conventions, every command group -mb skills list # every bundled skill the profile's server can use (--all for all of them) +mb skills list # every bundled skill the profile's server can use (--unfiltered for all of them) ``` **Doing a whole job, not one command?** If the user wants an outcome — "make sense of my data", "build a data model", "go from raw data to a dashboard", "answer questions about my data", "be my data analyst", "set up analytics for X" — load the guided end-to-end skill instead and let it drive: diff --git a/packages/cli/src/commands/skills/get.test.ts b/packages/cli/src/commands/skills/get.test.ts index 9f4c2c58..c9c13c53 100644 --- a/packages/cli/src/commands/skills/get.test.ts +++ b/packages/cli/src/commands/skills/get.test.ts @@ -117,7 +117,7 @@ describe("skills get command", () => { expect(stdout.chunks.join("")).toBe(`${GAMMA_SKILL_MD.trimEnd()}\n`); expect(stderr.chunks.join("")).toBe( - 'Skills are unfiltered: profile "default" has no cached server probe (run `mb auth login` to record one).\n', + 'Skills are unfiltered: there is no profile "default" (run `mb auth login` to create one and record its server).\n', ); }); @@ -162,7 +162,7 @@ describe("skills get command", () => { expect(stdout.chunks).toEqual([]); expect(stderr.chunks.join("")).toBe( - 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --all to print it anyway.\n', + 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.\n', ); }); @@ -192,12 +192,12 @@ describe("skills get command", () => { expect(stdout.chunks.join("")).toBe(`${GAMMA_WITH_ACTIVATION.trimEnd()}\n`); }); - it("--all with a name prints that skill as written regardless of the cached server", async () => { + it("--unfiltered with a name prints that skill as written regardless of the cached server", async () => { await seedProbedProfile("default", probeAt(58)); const stdout = capture(process.stdout); const stderr = capture(process.stderr); - await runCommand(skillsGetCommand, { rawArgs: ["gamma", "--all", "--json"] }); + await runCommand(skillsGetCommand, { rawArgs: ["gamma", "--unfiltered", "--json"] }); expect(stdout.parse(SkillGetEnvelope)).toEqual({ returned: 1, @@ -211,12 +211,59 @@ describe("skills get command", () => { expect(stderr.chunks).toEqual([]); }); - it("--all alone prints every non-hidden skill as written", async () => { + it("--all alone selects every non-hidden skill as the cached server can use it", async () => { await seedProbedProfile("default", probeAt(58)); const stdout = capture(process.stdout); await runCommand(skillsGetCommand, { rawArgs: ["--all", "--json", "--max-bytes", "0"] }); + expect(stdout.parse(SkillGetEnvelope)).toEqual({ + returned: 2, + offset: 0, + total: 2, + has_more: false, + next_offset: null, + unavailable: [ + { + name: "gamma", + failure: { + reason: "version-too-old", + detail: + "This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it.", + feature: "transforms", + since: 59, + tokenFeature: null, + serverVersion: "v0.58.0", + }, + }, + ], + data: [ + { + name: "alpha", + description: "The first skill.", + body: "---\nname: alpha\ndescription: The first skill.\n---\n\nAlpha instructions.\n", + references: [], + templates: [], + }, + { + name: "beta", + description: "The second skill.", + body: "---\nname: beta\ndescription: The second skill.\n---\n\nBeta instructions.\n", + references: [], + templates: [], + }, + ], + }); + }); + + it("--all --unfiltered prints every non-hidden skill as written", async () => { + await seedProbedProfile("default", probeAt(58)); + const stdout = capture(process.stdout); + + await runCommand(skillsGetCommand, { + rawArgs: ["--all", "--unfiltered", "--json", "--max-bytes", "0"], + }); + expect(stdout.parse(SkillGetEnvelope)).toEqual({ returned: 3, offset: 0, diff --git a/packages/cli/src/commands/skills/get.ts b/packages/cli/src/commands/skills/get.ts index df39cca2..95a2086e 100644 --- a/packages/cli/src/commands/skills/get.ts +++ b/packages/cli/src/commands/skills/get.ts @@ -13,11 +13,7 @@ import { } from "../../core/skills"; import { warn } from "../../output/notice"; import { renderListWithExtras, writeText } from "../../output/render"; -import { - skillFilterNotices, - UnavailableSkills, - unavailableSkillsFlag, -} from "../../output/skill-list"; +import { skillFilterNotices, UnavailableSkills, unfilteredFlag } from "../../output/skill-list"; import { listEnvelopeSchemaWithExtras } from "../../output/types"; import type { ResourceView } from "../../output/view"; import { windowList } from "../../output/window"; @@ -46,7 +42,7 @@ export default defineMetabaseCommand({ meta: { name: "get", description: - "Print one or more skills' SKILL.md content, as the profile's server can use it: a skill it lacks the features for is reported under `unavailable`, and a section it cannot use is left out. Pass comma-separated names, or --all for every non-hidden skill; --all also prints everything regardless of the server. --full includes references and templates.", + "Print one or more skills' SKILL.md content, as the profile's server can use it: a skill it lacks the features for is reported under `unavailable`, and a section it cannot use is left out. Pass comma-separated names, or --all for every non-hidden skill. --unfiltered prints the selection as written, regardless of the server. --full includes references and templates.", }, requires: null, args: { @@ -59,7 +55,11 @@ export default defineMetabaseCommand({ "Skill name (or comma-separated list). Omit to combine with --all for every non-hidden skill.", required: false, }, - ...unavailableSkillsFlag, + all: { + type: "boolean", + description: "Every non-hidden skill", + }, + ...unfilteredFlag, }, outputSchema: SkillGetEnvelope, examples: [ @@ -67,13 +67,16 @@ export default defineMetabaseCommand({ "mb skills get core --full", "mb skills get git-sync,transform --json", "mb skills get --all --json", - "mb skills get transform --all", + "mb skills get transform --unfiltered", ], async run({ args, ctx }) { const profileName = resolveProfileName(args.profile); - const bypassed = args.all === true; - const profile = bypassed ? null : await readCachedServerProfile(profileName); - const selection = selectForProfile(pickSkills({ names: args.names, all: bypassed }), profile); + const cached = args.unfiltered === true ? null : await readCachedServerProfile(profileName); + const profile = cached !== null && cached.kind === "found" ? cached.profile : null; + const selection = selectForProfile( + pickSkills({ names: args.names, all: args.all === true }), + profile, + ); const payloads = selection.skills.map((info) => readSkillContent(info, { includeExtras: ctx.full, profile }), ); @@ -89,7 +92,7 @@ export default defineMetabaseCommand({ if (envelope.data.length > 0) { writeText(renderText(envelope.data, ctx.full)); } - for (const notice of skillFilterNotices(selection.unavailable, { profileName, bypassed })) { + for (const notice of skillFilterNotices(selection.unavailable, { profileName, cached })) { warn(notice); } }, diff --git a/packages/cli/src/commands/skills/list.test.ts b/packages/cli/src/commands/skills/list.test.ts index 33791932..50e26ae1 100644 --- a/packages/cli/src/commands/skills/list.test.ts +++ b/packages/cli/src/commands/skills/list.test.ts @@ -80,7 +80,7 @@ describe("skills list command", () => { expect(stdout.chunks.join("")).toBe("alpha\n The first skill.\n\n"); expect(stderr.chunks.join("")).toBe( - 'Skills are unfiltered: profile "default" has no cached server probe (run `mb auth login` to record one).\n', + 'Skills are unfiltered: there is no profile "default" (run `mb auth login` to create one and record its server).\n', ); }); @@ -135,7 +135,7 @@ describe("skills list command", () => { "alpha\n The first skill.\n\nbeta\n The second skill.\n\n", ); expect(stderr.chunks.join("")).toBe( - 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --all to print it anyway.\n', + 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.\n', ); }); @@ -159,7 +159,7 @@ describe("skills list command", () => { ].join("\n"), ); expect(stderr.chunks.join("")).toBe( - 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --all to print it anyway.\n', + 'Skipped skill "gamma": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.\n', ); }); @@ -186,12 +186,12 @@ describe("skills list command", () => { expect(stderr.chunks).toEqual([]); }); - it("--all bypasses the filter and says nothing about it", async () => { + it("--unfiltered bypasses the filter and says nothing about it", async () => { await seedProbedProfile("default", probeAt(58)); const stdout = capture(process.stdout); const stderr = capture(process.stderr); - await runCommand(skillsListCommand, { rawArgs: ["--all", "--json"] }); + await runCommand(skillsListCommand, { rawArgs: ["--unfiltered", "--json"] }); expect(stdout.parse(SkillListEnvelope)).toEqual({ returned: 3, diff --git a/packages/cli/src/commands/skills/list.ts b/packages/cli/src/commands/skills/list.ts index 1f3bcce4..42bd9ceb 100644 --- a/packages/cli/src/commands/skills/list.ts +++ b/packages/cli/src/commands/skills/list.ts @@ -10,7 +10,7 @@ import { renderSkillList, skillFilterNotices, UnavailableSkills, - unavailableSkillsFlag, + unfilteredFlag, } from "../../output/skill-list"; import { listEnvelopeSchemaWithExtras } from "../../output/types"; import { windowList } from "../../output/window"; @@ -40,15 +40,16 @@ export default defineMetabaseCommand({ meta: { name: "list", description: - "List CLI-bundled skills — always consult the matching skill before acting on a task; they are the source of truth for every workflow. Skills the profile's server cannot use are left out; --all lists them too.", + "List CLI-bundled skills — always consult the matching skill before acting on a task; they are the source of truth for every workflow. Skills the profile's server cannot use are left out; --unfiltered lists them too.", }, requires: null, - args: { ...outputFlags, ...listFlags, ...profileFlag, ...unavailableSkillsFlag }, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...unfilteredFlag }, outputSchema: SkillListEnvelope, - examples: ["mb skills list", "mb skills list --json", "mb skills list --all"], + examples: ["mb skills list", "mb skills list --json", "mb skills list --unfiltered"], async run({ args, ctx }) { const profileName = resolveProfileName(args.profile); - const profile = args.all === true ? null : await readCachedServerProfile(profileName); + const cached = args.unfiltered === true ? null : await readCachedServerProfile(profileName); + const profile = cached !== null && cached.kind === "found" ? cached.profile : null; const selection = selectForProfile(loadVisibleSkills(), profile); const items: SkillSummaryJson[] = selection.skills.map((s) => ({ name: s.name, @@ -63,10 +64,7 @@ export default defineMetabaseCommand({ if (ctx.format === "json") { return; } - for (const notice of skillFilterNotices(selection.unavailable, { - profileName, - bypassed: args.all === true, - })) { + for (const notice of skillFilterNotices(selection.unavailable, { profileName, cached })) { warn(notice); } }, diff --git a/packages/cli/src/core/auth/cached-server.test.ts b/packages/cli/src/core/auth/cached-server.test.ts index 17d5081d..dbb1373b 100644 --- a/packages/cli/src/core/auth/cached-server.test.ts +++ b/packages/cli/src/core/auth/cached-server.test.ts @@ -34,35 +34,42 @@ describe("readCachedServerProfile", () => { home.cleanup(); }); - it("returns null when the profile has no record", async () => { - expect(await readCachedServerProfile("default")).toBeNull(); + it("reports a profile that has no record", async () => { + expect(await readCachedServerProfile("default")).toEqual({ kind: "no-profile" }); }); - it("returns null when the record was never probed", async () => { + it("reports a record that was never probed", async () => { await writeProfile({ url: "http://127.0.0.1:1", apiKey: "secret-key" }, "default"); - expect(await readCachedServerProfile("default")).toBeNull(); + expect(await readCachedServerProfile("default")).toEqual({ kind: "never-probed" }); }); it("derives the profile from the record's cached probe", async () => { await seedProbedProfile("staging", probeAt(61, { library: true })); - expect(await readCachedServerProfile("staging")).toEqual( - createServerProfile(probeAt(61, { library: true })), - ); + expect(await readCachedServerProfile("staging")).toEqual({ + kind: "found", + profile: createServerProfile(probeAt(61, { library: true })), + }); }); - it("ignores the record's probe when the environment points the profile at another server", async () => { + it("names the URL in use when the environment points the profile at another server", async () => { await seedProbedProfile("staging", probeAt(61, { library: true })); - process.env["MB_URL"] = "https://other.example.com"; + process.env["MB_URL"] = "https://other.example.com/"; - expect(await readCachedServerProfile("staging")).toBeNull(); + expect(await readCachedServerProfile("staging")).toEqual({ + kind: "other-url", + url: "https://other.example.com", + }); }); it("keeps the record's probe when the environment names the record's own server", async () => { await seedProbedProfile("staging", probeAt(61)); process.env["MB_URL"] = "http://127.0.0.1:1/"; - expect(await readCachedServerProfile("staging")).toEqual(createServerProfile(probeAt(61))); + expect(await readCachedServerProfile("staging")).toEqual({ + kind: "found", + profile: createServerProfile(probeAt(61)), + }); }); }); diff --git a/packages/cli/src/core/auth/cached-server.ts b/packages/cli/src/core/auth/cached-server.ts index 7ab3e3ce..d842ec50 100644 --- a/packages/cli/src/core/auth/cached-server.ts +++ b/packages/cli/src/core/auth/cached-server.ts @@ -5,27 +5,70 @@ import { readEnvCredentials } from "../config"; import type { ProfileLastProbe } from "./profile-record"; import { readProfileRecord } from "./storage"; +interface CachedProbeFound { + kind: "found"; + probe: ProfileLastProbe; +} + +interface NoProfileRecord { + kind: "no-profile"; +} + +interface ProfileNeverProbed { + kind: "never-probed"; +} + // The record's probe describes the record's server, and only that one: a URL from a flag or the // environment can point the same profile name at another host, whose features and shapes the -// cached probe says nothing about. `null` when there is no probe to trust. -export async function readCachedProbe( +// cached probe says nothing about. +interface ProbeForOtherUrl { + kind: "other-url"; + url: string; +} + +export type CachedProbeMiss = NoProfileRecord | ProfileNeverProbed | ProbeForOtherUrl; +type CachedProbeLookup = CachedProbeFound | CachedProbeMiss; + +interface CachedServerFound { + kind: "found"; + profile: ServerProfile; +} + +export type CachedServerProfile = CachedServerFound | CachedProbeMiss; + +async function lookupCachedProbe( profileName: string, url: string | null, -): Promise { +): Promise { const record = await readProfileRecord(profileName); - if (record === null || record.lastProbe === null) { - return null; + if (record === null) { + return { kind: "no-profile" }; + } + if (record.lastProbe === null) { + return { kind: "never-probed" }; } if (url !== null && normalizeUrl(record.url) !== url) { - return null; + return { kind: "other-url", url }; } - return record.lastProbe; + return { kind: "found", probe: record.lastProbe }; +} + +// `null` when there is no probe to trust. +export async function readCachedProbe( + profileName: string, + url: string | null, +): Promise { + const hit = await lookupCachedProbe(profileName, url); + return hit.kind === "found" ? hit.probe : null; } // For a command that takes no `--url` and so can only be pointed elsewhere by the environment. // Nothing here reaches the network. -export async function readCachedServerProfile(profileName: string): Promise { +export async function readCachedServerProfile(profileName: string): Promise { const envUrl = readEnvCredentials().url; - const probe = await readCachedProbe(profileName, envUrl === null ? null : normalizeUrl(envUrl)); - return probe === null ? null : createServerProfile(probe); + const hit = await lookupCachedProbe(profileName, envUrl === null ? null : normalizeUrl(envUrl)); + if (hit.kind !== "found") { + return hit; + } + return { kind: "found", profile: createServerProfile(hit.probe) }; } diff --git a/packages/cli/src/output/skill-list.test.ts b/packages/cli/src/output/skill-list.test.ts index 963b8573..bcab81d2 100644 --- a/packages/cli/src/output/skill-list.test.ts +++ b/packages/cli/src/output/skill-list.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createServerProfile } from "@metabase/client/version/profile"; + +import type { CachedServerProfile } from "../core/auth/cached-server"; import type { UnavailableSkill } from "../core/skills"; import { renderSkillList, skillFilterNotices, type SkillListRow } from "./skill-list"; @@ -89,21 +92,47 @@ describe("skillFilterNotices", () => { }, ]; + const probed: CachedServerProfile = { + kind: "found", + profile: createServerProfile({ + edition: "oss", + version: { tag: "v0.58.0", major: 58, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, + }), + }; + it("says nothing when the filter was bypassed", () => { - expect(skillFilterNotices(skipped, { profileName: "default", bypassed: true })).toEqual([]); - expect(skillFilterNotices(null, { profileName: "default", bypassed: true })).toEqual([]); + expect(skillFilterNotices(skipped, { profileName: "default", cached: null })).toEqual([]); + expect(skillFilterNotices(null, { profileName: "default", cached: null })).toEqual([]); }); - it("names the profile that had no probe to filter by", () => { - expect(skillFilterNotices(null, { profileName: "staging", bypassed: false })).toEqual([ - 'Skills are unfiltered: profile "staging" has no cached server probe (run `mb auth login` to record one).', + it("says why the profile had no probe to filter by", () => { + expect( + skillFilterNotices(null, { profileName: "staging", cached: { kind: "no-profile" } }), + ).toEqual([ + 'Skills are unfiltered: there is no profile "staging" (run `mb auth login` to create one and record its server).', + ]); + expect( + skillFilterNotices(null, { profileName: "staging", cached: { kind: "never-probed" } }), + ).toEqual([ + 'Skills are unfiltered: profile "staging" has no cached server probe yet (run `mb auth login` to record one).', + ]); + expect( + skillFilterNotices(null, { + profileName: "staging", + cached: { kind: "other-url", url: "https://other.example.com" }, + }), + ).toEqual([ + 'Skills are unfiltered: profile "staging" has no cached server probe for https://other.example.com, the URL in use (run `mb auth login` to record one).', ]); }); it("names each skipped skill with the client's reason and the way around it", () => { - expect(skillFilterNotices(skipped, { profileName: "default", bypassed: false })).toEqual([ - 'Skipped skill "transform": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --all to print it anyway.', + expect(skillFilterNotices(skipped, { profileName: "default", cached: probed })).toEqual([ + 'Skipped skill "transform": This operation requires Metabase v59+ (this server is v0.58.0). Upgrade Metabase to use it. Pass --unfiltered to print it anyway.', ]); - expect(skillFilterNotices([], { profileName: "default", bypassed: false })).toEqual([]); + expect(skillFilterNotices([], { profileName: "default", cached: probed })).toEqual([]); }); }); diff --git a/packages/cli/src/output/skill-list.ts b/packages/cli/src/output/skill-list.ts index f1006c91..7b8de5c2 100644 --- a/packages/cli/src/output/skill-list.ts +++ b/packages/cli/src/output/skill-list.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { RequirementFailure } from "@metabase/client/version/preflight-error"; +import type { CachedProbeMiss, CachedServerProfile } from "../core/auth/cached-server"; import type { UnavailableSkill } from "../core/skills"; import { listTruncationNotice, warn } from "./notice"; @@ -19,39 +20,53 @@ export const UnavailableSkills = z .array(UnavailableSkillJson) .nullable() .describe( - "Skills left out because the profile's server lacks a feature they need, each with the client's account of the first one missing; `null` when nothing was filtered — `--all` was passed, or the profile has no cached server probe to filter by.", + "Skills left out because the profile's server lacks a feature they need, each with the client's account of the first one missing; `null` when nothing was filtered — `--unfiltered` was passed, or the profile has no cached server probe to filter by.", ); -export const unavailableSkillsFlag = { - all: { +export const unfilteredFlag = { + unfiltered: { type: "boolean", description: - "Every non-hidden skill, in full, regardless of what the profile's server supports", + "Print the selected skills as written, regardless of what the profile's server supports", }, } as const; +// `cached` is `null` when `--unfiltered` skipped the lookup, which asked for the lot and gets no note. interface SkillFilterContext { profileName: string; - bypassed: boolean; + cached: CachedServerProfile | null; } -// `--all` asked for the lot and gets no note. export function skillFilterNotices( unavailable: readonly UnavailableSkill[] | null, context: SkillFilterContext, ): string[] { - if (context.bypassed) { + if (context.cached === null) { return []; } - if (unavailable === null) { - return [ - `Skills are unfiltered: profile "${context.profileName}" has no cached server probe (run \`mb auth login\` to record one).`, - ]; + if (unavailable !== null) { + return unavailable.map( + (skill) => + `Skipped skill "${skill.name}": ${skill.failure.detail} Pass --unfiltered to print it anyway.`, + ); + } + return context.cached.kind === "found" + ? [] + : [unfilteredNotice(context.profileName, context.cached)]; +} + +function unfilteredNotice(profileName: string, miss: CachedProbeMiss): string { + switch (miss.kind) { + case "no-profile": { + return `Skills are unfiltered: there is no profile "${profileName}" (run \`mb auth login\` to create one and record its server).`; + } + case "never-probed": { + return `Skills are unfiltered: profile "${profileName}" has no cached server probe yet (run \`mb auth login\` to record one).`; + } + case "other-url": { + return `Skills are unfiltered: profile "${profileName}" has no cached server probe for ${miss.url}, the URL in use (run \`mb auth login\` to record one).`; + } } - return unavailable.map( - (skill) => - `Skipped skill "${skill.name}": ${skill.failure.detail} Pass --all to print it anyway.`, - ); } const DEFAULT_TERMINAL_WIDTH = 80; diff --git a/tests/e2e/skills.e2e.test.ts b/tests/e2e/skills.e2e.test.ts index 51d4e940..a960a125 100644 --- a/tests/e2e/skills.e2e.test.ts +++ b/tests/e2e/skills.e2e.test.ts @@ -41,7 +41,7 @@ const BUNDLED_VISIBLE_NAMES = [ ] as const; const UNFILTERED_NOTE = - 'Skills are unfiltered: profile "default" has no cached server probe (run `mb auth login` to record one).'; + 'Skills are unfiltered: there is no profile "default" (run `mb auth login` to create one and record its server).'; const SKILL_OVERSIZE_HINT = "a skill body is indivisible — pass --max-bytes 0 to print it whole, or `mb skills path ` to read it from disk"; @@ -175,11 +175,14 @@ describe("skills e2e", () => { expect(result.stderr).toBe(""); }); - it("list --all against a v58 profile lists every skill and reports `unavailable: null`", async () => { + it("list --unfiltered against a v58 profile lists every skill and reports `unavailable: null`", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 58); - const result = await runCli({ args: ["skills", "list", "--all", "--json"], configHome }); + const result = await runCli({ + args: ["skills", "list", "--unfiltered", "--json"], + configHome, + }); expect(result.exitCode, result.stderr).toBe(0); expect(parseJson(result.stdout, SkillListEnvelope)).toEqual(fullList(BUNDLED_VISIBLE)); @@ -198,13 +201,13 @@ describe("skills e2e", () => { ); expect(result.stderr).toBe( [ - `Skipped skill "git-sync": ${GIT_SYNC_UNAVAILABLE_ON_58.failure.detail} Pass --all to print it anyway.`, - `Skipped skill "transform": ${TRANSFORM_UNAVAILABLE_ON_58.failure.detail} Pass --all to print it anyway.`, + `Skipped skill "git-sync": ${GIT_SYNC_UNAVAILABLE_ON_58.failure.detail} Pass --unfiltered to print it anyway.`, + `Skipped skill "transform": ${TRANSFORM_UNAVAILABLE_ON_58.failure.detail} Pass --unfiltered to print it anyway.`, ].join("\n"), ); }); - it("get transform against a v58 profile withholds the body, and --all prints it", async () => { + it("get transform against a v58 profile withholds the body, and --unfiltered prints it", async () => { const configHome = await makeIsolatedConfigHome(); await seedProbedProfile(configHome, 58); @@ -221,7 +224,7 @@ describe("skills e2e", () => { }); const printed = await runCli({ - args: ["skills", "get", "transform", "--all", "--json", "--max-bytes", "0"], + args: ["skills", "get", "transform", "--unfiltered", "--json", "--max-bytes", "0"], configHome, }); expect(printed.exitCode, printed.stderr).toBe(0); From d291526d20084a4f3955276bdb013e9fed143d67 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:23:11 -0400 Subject: [PATCH 18/22] Parse requires markers with string checks, skip fences, refuse table rows --- packages/cli/src/core/skills.test.ts | 57 ++++++++- packages/cli/src/core/skills.ts | 173 +++++++++++++++++++++------ 2 files changed, 194 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/core/skills.test.ts b/packages/cli/src/core/skills.test.ts index d0b734ec..67ec2eae 100644 --- a/packages/cli/src/core/skills.test.ts +++ b/packages/cli/src/core/skills.test.ts @@ -353,7 +353,12 @@ describe("resolveSections", () => { }); it("returns balanced sections as written without features, and marker-free with them", () => { - const plainLine = fc.string().filter((line) => !line.includes("", + "gated", + "", + "```", + "", + "~~~", + "", + "~~~", + ].join("\n"); + expect(resolveSections(text, profileAt(58).features, "skill x")).toBe(text); + }); + + it("throws ConfigError on a marker between table rows", () => { + const text = [ + "| a |", + "|---|", + "", + "| b |", + "", + ].join("\n"); + expect(() => resolveSections(text, null, "skill x")).toThrow( + new ConfigError("skill x: a requires marker cannot sit inside a Markdown table (line 3)"), + ); + }); + + it("throws ConfigError on a marker closed with the `--!>` comment end", () => { + expect(() => + resolveSections("\n", null, "skill x"), + ).toThrow( + new ConfigError( + "skill x: malformed requires marker at line 1 (expected `` or ``)", + ), + ); + }); + + it("throws ConfigError on a marker missing the colon after the keyword", () => { + expect(() => + resolveSections("\n", null, "skill x"), + ).toThrow( + new ConfigError( + "skill x: malformed requires marker at line 1 (expected `` or ``)", + ), + ); + }); + it("throws ConfigError on a section naming no feature", () => { expect(() => resolveSections("\n", null, "skill x"), diff --git a/packages/cli/src/core/skills.ts b/packages/cli/src/core/skills.ts index 79b2c2e0..039164bf 100644 --- a/packages/cli/src/core/skills.ts +++ b/packages/cli/src/core/skills.ts @@ -59,7 +59,7 @@ export interface UnavailableSkill { } // `unavailable` is `null` when nothing was filtered out because there was no server to filter by. -export interface SkillSelection { +interface SkillSelection { skills: SkillInfo[]; unavailable: UnavailableSkill[] | null; } @@ -77,11 +77,12 @@ const SKILL_TEMPLATES_DIR = "templates"; const FRONTMATTER_PREFIX_BYTES = 8192; const FRONTMATTER_FENCE = "---"; -const SECTION_OPEN_PREFIX = /^$/; -const SECTION_OPEN = /^$/; -const SECTION_CLOSE = /^$/; -const SECTION_MARKER_TEXT = /"; +const MARKER_KEYWORD = "requires"; +const MARKER_OPEN_KEYWORD = `${MARKER_KEYWORD}:`; +const MARKER_CLOSE_KEYWORD = `/${MARKER_KEYWORD}`; +const FENCE_RUN = /^(?:`{3,}|~{3,})/; export function loadAllSkills(): SkillInfo[] { return discoverSkills(resolveSkillDirs()); @@ -306,43 +307,54 @@ interface OpenSection { met: boolean; } +interface OpenMarker { + kind: "open"; + names: string[]; +} + +interface CloseMarker { + kind: "close"; +} + +type Marker = OpenMarker | CloseMarker; + +interface MarkerSite { + where: string; + lineNumber: number; +} + // Without features the markers stay in the text, so a reader still sees what each section needs. // They are validated either way, so a typo fails on every read rather than only against some server. export function resolveSections(text: string, features: Features | null, where: string): string { + const lines = text.split("\n"); const out: string[] = []; let open: OpenSection | null = null; + let fence: string | null = null; let collapseBlank = false; - for (const [index, line] of text.split("\n").entries()) { - const lineNumber = index + 1; + for (const [index, line] of lines.entries()) { + const site: MarkerSite = { where, lineNumber: index + 1 }; const trimmed = line.trim(); - if (SECTION_OPEN.test(trimmed)) { - if (open !== null) { - throw new ConfigError( - `${where}: nested requires section at line ${lineNumber} (opened at line ${open.line})`, - ); + const opening: string | null = fence === null ? fenceRun(trimmed) : null; + if (fence !== null) { + if (closesFence(trimmed, fence)) { + fence = null; } - const named = parseFeatureNames(sectionFeatureNames(trimmed), `${where} line ${lineNumber}`); - if (named.length === 0) { - throw new ConfigError(`${where}: requires section at line ${lineNumber} names no feature`); + } else if (opening !== null) { + fence = opening; + } else { + const marker = parseMarker(trimmed); + if (marker === null && mentionsMarker(line)) { + throw markerError(trimmed, site); } - open = { line: lineNumber, met: features === null || named.every((name) => features[name]) }; - collapseBlank = dropMarker(out, line, features); - continue; - } - if (SECTION_CLOSE.test(trimmed)) { - if (open === null) { - throw new ConfigError( - `${where}: requires section closed at line ${lineNumber} was never opened`, - ); + if (marker !== null) { + assertOutsideTable(lines, index, where); + open = + marker.kind === "open" + ? openSection(marker, open, features, site) + : closeSection(open, site); + collapseBlank = dropMarker(out, line, features); + continue; } - collapseBlank = dropMarker(out, line, features); - open = null; - continue; - } - if (SECTION_MARKER_TEXT.test(line)) { - throw new ConfigError( - `${where}: a requires marker must be on its own line (line ${lineNumber})`, - ); } if (collapseBlank) { collapseBlank = false; @@ -369,8 +381,99 @@ function dropMarker(out: string[], marker: string, features: Features | null): b return out.length === 0 || out[out.length - 1] === ""; } -function sectionFeatureNames(marker: string): string[] { - return parseCsv(marker.replace(SECTION_OPEN_PREFIX, "").replace(MARKER_END, "")); +// A table ends at a blank line, so only a row on the very next or previous line puts a marker +// inside one. +function assertOutsideTable(lines: readonly string[], index: number, where: string): void { + if (isTableRow(lines[index - 1]) || isTableRow(lines[index + 1])) { + throw new ConfigError( + `${where}: a requires marker cannot sit inside a Markdown table (line ${index + 1})`, + ); + } +} + +function isTableRow(line: string | undefined): boolean { + return line !== undefined && line.trimStart().startsWith("|"); +} + +// The backtick or tilde run that opens a fenced code block, or `null` for any other line. What a +// fence encloses is text, marker-shaped lines included. +function fenceRun(trimmed: string): string | null { + const match = FENCE_RUN.exec(trimmed); + return match === null ? null : match[0]; +} + +// A closing fence is the opening run's character, at least as long, and nothing else on the line. +function closesFence(trimmed: string, fence: string): boolean { + const run = fenceRun(trimmed); + return run !== null && run.startsWith(fence) && run === trimmed; +} + +function openSection( + marker: OpenMarker, + open: OpenSection | null, + features: Features | null, + site: MarkerSite, +): OpenSection { + const { where, lineNumber } = site; + if (open !== null) { + throw new ConfigError( + `${where}: nested requires section at line ${lineNumber} (opened at line ${open.line})`, + ); + } + const named = parseFeatureNames(marker.names, `${where} line ${lineNumber}`); + if (named.length === 0) { + throw new ConfigError(`${where}: requires section at line ${lineNumber} names no feature`); + } + return { line: lineNumber, met: features === null || named.every((name) => features[name]) }; +} + +function closeSection(open: OpenSection | null, site: MarkerSite): null { + if (open === null) { + throw new ConfigError( + `${site.where}: requires section closed at line ${site.lineNumber} was never opened`, + ); + } + return null; +} + +// The whole grammar, on a line that is nothing else; `null` for any other line. Plain string +// checks rather than a comment-shaped regex, which is the pattern HTML sanitizers get wrong. +function parseMarker(trimmed: string): Marker | null { + if (!trimmed.startsWith(COMMENT_START) || !trimmed.endsWith(COMMENT_END)) { + return null; + } + const inner = trimmed.slice(COMMENT_START.length, -COMMENT_END.length).trim(); + if (inner === MARKER_CLOSE_KEYWORD) { + return { kind: "close" }; + } + if (inner.startsWith(MARKER_OPEN_KEYWORD)) { + return { kind: "open", names: parseCsv(inner.slice(MARKER_OPEN_KEYWORD.length)) }; + } + return null; +} + +// A comment that opens on the keyword anywhere in the line was meant as a marker, colon or not. +function mentionsMarker(line: string): boolean { + const at = line.indexOf(COMMENT_START); + if (at < 0) { + return false; + } + const inner = line.slice(at + COMMENT_START.length).trimStart(); + const keyword = inner.startsWith("/") ? inner.slice(1) : inner; + return keyword.startsWith(MARKER_KEYWORD); +} + +// A marker attempt that fills the line is malformed; one sharing the line with prose is misplaced. +function markerError(trimmed: string, site: MarkerSite): ConfigError { + const fillsLine = trimmed.startsWith(COMMENT_START) && trimmed.endsWith(">"); + if (fillsLine) { + return new ConfigError( + `${site.where}: malformed requires marker at line ${site.lineNumber} (expected \`\` or \`\`)`, + ); + } + return new ConfigError( + `${site.where}: a requires marker must be on its own line (line ${site.lineNumber})`, + ); } function collectExtraFiles(skillDir: string, subdirName: string): SkillExtraFile[] { From 4975be367a7830de43f488ae9d2cd6f1a0585bb4 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:24:56 -0400 Subject: [PATCH 19/22] Keep the profile-refresh error out of core/auth so output/ stays inside the layering rule --- packages/cli/src/commands/runtime.ts | 3 ++- packages/cli/src/core/auth/server-summary.ts | 16 ---------------- packages/cli/src/core/profile-refreshed-error.ts | 16 ++++++++++++++++ packages/cli/src/output/error.test.ts | 2 +- packages/cli/src/output/error.ts | 2 +- tests/e2e/version.e2e.test.ts | 4 ++-- 6 files changed, 22 insertions(+), 21 deletions(-) create mode 100644 packages/cli/src/core/profile-refreshed-error.ts diff --git a/packages/cli/src/commands/runtime.ts b/packages/cli/src/commands/runtime.ts index 4b7ca401..ecba6e41 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -12,7 +12,8 @@ import { checkFeatures } from "@metabase/client/version/requirement-check"; import { type MethodKey, methodRequirements } from "@metabase/client/version/requirements"; import type { ProfileLastProbe } from "../core/auth/profile-record"; -import { ProfileRefreshedError, serverChangeNote, skewNotice } from "../core/auth/server-summary"; +import { ProfileRefreshedError } from "../core/profile-refreshed-error"; +import { serverChangeNote, skewNotice } from "../core/auth/server-summary"; import { readCachedProbe } from "../core/auth/cached-server"; import { consumeKeyringDowngradeWarning, diff --git a/packages/cli/src/core/auth/server-summary.ts b/packages/cli/src/core/auth/server-summary.ts index b3c28cc4..97b8e1e4 100644 --- a/packages/cli/src/core/auth/server-summary.ts +++ b/packages/cli/src/core/auth/server-summary.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { TokenFeatures } from "@metabase/client/domain/session-properties"; -import { type ErrorCategory, MetabaseError } from "@metabase/client/errors"; import { Features } from "@metabase/client/version/features"; import type { ServerInfo } from "@metabase/client/version/probe"; import { KNOWN_RANGE } from "@metabase/client/version/known-range"; @@ -74,21 +73,6 @@ export function skewNotice(profile: ServerProfile): string | null { const PROFILE_REFRESHED_REMEDY = "the profile was refreshed — retry the command."; -// A refusal or shape error raised under a cached profile that a fresh probe has since rewritten. -// The note is the CLI's own, so it rides on the message and leaves the client's detail as it was. -export class ProfileRefreshedError extends MetabaseError { - readonly category: ErrorCategory; - readonly isRetryable = false; - readonly developerDetail: unknown; - - constructor(cause: MetabaseError, note: string) { - super(`${cause.userMessage}\n${note}`); - this.name = "ProfileRefreshedError"; - this.category = cause.category; - this.developerDetail = cause.developerDetail; - } -} - // What a fresh probe says that the cached one did not, or `null` when the two agree on everything // a feature switch reads: the version tag and the premium features. export function serverChangeNote(cached: ServerInfo, fresh: ServerInfo): string | null { diff --git a/packages/cli/src/core/profile-refreshed-error.ts b/packages/cli/src/core/profile-refreshed-error.ts new file mode 100644 index 00000000..2bea8499 --- /dev/null +++ b/packages/cli/src/core/profile-refreshed-error.ts @@ -0,0 +1,16 @@ +import { type ErrorCategory, MetabaseError } from "@metabase/client/errors"; + +// A refusal or shape error raised under a cached profile that a fresh probe has since rewritten. +// The note is the CLI's own, so it rides on the message and leaves the client's detail as it was. +export class ProfileRefreshedError extends MetabaseError { + readonly category: ErrorCategory; + readonly isRetryable = false; + readonly developerDetail: unknown; + + constructor(cause: MetabaseError, note: string) { + super(`${cause.userMessage}\n${note}`); + this.name = "ProfileRefreshedError"; + this.category = cause.category; + this.developerDetail = cause.developerDetail; + } +} diff --git a/packages/cli/src/output/error.test.ts b/packages/cli/src/output/error.test.ts index f855a317..115b9299 100644 --- a/packages/cli/src/output/error.test.ts +++ b/packages/cli/src/output/error.test.ts @@ -16,7 +16,7 @@ import { HttpError } from "@metabase/client/http/errors"; import { CapabilityError } from "@metabase/client/version/preflight-error"; import { createServerProfile } from "@metabase/client/version/profile"; import { checkFeatures } from "@metabase/client/version/requirement-check"; -import { ProfileRefreshedError } from "../core/auth/server-summary"; +import { ProfileRefreshedError } from "../core/profile-refreshed-error"; import { exitCodeFor, reportError } from "./error"; interface CapturedStreams { diff --git a/packages/cli/src/output/error.ts b/packages/cli/src/output/error.ts index 22bfefde..ac405588 100644 --- a/packages/cli/src/output/error.ts +++ b/packages/cli/src/output/error.ts @@ -2,7 +2,7 @@ import { AbortError, toMetabaseError } from "@metabase/client/errors"; import type { ErrorCategory, MetabaseError } from "@metabase/client/errors"; import type { RequirementFailure } from "@metabase/client/version/preflight-error"; -import { ProfileRefreshedError } from "../core/auth/server-summary"; +import { ProfileRefreshedError } from "../core/profile-refreshed-error"; import { consumeLegacyEnvWarnings, ENV_VERBOSE, readEnv } from "../core/env"; import { warn } from "./notice"; import { isPromptCancel } from "./prompt"; diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index 90a5e68d..54513981 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -170,7 +170,7 @@ describe("version skew notices e2e", () => { it("prints exactly one older-server notice on stderr and still runs a baseline command when the cached probe is below the known range", async () => { const configHome = await makeIsolatedConfigHome(); - await seedProbedProfileAt(configHome, liveTarget(), versionAt(BELOW_KNOWN)); + await seedProbedProfileAt(configHome, liveTarget(), probeAt(BELOW_KNOWN)); const result = await runCli({ args: ["card", "list", "--limit", "1", "--json"], configHome }); @@ -255,7 +255,7 @@ describe("version skew notices e2e", () => { expect(result.exitCode).toBe(2); expect(cliErrorCategory(result.stderr)).toBe("capability"); expect(cliErrorMessage(result.stderr)).toBe( - `${MEASURES_REFUSAL}\n${versionChangedNote("v0.58.0")}\n${DOWNGRADE_REMEDY}`, + `${MEASURES_REFUSAL}\n${versionChangedNote("v0.58.0")}`, ); expect(result.stdout).toBe(""); From 0f2d87462bd4f93efce8b4485d67063c0cf89a88 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:43:35 -0400 Subject: [PATCH 20/22] Read transport.require literals off the resource AST instead of regexes --- .../client/src/version/requirements.test.ts | 152 +++++++++++++----- 1 file changed, 111 insertions(+), 41 deletions(-) diff --git a/packages/client/src/version/requirements.test.ts b/packages/client/src/version/requirements.test.ts index a211e215..890be2ac 100644 --- a/packages/client/src/version/requirements.test.ts +++ b/packages/client/src/version/requirements.test.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import ts from "typescript"; import { describe, expect, it } from "vitest"; import { createClient } from "../client"; @@ -103,60 +104,129 @@ interface RequiredLiteral { readonly method: string; } -const REQUIRE_CALL = /transport\.require\("([^"]+)", options\)/g; -const FUNCTION_DECLARATION = /function\*? ([A-Za-z]+)\(/g; -const RETURNED_OBJECT = /return \{([^}]*)\}/g; -const ALIASED_ENTRY = /([A-Za-z]+): ([A-Za-z]+)/g; +const RESOURCE_FACTORY_SUFFIX = "Resource"; +const TRANSPORT_PARAMETER = "transport"; +const REQUIRE_METHOD = "require"; + +function parseSource(file: string): ts.SourceFile { + const text = readFileSync(resolve(RESOURCES_DIR, file), "utf8"); + return ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); +} + +function descendantsOf(node: ts.Node): ts.Node[] { + const found: ts.Node[] = []; + const visit = (child: ts.Node): void => { + found.push(child); + ts.forEachChild(child, visit); + }; + ts.forEachChild(node, visit); + return found; +} + +function isExportedResourceFactory(statement: ts.Statement): statement is ts.FunctionDeclaration { + if (!ts.isFunctionDeclaration(statement) || statement.name === undefined) { + return false; + } + const exported = (ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Export) !== 0; + return exported && statement.name.text.endsWith(RESOURCE_FACTORY_SUFFIX); +} + +function resourceFactoryOf(source: ts.SourceFile): ts.FunctionDeclaration | null { + const factories = source.statements.filter(isExportedResourceFactory); + if (factories.length > 1) { + throw new Error(`${source.fileName}: ${factories.length} resource factories in one file`); + } + const [factory] = factories; + return factory ?? null; +} + +type ExposedEntry = readonly [declared: string, exposed: string]; + +function exposedEntryOf(property: ts.ObjectLiteralElementLike, file: string): ExposedEntry { + if (ts.isShorthandPropertyAssignment(property)) { + return [property.name.text, property.name.text]; + } + const aliased = + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + ts.isIdentifier(property.initializer); + if (!aliased) { + throw new Error( + `${file}: \`${property.getText()}\` exposes something other than a declared function`, + ); + } + return [property.initializer.text, property.name.text]; +} // `{ delete: remove, import: importFromRemote }`: the name a caller reaches a method by, for a -// function whose own name is a reserved word. Only a declared function counts as aliased, so a -// returned data object (`{ data: rows, total: null }`) contributes nothing. -function exposedNamesOf(source: string): ReadonlyMap { - const declaredFunctions = new Set( - [...source.matchAll(FUNCTION_DECLARATION)].flatMap((match) => - match[1] === undefined ? [] : [match[1]], - ), - ); - const entries = [...source.matchAll(RETURNED_OBJECT)].flatMap((object) => - object[1] === undefined ? [] : Array.from(object[1].matchAll(ALIASED_ENTRY)), - ); - return new Map( - entries.flatMap(([, exposed, declared]) => - exposed !== undefined && declared !== undefined && declaredFunctions.has(declared) - ? [[declared, exposed]] - : [], - ), +// function whose own name is a reserved word. +function exposedNamesOf( + factory: ts.FunctionDeclaration, + file: string, +): ReadonlyMap { + const returned = factory.body?.statements.find(ts.isReturnStatement); + if (returned?.expression === undefined || !ts.isObjectLiteralExpression(returned.expression)) { + throw new Error(`${file}: the resource factory returns no object literal`); + } + return new Map(returned.expression.properties.map((property) => exposedEntryOf(property, file))); +} + +function isRequireCall(node: ts.Node): node is ts.CallExpression { + if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) { + return false; + } + const { expression: receiver, name } = node.expression; + return ( + ts.isIdentifier(receiver) && + receiver.text === TRANSPORT_PARAMETER && + name.text === REQUIRE_METHOD ); } -function enclosingFunctionAt(source: string, index: number): string { - let enclosing: string | null = null; - for (const match of source.matchAll(FUNCTION_DECLARATION)) { - if (match.index > index) { - break; - } - enclosing = match[1] ?? null; +function requiredKeyOf(call: ts.CallExpression, file: string): string { + const [key] = call.arguments; + if (key === undefined || !ts.isStringLiteral(key)) { + throw new Error( + `${file}: \`${call.getText()}\` asks for something other than a string literal`, + ); } - if (enclosing === null) { - throw new Error(`no function declared before offset ${index}`); + return key.text; +} + +function enclosingDeclaredFunction( + call: ts.CallExpression, + factory: ts.FunctionDeclaration, + file: string, +): string { + let current: ts.Node = call.parent; + while (current !== factory) { + if (ts.isFunctionDeclaration(current) && current.name !== undefined) { + return current.name.text; + } + current = current.parent; } - return enclosing; + throw new Error(`${file}: \`${call.getText()}\` sits outside a declared method`); } function requiredLiterals(): RequiredLiteral[] { return readdirSync(RESOURCES_DIR) .filter((name) => name.endsWith(".ts") && !name.endsWith(".test.ts")) .flatMap((file) => { - const source = readFileSync(resolve(RESOURCES_DIR, file), "utf8"); - const exposed = exposedNamesOf(source); - return [...source.matchAll(REQUIRE_CALL)].flatMap((match) => { - const key = match[1]; - if (key === undefined) { - return []; - } - const declared = enclosingFunctionAt(source, match.index); - return [{ file, key, method: exposed.get(declared) ?? declared }]; - }); + const factory = resourceFactoryOf(parseSource(file)); + if (factory === null) { + return []; + } + const exposed = exposedNamesOf(factory, file); + return descendantsOf(factory) + .filter(isRequireCall) + .map((call) => { + const declared = enclosingDeclaredFunction(call, factory, file); + return { + file, + key: requiredKeyOf(call, file), + method: exposed.get(declared) ?? declared, + }; + }); }); } From 34a4aea372313e757b8c5176ff2c57cfe0f6a6ca Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:45:02 -0400 Subject: [PATCH 21/22] Read command requires and client calls off the AST and refuse client aliases --- .../cli/src/commands/requires-guard.test.ts | 294 +++++++++++++++--- 1 file changed, 256 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/commands/requires-guard.test.ts b/packages/cli/src/commands/requires-guard.test.ts index d56bc688..c35826ad 100644 --- a/packages/cli/src/commands/requires-guard.test.ts +++ b/packages/cli/src/commands/requires-guard.test.ts @@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { dirname, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import ts from "typescript"; import { describe, expect, it } from "vitest"; import { isMethodKey, type MethodKey } from "@metabase/client/version/requirements"; @@ -9,10 +10,10 @@ import { isMethodKey, type MethodKey } from "@metabase/client/version/requiremen const HERE = dirname(fileURLToPath(import.meta.url)); const CLI_SRC = resolve(HERE, ".."); -// Any `x.ns.method(` call; filtering through the requirements table drops what merely looks like one. -const METHOD_CALL = /\b[A-Za-z_$][\w$]*\.([A-Za-z]+)\.([A-Za-z]+)\(/g; -const REQUIRES_DECLARATION = /^\s*requires: (null|\[[^\]]*\]),?$/m; -const STRING_LITERAL = /"([^"]+)"/g; +const COMMAND_DEFINER = "defineMetabaseCommand"; +const REQUIRES_FIELD = "requires"; +const CLIENT_GETTER = "getClient"; +const CLIENT_TYPE = "MetabaseClient"; // Helpers a command hands its client to. The methods they call count as the command's, so a // command declares what reaches the wire on its behalf rather than what its own body spells out. @@ -22,41 +23,110 @@ const CLIENT_HELPERS: Readonly> = { verifyAndProbe: "core/auth/verify.ts", }; -function commandFiles(): string[] { - return readdirSync(HERE, { recursive: true, withFileTypes: true }) +interface ParsedFile { + readonly file: string; + readonly source: ts.SourceFile; +} + +interface CommandFile extends ParsedFile { + readonly definition: ts.ObjectLiteralExpression; +} + +function parseSource(file: string): ParsedFile { + const text = readFileSync(resolve(CLI_SRC, file), "utf8"); + return { file, source: ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true) }; +} + +function sourceFiles(): ParsedFile[] { + return readdirSync(CLI_SRC, { recursive: true, withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith(".ts")) .filter((entry) => !entry.name.endsWith(".test.ts")) .map((entry) => relative(CLI_SRC, resolve(entry.parentPath, entry.name)).split(sep).join("/")) - .filter((file) => readSource(file).includes("defineMetabaseCommand({")); + .toSorted() + .map(parseSource); } -function readSource(file: string): string { - return readFileSync(resolve(CLI_SRC, file), "utf8"); +const FILES = sourceFiles(); + +function descendantsOf(node: ts.Node): ts.Node[] { + const found: ts.Node[] = []; + const visit = (child: ts.Node): void => { + found.push(child); + ts.forEachChild(child, visit); + }; + ts.forEachChild(node, visit); + return found; +} + +function isCallTo(node: ts.Node, callee: string): node is ts.CallExpression { + return ( + ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === callee + ); +} + +function commandDefinitionOf(parsed: ParsedFile): ts.ObjectLiteralExpression | null { + const definer = descendantsOf(parsed.source).find((node) => isCallTo(node, COMMAND_DEFINER)); + if (definer === undefined) { + return null; + } + const [definition] = definer.arguments; + if (definition === undefined || !ts.isObjectLiteralExpression(definition)) { + throw new Error(`${parsed.file}: ${COMMAND_DEFINER} takes an object literal`); + } + return definition; +} + +function commandFiles(): CommandFile[] { + return FILES.flatMap((parsed) => { + const definition = commandDefinitionOf(parsed); + return definition === null ? [] : [{ ...parsed, definition }]; + }); +} + +function parsedFile(file: string): ParsedFile { + const parsed = FILES.find((candidate) => candidate.file === file); + if (parsed === undefined) { + throw new Error(`${file}: not a CLI source file`); + } + return parsed; } // Every non-command source file that reaches a client method, whether or not `CLIENT_HELPERS` names it. function clientReachingHelpers(): string[] { - return readdirSync(CLI_SRC, { recursive: true, withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith(".ts")) - .filter((entry) => !entry.name.endsWith(".test.ts")) - .map((entry) => relative(CLI_SRC, resolve(entry.parentPath, entry.name)).split(sep).join("/")) - .filter((file) => !readSource(file).includes("defineMetabaseCommand({")) - .filter((file) => methodsCalledIn(readSource(file)).length > 0) - .toSorted(); + return FILES.filter((parsed) => commandDefinitionOf(parsed) === null) + .filter((parsed) => methodsCalledIn(parsed).length > 0) + .map((parsed) => parsed.file); +} + +// Any `x.ns.method(` call; filtering through the requirements table drops what merely looks like one. +function methodKeyOf(call: ts.CallExpression): MethodKey | null { + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee) || !ts.isPropertyAccessExpression(callee.expression)) { + return null; + } + const key = `${callee.expression.name.text}.${callee.name.text}`; + return isMethodKey(key) ? key : null; } -function methodsCalledIn(source: string): MethodKey[] { - return [...source.matchAll(METHOD_CALL)] - .map((match) => `${match[1]}.${match[2]}`) - .filter(isMethodKey); +function methodsCalledIn(parsed: ParsedFile): MethodKey[] { + return descendantsOf(parsed.source) + .filter(ts.isCallExpression) + .flatMap((call) => { + const key = methodKeyOf(call); + return key === null ? [] : [key]; + }); +} + +function helpersCalledIn(parsed: ParsedFile): ParsedFile[] { + const nodes = descendantsOf(parsed.source); + return Object.entries(CLIENT_HELPERS) + .filter(([helper]) => nodes.some((node) => isCallTo(node, helper))) + .map(([, file]) => parsedFile(file)); } -function methodsReachedBy(file: string): MethodKey[] { - const source = readSource(file); - const viaHelpers = Object.entries(CLIENT_HELPERS) - .filter(([helper]) => new RegExp(`\\b${helper}\\(`).test(source)) - .flatMap(([, helperFile]) => methodsCalledIn(readSource(helperFile))); - return [...new Set([...methodsCalledIn(source), ...viaHelpers])].toSorted(); +function methodsReachedBy(parsed: ParsedFile): MethodKey[] { + const viaHelpers = helpersCalledIn(parsed).flatMap(methodsCalledIn); + return [...new Set([...methodsCalledIn(parsed), ...viaHelpers])].toSorted(); } interface Declaration { @@ -64,17 +134,37 @@ interface Declaration { readonly requires: readonly string[] | null; } -function declarationOf(file: string): Declaration { - const match = REQUIRES_DECLARATION.exec(readSource(file)); - if (match === null || match[1] === undefined) { - throw new Error(`${file}: no \`requires\` declaration found`); +function isRequiresField(property: ts.ObjectLiteralElementLike): property is ts.PropertyAssignment { + return ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + property.name.text === REQUIRES_FIELD + ); +} + +function declaredKeyOf(element: ts.Expression, file: string): string { + if (!ts.isStringLiteral(element)) { + throw new Error( + `${file}: \`${REQUIRES_FIELD}\` lists \`${element.getText()}\`, not a string literal`, + ); + } + return element.text; +} + +function declarationOf(command: CommandFile): Declaration { + const { file } = command; + const field = command.definition.properties.find(isRequiresField); + if (field === undefined) { + throw new Error(`${file}: no \`${REQUIRES_FIELD}\` declaration found`); } - if (match[1] === "null") { + const { initializer } = field; + if (initializer.kind === ts.SyntaxKind.NullKeyword) { return { file, requires: null }; } - const requires = [...match[1].matchAll(STRING_LITERAL)].flatMap((literal) => - literal[1] === undefined ? [] : [literal[1]], - ); + if (!ts.isArrayLiteralExpression(initializer)) { + throw new Error(`${file}: \`${REQUIRES_FIELD}\` is neither null nor an array literal`); + } + const requires = initializer.elements.map((element) => declaredKeyOf(element, file)); return { file, requires: requires.toSorted() }; } @@ -89,14 +179,137 @@ function onlineOnly(declarations: readonly Declaration[]): OnlineDeclaration[] { ); } +function isClientParameter(node: ts.Node): node is ts.ParameterDeclaration { + return ( + ts.isParameter(node) && + node.type !== undefined && + ts.isTypeReferenceNode(node.type) && + ts.isIdentifier(node.type.typeName) && + node.type.typeName.text === CLIENT_TYPE + ); +} + +function innermostValue(node: ts.Node): ts.Node { + let current = node; + while (ts.isAwaitExpression(current) || ts.isParenthesizedExpression(current)) { + current = current.expression; + } + return current; +} + +function outermostValue(node: ts.Node): ts.Node { + let current = node; + while (ts.isAwaitExpression(current.parent) || ts.isParenthesizedExpression(current.parent)) { + current = current.parent; + } + return current; +} + +interface ClientBinding extends ts.VariableDeclaration { + readonly name: ts.Identifier; +} + +function isClientBinding(node: ts.Node): node is ClientBinding { + return ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer !== undefined && + isCallTo(innermostValue(node.initializer), CLIENT_GETTER) + ); +} + +// The names a file reaches its client by: what `getClient()` was bound to and every parameter +// typed as the client. Resolved by name within the file, so a shadowing binding is refused too. +function clientNamesOf(nodes: readonly ts.Node[]): ReadonlySet { + const names = new Set(); + for (const node of nodes) { + if (isClientBinding(node)) { + names.add(node.name.text); + } + if (isClientParameter(node) && ts.isIdentifier(node.name)) { + names.add(node.name.text); + } + } + return names; +} + +function isDeclaringName(identifier: ts.Identifier): boolean { + const { parent } = identifier; + if (ts.isVariableDeclaration(parent) || ts.isParameter(parent)) { + return parent.name === identifier; + } + if (ts.isPropertyAccessExpression(parent) || ts.isPropertyAssignment(parent)) { + return parent.name === identifier; + } + return ts.isBindingElement(parent) && parent.propertyName === identifier; +} + +function isClientReference(node: ts.Node, names: ReadonlySet): node is ts.Identifier { + return ts.isIdentifier(node) && names.has(node.text) && !isDeclaringName(node); +} + +function isNamespaceMethodReceiver(value: ts.Node): boolean { + const namespace = value.parent; + if (!ts.isPropertyAccessExpression(namespace) || namespace.expression !== value) { + return false; + } + const method = namespace.parent; + if (!ts.isPropertyAccessExpression(method) || method.expression !== namespace) { + return false; + } + const call = method.parent; + return ts.isCallExpression(call) && call.expression === method; +} + +function isClientHelperArgument(value: ts.Node): boolean { + const call = value.parent; + return ( + ts.isCallExpression(call) && + ts.isIdentifier(call.expression) && + Object.hasOwn(CLIENT_HELPERS, call.expression.text) && + call.arguments.some((argument) => argument === value) + ); +} + +function isBoundToName(value: ts.Node): boolean { + return isClientBinding(value.parent) && value.parent.initializer === value; +} + +function describeMisuse(parsed: ParsedFile, node: ts.Node): string { + const { line } = parsed.source.getLineAndCharacterOfPosition(node.getStart()); + return `${parsed.file}:${line + 1}: ${node.getText()}`; +} + +function clientMisusesIn(parsed: ParsedFile): string[] { + const nodes = descendantsOf(parsed.source); + const names = clientNamesOf(nodes); + return nodes.flatMap((node) => { + if (isClientParameter(node) && !ts.isIdentifier(node.name)) { + return [describeMisuse(parsed, node)]; + } + if (isCallTo(node, CLIENT_GETTER)) { + const value = outermostValue(node); + const allowed = + isBoundToName(value) || isNamespaceMethodReceiver(value) || isClientHelperArgument(value); + return allowed ? [] : [describeMisuse(parsed, value.parent)]; + } + if (isClientReference(node, names)) { + const allowed = isNamespaceMethodReceiver(node) || isClientHelperArgument(node); + return allowed ? [] : [describeMisuse(parsed, node.parent)]; + } + return []; + }); +} + describe("every command declares exactly the client methods it reaches", () => { - const declarations = commandFiles().map(declarationOf); + const commands = commandFiles(); + const declarations = commands.map(declarationOf); it("declares the methods called in its body and in the helpers it hands the client to", () => { const online = onlineOnly(declarations); const declared = Object.fromEntries(online.map((entry) => [entry.file, entry.requires])); const reached = Object.fromEntries( - online.map((entry) => [entry.file, methodsReachedBy(entry.file)]), + online.map((entry) => [entry.file, methodsReachedBy(parsedFile(entry.file))]), ); expect(declared).toEqual(reached); }); @@ -104,7 +317,7 @@ describe("every command declares exactly the client methods it reaches", () => { it("reaches no client method at all when it declares it never reaches a server", () => { const offline = declarations.filter((declaration) => declaration.requires === null); const reached = Object.fromEntries( - offline.map((entry) => [entry.file, methodsReachedBy(entry.file)]), + offline.map((entry) => [entry.file, methodsReachedBy(parsedFile(entry.file))]), ); expect(reached).toEqual(Object.fromEntries(offline.map((entry) => [entry.file, []]))); }); @@ -115,7 +328,7 @@ describe("every command declares exactly the client methods it reaches", () => { it("names only helpers that still exist and still call a client method", () => { const reached = Object.fromEntries( - Object.values(CLIENT_HELPERS).map((file) => [file, methodsCalledIn(readSource(file))]), + Object.values(CLIENT_HELPERS).map((file) => [file, methodsCalledIn(parsedFile(file))]), ); expect(reached).toEqual({ "commands/dashboard/preflight.ts": ["dashboard.checkCardReferences"], @@ -123,4 +336,9 @@ describe("every command declares exactly the client methods it reaches", () => { "core/auth/verify.ts": ["user.current"], }); }); + + it("uses a client value only as the receiver of a `ns.method(` call or as an argument to a client helper", () => { + const helpers = Object.values(CLIENT_HELPERS).map(parsedFile); + expect([...commands, ...helpers].flatMap(clientMisusesIn)).toEqual([]); + }); }); From b4e7cd93a082681c59e73e56092c6ec886e9259c Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 18 Sep 2026 17:46:23 -0400 Subject: [PATCH 22/22] Keep the e2e stack list in its own module and read the CI matrix as YAML --- docs/architecture.md | 2 +- .../src/version/matrix-coverage.test.ts | 60 +++++++++---------- scripts/e2e-matrix.ts | 23 +------ scripts/e2e-stacks.ts | 22 +++++++ 4 files changed, 54 insertions(+), 53 deletions(-) create mode 100644 scripts/e2e-stacks.ts diff --git a/docs/architecture.md b/docs/architecture.md index 3c86fbe7..d7f0459c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,7 +74,7 @@ Two things that look like version drift are not, and must not become switches: a **Adding a version-conditional feature** is: a rule in `FEATURE_RULES` named after the behaviour, verified against the reference Metabase checkout's release branches (route files `src/metabase/api_routes/routes.clj` and `enterprise/backend/src/metabase_enterprise/api_routes/routes.clj`; token-feature keys in `src/metabase/premium_features/settings.clj`; trust the source over a branch's committed `openapi.json`, which can lag its routes); the method's entry in `METHOD_REQUIREMENTS`; `requires` on the command; and, where a shape differs per generation, the wire variant and converter. The e2e suite gates with `requireServer(lane, [])` where a server may lack the route and branches with `serverHas(feature)` where every stack answers and the answers differ. -**When a new Metabase major ships:** raise `KNOWN_RANGE.max`, add the `oss-` / `ee-` stacks to `scripts/e2e-matrix.ts` and `.github/workflows/e2e.yml` and run them — `matrix-coverage.test.ts` fails until every major in the range has both lanes, pinned to an image of its own major and edition — and when a major leaves the window at the other end raise `min` and run the unit tier — `features.test.ts` flags every rule that is now constant, and each one is deleted together with its wire variant, converter, `METHOD_REQUIREMENTS` entries, skill markers and e2e branches. Update the range the root `README.md` states. Nothing else names a major. +**When a new Metabase major ships:** raise `KNOWN_RANGE.max`, add the `oss-` / `ee-` stacks to `scripts/e2e-stacks.ts` and `.github/workflows/e2e.yml` and run them — `matrix-coverage.test.ts` fails until every major in the range has both lanes, pinned to an image of its own major and edition — and when a major leaves the window at the other end raise `min` and run the unit tier — `features.test.ts` flags every rule that is now constant, and each one is deleted together with its wire variant, converter, `METHOD_REQUIREMENTS` entries, skill markers and e2e branches. Update the range the root `README.md` states. Nothing else names a major. ## The e2e stack diff --git a/packages/client/src/version/matrix-coverage.test.ts b/packages/client/src/version/matrix-coverage.test.ts index 986c414a..00c1d825 100644 --- a/packages/client/src/version/matrix-coverage.test.ts +++ b/packages/client/src/version/matrix-coverage.test.ts @@ -2,34 +2,31 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import yaml from "js-yaml"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { type Stack, STACKS } from "../../../../scripts/e2e-stacks"; import { KNOWN_RANGE } from "./known-range"; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(HERE, "..", "..", "..", ".."); - -const MATRIX_STACK = /\{ id: "([a-z]+-(?:\d+|head))", image: "([^"]+)"/g; -const CI_STACK = /^\s+- ([a-z]+-(?:\d+|head))$/gm; - -function group(match: RegExpMatchArray, index: number): string { - const value = match[index]; - if (value === undefined) { - throw new Error(`no capture group ${index} in ${match[0]}`); - } - return value; -} - -function matrixStacks(): Map { - const source = readFileSync(resolve(REPO_ROOT, "scripts/e2e-matrix.ts"), "utf8"); - return new Map( - [...source.matchAll(MATRIX_STACK)].map((match) => [group(match, 1), group(match, 2)]), - ); -} +const WORKFLOW = resolve(REPO_ROOT, ".github/workflows/e2e.yml"); + +const Workflow = z.object({ + jobs: z.object({ + e2e: z.object({ + strategy: z.object({ + matrix: z.object({ stack: z.array(z.string()) }), + }), + }), + }), +}); function ciStacks(): string[] { - const source = readFileSync(resolve(REPO_ROOT, ".github/workflows/e2e.yml"), "utf8"); - return [...source.matchAll(CI_STACK)].map((match) => group(match, 1)); + const workflow = Workflow.parse(yaml.load(readFileSync(WORKFLOW, "utf8"))); + return workflow.jobs.e2e.strategy.matrix.stack; } function expectedLanes(): string[] { @@ -40,23 +37,26 @@ function expectedLanes(): string[] { return [...majors.map(String), "head"].flatMap((major) => [`oss-${major}`, `ee-${major}`]); } +function pinsOwnMajorAndEdition(stack: Stack): boolean { + const [edition, major] = stack.id.split("-"); + const repo = edition === "ee" ? "metabase/metabase-enterprise" : "metabase/metabase"; + const semverMajor = edition === "ee" ? 1 : 0; + return stack.image.startsWith(`${repo}:v${semverMajor}.${major}.`); +} + describe("every supported major has an e2e lane", () => { + const stackIds = STACKS.map((stack) => stack.id); + it("the matrix boots an oss and an ee stack for each major in KNOWN_RANGE, plus head", () => { - expect([...matrixStacks().keys()]).toEqual(expectedLanes()); + expect(stackIds).toEqual(expectedLanes()); }); it("CI runs exactly the matrix's lanes", () => { - expect(ciStacks()).toEqual([...matrixStacks().keys()]); + expect(ciStacks()).toEqual(stackIds); }); it("each released lane pins the image of its own major and edition", () => { - const released = [...matrixStacks()].filter(([id]) => !id.endsWith("-head")); - const mismatched = released.filter(([id, image]) => { - const [edition, major] = id.split("-"); - const repo = edition === "ee" ? "metabase/metabase-enterprise" : "metabase/metabase"; - const semverMajor = edition === "ee" ? 1 : 0; - return !image.startsWith(`${repo}:v${semverMajor}.${major}.`); - }); - expect(mismatched).toEqual([]); + const released = STACKS.filter((stack) => !stack.id.endsWith("-head")); + expect(released.filter((stack) => !pinsOwnMajorAndEdition(stack))).toEqual([]); }); }); diff --git a/scripts/e2e-matrix.ts b/scripts/e2e-matrix.ts index 47402605..3e332220 100644 --- a/scripts/e2e-matrix.ts +++ b/scripts/e2e-matrix.ts @@ -9,28 +9,7 @@ import { execa } from "execa"; import { errorMessage } from "@metabase/client/errors"; -interface Stack { - id: string; - image: string; - port: number; -} - -const STACKS: readonly Stack[] = [ - { id: "oss-58", image: "metabase/metabase:v0.58.34", port: 13058 }, - { id: "ee-58", image: "metabase/metabase-enterprise:v1.58.34", port: 13158 }, - { id: "oss-59", image: "metabase/metabase:v0.59.31", port: 13059 }, - { id: "ee-59", image: "metabase/metabase-enterprise:v1.59.31", port: 13159 }, - { id: "oss-60", image: "metabase/metabase:v0.60.27", port: 13060 }, - { id: "ee-60", image: "metabase/metabase-enterprise:v1.60.27", port: 13160 }, - { id: "oss-61", image: "metabase/metabase:v0.61.21", port: 13061 }, - { id: "ee-61", image: "metabase/metabase-enterprise:v1.61.21", port: 13161 }, - { id: "oss-62", image: "metabase/metabase:v0.62.19.5", port: 13062 }, - { id: "ee-62", image: "metabase/metabase-enterprise:v1.62.19.5", port: 13162 }, - { id: "oss-63", image: "metabase/metabase:v0.63.18", port: 13063 }, - { id: "ee-63", image: "metabase/metabase-enterprise:v1.63.18", port: 13163 }, - { id: "oss-head", image: "metabase/metabase-head:latest", port: 13064 }, - { id: "ee-head", image: "metabase/metabase-enterprise-head:latest", port: 13164 }, -]; +import { type Stack, STACKS } from "./e2e-stacks"; const DEFAULT_PARALLELISM = 2; diff --git a/scripts/e2e-stacks.ts b/scripts/e2e-stacks.ts new file mode 100644 index 00000000..76b5d52f --- /dev/null +++ b/scripts/e2e-stacks.ts @@ -0,0 +1,22 @@ +export interface Stack { + id: string; + image: string; + port: number; +} + +export const STACKS: readonly Stack[] = [ + { id: "oss-58", image: "metabase/metabase:v0.58.34", port: 13058 }, + { id: "ee-58", image: "metabase/metabase-enterprise:v1.58.34", port: 13158 }, + { id: "oss-59", image: "metabase/metabase:v0.59.31", port: 13059 }, + { id: "ee-59", image: "metabase/metabase-enterprise:v1.59.31", port: 13159 }, + { id: "oss-60", image: "metabase/metabase:v0.60.27", port: 13060 }, + { id: "ee-60", image: "metabase/metabase-enterprise:v1.60.27", port: 13160 }, + { id: "oss-61", image: "metabase/metabase:v0.61.21", port: 13061 }, + { id: "ee-61", image: "metabase/metabase-enterprise:v1.61.21", port: 13161 }, + { id: "oss-62", image: "metabase/metabase:v0.62.19.5", port: 13062 }, + { id: "ee-62", image: "metabase/metabase-enterprise:v1.62.19.5", port: 13162 }, + { id: "oss-63", image: "metabase/metabase:v0.63.18", port: 13063 }, + { id: "ee-63", image: "metabase/metabase-enterprise:v1.63.18", port: 13163 }, + { id: "oss-head", image: "metabase/metabase-head:latest", port: 13064 }, + { id: "ee-head", image: "metabase/metabase-enterprise-head:latest", port: 13164 }, +];