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..bd212be9 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` + `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 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. + +```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 }`. 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/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 7039790b..c77ee55e 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, 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 errors.ts MetabaseError taxonomy, toMetabaseError, isFileNotFoundError, errorMessage list.ts ListResult = { data, total } — what a one-response list method returns @@ -62,8 +64,8 @@ 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. -- `domain/*` imports only `zod` and sibling `domain/*` files. Nothing from elsewhere in either package. +- 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. - `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 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 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). + ## 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. @@ -122,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/` @@ -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) @@ -201,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. 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 7ce8370f..8cb82635 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,13 +29,13 @@ 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. +- 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`. @@ -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.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. - 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..377cd235 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 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. -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 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 @@ -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`, `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 @@ -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,17 +1917,21 @@ 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 --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 --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 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: | 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), 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/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 1bb65f1d..d7f0459c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,13 +4,13 @@ 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. `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. @@ -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/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. -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 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. -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 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 }`; 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)`. + +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, 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. + +**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-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 @@ -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..89149246 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; `--unfiltered` 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..72d41190 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,16 @@ 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. + + +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/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..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 # everything available on the installed version +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/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..2540222e 100644 --- a/packages/cli/src/commands/auth/list.test.ts +++ b/packages/cli/src/commands/auth/list.test.ts @@ -1,9 +1,13 @@ 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 { 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"; const hoisted = vi.hoisted(() => { @@ -66,14 +70,64 @@ 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 { + edition: "oss", + 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: { + server: successServer(), + }; +} + +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, + edition: "oss", version: { tag: "v0.58.7", major: 58, patch: 7 }, + date: null, + hash: null, tokenFeatures: null, + user: { id: 1, name: "Tester", isAdmin: true }, }, + lastFailure: null, }; } @@ -85,9 +139,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(); }); @@ -114,22 +171,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 () => { @@ -146,15 +202,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( @@ -202,22 +275,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 }, - tokenFeatures: null, - 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, @@ -228,13 +287,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", @@ -242,11 +304,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/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..9982ad53 100644 --- a/packages/cli/src/commands/auth/render.ts +++ b/packages/cli/src/commands/auth/render.ts @@ -1,5 +1,15 @@ +import { KNOWN_RANGE } from "@metabase/client/version/known-range"; +import { Skew } from "@metabase/client/version/profile"; + 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", +}); + function pickProperty(value: unknown, key: string): unknown { if (typeof value !== "object" || value === null || !(key in value)) { return undefined; @@ -47,3 +57,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..9f19c42a 100644 --- a/packages/cli/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -3,6 +3,9 @@ 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 { KNOWN_RANGE } from "@metabase/client/version/known-range"; +import { createServerProfile } from "@metabase/client/version/profile"; const hoisted = vi.hoisted(() => ({ store: new Map(), @@ -39,15 +42,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(); }); @@ -62,7 +70,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 +91,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 +114,18 @@ 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 probe = await writeProbeResult("default", { + const server: ServerInfo = { + edition: "oss", + version: { tag: "v0.58.7", major: 58, patch: 7 }, + date: null, + hash: null, + tokenFeatures: null, + }; + 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,8 +137,46 @@ 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, - lastProbedAt: probe?.at ?? null, + features: createServerProfile(server).features, + lastProbedAt: PROBED_AT, + 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 = { + edition: "ee", + version: { tag: `v1.${beyond}.0`, major: beyond, patch: 0 }, + date: null, + hash: null, + tokenFeatures: { library: true }, + }; + 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: PROBED_AT, 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..b30e716b 100644 --- a/packages/cli/src/commands/group.ts +++ b/packages/cli/src/commands/group.ts @@ -28,7 +28,7 @@ export function defineCommandGroup(def: CommandGroupDef): CommandDef { skills: def.skills ?? [], inputSchema: null, outputSchema: null, - capabilities: null, + requires: 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..c35826ad --- /dev/null +++ b/packages/cli/src/commands/requires-guard.test.ts @@ -0,0 +1,344 @@ +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"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_SRC = resolve(HERE, ".."); + +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. +const CLIENT_HELPERS: Readonly> = { + preflightDashcardCardReferences: "commands/dashboard/preflight.ts", + warnIfOutsideSyncScope: "commands/git-sync/sync-scope.ts", + verifyAndProbe: "core/auth/verify.ts", +}; + +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("/")) + .toSorted() + .map(parseSource); +} + +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 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(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(parsed: ParsedFile): MethodKey[] { + const viaHelpers = helpersCalledIn(parsed).flatMap(methodsCalledIn); + return [...new Set([...methodsCalledIn(parsed), ...viaHelpers])].toSorted(); +} + +interface Declaration { + readonly file: string; + readonly requires: readonly string[] | null; +} + +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`); + } + const { initializer } = field; + if (initializer.kind === ts.SyntaxKind.NullKeyword) { + return { file, requires: null }; + } + 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() }; +} + +interface OnlineDeclaration { + readonly file: string; + readonly requires: readonly string[]; +} + +function onlineOnly(declarations: readonly Declaration[]): OnlineDeclaration[] { + return declarations.flatMap(({ file, requires }) => + requires === null ? [] : [{ file, requires }], + ); +} + +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 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(parsedFile(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(parsedFile(entry.file))]), + ); + expect(reached).toEqual(Object.fromEntries(offline.map((entry) => [entry.file, []]))); + }); + + 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(parsedFile(file))]), + ); + expect(reached).toEqual({ + "commands/dashboard/preflight.ts": ["dashboard.checkCardReferences"], + "commands/git-sync/sync-scope.ts": ["gitSync.remoteUrl"], + "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([]); + }); +}); diff --git a/packages/cli/src/commands/runtime.test.ts b/packages/cli/src/commands/runtime.test.ts index fa64ef69..9e83abac 100644 --- a/packages/cli/src/commands/runtime.test.ts +++ b/packages/cli/src/commands/runtime.test.ts @@ -1,9 +1,15 @@ 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 { KNOWN_RANGE } from "@metabase/client/version/known-range"; +import { createServerProfile } 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(), @@ -17,7 +23,26 @@ 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 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 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 { + return parseJson(stderr.join(""), z.unknown(), { source: "stderr" }); +} async function seedProbedProfile(name: string, info: ServerInfo): Promise { await writeProfile({ url: "https://m.example.com", apiKey: "secret-key" }, name); @@ -27,13 +52,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 }, - 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) => { @@ -57,10 +90,15 @@ 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(); delete process.env[SKIP_PREFLIGHT_ENV]; process.exitCode = previousExitCode; @@ -71,7 +109,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 +124,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 +141,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(); @@ -116,16 +154,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" }, - capabilities: {}, + 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 () => { @@ -134,7 +178,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 +192,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(); @@ -158,7 +202,7 @@ describe("defineMetabaseCommand", () => { await runCommand(cmd, { rawArgs: [] }); - const parsed: unknown = JSON.parse(stderr.join("")); + const parsed = errorEnvelopeOf(stderr); expect(parsed).toEqual({ ok: false, error: { @@ -175,7 +219,7 @@ describe("defineMetabaseCommand", () => { const ran = vi.fn(); const cmd = defineMetabaseCommand({ meta: { name: "ranged", description: "takes a range" }, - capabilities: {}, + requires: [], args: { ...outputFlags, ...listFlags }, run() { ran(); @@ -185,7 +229,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: { @@ -201,7 +245,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; @@ -211,7 +255,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: { @@ -223,10 +267,10 @@ 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" }, - capabilities: {}, + requires: [], args: { ...outputFlags }, run() { return; @@ -240,14 +284,15 @@ describe("defineMetabaseCommand", () => { expect(process.exitCode).toBe(2); }); - it("refuses with CapabilityError exit code 2 when the cached server major is below required minVersion", async () => { - await seedProbedProfile("default", fakeServerInfo(58)); + it("refuses with CapabilityError exit code 2 before run does any work when the cached server lacks a required feature", async () => { + await seedProbedProfile("default", probeAt(58)); + vi.stubGlobal("fetch", captureFetch([sameServerProbe(58)]).fetch); 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 +303,21 @@ 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", probeAt(61)); + vi.stubGlobal("fetch", captureFetch([sameServerProbe(61)]).fetch); const ran = vi.fn(); const cmd = defineMetabaseCommand({ - meta: { name: "needs-transforms", description: "wants transforms" }, + meta: { name: "needs-library", description: "wants the library" }, args: {}, - capabilities: { tokenFeature: "transforms" }, + requires: ["library.get"], async run({ getClient }) { await getClient(); ran(); @@ -282,19 +328,46 @@ 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 the 'library' premium feature (not enabled on this server).", ); expect(process.exitCode).toBe(2); expect(ran).not.toHaveBeenCalled(); }); - it("runs a baseline-capabilities command without consulting the cached probe", async () => { + 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({ + meta: { name: "two-methods", description: "calls two gated methods" }, + args: {}, + requires: ["measure.list", "transformJob.setActive"], + async run({ getClient }) { + await getClient(); + ran(); + }, + }); + const stderr = captureStderr(); + + await runCommand(cmd, { rawArgs: [] }); + + expect(stderr.join("")).toContain( + "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("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 +376,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 cached probe's profile and never probes", async () => { + const info = probeAt(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 +469,65 @@ 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("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, + 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,19 +537,352 @@ 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", probeAt(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", probeAt(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", probeAt(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", probeAt(59)); + vi.setSystemTime(new Date(REPROBED_AT)); + 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); + 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", + 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", { + ...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 } }), + ]); + 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.`, + ), + ); + expect(await readProfileRecord("default")).toEqual( + reprobedRecord({ + at: REPROBED_AT, + edition: "oss", + 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", probeAt(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", probeAt(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(SHAPE_LEAD)); + }); + + it("re-probes on a shape error even when the preflight was skipped", async () => { + await seedProbedProfile("default", probeAt(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.", + ), + ); + }); + }); + + 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.", + ), + ); + expect(process.exitCode).toBe(2); + expect(await readProfileRecord("default")).toEqual( + reprobedRecord({ + at: REPROBED_AT, + edition: "oss", + 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({ meta: { name: "skip-preflight-flag", description: "skip via flag" }, args: { ...connectionFlags }, - capabilities: { minVersion: 99 }, + requires: ["transformJob.setActive"], async run({ getClient }) { await getClient(); ran(); @@ -374,15 +893,63 @@ 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", probeAt(58)); + vi.stubGlobal("fetch", captureFetch([sameServerProbe(58)]).fetch); + 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", probeAt(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)); + await seedProbedProfile("default", probeAt(58)); process.env[SKIP_PREFLIGHT_ENV] = "1"; const ran = vi.fn(); 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..ecba6e41 100644 --- a/packages/cli/src/commands/runtime.ts +++ b/packages/cli/src/commands/runtime.ts @@ -3,19 +3,22 @@ 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 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 { ProfileRefreshedError } from "../core/profile-refreshed-error"; +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 { createCredentialRefresher, @@ -29,7 +32,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 +53,6 @@ interface MetabaseCommandContext { ctx: CommonContext; getClient: () => Promise; getResolvedConfig: () => Promise; - getServerInfo: () => Promise; } interface MetabaseCommandDef { @@ -57,15 +63,15 @@ interface MetabaseCommandDef { skills?: readonly SkillPointer[]; inputSchema?: ZodType; outputSchema?: ZodType; - capabilities: Partial | null; + // `null` for a command that never reaches a server. + 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 +84,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 readCachedProbe(resolved.profile, resolved.url); + 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 +135,9 @@ export function defineMetabaseCommand( ctx, getClient, getResolvedConfig, - getServerInfo, }); + } catch (error) { + throw await refreshProfileOnStaleError(error, cachedServer); } finally { emitPendingWarnings(); } @@ -142,11 +152,16 @@ export function defineMetabaseCommand( skills: def.skills ?? [], inputSchema: def.inputSchema ?? null, outputSchema: def.outputSchema ?? null, - capabilities: required, + requires: requirements, }); 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 +176,95 @@ function emitPendingWarnings(): void { } } -async function loadServerInfo( - getResolvedConfig: () => Promise, -): Promise { - const resolved = await getResolvedConfig(); - const record = await readProfileRecord(resolved.profile); - if (record === null || record.lastProbe === null) { - return null; - } - return { - version: record.lastProbe.version, - tokenFeatures: record.lastProbe.tokenFeatures, +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 () => {}; +// 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( - 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 profile = await client.server(); + noticeSkew(profile); + const failure = checkFeatures(features, profile); + if (failure !== null) { + throw new CapabilityError(failure); } - const failure = checkCapabilities(info, required); - if (failure === null || failure.reason === "unknown-version") { - return; - } - 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 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 (cached === null) { + return error; + } + if (!(error instanceof ResponseShapeError) && !(error instanceof CapabilityError)) { + return error; + } + const note = await refreshChangedProbe(cached); + return note === null ? error : new ProfileRefreshedError(error, note); +} + +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..c9c13c53 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"; -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" }), + }; +} + +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,220 @@ 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: there is no profile "default" (run `mb auth login` to create one and record its server).\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 --unfiltered 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("--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", "--unfiltered", "--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 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, + 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 +298,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..95a2086e 100644 --- a/packages/cli/src/commands/skills/get.ts +++ b/packages/cli/src/commands/skills/get.ts @@ -1,22 +1,29 @@ 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, unfilteredFlag } 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 +42,24 @@ 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. --unfiltered prints the selection as written, 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", + description: "Every non-hidden skill", }, + ...unfilteredFlag, }, outputSchema: SkillGetEnvelope, examples: [ @@ -57,17 +67,34 @@ export default defineMetabaseCommand({ "mb skills get core --full", "mb skills get git-sync,transform --json", "mb skills get --all --json", + "mb skills get transform --unfiltered", ], - 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 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 }), + ); 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, cached })) { + warn(notice); + } }, }); @@ -77,16 +104,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..50e26ae1 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,152 @@ 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: there is no profile "default" (run `mb auth login` to create one and record its server).\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 --unfiltered to print it anyway.\n', + ); + }); + + 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 --unfiltered 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("--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: ["--unfiltered", "--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..42bd9ceb 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, + unfilteredFlag, +} 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,32 @@ 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; --unfiltered lists them too.", }, - capabilities: null, - args: { ...outputFlags, ...listFlags }, + requires: null, + args: { ...outputFlags, ...listFlags, ...profileFlag, ...unfilteredFlag }, 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 --unfiltered"], + async run({ args, ctx }) { + const profileName = resolveProfileName(args.profile); + 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, 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); + } else { + renderSkillList(envelope.data, ctx.maxBytes); + } + if (ctx.format === "json") { return; } - renderSkillList(envelope.data, ctx.maxBytes); + for (const notice of skillFilterNotices(selection.unavailable, { profileName, cached })) { + warn(notice); + } }, }); diff --git a/packages/cli/src/commands/skills/path.ts b/packages/cli/src/commands/skills/path.ts index 33c8e42b..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"; @@ -31,7 +36,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, @@ -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/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..e2f4db16 100644 --- a/packages/cli/src/commands/transform-job/run.ts +++ b/packages/cli/src/commands/transform-job/run.ts @@ -13,16 +13,30 @@ 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 === 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).`; + } + 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 +54,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..dbb1373b --- /dev/null +++ b/packages/cli/src/core/auth/cached-server.test.ts @@ -0,0 +1,75 @@ +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(); + delete process.env["MB_URL"]; + }); + + afterEach(() => { + home.cleanup(); + }); + + it("reports a profile that has no record", async () => { + expect(await readCachedServerProfile("default")).toEqual({ kind: "no-profile" }); + }); + + 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")).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({ + kind: "found", + profile: createServerProfile(probeAt(61, { library: true })), + }); + }); + + 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/"; + + 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({ + 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 new file mode 100644 index 00000000..d842ec50 --- /dev/null +++ b/packages/cli/src/core/auth/cached-server.ts @@ -0,0 +1,74 @@ +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"; + +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. +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 { + const record = await readProfileRecord(profileName); + if (record === null) { + return { kind: "no-profile" }; + } + if (record.lastProbe === null) { + return { kind: "never-probed" }; + } + if (url !== null && normalizeUrl(record.url) !== url) { + return { kind: "other-url", url }; + } + 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 { + const envUrl = readEnvCredentials().url; + 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/core/auth/profile-record.ts b/packages/cli/src/core/auth/profile-record.ts index 874985b4..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(), @@ -10,9 +10,15 @@ 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. `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(), 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..a8fa672e --- /dev/null +++ b/packages/cli/src/core/auth/server-summary.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; + +import type { ServerInfo } from "@metabase/client/version/probe"; +import { KNOWN_RANGE } from "@metabase/client/version/known-range"; +import { createServerProfile } from "@metabase/client/version/profile"; + +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, + tokenFeatures, + }; +} + +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", () => { + 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 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( + `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 a head build", () => { + expect(skewNotice(createServerProfile(UNPARSEABLE))).toBe( + `Could not parse the Metabase version; assuming a head build past 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..97b8e1e4 --- /dev/null +++ b/packages/cli/src/core/auth/server-summary.ts @@ -0,0 +1,108 @@ +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 { 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({ + min: z.number().int(), + max: z.number().int(), +}); + +// 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(), + 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 { 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.`; + } + case "unknown": { + return `Could not parse the Metabase version; assuming a head build past 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..5f61f9be 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,36 +257,57 @@ 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 }, + edition: "oss", + 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" }); 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, 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 () => { 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, tokenFeatures: null, }, }); @@ -290,25 +317,37 @@ 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: { + edition: "oss", version: { tag: "v0.58.7", major: 58, patch: 7 }, + date: null, + hash: null, 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"); }); }); @@ -336,13 +375,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", + }); }); }); @@ -414,8 +457,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); }); }); @@ -462,7 +505,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(); }); @@ -474,7 +517,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(); }); @@ -495,7 +550,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; } @@ -539,12 +599,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/auth/storage.ts b/packages/cli/src/core/auth/storage.ts index 045a4db7..71dfccc4 100644 --- a/packages/cli/src/core/auth/storage.ts +++ b/packages/cli/src/core/auth/storage.ts @@ -451,6 +451,9 @@ 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, 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..0a318902 100644 --- a/packages/cli/src/core/auth/temp-config-home.ts +++ b/packages/cli/src/core/auth/temp-config-home.ts @@ -2,6 +2,24 @@ 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 { editionFromTag } from "@metabase/client/version/tag"; + +import type { ProbedUser } from "./profile-record"; +import { writeProbeResult, writeProfile } from "./storage"; + +// 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; cleanup(): void; @@ -20,3 +38,37 @@ export function setupTempConfigHome(): TempConfigHome { }, }; } + +// A profile record as `auth login` would have left it: the key stored, the server probed. +export async function seedProbedProfile( + name: string, + server: ServerInfo, + target: SeedTarget = UNREACHABLE_TARGET, +): Promise { + await writeProfile(target, name); + await writeProbeResult(name, { user: SEED_USER, server }); +} + +// 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: 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/packages/cli/src/core/auth/verify.test.ts b/packages/cli/src/core/auth/verify.test.ts index 8352f76a..b174262e 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,9 @@ 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 558405a5..42c54984 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 () => { @@ -248,7 +251,10 @@ 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, tokenFeatures: null, }, }); @@ -257,7 +263,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/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/core/skills.test.ts b/packages/cli/src/core/skills.test.ts index 005f42e3..67ec2eae 100644 --- a/packages/cli/src/core/skills.test.ts +++ b/packages/cli/src/core/skills.test.ts @@ -2,73 +2,113 @@ 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 { 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"; 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({ + edition: "oss", + version: { tag: `v0.${major}.0`, major, patch: 0 }, + date: null, + hash: null, + tokenFeatures, + }); +} + 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."), - ).toEqual({ name: "test-skill", description: "A test skill.", hidden: false }); + 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", description: "First sentence. Second line. Third line.", hidden: false, + requires: [], }); }); 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, + requires: [], }); }); - 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: "); }); }); @@ -91,6 +131,7 @@ interface WriteSkillFrontmatter { name: string; description: string; hidden?: boolean; + requires?: readonly string[]; } function writeSkill( @@ -105,6 +146,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,39 +181,352 @@ 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"), }, ]); }); - 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([ - { name: "real", description: "Real.", hidden: false, dir: join(temp.skillData, "real") }, + { + name: "real", + description: "Real.", + hidden: false, + requires: [], + dir: join(temp.skillData, "real"), + }, ]); }); + 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( [], ); }); + + 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")).toBe( + ["Intro.", "", "## Transforms", "", "Run one.", "", "Outro."].join("\n"), + ); + 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("keeps a marker inside a fenced code block as text", () => { + const text = [ + "Write it as:", + "", + "```md", + "", + "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"), + ).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 +557,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 +587,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 +601,114 @@ 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"] }); + const withMarkersLeft = all + .filter((skill) => { + const content = readSkillContent(skill, { includeExtras: true, profile: profileAt(58) }); + return content.body.includes(""; +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()); } @@ -59,6 +92,26 @@ export function loadVisibleSkills(): SkillInfo[] { return loadAllSkills().filter((s) => !s.hidden); } +export function selectForProfile( + skills: readonly SkillInfo[], + profile: ServerProfile | null, +): SkillSelection { + if (profile === null) { + return { skills: [...skills], unavailable: null }; + } + const available: SkillInfo[] = []; + const unavailable: UnavailableSkill[] = []; + for (const skill of skills) { + const failure = checkFeatures(skill.requires, profile); + if (failure === null) { + available.push(skill); + } else { + unavailable.push({ name: skill.name, failure }); + } + } + return { skills: available, unavailable }; +} + export function findSkillByName(all: readonly SkillInfo[], name: string): SkillInfo { const hit = all.find((s) => s.name === name); if (hit === undefined) { @@ -148,20 +201,29 @@ export function discoverSkills(dirs: readonly string[]): SkillInfo[] { if (fm === null) { continue; } - skills.push({ name: fm.name, description: fm.description, hidden: fm.hidden, dir: skillDir }); + skills.push({ + name: fm.name, + description: fm.description, + hidden: fm.hidden, + requires: parseFeatureNames(fm.requires, `skill ${fm.name}`), + dir: skillDir, + }); } } skills.sort((a, b) => a.name.localeCompare(b.name)); return skills; } +// `null` only when there is no SKILL.md, so a stray entry in a skills directory is not a skill. A +// SKILL.md that is there but malformed is a broken skill, and a broken skill refuses rather than +// vanishing from the list. function readFrontmatterFromSkill(skillDir: string): Frontmatter | null { const skillMd = join(skillDir, SKILL_MD_FILENAME); const prefix = readFilePrefix(skillMd, FRONTMATTER_PREFIX_BYTES); if (prefix === null) { return null; } - return parseFrontmatter(prefix); + return parseFrontmatter(prefix, skillMd); } function readFilePrefix(path: string, maxBytes: number): string | null { @@ -183,26 +245,23 @@ function readFilePrefix(path: string, maxBytes: number): string | null { } } -export function parseFrontmatter(content: string): Frontmatter | null { +export function parseFrontmatter(content: string, source: string): Frontmatter { const trimmed = content.trimStart(); if (!trimmed.startsWith(FRONTMATTER_FENCE)) { - return null; + throw new ConfigError(`${source}: missing frontmatter`); } const afterOpening = trimmed.slice(FRONTMATTER_FENCE.length); const closingIndex = afterOpening.indexOf(`\n${FRONTMATTER_FENCE}`); if (closingIndex < 0) { - return null; + throw new ConfigError(`${source}: unterminated frontmatter`); } - const block = afterOpening.slice(0, closingIndex); - const result = parseYamlResult(block, Frontmatter); - if (!result.ok) { - return null; - } - return result.value; + return parseYaml(afterOpening.slice(0, closingIndex), Frontmatter, { source }); } export function readSkillContent(info: SkillInfo, opts: ReadSkillContentOptions): SkillContent { - const body = readFileSync(join(info.dir, SKILL_MD_FILENAME), "utf8"); + const features = opts.profile === null ? null : opts.profile.features; + const raw = readFileSync(join(info.dir, SKILL_MD_FILENAME), "utf8"); + const body = resolveSections(raw, features, `skill ${info.name}`); if (!opts.includeExtras) { return { name: info.name, @@ -212,15 +271,211 @@ export function readSkillContent(info: SkillInfo, opts: ReadSkillContentOptions) templates: [], }; } + const references = collectExtraFiles(info.dir, SKILL_REFERENCES_DIR).map((file) => ({ + path: file.path, + content: resolveSections(file.content, features, `skill ${info.name} (${file.path})`), + })); return { name: info.name, description: info.description, body, - references: collectExtraFiles(info.dir, SKILL_REFERENCES_DIR), + references, templates: collectExtraFiles(info.dir, SKILL_TEMPLATES_DIR), }; } +function parseFeatureNames(names: readonly string[], where: string): FeatureName[] { + const known: FeatureName[] = []; + const unknown: string[] = []; + for (const name of names) { + if (isFeatureName(name)) { + known.push(name); + } else { + unknown.push(name); + } + } + if (unknown.length > 0) { + throw new ConfigError( + `${where}: unknown feature in requires: ${unknown.join(", ")} (known: ${FEATURE_NAMES.join(", ")})`, + ); + } + return known; +} + +interface OpenSection { + line: number; + 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 lines.entries()) { + const site: MarkerSite = { where, lineNumber: index + 1 }; + const trimmed = line.trim(); + const opening: string | null = fence === null ? fenceRun(trimmed) : null; + if (fence !== null) { + if (closesFence(trimmed, fence)) { + fence = null; + } + } else if (opening !== null) { + fence = opening; + } else { + const marker = parseMarker(trimmed); + if (marker === null && mentionsMarker(line)) { + throw markerError(trimmed, site); + } + 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; + } + } + 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"); +} + +// 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); + return false; + } + return out.length === 0 || out[out.length - 1] === ""; +} + +// 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[] { 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..b9ac79a1 100644 --- a/packages/cli/src/core/temp-skills-dir.ts +++ b/packages/cli/src/core/temp-skills-dir.ts @@ -15,14 +15,20 @@ interface SkillFixture { body: string; } +const GAMMA_FRONTMATTER = + "---\nname: gamma\ndescription: The transform skill.\nrequires: [transforms]\n---\n\n"; + +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`; + +// 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 +36,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 +47,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..54f5a4a8 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -58,7 +58,7 @@ setMetabaseAugment(main, { ], inputSchema: null, outputSchema: null, - capabilities: null, + requires: null, }); export default main; diff --git a/packages/cli/src/output/error.test.ts b/packages/cli/src/output/error.test.ts index acdf17dc..115b9299 100644 --- a/packages/cli/src/output/error.test.ts +++ b/packages/cli/src/output/error.test.ts @@ -13,8 +13,10 @@ 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 { ProfileRefreshedError } from "../core/profile-refreshed-error"; import { exitCodeFor, reportError } from "./error"; interface CapturedStreams { @@ -142,9 +144,15 @@ 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({ + edition: "oss", + version: { tag: "v0.58.0", major: 58, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, + }), ); assert(failure !== null); reportError(new CapabilityError(failure)); @@ -156,10 +164,61 @@ 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 = checkCapabilities( - { version: { tag: "v0.61.0", major: 61, patch: 0 }, tokenFeatures: null }, - { minVersion: 61, tokenFeature: "library" }, + const failure = checkFeatures( + ["library"], + createServerProfile({ + edition: "oss", + version: { tag: "v0.61.0", major: 61, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, + }), ); assert(failure !== null); reportError(new CapabilityError(failure)); @@ -170,9 +229,15 @@ 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({ + edition: "oss", + 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 +390,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..ac405588 100644 --- a/packages/cli/src/output/error.ts +++ b/packages/cli/src/output/error.ts @@ -1,7 +1,8 @@ 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 { ProfileRefreshedError } from "../core/profile-refreshed-error"; import { consumeLegacyEnvWarnings, ENV_VERBOSE, readEnv } from "../core/env"; import { warn } from "./notice"; import { isPromptCancel } from "./prompt"; @@ -74,7 +75,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 && @@ -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]; } diff --git a/packages/cli/src/output/help.test.ts b/packages/cli/src/output/help.test.ts index 6c979ef1..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 { BASELINE_CAPABILITIES } from "@metabase/client/version/capabilities"; import { connectionFlags, outputFlags, profileFlag } from "../commands/flags"; import { defineCommandGroup } from "../commands/group"; @@ -64,7 +63,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 +82,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 +97,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 +118,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 +167,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 +199,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 +216,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 +239,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 +256,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 +290,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 +312,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 +339,11 @@ describe("showUsage", () => { skills: [{ skill: "core", purpose: "auth and conventions" }], inputSchema: null, outputSchema: null, - capabilities: null, + requires: null, }); const leaf = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, - capabilities: {}, + requires: [], args: {}, skills: [{ skill: "core", purpose: "auth and conventions" }], run() { @@ -375,7 +374,7 @@ describe("showUsage", () => { }); const leaf = defineMetabaseCommand({ meta: { name: "list", description: "demo list" }, - capabilities: {}, + requires: [], args: {}, outputSchema: z.object({ ok: z.boolean() }), run() { @@ -414,7 +413,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 +439,14 @@ describe("showUsageJson", () => { required: ["ok"], additionalProperties: false, }, - capabilities: BASELINE_CAPABILITIES, + requires: { methods: ["card.list"], features: [] }, }); }); 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 +454,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..e86f2e44 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( @@ -340,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", () => { @@ -412,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`, ); @@ -483,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/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..bcab81d2 100644 --- a/packages/cli/src/output/skill-list.test.ts +++ b/packages/cli/src/output/skill-list.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderSkillList, type SkillListRow } from "./skill-list"; +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"; interface Streams { stdout: string; @@ -71,3 +75,64 @@ 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", + }, + }, + ]; + + 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", cached: null })).toEqual([]); + expect(skillFilterNotices(null, { profileName: "default", cached: null })).toEqual([]); + }); + + 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", 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", cached: probed })).toEqual([]); + }); +}); diff --git a/packages/cli/src/output/skill-list.ts b/packages/cli/src/output/skill-list.ts index 2354a068..7b8de5c2 100644 --- a/packages/cli/src/output/skill-list.ts +++ b/packages/cli/src/output/skill-list.ts @@ -1,3 +1,9 @@ +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"; export interface SkillListRow { @@ -5,6 +11,64 @@ 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 — `--unfiltered` was passed, or the profile has no cached server probe to filter by.", + ); + +export const unfilteredFlag = { + unfiltered: { + type: "boolean", + description: + "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; + cached: CachedServerProfile | null; +} + +export function skillFilterNotices( + unavailable: readonly UnavailableSkill[] | null, + context: SkillFilterContext, +): string[] { + if (context.cached === null) { + return []; + } + 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).`; + } + } +} + 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..3b061d05 100644 --- a/packages/cli/src/output/types.ts +++ b/packages/cli/src/output/types.ts @@ -27,7 +27,20 @@ export interface ListEnvelope { } export function listEnvelopeSchema(item: ZodType): ZodType> { - return z.object({ + return z.object(listEnvelopeShape(item)); +} + +// 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, +) { + 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 +90,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..18959274 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,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: z.object({ id: z.number() }), - capabilities: BASELINE_CAPABILITIES, + requires: null, }; setMetabaseAugment(cmd, augment); expect(getMetabaseAugment(cmd)).toBe(augment); @@ -34,7 +32,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: null, - capabilities: BASELINE_CAPABILITIES, + requires: null, }; const secondAugment: MetabaseAugment = { examples: ["b"], @@ -42,7 +40,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: null, - capabilities: BASELINE_CAPABILITIES, + requires: null, }; setMetabaseAugment(first, firstAugment); setMetabaseAugment(second, secondAugment); @@ -58,7 +56,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: null, - capabilities: BASELINE_CAPABILITIES, + requires: null, }; const replacement: MetabaseAugment = { examples: ["after"], @@ -66,7 +64,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: null, - capabilities: BASELINE_CAPABILITIES, + requires: null, }; setMetabaseAugment(cmd, initial); setMetabaseAugment(cmd, replacement); @@ -81,7 +79,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: null, - capabilities: BASELINE_CAPABILITIES, + requires: null, }; setMetabaseAugment(cmd, augment); const recalled = getMetabaseAugment(cmd); @@ -91,7 +89,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { skills: [], inputSchema: null, outputSchema: null, - capabilities: BASELINE_CAPABILITIES, + requires: null, }); }); }); diff --git a/packages/cli/src/runtime/command-augment.ts b/packages/cli/src/runtime/command-augment.ts index 959f02cc..15140dbe 100644 --- a/packages/cli/src/runtime/command-augment.ts +++ b/packages/cli/src/runtime/command-augment.ts @@ -1,19 +1,25 @@ import type { ZodType } from "zod"; -import type { Capabilities } from "@metabase/client/version/capabilities"; +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; } 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..26254e3f 100644 --- a/packages/cli/src/runtime/command-help.test.ts +++ b/packages/cli/src/runtime/command-help.test.ts @@ -2,7 +2,7 @@ import { defineCommand } from "citty"; import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { BASELINE_CAPABILITIES } from "@metabase/client/version/capabilities"; +import { type MethodKey, methodRequirements } from "@metabase/client/version/requirements"; import { defineMetabaseCommand } from "../commands/runtime"; import main from "../main"; @@ -13,7 +13,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 +40,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 +48,7 @@ describe("buildHelpIndex", () => { }); const hidden = defineMetabaseCommand({ meta: { name: "hidden", description: "hidden", hidden: true }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -98,7 +98,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 +146,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 +174,14 @@ describe("buildHelpEntry", () => { required: ["ok", "name"], additionalProperties: false, }, - capabilities: BASELINE_CAPABILITIES, + requires: { methods: [], features: [] }, }); }); 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 +205,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 +214,7 @@ describe("buildHelpEntry", () => { }); const without = defineMetabaseCommand({ meta: { name: "without", description: "short" }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -231,7 +231,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 +240,7 @@ describe("buildHelpEntry", () => { }); const without = defineMetabaseCommand({ meta: { name: "without", description: "short" }, - capabilities: {}, + requires: [], args: {}, run() { return; @@ -273,7 +273,7 @@ describe("buildHelpEntry", () => { args: [], inputSchema: null, outputSchema: null, - capabilities: null, + requires: null, }); }); @@ -296,7 +296,7 @@ describe("buildHelpEntry", () => { args: [], inputSchema: null, outputSchema: null, - capabilities: null, + requires: null, }); }); }); @@ -445,13 +445,45 @@ 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; +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)) }; +} let cachedEntries: Promise | null = null; @@ -472,89 +504,111 @@ 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("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); + 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("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.capabilities]), + .map((entry) => [entry.command, entry.requires]), ); - 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(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"), }); }); @@ -576,11 +630,9 @@ describe("command tree contract", () => { }); }); - it("reports null 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.capabilities === null) - .map((entry) => entry.command); + const local = entries.filter((entry) => entry.requires === null).map((entry) => entry.command); 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..3676ac0c 100644 --- a/packages/cli/src/runtime/command-help.ts +++ b/packages/cli/src/runtime/command-help.ts @@ -1,9 +1,15 @@ import type { ArgDef, ArgsDef, CommandDef, CommandMeta } from "citty"; import { z } from "zod"; -import { Capabilities } from "@metabase/client/version/capabilities"; +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 +28,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 +42,7 @@ export const CommandHelpEntry = z.object({ args: z.array(CommandHelpArg), inputSchema: z.unknown().nullable(), outputSchema: z.unknown().nullable(), - capabilities: Capabilities.nullable(), + requires: CommandHelpRequires.nullable(), }); export type CommandHelpEntry = z.infer; @@ -54,7 +65,7 @@ const EMPTY_AUGMENT: MetabaseAugment = { skills: [], inputSchema: null, outputSchema: null, - capabilities: null, + requires: null, }; export async function buildHelpEntry( @@ -72,7 +83,7 @@ export async function buildHelpEntry( args: convertArgs(args), inputSchema: augment.inputSchema ? z.toJSONSchema(augment.inputSchema) : null, outputSchema: augment.outputSchema ? z.toJSONSchema(augment.outputSchema) : null, - capabilities: augment.capabilities, + requires: augment.requires, }; if (augment.details !== null) { entry.details = augment.details; diff --git a/packages/client/README.md b/packages/client/README.md index a450b28f..bdb75d31 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 @@ -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"; @@ -282,6 +279,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 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 `createClient` forwards `requestParsed`, `requestRaw` and `requestStream` off the transport it builds, @@ -337,7 +362,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`, @@ -380,15 +405,19 @@ 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. + +`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. + +`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`. + +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. -`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`. +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 83b2dff5..885d2309 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/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", + "./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", @@ -52,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/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..5c3268d0 --- /dev/null +++ b/packages/client/src/domain/transform-job.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { evaluateFeatures } from "../version/features"; + +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); + }); +}); + +describe("transformJobRunResultSchema", () => { + 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: null, 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..1426cace 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"]); @@ -26,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(), @@ -43,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, @@ -79,15 +93,41 @@ 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 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(), - job_run_id: z.union([z.string(), z.number().int().positive()]).nullable(), + started: z.boolean().nullable(), + 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: null, 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..bfc9a662 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,49 @@ 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 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, + target_table_id: 42, + }); + }); + + 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, + }); + }); +}); + +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..3b8e71a6 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; +// 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 { + return { ...wire, target_table_id: wire.table === null ? null : wire.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..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", () => { @@ -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..50f66f3a 100644 --- a/packages/client/src/errors.ts +++ b/packages/client/src/errors.ts @@ -1,6 +1,8 @@ import { core as zodCore, ZodError } from "zod"; import { escapeJsonPointerSegment } from "./json-pointer"; +import { KNOWN_RANGE } from "./version/known-range"; +import type { Skew } from "./version/profile"; export type ErrorCategory = | "network" @@ -47,6 +49,7 @@ export interface ZodResponseShapeDetail { status: number; zodIssues: ZodError["issues"]; serverTag: string | null; + serverSkew: Skew | null; } export interface DecodedResponseShapeDetail { @@ -164,24 +167,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..3d55bf67 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", @@ -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/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..bd5ef4b1 100644 --- a/packages/client/src/http/transport.test.ts +++ b/packages/client/src/http/transport.test.ts @@ -13,7 +13,13 @@ 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"; +import { PROBE_PATH } from "../version/probe"; +import { KNOWN_RANGE } from "../version/known-range"; +import { createServerProfile } from "../version/profile"; const CONFIG: ClientCredentials = { url: "https://m.example.com", @@ -247,6 +253,7 @@ describe("createTransport.requestParsed", () => { status: 200, zodIssues: expectedIssues, serverTag: null, + serverSkew: null, }); }); @@ -274,6 +281,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 +395,334 @@ 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({ + edition: "ee", + 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(new Error("operator interrupt")); + + const error = await pending.catch((caught: unknown) => caught); + assert(error instanceof AbortError, "expected AbortError"); + 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; + 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("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" })]); + const client = createTransport(CONFIG, { + userAgent: TEST_USER_AGENT, + fetchImpl: fakeFetch.fetch, + server: createServerProfile({ + edition: "oss", + 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({ + edition: "oss", + 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("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, { + 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"); @@ -495,18 +831,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", () => { @@ -537,7 +872,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", + ]); }); }); @@ -549,7 +886,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]); }); }); @@ -993,9 +1330,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", - ); + ]); }); }); @@ -1022,8 +1359,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 () => { @@ -1047,8 +1385,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 () => { @@ -1106,8 +1446,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 () => { @@ -1184,7 +1528,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 89ee0523..57d67ff4 100644 --- a/packages/client/src/http/transport.ts +++ b/packages/client/src/http/transport.ts @@ -2,8 +2,13 @@ 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"; +import { createServerProfile, type ServerProfile, type Skew } from "../version/profile"; +import { checkRequirements } from "../version/requirement-check"; +import { type MethodKey, methodRequirements } from "../version/requirements"; import { assertCredentialHeaderSafe, @@ -15,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"; @@ -57,8 +62,17 @@ 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. `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, options?: WaitOptions): Promise; } +export type WaitOptions = Pick; + export interface ClientCredentials { url: string; credential: Credential; @@ -90,18 +104,30 @@ 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`, 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 // 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; + // 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)); @@ -150,16 +176,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 }) }; @@ -175,9 +196,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"); @@ -221,8 +241,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, @@ -277,7 +295,41 @@ 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. + async function server(wait: WaitOptions = {}): Promise { + throwIfAborted(wait.signal); + if (serverProfile === null) { + const probe = probeServer(transport).then((info) => { + settledProfile = createServerProfile(info); + return settledProfile; + }); + probe.catch(() => { + serverProfile = null; + }); + serverProfile = probe; + } + return untilAborted(serverProfile, wait.signal); + } + + const enforceRequirements = options.enforceRequirements ?? true; + + async function requireFeatures(key: MethodKey, wait: WaitOptions = {}): Promise { + if (!enforceRequirements || methodRequirements(key).length === 0) { + return; + } + const failure = checkRequirements(key, await server(wait)); + 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 +344,7 @@ export function createTransport(config: ClientCredentials, options: ClientOption url: prepared.url, status: response.status, getServerTag, + serverSkew: serverSkew(), }); }, async requestStream(path, opts) { @@ -309,6 +362,14 @@ export function createTransport(config: ClientCredentials, options: ClientOption return response.body; }, }; + return transport; +} + +function tagOf(profile: ServerProfile | null): string | null { + if (profile === null || profile.version === null) { + return null; + } + return profile.version.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..94bd6483 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -56,15 +56,33 @@ 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 { KNOWN_RANGE } from "./version/known-range"; +export { createServerProfile, featureGap, 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 { 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..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"); @@ -250,6 +244,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..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 }; } -// 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 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/card.ts b/packages/client/src/resources/card.ts index cbc85be3..c03cbe10 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", options); 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", 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", options); 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", options); 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", options); 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", options); 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", 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 2dafa780..63d4ed95 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", options); 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", 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", options); 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", options); 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", options); 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", options); 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", options); + 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", 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 dfdb692f..92d2099a 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,22 @@ 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({ + edition: "ee", + 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..cfde836b 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", options); 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", 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 94c20579..ca496fe2 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", options); 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", options); 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", options); 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", options); 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", options); 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", options); 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", options); 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", options); 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", 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 43ec4372..1c17eec6 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", options); 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", options); 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", options); 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", options); 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", options); 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", 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 c6a7a4ab..d2e87130 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", 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 79327500..e39b5c94 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", options); 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", options); 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", options); 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", options); 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", 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 441079a7..5f5465f2 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", 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 69a442e2..de0cb82d 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", options); 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", options); 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", options); 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", 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 9447b203..a270f0e1 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,22 @@ 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({ + edition: "ee", + 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 }; } @@ -530,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 140138c8..309014f6 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", 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", options); 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", options); 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", options); 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", options); 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", options); 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", options); 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", options); 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", options); 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", options); 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", options); 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", 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", options); try { const url = await fetchOptionalParsed( transport, @@ -271,14 +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 { - return fetchOptionalParsed( - transport, - "/api/setting/remote-sync-branch", - RemoteSyncSetting, - options, - ); + await transport.require("gitSync.branch", options); + try { + return await fetchOptionalParsed( + transport, + "/api/setting/remote-sync-branch", + RemoteSyncSetting, + options, + ); + } catch (error) { + if (isRemoteUnreadable(error)) { + return null; + } + throw error; + } } /** @@ -289,6 +313,7 @@ export function gitSyncResource(transport: Transport) { wait: PollOptions, options: RequestOptions = {}, ): Promise { + 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 9f8d9721..a5e65bbc 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,31 @@ 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({ + edition: "ee", + 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({ + edition: "ee", + 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 +135,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 +144,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 +175,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 +194,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 +236,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 +292,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 +310,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 +327,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..f0cb36be 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", options); + const { features } = await transport.server(options); + 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", options); 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", options); 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", options); 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", 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 6e8069eb..29bda923 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,22 @@ 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({ + edition: "oss", + 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..d686a838 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", 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", options); 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", options); 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", options); 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", 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 8d0a175c..897520b8 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", options); 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", options); 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", options); 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", options); 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", options); 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", options); 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..7d05f342 100644 --- a/packages/client/src/resources/optional-parsed.ts +++ b/packages/client/src/resources/optional-parsed.ts @@ -5,9 +5,9 @@ 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 -// 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/pulse.ts b/packages/client/src/resources/pulse.ts index 21b96a55..fde3445c 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", options); 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", 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", options); 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", options); 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", options); return update(id, { archived: true }, options); } diff --git a/packages/client/src/resources/search.ts b/packages/client/src/resources/search.ts index 706383fe..bf16bb74 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", 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 109f2ed8..adf3899e 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", 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", options); 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", options); 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", options); 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", options); 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..68224fea 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", options); 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", options); 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", 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 b3e941b5..b4d34b19 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", 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 4414e752..a1da6f1c 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", options); 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", options); 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", options); 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", options); 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", options); return update(id, { archived: true }, options); } diff --git a/packages/client/src/resources/table.ts b/packages/client/src/resources/table.ts index 78a68fca..4740614e 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", 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", options); 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", options); 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", options); 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", options); 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", 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 739362c0..aaf41ef4 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", options); 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", options); 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", options); 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", 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", 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 4458b192..c5e991cb 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", options); 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", options); 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", options); 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", options); 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", options); 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", 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", 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 2f2195d6..05d5232a 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,8 @@ import { jsonResponse, TEST_USER_AGENT, } from "../testing/fetch-capture"; +import { KNOWN_RANGE } from "../version/known-range"; +import { createServerProfile, type ServerProfile } from "../version/profile"; const CREDENTIALS: ClientCredentials = { url: "https://mb.example.com/metabase", @@ -22,6 +25,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", @@ -39,6 +43,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 +65,37 @@ 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({ + edition: "oss", + version: { tag: "v0.61.0", major: 61, patch: 0 }, + date: null, + hash: null, + tokenFeatures: null, +}); + +// 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({ + edition: "oss", + version: { tag: `v0.${NUMERIC_RUN_ID_MAJOR}.0`, major: NUMERIC_RUN_ID_MAJOR, 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 +183,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 +197,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.${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", + ); }); 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..3fe7cdbd 100644 --- a/packages/client/src/resources/transform-job.ts +++ b/packages/client/src/resources/transform-job.ts @@ -1,25 +1,18 @@ import { z } from "zod"; -import { Transform } from "../domain/transform"; +import { type Transform, transformRowSchema } from "../domain/transform"; import { - TransformJob, + type TransformJob, TransformJobActiveResult, type TransformJobCreateInput, - TransformJobRunResult, + 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); - -// `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,15 +22,24 @@ export interface TransformJobRunParams { export function transformJobResource(transport: Transport) { /** List every transform job the caller can see. */ async function list(options: RequestOptions = {}): Promise> { - const data = await transport.requestParsed(TransformJobApiList, "/api/transform-job", { - ...options, - }); + 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)), + "/api/transform-job", + { ...options }, + ); return { data, total: null }; } /** Get one transform job by id. */ async function get(id: number, options: RequestOptions = {}): Promise { - return transport.requestParsed(TransformJob, `/api/transform-job/${id}`, { ...options }); + await transport.require("transformJob.get", options); + const { features } = await transport.server(options); + 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. */ @@ -45,7 +47,9 @@ export function transformJobResource(transport: Transport) { params: TransformJobCreateInput, options: RequestOptions = {}, ): Promise { - return transport.requestParsed(TransformJob, "/api/transform-job", { + await transport.require("transformJob.create", options); + const { features } = await transport.server(options); + return transport.requestParsed(transformJobSchema(features), "/api/transform-job", { ...options, method: "POST", body: params, @@ -58,7 +62,9 @@ export function transformJobResource(transport: Transport) { params: TransformJobUpdateInput, options: RequestOptions = {}, ): Promise { - return transport.requestParsed(TransformJob, `/api/transform-job/${id}`, { + await transport.require("transformJob.update", options); + const { features } = await transport.server(options); + return transport.requestParsed(transformJobSchema(features), `/api/transform-job/${id}`, { ...options, method: "PUT", body: params, @@ -67,6 +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", options); await transport.requestRaw(`/api/transform-job/${id}`, { ...options, method: "DELETE", @@ -83,11 +90,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", options); + const { features } = await transport.server(options); + 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 +104,10 @@ export function transformJobResource(transport: Transport) { id: number, options: RequestOptions = {}, ): Promise> { + await transport.require("transformJob.transforms", options); + const { features } = await transport.server(options); const data = await transport.requestParsed( - TransformApiList, + z.array(transformRowSchema(features)), `/api/transform-job/${id}/transforms`, { ...options }, ); @@ -111,6 +122,7 @@ export function transformJobResource(transport: Transport) { active: boolean, options: RequestOptions = {}, ): Promise { + 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 e5e74ad4..a1c2323b 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,22 @@ 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({ + edition: "oss", + 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..b73e13fa 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", options); 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", options); 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", options); 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", 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 fde759c7..ca3a66f2 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,32 @@ 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({ + edition: "oss", + 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({ + edition: "oss", + 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 +150,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 +246,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 +362,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 +380,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 +399,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 +430,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 +440,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..46aae0f6 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,33 @@ export type TransformRunPageOptions = Omit; // Every path parameter here is a numeric id, so no fragment needs `encodeURIComponent`. export function transformResource(transport: Transport) { + async function requestTransformList( + path: string, + opts: TransportRequestOptions, + ): Promise> { + 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(opts); + 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", options); + 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", options); + const { features } = await transport.server(options); + 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 +84,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", options); + return requestTransform("/api/transform", { ...options, method: "POST", body: params }); } /** Update a transform by id, patching only the fields the body carries. */ @@ -74,15 +94,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", 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", options); await transport.requestRaw(`/api/transform/${id}`, { ...options, method: "DELETE", @@ -92,6 +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", options); await transport.requestRaw(`/api/transform/${id}/table`, { ...options, method: "DELETE", @@ -104,16 +123,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", 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", options); await transport.requestRaw(`/api/transform/${id}/cancel`, { ...options, method: "POST", @@ -123,15 +139,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", options); 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", options); + 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 +168,7 @@ export function transformResource(transport: Transport) { params: TransformRunParams = {}, options: RequestOptions = {}, ): Promise { + await transport.require("transform.run", options); const kickoff = await transport.requestParsed(TransformRunKickoff, `/api/transform/${id}/run`, { ...options, method: "POST", @@ -175,10 +194,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 +205,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 +231,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..7958fce0 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", 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 c360dfec..1a660714 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", 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..8440bb1a --- /dev/null +++ b/packages/client/src/testing/deferred.ts @@ -0,0 +1,18 @@ +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. +function unsettled(): never { + throw new Error("deferred: executor did not run"); +} + +export function deferred(): Deferred { + let settle: (value: T) => void = unsettled; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { promise, resolve: (value) => settle(value) }; +} diff --git a/packages/client/src/testing/fake-client.test.ts b/packages/client/src/testing/fake-client.test.ts index fc7d8db4..50c0558e 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,38 @@ 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, + 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/features.test.ts b/packages/client/src/version/features.test.ts new file mode 100644 index 00000000..90ed77db --- /dev/null +++ b/packages/client/src/version/features.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluateFeatures, + FEATURE_NAMES, + FEATURE_RULES, + type FeatureName, + type FeatureRule, + ruleGap, +} from "./features"; +import { KNOWN_RANGE } from "./known-range"; + +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); + }); +}); + +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..309c56c9 --- /dev/null +++ b/packages/client/src/version/features.ts @@ -0,0 +1,96 @@ +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. +// 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; + 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: 64 }, + invalidMbqlIsBadRequest: { since: 59 }, + 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/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/major-comparison-guard.test.ts b/packages/client/src/version/major-comparison-guard.test.ts new file mode 100644 index 00000000..44101b7e --- /dev/null +++ b/packages/client/src/version/major-comparison-guard.test.ts @@ -0,0 +1,62 @@ +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", "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 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", +]); + +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) => + 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); +} + +describe("Metabase majors are compared in one place", () => { + it("no source outside the version evaluators compares a parsed major", () => { + const hits = sourceFiles(SOURCE_ROOTS) + .filter((file) => !mayCompareMajors(file)) + .flatMap((file) => offendingLines(file, MAJOR_COMPARISON)); + expect(hits).toEqual([]); + }); + + 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/matrix-coverage.test.ts b/packages/client/src/version/matrix-coverage.test.ts new file mode 100644 index 00000000..00c1d825 --- /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 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 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 workflow = Workflow.parse(yaml.load(readFileSync(WORKFLOW, "utf8"))); + return workflow.jobs.e2e.strategy.matrix.stack; +} + +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}`]); +} + +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(stackIds).toEqual(expectedLanes()); + }); + + it("CI runs exactly the matrix's lanes", () => { + expect(ciStacks()).toEqual(stackIds); + }); + + it("each released lane pins the image of its own major and edition", () => { + const released = STACKS.filter((stack) => !stack.id.endsWith("-head")); + expect(released.filter((stack) => !pinsOwnMajorAndEdition(stack))).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..3b5b9845 100644 --- a/packages/client/src/version/probe.test.ts +++ b/packages/client/src/version/probe.test.ts @@ -24,6 +24,9 @@ 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 }, }); }); @@ -34,10 +37,13 @@ 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 }, + edition: "oss", + date: null, + hash: null, tokenFeatures: null, }); }); @@ -51,6 +57,9 @@ 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 bf1254d6..81f0d8e1 100644 --- a/packages/client/src/version/probe.ts +++ b/packages/client/src/version/probe.ts @@ -1,13 +1,18 @@ 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; } @@ -15,14 +20,23 @@ 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, }); - 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 new file mode 100644 index 00000000..3f69815b --- /dev/null +++ b/packages/client/src/version/profile.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; + +import { evaluateFeatures } from "./features"; +import type { ServerInfo } from "./probe"; +import { KNOWN_RANGE } from "./known-range"; +import { createServerProfile, featureGap } 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, + } 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, + edition: 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 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 }, + buildDate: "2026-05-19", + hash: "0c64e27", + edition: "oss", + tokenFeatures: null, + features: evaluateFeatures(57, null), + skew: "older-than-known", + }); + }); + + 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 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 }, + }; + expect(createServerProfile(info).edition).toBe("ee"); + }); + + it("carries a probe without date, hash or token features as nulls", () => { + const info: ServerInfo = { + version: null, + edition: 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, + 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 new file mode 100644 index 00000000..9e5ba2ff --- /dev/null +++ b/packages/client/src/version/profile.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +import { TokenFeatures } from "../domain/session-properties"; + +import { + evaluateFeatures, + FEATURE_RULES, + type FeatureGap, + type FeatureName, + Features, + ruleGap, +} from "./features"; +import { KNOWN_RANGE } from "./known-range"; +import type { ServerInfo } from "./probe"; +import { Edition, ParsedVersion } from "./tag"; + +export const Skew = z.enum(["supported", "older-than-known", "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" }; + } + if (version.major < KNOWN_RANGE.min) { + return { effectiveMajor: version.major, skew: "older-than-known" }; + } + return { effectiveMajor: version.major, skew: "supported" }; +} + +// 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 { + if (info.edition !== null) { + return info.edition; + } + 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..870b7fc4 --- /dev/null +++ b/packages/client/src/version/requirement-check.test.ts @@ -0,0 +1,123 @@ +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, + }); +} + +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 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", + 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..890be2ac --- /dev/null +++ b/packages/client/src/version/requirements.test.ts @@ -0,0 +1,452 @@ +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"; +import { errorMessage } from "../errors"; +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_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({ + edition: "oss", + 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; + // The function whose body holds the call, named as the resource exposes it. + readonly method: string; +} + +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. +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 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`, + ); + } + 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; + } + 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 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, + }; + }); + }); +} + +// 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 errorMessage(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, method }) => key !== `${namespaceOf(file)}.${method}`, + ); + 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("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..81b2c1ac 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", () => { @@ -23,13 +23,42 @@ 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(); }); }); + +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"], + ["an EE hotfix", "v1.63.16.4", "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..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({ @@ -8,23 +7,54 @@ export const ParsedVersion = z.object({ }); export type ParsedVersion = z.infer; -// 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"; +export const Edition = z.enum(["oss", "ee"]); +export type Edition = z.infer; -export function tryParseTag(tag: string): ParsedVersion | null { - if (tag.endsWith(DEV_BUILD_SUFFIX)) { +// 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 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"; + +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 || (parsed.major !== 0 && parsed.major !== 1)) { + 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 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 }; +} + +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..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.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, - }, -]; +import { type Stack, STACKS } from "./e2e-stacks"; const DEFAULT_PARALLELISM = 2; @@ -51,22 +30,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/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 }, +]; diff --git a/tests/e2e/auth.e2e.test.ts b/tests/e2e/auth.e2e.test.ts index 60257a78..daade6f9 100644 --- a/tests/e2e/auth.e2e.test.ts +++ b/tests/e2e/auth.e2e.test.ts @@ -1,19 +1,32 @@ 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 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 { 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"; +type AuthProfileEntry = z.infer["data"][number]; + const BAD_API_KEY = "mb_definitely_not_valid_key_aaaaaaaaaa"; +const UNREACHABLE_REASON = "Could not reach Metabase: fetch failed"; function profilesPath(configHome: string): string { return join(configHome, "metabase-cli", "profiles.json"); @@ -24,6 +37,99 @@ async function readProfilesJson(configHome: string): Promise { 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; +} + +interface ProbedUserPayload { + user: ProbedUser | null; +} + +function userOf(payload: ProbedUserPayload): 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 { + name, + url: bootstrap.baseUrl, + apiKey: bootstrap.adminApiKey, + oauth: null, + lastProbe: { + at: probedAt, + version: bootstrap.server.version, + edition: bootstrap.server.edition, + date: bootstrap.server.date, + hash: bootstrap.server.hash, + tokenFeatures: bootstrap.server.tokenFeatures, + user, + }, + lastFailure: null, + }; +} + +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", () => { let bootstrap: E2EBootstrap; const tempDirs: string[] = []; @@ -62,35 +168,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 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(statusPayload.version?.tag).toBe(loginPayload.version?.tag); - 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 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 () => { @@ -112,22 +244,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 () => { @@ -148,10 +285,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: [ @@ -169,13 +307,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 () => { @@ -198,29 +338,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", @@ -234,30 +397,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 () => { @@ -278,6 +453,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: [ @@ -294,8 +472,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: [ @@ -313,8 +498,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 () => { @@ -345,10 +531,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 () => { @@ -383,14 +566,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"], @@ -399,8 +586,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 86e1a47f..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,10 +44,11 @@ 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(), - // 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 fb4dca6a..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"; @@ -10,7 +15,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 +67,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", () => { @@ -284,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 () => { @@ -296,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, }); }); @@ -418,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(""); }); @@ -437,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(""); }); @@ -597,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/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..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"; @@ -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", () => { @@ -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 f791082a..b766c165 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[] = []; @@ -41,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(""); }); @@ -52,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(""); }); @@ -63,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(""); }); @@ -74,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(""); }); @@ -85,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(""); }); @@ -96,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(""); }); @@ -107,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(""); }); @@ -118,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/help-json.e2e.test.ts b/tests/e2e/help-json.e2e.test.ts index 81636e5a..6ec36bbe 100644 --- a/tests/e2e/help-json.e2e.test.ts +++ b/tests/e2e/help-json.e2e.test.ts @@ -50,6 +50,57 @@ describe("--help --json e2e", () => { expect(index).toEqual(await buildHelpIndex(card, ["card"])); }); + 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(), + }); + + 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"], + }); + }); + + 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"] }); + }); + + it("reports a baseline command's methods with no features", 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: [] }); + }); + + it("reports null requires 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(); + }); + 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..d7c58dd1 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[] = []; @@ -81,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(""); }); @@ -105,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(""); }); @@ -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..e5c1a224 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; @@ -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/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/run-cli.e2e.test.ts b/tests/e2e/run-cli.e2e.test.ts index cc5c5c93..6a646a50 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, }); @@ -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/seed-profile.ts b/tests/e2e/seed-profile.ts new file mode 100644 index 00000000..18968508 --- /dev/null +++ b/tests/e2e/seed-profile.ts @@ -0,0 +1,55 @@ +import type { ServerInfo } from "@metabase/client/version/probe"; + +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"; + +const SEED_PROFILE_NAME = "default"; + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +// 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"]; + 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_TARGET, SEED_PROFILE_NAME); + }); +} + +export async function seedProbedProfileAt( + configHome: string, + target: SeedTarget, + server: ServerInfo, +): Promise { + await withSeedEnv(configHome, async () => { + await writeProbedProfile(SEED_PROFILE_NAME, server, target); + }); +} + +export async function seedProbedProfile(configHome: string, major: number): Promise { + await seedProbedProfileAt(configHome, UNREACHABLE_TARGET, probeAt(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..3c2b8080 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 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); } // `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,19 @@ 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; +// 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]; } -// 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; -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; -} +// 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 names the field. +export const QUERY_NORMALIZATION_MESSAGE = + "Invalid query: missing or invalid Database ID (:database)"; diff --git a/tests/e2e/setting.e2e.test.ts b/tests/e2e/setting.e2e.test.ts index 36e80ed0..375f3bb6 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"], @@ -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(""); @@ -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..25f07f27 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 { @@ -20,6 +21,7 @@ import { BOOTSTRAP_FILE_PATH, type E2EBootstrap, type SeededIds, + type ServerIdentity, } from "../bootstrap-data"; import { DEFAULT_E2E_STACK, @@ -63,10 +65,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(); @@ -110,19 +109,24 @@ 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)); + const reused: E2EBootstrap = { ...existing, server }; + assertSnapshotMatchesSeed(reused); + await reportSnapshotTransforms(apiKeyClient(existing.adminApiKey), server); + await writeStoredBootstrap(reused); process.stdout.write(`bootstrap: reusing ${BOOTSTRAP_FILE_PATH}\n`); return; } @@ -134,14 +138,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); @@ -361,9 +363,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 +376,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..c336022f 100644 --- a/tests/e2e/skills.e2e.test.ts +++ b/tests/e2e/skills.e2e.test.ts @@ -1,11 +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 { probeAt, UNREACHABLE_TARGET } from "../../packages/cli/src/core/auth/temp-config-home"; +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 } 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", @@ -21,6 +41,85 @@ const BUNDLED_VISIBLE_NAMES = [ "visualization", ] as const; +const UNFILTERED_NOTE = + '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"; + +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", + 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[] = []; @@ -34,30 +133,145 @@ describe("skills e2e", () => { return dir; } - it("list returns the eleven bundled non-hidden skills, sorted by name", async () => { + it("ships exactly the eleven visible skills this suite names", () => { + expect(BUNDLED_VISIBLE.map((skill) => skill.name)).toEqual([...BUNDLED_VISIBLE_NAMES]); + }); + + 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); - 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); - } + expect(parseJson(result.stdout, SkillListEnvelope)).toEqual(fullList(BUNDLED_VISIBLE)); + expect(result.stderr).toBe(""); }); - it("list hides the metabase-cli discovery stub", async () => { + it("list in text mode notes that nothing was filtered when there is no cached probe", async () => { const result = await runCli({ - args: ["skills", "list", "--json"], + args: ["skills", "list", "--format", "text"], 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(namesInTextListing(result.stdout)).toEqual([...BUNDLED_VISIBLE_NAMES]); + 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 usable = BUNDLED_VISIBLE.filter( + (skill) => skill.name !== "git-sync" && skill.name !== "transform", + ); + expect(parseJson(result.stdout, SkillListEnvelope)).toEqual({ + ...fullList(usable), + unavailable: [GIT_SYNC_UNAVAILABLE_ON_58, TRANSFORM_UNAVAILABLE_ON_58], + }); + expect(result.stderr).toBe(""); + }); + + 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", "--unfiltered", "--json"], + configHome, + }); + + expect(result.exitCode, result.stderr).toBe(0); + 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 () => { + const configHome = await makeIsolatedConfigHome(); + await seedProbedProfile(configHome, 58); + + const result = await runCli({ args: ["skills", "list", "--format", "text"], configHome }); + + expect(result.exitCode).toBe(0); + 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 --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 --unfiltered 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", "--unfiltered", "--json", "--max-bytes", "0"], + configHome, + }); + expect(printed.exitCode, printed.stderr).toBe(0); + 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 () => { + const oss58 = await makeIsolatedConfigHome(); + await seedProbedProfile(oss58, 58); + const ee63 = await makeIsolatedConfigHome(); + 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 }); + const unfiltered = await runCli({ + args: ["skills", "get", "core", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + 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.**"); + expect(oss58Body).not.toContain("