From 02967ebc278382a8f98a637907d3348f5140193b Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 6 May 2026 01:00:11 -0400 Subject: [PATCH 01/47] workspaces commands --- README.md | 159 +++++++++ src/commands/api-key/create.ts | 47 +++ src/commands/api-key/index.ts | 8 + src/commands/context.ts | 6 +- src/commands/delete-runtime.ts | 4 + src/commands/eid/index.ts | 11 + src/commands/eid/translate.ts | 76 +++++ src/commands/flag-pair.test.ts | 49 +++ src/commands/flag-pair.ts | 26 ++ src/commands/search.ts | 6 +- src/commands/setup.ts | 35 ++ src/commands/transform/run.ts | 26 +- src/commands/wait-flags.test.ts | 34 ++ src/commands/wait-flags.ts | 49 +++ src/commands/workspace/config.ts | 24 ++ src/commands/workspace/create.ts | 35 ++ .../workspace/database/deprovision.ts | 45 +++ src/commands/workspace/database/index.ts | 13 + src/commands/workspace/database/provision.ts | 76 +++++ src/commands/workspace/database/update.ts | 73 ++++ src/commands/workspace/database/wait.ts | 36 ++ src/commands/workspace/index.ts | 12 + src/commands/workspace/list.ts | 23 ++ src/commands/workspace/metadata-export.ts | 48 +++ src/domain/api-key.ts | 72 ++++ src/domain/eid-translation.ts | 64 ++++ src/domain/setup.ts | 45 +++ src/domain/workspace.ts | 103 ++++++ src/main.ts | 4 + src/runtime/csv.test.ts | 21 ++ src/runtime/csv.ts | 6 + tests/e2e/api-key.e2e.test.ts | 103 ++++++ tests/e2e/eid-translation.e2e.test.ts | 112 +++++++ tests/e2e/manifest.e2e.test.ts | 18 +- tests/e2e/setup.e2e.test.ts | 73 ++++ tests/e2e/setup/restore-each.ts | 2 + tests/e2e/setup/warehouse.ts | 73 ++-- tests/e2e/transform.e2e.test.ts | 7 +- tests/e2e/workspace.e2e.test.ts | 315 ++++++++++++++++++ 39 files changed, 1872 insertions(+), 67 deletions(-) create mode 100644 src/commands/api-key/create.ts create mode 100644 src/commands/api-key/index.ts create mode 100644 src/commands/eid/index.ts create mode 100644 src/commands/eid/translate.ts create mode 100644 src/commands/flag-pair.test.ts create mode 100644 src/commands/flag-pair.ts create mode 100644 src/commands/setup.ts create mode 100644 src/commands/wait-flags.test.ts create mode 100644 src/commands/wait-flags.ts create mode 100644 src/commands/workspace/config.ts create mode 100644 src/commands/workspace/create.ts create mode 100644 src/commands/workspace/database/deprovision.ts create mode 100644 src/commands/workspace/database/index.ts create mode 100644 src/commands/workspace/database/provision.ts create mode 100644 src/commands/workspace/database/update.ts create mode 100644 src/commands/workspace/database/wait.ts create mode 100644 src/commands/workspace/index.ts create mode 100644 src/commands/workspace/list.ts create mode 100644 src/commands/workspace/metadata-export.ts create mode 100644 src/domain/api-key.ts create mode 100644 src/domain/eid-translation.ts create mode 100644 src/domain/setup.ts create mode 100644 src/domain/workspace.ts create mode 100644 src/runtime/csv.test.ts create mode 100644 src/runtime/csv.ts create mode 100644 tests/e2e/api-key.e2e.test.ts create mode 100644 tests/e2e/eid-translation.e2e.test.ts create mode 100644 tests/e2e/setup.e2e.test.ts create mode 100644 tests/e2e/workspace.e2e.test.ts diff --git a/README.md b/README.md index 2c436c4..0e31f6c 100644 --- a/README.md +++ b/README.md @@ -573,6 +573,165 @@ metabase sync create-branch feat/dashboards metabase sync create-branch feat/x --json ``` +## Workspaces + +CRUD on `/api/ee/workspace-manager`. Run against the workspace-manager parent instance. + +### `metabase workspace list` + +```sh +metabase workspace list +metabase workspace list --json +``` + +### `metabase workspace create` + +```sh +metabase workspace create --name analytics +echo '{"name":"analytics"}' | metabase workspace create +metabase workspace create --file workspace.json +``` + +| Flag | Description | +| --------------- | ------------------------------------------------------- | +| `--name ` | Workspace name. Shortcut for `--body '{"name":""}'`. | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | + +### `metabase workspace config ` + +Stream the workspace's config file (raw bytes) to stdout. + +```sh +metabase workspace config 1 > config.yml +metabase workspace config 1 | yq . +``` + +### `metabase workspace metadata-export ` + +Stream the workspace's table metadata export (JSON) to stdout. The backend defaults all sections off; the CLI flips them on so the export is non-empty by default. + +```sh +metabase workspace metadata-export 1 > metadata.json +metabase workspace metadata-export 1 --no-with-fields > metadata.json +``` + +| Flag | Description | +| ------------------ | --------------------------------------- | +| `--with-databases` | Include database entries (default: on). | +| `--with-tables` | Include table entries (default: on). | +| `--with-fields` | Include field entries (default: on). | + +### `metabase workspace database provision ` + +Provision a database into a workspace. The backend kicks off the work asynchronously and returns the workspace with the new entry in `status: "provisioning"`. Pass `--wait` to poll until the entry reaches `status: "provisioned"` and surface the polled state instead of the initial response. + +```sh +metabase workspace database provision 1 --database-id 5 --schemas analytics,github +metabase workspace database provision 1 --database-id 5 --schemas analytics --wait +metabase workspace database provision 1 --file provision.json +``` + +| Flag | Description | +| -------------------- | -------------------------------------------------------------- | +| `--database-id ` | Database id (used with `--schemas`). | +| `--schemas ` | Comma-separated input schemas (used with `--database-id`). | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | +| `--wait` | Poll until the database entry reaches `status: "provisioned"`. | +| `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | +| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | + +### `metabase workspace database update ` + +Update a workspace's provisioned database (server-side this is deprovision + provision). Body accepts only `input_schemas` — the database id comes from the URL. + +```sh +metabase workspace database update 1 5 --schemas analytics,github +metabase workspace database update 1 5 --schemas analytics --wait +metabase workspace database update 1 5 --file update.json +``` + +| Flag | Description | +| ----------------- | ----------------------------------------------------------------- | +| `--schemas ` | Comma-separated input schemas. Shortcut for body. | +| `--body ` | Inline JSON body (`{"input_schemas":[...]}`). | +| `--file ` | Path to JSON body file. | +| `--wait` | Poll until the database entry returns to `status: "provisioned"`. | +| `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | +| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | + +### `metabase workspace database deprovision ` + +```sh +metabase workspace database deprovision 1 5 --yes +metabase workspace database deprovision 1 5 --yes --wait +``` + +| Flag | Description | +| ----------------- | ------------------------------------------------------------ | +| `--yes` | Skip confirmation. Required on non-TTY. | +| `--wait` | Poll until the database entry is removed from the workspace. | +| `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | +| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | + +## Instance setup + +Operations against a workspace-instance Metabase. The setup wizard and API key creation are distinct endpoints — there is no shared body schema. + +### `metabase setup` + +Complete the initial setup wizard (`POST /api/setup`). The body must include the setup token, the default user, and the `prefs` block (with `site_name`). + +```sh +cat setup.json | metabase setup +metabase setup --file setup.json +metabase setup --body '{"token":"","user":{"email":"a@b.c","password":"..."},"prefs":{"site_name":"Acme"}}' +``` + +| Flag | Description | +| --------------- | ----------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | + +### `metabase api-key create` + +Create a new API key (`POST /api/api-key`). The unmasked key is returned on creation only; capture it from the output. + +```sh +metabase api-key create --name deploy-bot --group-id 2 +echo '{"name":"k","group_id":2}' | metabase api-key create +metabase api-key create --file key.json +``` + +| Flag | Description | +| ----------------- | ----------------------------------------- | +| `--name ` | API key name (used with `--group-id`). | +| `--group-id ` | Permission group id (used with `--name`). | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | + +## Agent helpers + +Endpoints commonly used by agents driving the instance. `card query` and `transform run` are documented in their own sections; the helper below covers entity-id translation. + +### `metabase eid translate` + +Translate string entity ids (EIDs) to numeric ids (`POST /api/eid-translation/translate`). + +```sh +metabase eid translate --model card --eids abc123XYZ,def456ABC +metabase eid translate --file translate.json +metabase eid translate --body '{"entity_ids":{"card":["abc123XYZ"]}}' +``` + +| Flag | Description | +| ---------------- | ------------------------------------------------------------------------------------------------ | +| `--model ` | Entity model for the shortcut form (e.g. `card`, `dashboard`, `collection`). Used with `--eids`. | +| `--eids ` | Comma-separated EIDs. Used with `--model`. | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | + ## Environment variables | Variable | Effect | diff --git a/src/commands/api-key/create.ts b/src/commands/api-key/create.ts new file mode 100644 index 0000000..e99e6a5 --- /dev/null +++ b/src/commands/api-key/create.ts @@ -0,0 +1,47 @@ +import { ApiKey, ApiKeyCreateInput, apiKeyView } from "../../domain/api-key"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { requireBothOrNeither } from "../flag-pair"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "create", description: "Create a new API key" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + name: { type: "string", description: "API key name (alternative to --body / --file)" }, + "group-id": { + type: "string", + description: "Permission group id (alternative to --body / --file)", + }, + }, + outputSchema: ApiKey, + examples: [ + 'metabase api-key create --name "deploy-bot" --group-id 2', + 'echo \'{"name":"k","group_id":2}\' | metabase api-key create', + "metabase api-key create --file key.json", + ], + async run({ args, ctx, getClient }) { + const pair = requireBothOrNeither( + { name: "--name", value: args.name }, + { name: "--group-id", value: args["group-id"] }, + ); + const body = pair + ? ApiKeyCreateInput.parse({ + name: pair.first, + group_id: parseId(pair.second, "--group-id"), + }) + : await readBody({ flag: args.body, file: args.file }, ApiKeyCreateInput); + const client = await getClient(); + const created = await client.requestParsed(ApiKey, "/api/api-key", { + method: "POST", + body, + }); + renderItem(created, apiKeyView, ctx); + }, +}); diff --git a/src/commands/api-key/index.ts b/src/commands/api-key/index.ts new file mode 100644 index 0000000..001bd0a --- /dev/null +++ b/src/commands/api-key/index.ts @@ -0,0 +1,8 @@ +import { defineCommand } from "citty"; + +export default defineCommand({ + meta: { name: "api-key", description: "Manage Metabase API keys" }, + subCommands: { + create: () => import("./create").then((mod) => mod.default), + }, +}); diff --git a/src/commands/context.ts b/src/commands/context.ts index 8589758..acc8a6a 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -1,6 +1,7 @@ import { ConfigError } from "../core/errors"; import { resolveFormat } from "../output/format"; import { DEFAULT_MAX_BYTES, type Format } from "../output/types"; +import { parseCsv } from "../runtime/csv"; import type { connectionFlags, outputFlags, profileFlag } from "./flags"; type FlagValue = T extends { type: "boolean" } @@ -53,10 +54,7 @@ function parseFields(value: string | undefined): string[] | undefined { if (value === undefined || value === "") { return undefined; } - const parts = value - .split(",") - .map((part) => part.trim()) - .filter((part) => part.length > 0); + const parts = parseCsv(value); return parts.length > 0 ? parts : undefined; } diff --git a/src/commands/delete-runtime.ts b/src/commands/delete-runtime.ts index 8e2d036..41f46d3 100644 --- a/src/commands/delete-runtime.ts +++ b/src/commands/delete-runtime.ts @@ -31,6 +31,7 @@ export interface ConfirmAndDeleteArgs { promptMessage: string; client: Client; ctx: CommonContext; + afterDelete?: () => Promise; } export async function confirmAndDelete(args: ConfirmAndDeleteArgs): Promise { @@ -51,5 +52,8 @@ export async function confirmAndDelete(args: ConfirmAndDeleteArgs): Promise import("./translate").then((mod) => mod.default), + }, +}); diff --git a/src/commands/eid/translate.ts b/src/commands/eid/translate.ts new file mode 100644 index 0000000..7239fe9 --- /dev/null +++ b/src/commands/eid/translate.ts @@ -0,0 +1,76 @@ +import { + EID_MODELS, + EidModel, + EidTranslateInput, + EidTranslateResult, + eidTranslateView, +} from "../../domain/eid-translation"; +import { ConfigError } from "../../core/errors"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { parseCsv } from "../../runtime/csv"; +import { bodyInputFlags } from "../body-flags"; +import { requireBothOrNeither } from "../flag-pair"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "translate", + description: "Translate entity ids (EIDs) to numeric ids", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + model: { + type: "string", + description: `Entity model for shortcut form: ${EID_MODELS.join(" | ")}`, + }, + eids: { + type: "string", + description: "Comma-separated EIDs (used with --model as a shortcut)", + }, + }, + outputSchema: EidTranslateResult, + examples: [ + "metabase eid translate --model card --eids abc123XYZ,def456ABC", + "metabase eid translate --file translate.json", + 'metabase eid translate --body \'{"entity_ids":{"card":["abc123XYZ"]}}\'', + ], + async run({ args, ctx, getClient }) { + const pair = requireBothOrNeither( + { name: "--model", value: args.model }, + { name: "--eids", value: args.eids }, + ); + const body = pair + ? EidTranslateInput.parse({ + entity_ids: { [parseModel(pair.first)]: parseEids(pair.second) }, + }) + : await readBody({ flag: args.body, file: args.file }, EidTranslateInput); + const client = await getClient(); + const result = await client.requestParsed( + EidTranslateResult, + "/api/eid-translation/translate", + { method: "POST", body }, + ); + renderItem(result, eidTranslateView, ctx); + }, +}); + +function parseModel(raw: string): EidModel { + const result = EidModel.safeParse(raw); + if (!result.success) { + throw new ConfigError(`invalid --model: "${raw}" (expected one of: ${EID_MODELS.join(", ")})`); + } + return result.data; +} + +function parseEids(raw: string): string[] { + const parts = parseCsv(raw); + if (parts.length === 0) { + throw new ConfigError("--eids must contain at least one EID"); + } + return parts; +} diff --git a/src/commands/flag-pair.test.ts b/src/commands/flag-pair.test.ts new file mode 100644 index 0000000..192b1d9 --- /dev/null +++ b/src/commands/flag-pair.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { ConfigError } from "../core/errors"; + +import { requireBothOrNeither } from "./flag-pair"; + +describe("requireBothOrNeither", () => { + it("returns null when both flags are unset", () => { + expect( + requireBothOrNeither( + { name: "--name", value: undefined }, + { name: "--group-id", value: undefined }, + ), + ).toBeNull(); + }); + + it("returns null when both flags are empty strings", () => { + expect( + requireBothOrNeither({ name: "--name", value: "" }, { name: "--group-id", value: "" }), + ).toBeNull(); + }); + + it("returns the pair when both flags are set", () => { + expect( + requireBothOrNeither( + { name: "--name", value: "deploy-bot" }, + { name: "--group-id", value: "2" }, + ), + ).toEqual({ first: "deploy-bot", second: "2" }); + }); + + it("throws ConfigError naming the missing first flag", () => { + expect(() => + requireBothOrNeither( + { name: "--name", value: undefined }, + { name: "--group-id", value: "2" }, + ), + ).toThrowError(new ConfigError("--name is required when using --group-id")); + }); + + it("throws ConfigError naming the missing second flag", () => { + expect(() => + requireBothOrNeither( + { name: "--name", value: "deploy-bot" }, + { name: "--group-id", value: undefined }, + ), + ).toThrowError(new ConfigError("--group-id is required when using --name")); + }); +}); diff --git a/src/commands/flag-pair.ts b/src/commands/flag-pair.ts new file mode 100644 index 0000000..dfa965f --- /dev/null +++ b/src/commands/flag-pair.ts @@ -0,0 +1,26 @@ +import { ConfigError } from "../core/errors"; + +export interface NamedFlag { + readonly name: string; + readonly value: string | undefined; +} + +export interface FlagPair { + readonly first: string; + readonly second: string; +} + +export function requireBothOrNeither(first: NamedFlag, second: NamedFlag): FlagPair | null { + const firstSet = first.value !== undefined && first.value !== ""; + const secondSet = second.value !== undefined && second.value !== ""; + if (!firstSet && !secondSet) { + return null; + } + if (!firstSet) { + throw new ConfigError(`${first.name} is required when using ${second.name}`); + } + if (!secondSet) { + throw new ConfigError(`${second.name} is required when using ${first.name}`); + } + return { first: first.value, second: second.value }; +} diff --git a/src/commands/search.ts b/src/commands/search.ts index 224a3ca..036a6a1 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -10,6 +10,7 @@ import { } from "../domain/search"; import { renderList } from "../output/render"; import { listEnvelopeSchema, type ListEnvelope } from "../output/types"; +import { parseCsv } from "../runtime/csv"; import { connectionFlags, outputFlags, profileFlag } from "./flags"; import { parseId } from "./parse-id"; @@ -112,10 +113,7 @@ function parseModels(raw: string | undefined): SearchModel[] | undefined { if (raw === undefined || raw === "") { return undefined; } - const parts = raw - .split(",") - .map((part) => part.trim()) - .filter((part) => part.length > 0); + const parts = parseCsv(raw); if (parts.length === 0) { return undefined; } diff --git a/src/commands/setup.ts b/src/commands/setup.ts new file mode 100644 index 0000000..0cb183f --- /dev/null +++ b/src/commands/setup.ts @@ -0,0 +1,35 @@ +import { SetupInput, SetupResult, setupResultView } from "../domain/setup"; +import { renderItem } from "../output/render"; +import { readBody } from "../runtime/body"; + +import { bodyInputFlags } from "./body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "./flags"; +import { defineMetabaseCommand } from "./runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "setup", + description: "Complete the initial Metabase setup wizard with a default user", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + }, + outputSchema: SetupResult, + examples: [ + "cat setup.json | metabase setup", + "metabase setup --file setup.json", + 'metabase setup --body \'{"token":"...","user":{"email":"a@b.c","password":"..."}}\'', + ], + async run({ args, ctx, getClient }) { + const body = await readBody({ flag: args.body, file: args.file }, SetupInput); + const client = await getClient(); + const result = await client.requestParsed(SetupResult, "/api/setup", { + method: "POST", + body, + }); + renderItem(result, setupResultView, ctx); + }, +}); diff --git a/src/commands/transform/run.ts b/src/commands/transform/run.ts index 41ee7b3..3066cc0 100644 --- a/src/commands/transform/run.ts +++ b/src/commands/transform/run.ts @@ -3,10 +3,11 @@ import { z } from "zod"; import { TransformRun } from "../../domain/transform"; import type { ResourceView } from "../../domain/view"; import { renderItem } from "../../output/render"; -import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS, pollUntil } from "../../runtime/poll"; +import { pollUntil } from "../../runtime/poll"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { parseWaitFlags, waitFlags } from "../wait-flags"; const RUN_TERMINAL_STATUSES = new Set(["succeeded", "failed", "timeout", "canceled"]); const RUN_FAILURE_STATUSES = new Set(["failed", "timeout", "canceled"]); @@ -37,45 +38,30 @@ export default defineMetabaseCommand({ ...outputFlags, ...profileFlag, ...connectionFlags, - wait: { - type: "boolean", - description: "Poll until the run reaches a terminal status", - default: false, - }, - timeout: { - type: "string", - description: "Polling timeout in ms (used with --wait)", - default: String(DEFAULT_TIMEOUT_MS), - }, - interval: { - type: "string", - description: "Polling interval in ms (used with --wait)", - default: String(DEFAULT_INTERVAL_MS), - }, + ...waitFlags, id: { type: "positional", description: "Transform id", required: true }, }, outputSchema: TransformRunResult, examples: ["metabase transform run 1", "metabase transform run 1 --wait --json"], async run({ args, ctx, getClient }) { const id = parseId(args.id); + const wait = parseWaitFlags(args); const client = await getClient(); const kickoff = await client.requestParsed(TransformRunKickoff, `/api/transform/${id}/run`, { method: "POST", }); - if (!args.wait || kickoff.run_id === null) { + if (!wait.enabled || kickoff.run_id === null) { renderItem({ message: kickoff.message, run_id: kickoff.run_id }, transformRunView, ctx); return; } - const intervalMs = parseId(args.interval, "interval"); - const timeoutMs = parseId(args.timeout, "timeout"); const runId = kickoff.run_id; const final = await pollUntil( async () => client.requestParsed(TransformRun, `/api/transform/run/${runId}`), (run) => RUN_TERMINAL_STATUSES.has(run.status), - { intervalMs, timeoutMs }, + wait.schedule, ); renderItem({ message: kickoff.message, run_id: runId, final }, transformRunView, ctx); diff --git a/src/commands/wait-flags.test.ts b/src/commands/wait-flags.test.ts new file mode 100644 index 0000000..2025c0f --- /dev/null +++ b/src/commands/wait-flags.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { ConfigError } from "../core/errors"; +import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from "../runtime/poll"; + +import { parseWaitFlags } from "./wait-flags"; + +describe("parseWaitFlags", () => { + it("returns disabled with default schedule when no flags are passed", () => { + expect(parseWaitFlags({})).toEqual({ + enabled: false, + schedule: { intervalMs: DEFAULT_INTERVAL_MS, timeoutMs: DEFAULT_TIMEOUT_MS }, + }); + }); + + it("enables waiting and honors --interval / --timeout overrides", () => { + expect(parseWaitFlags({ wait: true, interval: "500", timeout: "30000" })).toEqual({ + enabled: true, + schedule: { intervalMs: 500, timeoutMs: 30_000 }, + }); + }); + + it("rejects a non-numeric --interval with ConfigError", () => { + expect(() => parseWaitFlags({ wait: true, interval: "fast" })).toThrowError( + new ConfigError(`invalid interval: "fast" (expected integer)`), + ); + }); + + it("rejects a non-numeric --timeout with ConfigError", () => { + expect(() => parseWaitFlags({ wait: true, timeout: "soon" })).toThrowError( + new ConfigError(`invalid timeout: "soon" (expected integer)`), + ); + }); +}); diff --git a/src/commands/wait-flags.ts b/src/commands/wait-flags.ts new file mode 100644 index 0000000..1e01520 --- /dev/null +++ b/src/commands/wait-flags.ts @@ -0,0 +1,49 @@ +import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from "../runtime/poll"; + +import { parseId } from "./parse-id"; + +export const waitFlags = { + wait: { + type: "boolean", + description: "Poll until the operation reaches a terminal state", + default: false, + }, + timeout: { + type: "string", + description: "Polling timeout in ms (used with --wait)", + default: String(DEFAULT_TIMEOUT_MS), + }, + interval: { + type: "string", + description: "Polling interval in ms (used with --wait)", + default: String(DEFAULT_INTERVAL_MS), + }, +} as const; + +export interface WaitArgs { + wait?: boolean; + timeout?: string; + interval?: string; +} + +export interface WaitSchedule { + intervalMs: number; + timeoutMs: number; +} + +export interface WaitOptions { + enabled: boolean; + schedule: WaitSchedule; +} + +export function parseWaitFlags(args: WaitArgs): WaitOptions { + const interval = args.interval ?? String(DEFAULT_INTERVAL_MS); + const timeout = args.timeout ?? String(DEFAULT_TIMEOUT_MS); + return { + enabled: args.wait === true, + schedule: { + intervalMs: parseId(interval, "interval"), + timeoutMs: parseId(timeout, "timeout"), + }, + }; +} diff --git a/src/commands/workspace/config.ts b/src/commands/workspace/config.ts new file mode 100644 index 0000000..b9552c5 --- /dev/null +++ b/src/commands/workspace/config.ts @@ -0,0 +1,24 @@ +import { pipeToStdout } from "../../output/stream"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "config", + description: "Download a workspace's config file (raw stream to stdout)", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Workspace id", required: true }, + }, + examples: ["metabase workspace config 1 > config.yml", "metabase workspace config 1 | yq ."], + async run({ args, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const stream = await client.requestStream(`/api/ee/workspace-manager/${id}/config`); + await pipeToStdout(stream); + }, +}); diff --git a/src/commands/workspace/create.ts b/src/commands/workspace/create.ts new file mode 100644 index 0000000..392af2a --- /dev/null +++ b/src/commands/workspace/create.ts @@ -0,0 +1,35 @@ +import { Workspace, WorkspaceCreateInput, workspaceView } from "../../domain/workspace"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "create", description: "Create a workspace" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + name: { type: "string", description: "Workspace name (alternative to --body / --file)" }, + }, + outputSchema: Workspace, + examples: [ + 'metabase workspace create --name "analytics"', + 'echo \'{"name":"analytics"}\' | metabase workspace create', + "metabase workspace create --file workspace.json", + ], + async run({ args, ctx, getClient }) { + const body = + args.name !== undefined && args.name !== "" + ? WorkspaceCreateInput.parse({ name: args.name }) + : await readBody({ flag: args.body, file: args.file }, WorkspaceCreateInput); + const client = await getClient(); + const created = await client.requestParsed(Workspace, "/api/ee/workspace-manager", { + method: "POST", + body, + }); + renderItem(created, workspaceView, ctx); + }, +}); diff --git a/src/commands/workspace/database/deprovision.ts b/src/commands/workspace/database/deprovision.ts new file mode 100644 index 0000000..7f7d5a6 --- /dev/null +++ b/src/commands/workspace/database/deprovision.ts @@ -0,0 +1,45 @@ +import { confirmAndDelete, DeleteResult } from "../../delete-runtime"; +import { connectionFlags, outputFlags, profileFlag } from "../../flags"; +import { parseId } from "../../parse-id"; +import { defineMetabaseCommand } from "../../runtime"; +import { parseWaitFlags, waitFlags } from "../../wait-flags"; + +import { waitForDatabaseGone } from "./wait"; + +export default defineMetabaseCommand({ + meta: { + name: "deprovision", + description: "Deprovision a database from a workspace", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...waitFlags, + yes: { type: "boolean", description: "Skip confirmation", default: false }, + id: { type: "positional", description: "Workspace id", required: true }, + "db-id": { type: "positional", description: "Database id", required: true }, + }, + outputSchema: DeleteResult, + examples: [ + "metabase workspace database deprovision 1 5 --yes", + "metabase workspace database deprovision 1 5 --yes --wait", + ], + async run({ args, ctx, getClient }) { + const workspaceId = parseId(args.id); + const databaseId = parseId(args["db-id"], "db-id"); + const wait = parseWaitFlags(args); + const client = await getClient(); + await confirmAndDelete({ + id: databaseId, + path: `/api/ee/workspace-manager/${workspaceId}/database/${databaseId}`, + yes: args.yes, + promptMessage: `Deprovision database ${databaseId} from workspace ${workspaceId}?`, + client, + ctx, + ...(wait.enabled + ? { afterDelete: () => waitForDatabaseGone(client, workspaceId, databaseId, wait.schedule) } + : {}), + }); + }, +}); diff --git a/src/commands/workspace/database/index.ts b/src/commands/workspace/database/index.ts new file mode 100644 index 0000000..c5fdeb6 --- /dev/null +++ b/src/commands/workspace/database/index.ts @@ -0,0 +1,13 @@ +import { defineCommand } from "citty"; + +export default defineCommand({ + meta: { + name: "database", + description: "Manage databases provisioned to a workspace", + }, + subCommands: { + provision: () => import("./provision").then((mod) => mod.default), + update: () => import("./update").then((mod) => mod.default), + deprovision: () => import("./deprovision").then((mod) => mod.default), + }, +}); diff --git a/src/commands/workspace/database/provision.ts b/src/commands/workspace/database/provision.ts new file mode 100644 index 0000000..de78fc8 --- /dev/null +++ b/src/commands/workspace/database/provision.ts @@ -0,0 +1,76 @@ +import { Workspace, WorkspaceProvisionInput, workspaceView } from "../../../domain/workspace"; +import { ConfigError } from "../../../core/errors"; +import { renderItem } from "../../../output/render"; +import { readBody } from "../../../runtime/body"; +import { parseCsv } from "../../../runtime/csv"; +import { bodyInputFlags } from "../../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../../flags"; +import { parseId } from "../../parse-id"; +import { defineMetabaseCommand } from "../../runtime"; +import { parseWaitFlags, waitFlags } from "../../wait-flags"; + +import { waitForDatabaseProvisioned } from "./wait"; + +export default defineMetabaseCommand({ + meta: { + name: "provision", + description: "Provision a database into a workspace", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + ...waitFlags, + "database-id": { type: "string", description: "Database id (alternative to --body / --file)" }, + schemas: { + type: "string", + description: "Comma-separated input schemas (alternative to --body / --file)", + }, + id: { type: "positional", description: "Workspace id", required: true }, + }, + outputSchema: Workspace, + examples: [ + "metabase workspace database provision 1 --database-id 5 --schemas analytics,github", + "metabase workspace database provision 1 --database-id 5 --schemas analytics --wait", + "metabase workspace database provision 1 --file provision.json", + ], + async run({ args, ctx, getClient }) { + const workspaceId = parseId(args.id); + const databaseIdFlag = args["database-id"]; + const schemasFlag = args.schemas; + const wait = parseWaitFlags(args); + + let body: WorkspaceProvisionInput; + if (databaseIdFlag !== undefined && databaseIdFlag !== "") { + const databaseId = parseId(databaseIdFlag, "--database-id"); + const schemas = parseSchemas(schemasFlag); + body = WorkspaceProvisionInput.parse({ database_id: databaseId, input_schemas: schemas }); + } else { + body = await readBody({ flag: args.body, file: args.file }, WorkspaceProvisionInput); + } + + const client = await getClient(); + const initial = await client.requestParsed( + Workspace, + `/api/ee/workspace-manager/${workspaceId}/database`, + { method: "POST", body }, + ); + + const final = wait.enabled + ? await waitForDatabaseProvisioned(client, workspaceId, body.database_id, wait.schedule) + : initial; + renderItem(final, workspaceView, ctx); + }, +}); + +function parseSchemas(raw: string | undefined): string[] { + if (raw === undefined || raw === "") { + throw new ConfigError("--schemas is required when using --database-id"); + } + const parts = parseCsv(raw); + if (parts.length === 0) { + throw new ConfigError("--schemas must contain at least one schema name"); + } + return parts; +} diff --git a/src/commands/workspace/database/update.ts b/src/commands/workspace/database/update.ts new file mode 100644 index 0000000..7a179f3 --- /dev/null +++ b/src/commands/workspace/database/update.ts @@ -0,0 +1,73 @@ +import { Workspace, WorkspaceUpdateDatabaseInput, workspaceView } from "../../../domain/workspace"; +import { ConfigError } from "../../../core/errors"; +import { renderItem } from "../../../output/render"; +import { readBody } from "../../../runtime/body"; +import { parseCsv } from "../../../runtime/csv"; +import { bodyInputFlags } from "../../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../../flags"; +import { parseId } from "../../parse-id"; +import { defineMetabaseCommand } from "../../runtime"; +import { parseWaitFlags, waitFlags } from "../../wait-flags"; + +import { waitForDatabaseProvisioned } from "./wait"; + +export default defineMetabaseCommand({ + meta: { + name: "update", + description: + "Update a workspace's database (deprovisions then re-provisions with new input schemas)", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + ...waitFlags, + schemas: { + type: "string", + description: "Comma-separated input schemas (alternative to --body / --file)", + }, + id: { type: "positional", description: "Workspace id", required: true }, + "db-id": { type: "positional", description: "Database id", required: true }, + }, + outputSchema: Workspace, + examples: [ + "metabase workspace database update 1 5 --schemas analytics,github", + "metabase workspace database update 1 5 --schemas analytics --wait", + "metabase workspace database update 1 5 --file update.json", + ], + async run({ args, ctx, getClient }) { + const workspaceId = parseId(args.id); + const databaseId = parseId(args["db-id"], "db-id"); + const schemasFlag = args.schemas; + const wait = parseWaitFlags(args); + + let body: WorkspaceUpdateDatabaseInput; + if (schemasFlag !== undefined && schemasFlag !== "") { + const schemas = parseSchemas(schemasFlag); + body = WorkspaceUpdateDatabaseInput.parse({ input_schemas: schemas }); + } else { + body = await readBody({ flag: args.body, file: args.file }, WorkspaceUpdateDatabaseInput); + } + + const client = await getClient(); + const initial = await client.requestParsed( + Workspace, + `/api/ee/workspace-manager/${workspaceId}/database/${databaseId}`, + { method: "PUT", body }, + ); + + const final = wait.enabled + ? await waitForDatabaseProvisioned(client, workspaceId, databaseId, wait.schedule) + : initial; + renderItem(final, workspaceView, ctx); + }, +}); + +function parseSchemas(raw: string): string[] { + const parts = parseCsv(raw); + if (parts.length === 0) { + throw new ConfigError("--schemas must contain at least one schema name"); + } + return parts; +} diff --git a/src/commands/workspace/database/wait.ts b/src/commands/workspace/database/wait.ts new file mode 100644 index 0000000..17e88fa --- /dev/null +++ b/src/commands/workspace/database/wait.ts @@ -0,0 +1,36 @@ +import type { Client } from "../../../core/http/client"; +import { Workspace } from "../../../domain/workspace"; +import { pollUntil } from "../../../runtime/poll"; +import type { WaitSchedule } from "../../wait-flags"; + +export async function waitForDatabaseProvisioned( + client: Client, + workspaceId: number, + databaseId: number, + schedule: WaitSchedule, +): Promise { + return pollUntil( + () => client.requestParsed(Workspace, `/api/ee/workspace-manager/${workspaceId}`), + (workspace) => { + const entry = workspace.databases?.find((row) => row.database_id === databaseId); + return entry !== undefined && entry.status === "provisioned"; + }, + schedule, + ); +} + +export async function waitForDatabaseGone( + client: Client, + workspaceId: number, + databaseId: number, + schedule: WaitSchedule, +): Promise { + await pollUntil( + () => client.requestParsed(Workspace, `/api/ee/workspace-manager/${workspaceId}`), + (workspace) => { + const entry = workspace.databases?.find((row) => row.database_id === databaseId); + return entry === undefined; + }, + schedule, + ); +} diff --git a/src/commands/workspace/index.ts b/src/commands/workspace/index.ts new file mode 100644 index 0000000..cb5cedd --- /dev/null +++ b/src/commands/workspace/index.ts @@ -0,0 +1,12 @@ +import { defineCommand } from "citty"; + +export default defineCommand({ + meta: { name: "workspace", description: "Manage Metabase workspaces (workspace-manager)" }, + subCommands: { + list: () => import("./list").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + config: () => import("./config").then((mod) => mod.default), + "metadata-export": () => import("./metadata-export").then((mod) => mod.default), + database: () => import("./database").then((mod) => mod.default), + }, +}); diff --git a/src/commands/workspace/list.ts b/src/commands/workspace/list.ts new file mode 100644 index 0000000..c9f522c --- /dev/null +++ b/src/commands/workspace/list.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +import { Workspace, WorkspaceCompact, workspaceView } from "../../domain/workspace"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +const WorkspaceApiList = z.array(Workspace); + +export const WorkspaceListEnvelope = listEnvelopeSchema(WorkspaceCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List workspaces" }, + args: { ...outputFlags, ...profileFlag, ...connectionFlags }, + outputSchema: WorkspaceListEnvelope, + examples: ["metabase workspace list", "metabase workspace list --json"], + async run({ ctx, getClient }) { + const client = await getClient(); + const items = await client.requestParsed(WorkspaceApiList, "/api/ee/workspace-manager"); + renderList(wrapList(items), workspaceView, ctx); + }, +}); diff --git a/src/commands/workspace/metadata-export.ts b/src/commands/workspace/metadata-export.ts new file mode 100644 index 0000000..bd87437 --- /dev/null +++ b/src/commands/workspace/metadata-export.ts @@ -0,0 +1,48 @@ +import { pipeToStdout } from "../../output/stream"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "metadata-export", + description: "Download a workspace's table metadata (raw stream to stdout)", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + "with-databases": { + type: "boolean", + description: "Include database entries in the export", + default: true, + }, + "with-tables": { + type: "boolean", + description: "Include table entries in the export", + default: true, + }, + "with-fields": { + type: "boolean", + description: "Include field entries in the export", + default: true, + }, + id: { type: "positional", description: "Workspace id", required: true }, + }, + examples: [ + "metabase workspace metadata-export 1 > metadata.json", + "metabase workspace metadata-export 1 --no-with-fields > metadata.json", + ], + async run({ args, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const stream = await client.requestStream(`/api/ee/workspace-manager/${id}/metadata/export`, { + query: { + "with-databases": args["with-databases"], + "with-tables": args["with-tables"], + "with-fields": args["with-fields"], + }, + }); + await pipeToStdout(stream); + }, +}); diff --git a/src/domain/api-key.ts b/src/domain/api-key.ts new file mode 100644 index 0000000..08b2ab0 --- /dev/null +++ b/src/domain/api-key.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +import type { ResourceView } from "./view"; + +export const ApiKeyCreateInput = z + .object({ + name: z.string().min(1), + group_id: z.number().int().positive(), + }) + .loose(); +export type ApiKeyCreateInput = z.infer; + +const ApiKeyGroup = z + .object({ + id: z.number().int().nullable(), + name: z.string().nullable(), + }) + .loose(); + +const ApiKeyUpdatedBy = z + .object({ + id: z.number().int(), + common_name: z.string().nullable().optional(), + }) + .loose(); + +export const ApiKey = z + .object({ + id: z.number().int(), + name: z.string(), + group: ApiKeyGroup.nullable().optional(), + unmasked_key: z.string().nullable().optional(), + masked_key: z.string().nullable().optional(), + updated_by: ApiKeyUpdatedBy.nullable().optional(), + created_at: z.string().nullable().optional(), + updated_at: z.string().nullable().optional(), + }) + .loose(); +export type ApiKey = z.infer; + +export const ApiKeyCompact = ApiKey.pick({ + id: true, + name: true, + group: true, + masked_key: true, +}).strip(); +export type ApiKeyCompact = z.infer; + +export const apiKeyView: ResourceView = { + compactPick: ApiKeyCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "group", label: "Group", format: (value) => formatGroup(value) }, + { key: "unmasked_key", label: "Key" }, + ], +}; + +function formatGroup(value: unknown): string { + const parsed = ApiKeyGroup.nullable().safeParse(value); + if (!parsed.success || parsed.data === null) { + return ""; + } + const { id, name } = parsed.data; + if (name === null && id === null) { + return ""; + } + if (name === null) { + return String(id); + } + return id === null ? name : `${name} (${id})`; +} diff --git a/src/domain/eid-translation.ts b/src/domain/eid-translation.ts new file mode 100644 index 0000000..648abae --- /dev/null +++ b/src/domain/eid-translation.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +import type { ResourceView } from "./view"; + +export const EID_MODELS = [ + "action", + "card", + "collection", + "dashboard", + "dashboard-card", + "dashboard-tab", + "dataset", + "dimension", + "document", + "measure", + "metric", + "permissions-group", + "pulse", + "pulse-card", + "pulse-channel", + "segment", + "snippet", + "timeline", + "transform", + "user", +] as const; + +export const EidModel = z.enum(EID_MODELS); +export type EidModel = z.infer; + +export const EidTranslateInput = z + .object({ + // partialRecord (not record) — Zod 4's z.record(enum, …) treats every + // enum key as required, but the API accepts any subset of models. + entity_ids: z.partialRecord(EidModel, z.array(z.string().min(1)).min(1)), + }) + .loose(); +export type EidTranslateInput = z.infer; + +export const EidTranslateEntry = z + .object({ + status: z.string(), + type: EidModel, + id: z.number().int().optional(), + }) + .loose(); +export type EidTranslateEntry = z.infer; + +export const EidTranslateResult = z + .object({ + entity_ids: z.record(z.string(), EidTranslateEntry), + }) + .loose(); +export type EidTranslateResult = z.infer; + +export const EidTranslateResultCompact = EidTranslateResult.pick({ + entity_ids: true, +}).strip(); +export type EidTranslateResultCompact = z.infer; + +export const eidTranslateView: ResourceView = { + compactPick: EidTranslateResultCompact, + tableColumns: [{ key: "entity_ids", label: "Translated" }], +}; diff --git a/src/domain/setup.ts b/src/domain/setup.ts new file mode 100644 index 0000000..5cd63a2 --- /dev/null +++ b/src/domain/setup.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +import type { ResourceView } from "./view"; + +const SetupUserInput = z + .object({ + first_name: z.string().nullable().optional(), + last_name: z.string().nullable().optional(), + email: z.string().min(1), + password: z.string().min(1), + }) + .loose(); + +const SetupPrefsInput = z + .object({ + site_name: z.string().min(1), + site_locale: z.string().min(1).nullable().optional(), + }) + .loose(); + +export const SetupInput = z + .object({ + token: z.string().min(1), + user: SetupUserInput, + prefs: SetupPrefsInput, + }) + .loose(); +export type SetupInput = z.infer; + +export const SetupResult = z + .object({ + id: z.string(), + }) + .loose(); +export type SetupResult = z.infer; + +export const SetupResultCompact = SetupResult.pick({ + id: true, +}).strip(); +export type SetupResultCompact = z.infer; + +export const setupResultView: ResourceView = { + compactPick: SetupResultCompact, + tableColumns: [{ key: "id", label: "Session" }], +}; diff --git a/src/domain/workspace.ts b/src/domain/workspace.ts new file mode 100644 index 0000000..dad9717 --- /dev/null +++ b/src/domain/workspace.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +import type { ResourceView } from "./view"; + +const WorkspaceDatabaseStatus = z.enum([ + "unprovisioned", + "provisioning", + "provisioned", + "deprovisioning", +]); + +export const WorkspaceDatabase = z + .object({ + database_id: z.number().int(), + output_schema: z.string(), + input_schemas: z.array(z.string()), + status: WorkspaceDatabaseStatus, + }) + .loose(); +export type WorkspaceDatabase = z.infer; + +const WorkspaceCreator = z + .object({ + id: z.number().int(), + first_name: z.string().nullable(), + last_name: z.string().nullable(), + email: z.string(), + common_name: z.string().nullable().optional(), + }) + .loose(); + +export const Workspace = z + .object({ + id: z.number().int(), + name: z.string(), + creator: WorkspaceCreator.nullable(), + created_at: z.string(), + updated_at: z.string(), + databases: z.array(WorkspaceDatabase).optional(), + }) + .loose(); +export type Workspace = z.infer; + +export const WorkspaceCompact = Workspace.pick({ + id: true, + name: true, + databases: true, +}).strip(); +export type WorkspaceCompact = z.infer; + +const WorkspaceDatabaseList = z.array(WorkspaceDatabase); + +export const workspaceView: ResourceView = { + compactPick: WorkspaceCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { + key: "databases", + label: "Databases", + format: (value) => formatDatabases(value), + }, + ], +}; + +export const WorkspaceCreateInput = z + .object({ + name: z.string().min(1), + }) + .loose(); +export type WorkspaceCreateInput = z.infer; + +export const WorkspaceProvisionInput = z + .object({ + database_id: z.number().int().positive(), + input_schemas: z.array(z.string().min(1)).min(1), + }) + .loose(); +export type WorkspaceProvisionInput = z.infer; + +export const WorkspaceUpdateDatabaseInput = z + .object({ + input_schemas: z.array(z.string().min(1)).min(1), + }) + .loose(); +export type WorkspaceUpdateDatabaseInput = z.infer; + +function formatDatabases(value: unknown): string { + if (value === undefined) { + return ""; + } + const parsed = WorkspaceDatabaseList.safeParse(value); + if (!parsed.success || parsed.data.length === 0) { + return "(none)"; + } + return parsed.data + .map((entry) => { + const schemaList = + entry.input_schemas.length === 0 ? "" : ` [${entry.input_schemas.join(", ")}]`; + return `${entry.database_id} (${entry.status})${schemaList}`; + }) + .join("; "); +} diff --git a/src/main.ts b/src/main.ts index 210be77..6dfd4fb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,6 +22,10 @@ const main: CommandDef = defineCommand({ setting: () => import("./commands/setting").then((mod) => mod.default), search: () => import("./commands/search").then((mod) => mod.default), sync: () => import("./commands/sync").then((mod) => mod.default), + workspace: () => import("./commands/workspace").then((mod) => mod.default), + setup: () => import("./commands/setup").then((mod) => mod.default), + "api-key": () => import("./commands/api-key").then((mod) => mod.default), + eid: () => import("./commands/eid").then((mod) => mod.default), __manifest: (): Promise => import("./commands/manifest").then((mod) => mod.createManifestCommand(main)), }, diff --git a/src/runtime/csv.test.ts b/src/runtime/csv.test.ts new file mode 100644 index 0000000..fe528de --- /dev/null +++ b/src/runtime/csv.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { parseCsv } from "./csv"; + +describe("parseCsv", () => { + it("splits, trims, and drops empty parts", () => { + expect(parseCsv("analytics, github , reporting")).toEqual(["analytics", "github", "reporting"]); + }); + + it("returns an empty array for whitespace-only input", () => { + expect(parseCsv(" , , ")).toEqual([]); + }); + + it("returns an empty array for an empty string", () => { + expect(parseCsv("")).toEqual([]); + }); + + it("preserves a single non-empty token", () => { + expect(parseCsv("only")).toEqual(["only"]); + }); +}); diff --git a/src/runtime/csv.ts b/src/runtime/csv.ts new file mode 100644 index 0000000..c45b30d --- /dev/null +++ b/src/runtime/csv.ts @@ -0,0 +1,6 @@ +export function parseCsv(raw: string): string[] { + return raw + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} diff --git a/tests/e2e/api-key.e2e.test.ts b/tests/e2e/api-key.e2e.test.ts new file mode 100644 index 0000000..21d7741 --- /dev/null +++ b/tests/e2e/api-key.e2e.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { ApiKey } from "../../src/domain/api-key"; +import { parseJson } from "../../src/runtime/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { E2E_GROUPS } from "./seed/ids"; + +const FIRST_NEW_API_KEY_ID = 3; +const KEY_NAME = "e2e_apikey"; + +describe("api-key e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + METABASE_URL: bootstrap.baseUrl, + METABASE_API_KEY: bootstrap.adminApiKey, + }; + } + + it("create returns the hydrated api-key with the unmasked key, the masked key, and the resolved permission group", async () => { + const configHome = await makeIsolatedConfigHome(); + + // --full bypasses the compact projection so unmasked_key is included. + const result = await runCli({ + args: [ + "api-key", + "create", + "--name", + KEY_NAME, + "--group-id", + String(E2E_GROUPS.ADMIN), + "--full", + "--json", + ], + configHome, + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + + const created = parseJson(result.stdout, ApiKey); + + expect({ + id: created.id, + name: created.name, + group: created.group, + hasUnmaskedKey: typeof created.unmasked_key === "string" && created.unmasked_key.length > 0, + hasMaskedKey: typeof created.masked_key === "string" && created.masked_key.length > 0, + keysDistinct: created.unmasked_key !== created.masked_key, + }).toEqual({ + id: FIRST_NEW_API_KEY_ID, + name: KEY_NAME, + group: { id: E2E_GROUPS.ADMIN, name: "Administrators" }, + hasUnmaskedKey: true, + hasMaskedKey: true, + keysDistinct: true, + }); + }); + + it("create with a malformed --group-id (non-numeric) fails with ConfigError exit code", async () => { + const configHome = await makeIsolatedConfigHome(); + + const result = await runCli({ + args: ["api-key", "create", "--name", "irrelevant", "--group-id", "not-a-number", "--json"], + configHome, + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("invalid --group-id"); + }); + + it("create rejects --group-id without --name with ConfigError exit code", async () => { + const configHome = await makeIsolatedConfigHome(); + + const result = await runCli({ + args: ["api-key", "create", "--group-id", String(E2E_GROUPS.ADMIN), "--json"], + configHome, + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("--name is required when using --group-id"); + }); +}); diff --git a/tests/e2e/eid-translation.e2e.test.ts b/tests/e2e/eid-translation.e2e.test.ts new file mode 100644 index 0000000..e1bfe0f --- /dev/null +++ b/tests/e2e/eid-translation.e2e.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { Card } from "../../src/domain/card"; +import { EidTranslateResult } from "../../src/domain/eid-translation"; +import { parseJson } from "../../src/runtime/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { E2E_CARDS } from "./seed/ids"; + +describe("eid translate e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + METABASE_URL: bootstrap.baseUrl, + METABASE_API_KEY: bootstrap.adminApiKey, + }; + } + + async function getCardEid(cardId: number): Promise { + // --full bypasses the compact projection so entity_id is included. + const result = await runCli({ + args: ["card", "get", String(cardId), "--full", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + const card = parseJson(result.stdout, Card); + if (card.entity_id === null) { + throw new Error(`seeded card ${cardId} has no entity_id`); + } + return card.entity_id; + } + + it("translates a real card entity-id back to its numeric id with the --model/--eids shortcut", async () => { + const eid = await getCardEid(E2E_CARDS.ORDERS_BY_STATUS); + + const result = await runCli({ + args: ["eid", "translate", "--model", "card", "--eids", eid, "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, EidTranslateResult)).toEqual({ + entity_ids: { + [eid]: { id: E2E_CARDS.ORDERS_BY_STATUS, type: "card", status: "ok" }, + }, + }); + }); + + it("translates an unknown but well-formed entity-id with status not-found", async () => { + const fakeButValidEid = "Z".repeat(21); + + const result = await runCli({ + args: ["eid", "translate", "--model", "card", "--eids", fakeButValidEid, "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, EidTranslateResult)).toEqual({ + entity_ids: { + [fakeButValidEid]: { type: "card", status: "not-found" }, + }, + }); + }); + + it("rejects an unknown --model client-side with ConfigError exit code", async () => { + const result = await runCli({ + args: ["eid", "translate", "--model", "totally-invalid", "--eids", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid --model: "totally-invalid"'); + }); + + it("accepts the previously-missing transform model in the closed enum (synced with backend)", async () => { + const fakeButValidEid = "Y".repeat(21); + + const result = await runCli({ + args: ["eid", "translate", "--model", "transform", "--eids", fakeButValidEid, "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, EidTranslateResult)).toEqual({ + entity_ids: { + [fakeButValidEid]: { type: "transform", status: "not-found" }, + }, + }); + }); +}); diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index 8829eb8..4857a69 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -81,11 +81,27 @@ describe("__manifest e2e", () => { "sync stash", "sync branches", "sync create-branch", + "workspace list", + "workspace create", + "workspace config", + "workspace metadata-export", + "workspace database provision", + "workspace database update", + "workspace database deprovision", + "setup", + "api-key create", + "eid translate", ]); + // Streaming commands legitimately have no outputSchema — they pipe raw bytes + // (YAML / binary) to stdout rather than a typed JSON envelope. + const streamingCommands = new Set(["workspace config", "workspace metadata-export"]); + for (const entry of manifest.commands) { expect(entry.examples.length, `missing examples for ${entry.command}`).toBeGreaterThan(0); - expect(entry.outputSchema, `missing outputSchema for ${entry.command}`).not.toBeNull(); + if (!streamingCommands.has(entry.command)) { + expect(entry.outputSchema, `missing outputSchema for ${entry.command}`).not.toBeNull(); + } } }); }); diff --git a/tests/e2e/setup.e2e.test.ts b/tests/e2e/setup.e2e.test.ts new file mode 100644 index 0000000..822efc2 --- /dev/null +++ b/tests/e2e/setup.e2e.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; + +describe("setup e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + METABASE_URL: bootstrap.baseUrl, + METABASE_API_KEY: bootstrap.adminApiKey, + }; + } + + it("rejects a malformed body with a ValidationError before any HTTP round-trip", async () => { + const result = await runCli({ + args: ["setup", "--body", JSON.stringify({ user: { email: "x@y.z" } })], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + // ValidationError shares exit 1 with HttpError; the message anchor is + // what distinguishes "body failed Zod" from "backend rejected the call." + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + }); + + it("surfaces the backend's setup-token error as HttpError exit code on a fully-bootstrapped instance", async () => { + // bootstrap.ts has already consumed the real setup-token; sending any + // other token is the one cross-network failure mode we can reliably + // exercise without tearing down state. We assert exit code 1 (HttpError + // taxonomy, not ConfigError=2) and that the failure didn't come from + // CLI-side body validation — i.e. the request crossed the network. + const result = await runCli({ + args: [ + "setup", + "--body", + JSON.stringify({ + token: "bogus-token", + user: { + first_name: "E", + last_name: "E", + email: "setup@example.invalid", + password: "Sup3rs3cret!", + }, + prefs: { site_name: "e2e-setup-test" }, + }), + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).not.toContain("value did not match expected schema"); + expect(result.stderr).not.toContain("invalid JSON"); + }); +}); diff --git a/tests/e2e/setup/restore-each.ts b/tests/e2e/setup/restore-each.ts index 2207474..da147b5 100644 --- a/tests/e2e/setup/restore-each.ts +++ b/tests/e2e/setup/restore-each.ts @@ -1,7 +1,9 @@ import { beforeEach } from "vitest"; import { resetToCliDefault } from "./reset"; +import { resetWarehouse } from "./warehouse"; beforeEach(async () => { await resetToCliDefault(); + await resetWarehouse(); }); diff --git a/tests/e2e/setup/warehouse.ts b/tests/e2e/setup/warehouse.ts index 43b932e..f56bfc2 100644 --- a/tests/e2e/setup/warehouse.ts +++ b/tests/e2e/setup/warehouse.ts @@ -16,42 +16,46 @@ const SEED_TABLES = new Set([ "analytics.daily_sales", ]); -const LIST_TABLES_SQL = +const SEED_SCHEMAS = new Set([ + "public", + "analytics", + "information_schema", + "pg_catalog", + "pg_toast", +]); + +const LIST_NON_SEED_TABLES_SQL = "SELECT schemaname || '.' || tablename FROM pg_tables WHERE schemaname IN ('public','analytics');"; -export async function dropNonSeedWarehouseTables(): Promise { - const list = await execa( - "docker", - [ - "compose", - "-f", - COMPOSE_FILE, - "exec", - "-T", - "data-db", - "psql", - "-U", - "metabase_test", - "-d", - "warehouse", - "-At", - "-c", - LIST_TABLES_SQL, - ], - { encoding: "utf8" }, - ); - if (typeof list.stdout !== "string") { - throw new Error("docker exec psql returned non-string stdout"); +const LIST_NON_SEED_SCHEMAS_SQL = "SELECT schema_name FROM information_schema.schemata;"; + +export async function resetWarehouse(): Promise { + const [tables, schemas] = await Promise.all([ + runPsqlQuery(LIST_NON_SEED_TABLES_SQL), + runPsqlQuery(LIST_NON_SEED_SCHEMAS_SQL), + ]); + const tableDrops = tables.filter((line) => !SEED_TABLES.has(line)); + const schemaDrops = schemas.filter((line) => !SEED_SCHEMAS.has(line)); + if (tableDrops.length === 0 && schemaDrops.length === 0) { + return; } - const drops = list.stdout + const stmts = [ + ...tableDrops.map((qualified) => `DROP TABLE IF EXISTS ${qualified} CASCADE;`), + ...schemaDrops.map((schema) => `DROP SCHEMA IF EXISTS "${schema}" CASCADE;`), + ]; + await runPsql(stmts.join("\n")); +} + +async function runPsqlQuery(query: string): Promise { + const result = await runPsql(query); + return result .split("\n") .map((line) => line.trim()) - .filter((line) => line.length > 0 && !SEED_TABLES.has(line)) - .map((qualified) => `DROP TABLE IF EXISTS ${qualified} CASCADE;`); - if (drops.length === 0) { - return; - } - await execa( + .filter((line) => line.length > 0); +} + +async function runPsql(sql: string): Promise { + const result = await execa( "docker", [ "compose", @@ -65,9 +69,14 @@ export async function dropNonSeedWarehouseTables(): Promise { "metabase_test", "-d", "warehouse", + "-At", "-c", - drops.join("\n"), + sql, ], { encoding: "utf8" }, ); + if (typeof result.stdout !== "string") { + throw new Error("docker exec psql returned non-string stdout"); + } + return result.stdout; } diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index 69497a1..927abef 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { z } from "zod"; @@ -13,7 +13,6 @@ import { pollUntil } from "../../src/runtime/poll"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { E2E_DATABASES } from "./seed/ids"; -import { dropNonSeedWarehouseTables } from "./setup/warehouse"; const FIRST_TRANSFORM_ID = 1; const TRANSFORM_NAME = "e2e_transform"; @@ -79,10 +78,6 @@ describe("transform e2e", () => { adminClient = createClient({ url: bootstrap.baseUrl, apiKey: bootstrap.adminApiKey }); }); - beforeEach(async () => { - await dropNonSeedWarehouseTables(); - }); - afterEach(async () => { await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); }); diff --git a/tests/e2e/workspace.e2e.test.ts b/tests/e2e/workspace.e2e.test.ts new file mode 100644 index 0000000..656977b --- /dev/null +++ b/tests/e2e/workspace.e2e.test.ts @@ -0,0 +1,315 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { createClient, type Client } from "../../src/core/http/client"; +import { Workspace, WorkspaceCompact, type WorkspaceDatabase } from "../../src/domain/workspace"; +import { parseJson } from "../../src/runtime/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { E2E_DATABASES } from "./seed/ids"; +import { WorkspaceListEnvelope } from "../../src/commands/workspace/list"; + +const PROVISION_TIMEOUT_MS = 60_000; +const ANALYTICS_SCHEMA = "analytics"; +const PUBLIC_SCHEMA = "public"; +const FIRST_WORKSPACE_ID = 1; +const WORKSPACE_NAME = "e2e_workspace"; + +describe("workspace e2e", () => { + let bootstrap: E2EBootstrap; + let adminClient: Client; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + adminClient = createClient({ url: bootstrap.baseUrl, apiKey: bootstrap.adminApiKey }); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + METABASE_URL: bootstrap.baseUrl, + METABASE_API_KEY: bootstrap.adminApiKey, + }; + } + + async function createWorkspace(): Promise { + // --full bypasses the compact projection so creator/timestamps round-trip. + const result = await runCli({ + args: ["workspace", "create", "--name", WORKSPACE_NAME, "--full", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + const created = parseJson(result.stdout, Workspace); + expect(created.id).toBe(FIRST_WORKSPACE_ID); + expect(created.name).toBe(WORKSPACE_NAME); + expect(created.databases).toEqual([]); + return created; + } + + async function provisionDatabase( + workspaceId: number, + schemas: ReadonlyArray, + ): Promise { + const result = await runCli({ + args: [ + "workspace", + "database", + "provision", + String(workspaceId), + "--database-id", + String(E2E_DATABASES.WAREHOUSE), + "--schemas", + schemas.join(","), + "--full", + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + expect(result.exitCode, result.stderr).toBe(0); + return parseJson(result.stdout, Workspace); + } + + function findWarehouseDatabase(workspace: Workspace): WorkspaceDatabase { + const databases = workspace.databases ?? []; + const entry = databases.find((row) => row.database_id === E2E_DATABASES.WAREHOUSE); + if (!entry) { + throw new Error( + `expected workspace ${workspace.id} to contain database ${E2E_DATABASES.WAREHOUSE}, got: ${JSON.stringify(databases)}`, + ); + } + return entry; + } + + it("create returns a hydrated workspace and list surfaces it (databases omitted on list)", async () => { + await createWorkspace(); + + const listResult = await runCli({ + args: ["workspace", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(listResult.exitCode, listResult.stderr).toBe(0); + + expect(parseJson(listResult.stdout, WorkspaceListEnvelope)).toEqual({ + data: [ + WorkspaceCompact.parse({ + id: FIRST_WORKSPACE_ID, + name: WORKSPACE_NAME, + databases: [], + }), + ], + returned: 1, + total: 1, + }); + }); + + it("database provision adds the warehouse and the post-provision status reaches provisioned", async () => { + await createWorkspace(); + const provisioned = await provisionDatabase(FIRST_WORKSPACE_ID, [ANALYTICS_SCHEMA]); + + const entry = findWarehouseDatabase(provisioned); + expect({ + database_id: entry.database_id, + input_schemas: entry.input_schemas, + status: entry.status, + hasOutputSchema: entry.output_schema.length > 0, + }).toEqual({ + database_id: E2E_DATABASES.WAREHOUSE, + input_schemas: [ANALYTICS_SCHEMA], + status: "provisioned", + hasOutputSchema: true, + }); + }); + + it("database provision --wait returns the polled workspace with status=provisioned", async () => { + await createWorkspace(); + + const result = await runCli({ + args: [ + "workspace", + "database", + "provision", + String(FIRST_WORKSPACE_ID), + "--database-id", + String(E2E_DATABASES.WAREHOUSE), + "--schemas", + ANALYTICS_SCHEMA, + "--wait", + "--full", + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + + expect(result.exitCode, result.stderr).toBe(0); + const polled = parseJson(result.stdout, Workspace); + const entry = findWarehouseDatabase(polled); + expect(entry.status).toBe("provisioned"); + }); + + it("database update changes the input schemas and re-provisions", async () => { + await createWorkspace(); + await provisionDatabase(FIRST_WORKSPACE_ID, [ANALYTICS_SCHEMA]); + + const updateResult = await runCli({ + args: [ + "workspace", + "database", + "update", + String(FIRST_WORKSPACE_ID), + String(E2E_DATABASES.WAREHOUSE), + "--schemas", + PUBLIC_SCHEMA, + "--full", + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + expect(updateResult.exitCode, updateResult.stderr).toBe(0); + + const updated = parseJson(updateResult.stdout, Workspace); + const entry = findWarehouseDatabase(updated); + expect({ + input_schemas: entry.input_schemas, + status: entry.status, + }).toEqual({ + input_schemas: [PUBLIC_SCHEMA], + status: "provisioned", + }); + }); + + it("database deprovision removes the database from the workspace", async () => { + await createWorkspace(); + await provisionDatabase(FIRST_WORKSPACE_ID, [ANALYTICS_SCHEMA]); + + const deprovisionResult = await runCli({ + args: [ + "workspace", + "database", + "deprovision", + String(FIRST_WORKSPACE_ID), + String(E2E_DATABASES.WAREHOUSE), + "--yes", + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + expect(deprovisionResult.exitCode, deprovisionResult.stderr).toBe(0); + + // After deprovision the workspace's databases array is empty (or omitted). + const after = await adminClient.requestParsed( + Workspace, + `/api/ee/workspace-manager/${FIRST_WORKSPACE_ID}`, + ); + expect(after.databases ?? []).toEqual([]); + }); + + it("config streams a non-empty YAML file for a fully-provisioned workspace", async () => { + await createWorkspace(); + await provisionDatabase(FIRST_WORKSPACE_ID, [ANALYTICS_SCHEMA]); + + const result = await runCli({ + args: ["workspace", "config", String(FIRST_WORKSPACE_ID)], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout.length).toBeGreaterThan(0); + // Workspace config carries the workspace's databases — the warehouse + // we just provisioned should appear by id. + expect(result.stdout).toContain(String(E2E_DATABASES.WAREHOUSE)); + }); + + it("metadata-export streams JSON listing the warehouse database when --with-databases is on (default)", async () => { + await createWorkspace(); + await provisionDatabase(FIRST_WORKSPACE_ID, [ANALYTICS_SCHEMA]); + + const result = await runCli({ + args: ["workspace", "metadata-export", String(FIRST_WORKSPACE_ID)], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout.length).toBeGreaterThan(0); + + // The export is JSON with a databases section when --with-databases is on. + // We don't pin the full schema (it's owned by the serdes module); just + // assert it parses as JSON (throws on malformed) and mentions our + // warehouse by its portable name (the export uses string ids). + JSON.parse(result.stdout); + expect(result.stdout).toContain(`"name":"Warehouse"`); + }); + + it("metadata-export with all sections off emits an effectively-empty payload", async () => { + await createWorkspace(); + await provisionDatabase(FIRST_WORKSPACE_ID, [ANALYTICS_SCHEMA]); + + const result = await runCli({ + args: [ + "workspace", + "metadata-export", + String(FIRST_WORKSPACE_ID), + "--no-with-databases", + "--no-with-tables", + "--no-with-fields", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + + expect(result.exitCode, result.stderr).toBe(0); + JSON.parse(result.stdout); + expect(result.stdout).not.toContain(`"name":"Warehouse"`); + }); + + it("database update rejects --database-id smuggled in --body (backend's UpdateDatabaseParams is closed)", async () => { + await createWorkspace(); + await provisionDatabase(FIRST_WORKSPACE_ID, [ANALYTICS_SCHEMA]); + + const result = await runCli({ + args: [ + "workspace", + "database", + "update", + String(FIRST_WORKSPACE_ID), + String(E2E_DATABASES.WAREHOUSE), + "--body", + JSON.stringify({ + database_id: E2E_DATABASES.WAREHOUSE, + input_schemas: [PUBLIC_SCHEMA], + }), + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + + // Backend returns 400 for the disallowed extra key; our HTTP layer + // surfaces non-2xx as exit 1. + expect(result.exitCode).toBe(1); + }); +}); From df33d00f07d5ffa521c5bfb4c4b4c3f1e81901f3 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 6 May 2026 12:35:49 -0400 Subject: [PATCH 02/47] run workspace command --- README.md | 82 +++++ src/commands/context.test.ts | 6 +- src/commands/context.ts | 14 +- src/commands/parse-id.ts | 14 +- src/commands/parse-integer.test.ts | 57 ++++ src/commands/parse-integer.ts | 30 ++ src/commands/runtime.ts | 23 +- src/commands/workspace/index.ts | 6 + src/commands/workspace/logs.ts | 55 ++++ src/commands/workspace/ps.ts | 82 +++++ src/commands/workspace/remove.ts | 92 ++++++ src/commands/workspace/start.ts | 268 ++++++++++++++++ src/commands/workspace/stop.ts | 72 +++++ src/commands/workspace/url.ts | 60 ++++ src/core/docker.test.ts | 126 ++++++++ src/core/docker.ts | 431 ++++++++++++++++++++++++++ src/core/http/probe.ts | 20 ++ src/core/url.ts | 4 + src/runtime/port.test.ts | 76 +++++ src/runtime/port.ts | 26 ++ src/runtime/process.test.ts | 46 +++ src/runtime/process.ts | 102 ++++++ src/runtime/tempdir.test.ts | 29 ++ src/runtime/tempdir.ts | 29 ++ tests/e2e/manifest.e2e.test.ts | 14 +- tests/e2e/workspace-local.e2e.test.ts | 267 ++++++++++++++++ 26 files changed, 1995 insertions(+), 36 deletions(-) create mode 100644 src/commands/parse-integer.test.ts create mode 100644 src/commands/parse-integer.ts create mode 100644 src/commands/workspace/logs.ts create mode 100644 src/commands/workspace/ps.ts create mode 100644 src/commands/workspace/remove.ts create mode 100644 src/commands/workspace/start.ts create mode 100644 src/commands/workspace/stop.ts create mode 100644 src/commands/workspace/url.ts create mode 100644 src/core/docker.test.ts create mode 100644 src/core/docker.ts create mode 100644 src/core/http/probe.ts create mode 100644 src/runtime/port.test.ts create mode 100644 src/runtime/port.ts create mode 100644 src/runtime/process.test.ts create mode 100644 src/runtime/process.ts create mode 100644 src/runtime/tempdir.test.ts create mode 100644 src/runtime/tempdir.ts create mode 100644 tests/e2e/workspace-local.e2e.test.ts diff --git a/README.md b/README.md index 0e31f6c..351e897 100644 --- a/README.md +++ b/README.md @@ -675,6 +675,88 @@ metabase workspace database deprovision 1 5 --yes --wait | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | | `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | +### Local runtime + +These commands manage a Docker container that serves as the workspace's child Metabase instance. State lives in Docker labels and a named volume — there is no per-workspace local state directory. The container is named `metabase-workspace-`; the app-db volume is `metabase-workspace--appdb`. + +`start` is the only command that talks to the parent: it fetches `config.yml` (and optionally the metadata export) into a 0700 temp directory, bind-mounts it read-only into the container, polls `/api/health`, and **scrubs the temp dir on exit (success or failure)** so the parent's connection credentials and the EE token don't linger on disk. + +### `metabase workspace start ` + +```sh +metabase workspace start 1 +metabase workspace start 1 --port 3100 +metabase workspace start 1 --image metabase/metabase-dev:feature-workspaces-v2 --no-pull +metabase workspace start 1 --force +``` + +Resolves the parent via the active profile (or `--profile`/`--url`/`--api-key`) and the EE license via `resolveLicenseToken` (the same path `metabase license set` writes to). The license is forwarded to the container as `MB_PREMIUM_EMBEDDING_TOKEN` via `-e KEY` (no value on argv). Refuses to start if the workspace has any database that isn't `status: "provisioned"`. + +| Flag | Description | +| ---------------- | ---------------------------------------------------------------------------- | +| `--port ` | Host port (default: 3000; auto-shifts up to 100 ports if taken). | +| `--image ` | Docker image (default: `metabase/metabase-dev:feature-workspaces-v2`). | +| `--timeout ` | Health check deadline (default: 180000). | +| `--no-pull` | Skip `docker pull` (useful if the image is already present). | +| `--no-metadata` | Skip the metadata export — only mount `config.yml`. | +| `--force` | If a container for this workspace already exists, remove it before starting. | + +### `metabase workspace stop ` + +```sh +metabase workspace stop 1 +metabase workspace stop 1 --json +``` + +Stops the running container; no-ops if it's already exited or missing. Reports the prior state. + +### `metabase workspace remove ` + +```sh +metabase workspace remove 1 --yes +metabase workspace remove 1 --keep-volume --yes +``` + +Stops and removes the container. By default, also removes the app-db volume — pass `--keep-volume` to preserve it across rebuilds. **Does not affect the remote workspace** on the parent. + +| Flag | Description | +| --------------- | ------------------------------------------------------------- | +| `--yes` | Skip confirmation. Required on non-TTY. | +| `--keep-volume` | Preserve the app-db volume (`metabase-workspace--appdb`). | + +### `metabase workspace logs ` + +```sh +metabase workspace logs 1 +metabase workspace logs 1 --follow +metabase workspace logs 1 --tail 500 +``` + +Passthrough to `docker logs`. Output streams directly to your terminal; Ctrl-C terminates a follow. + +| Flag | Description | +| -------------- | --------------------------------------------- | +| `--follow, -f` | Stream indefinitely. | +| `--tail ` | Lines from the end of the logs (default 200). | + +### `metabase workspace url ` + +```sh +metabase workspace url 1 +metabase workspace url 1 --json +``` + +Prints `http://localhost:` for the workspace's container. Reads the host port from the container's `com.metabase.workspace.host-port` label. + +### `metabase workspace ps` + +```sh +metabase workspace ps +metabase workspace ps --json +``` + +Lists every container that carries the `com.metabase.workspace.id` label, running or stopped. The `--json` envelope is the canonical agent-facing shape and contains only `workspace_id`, `workspace_name`, `state`, and `url`; `--full --json` emits the wider record (image, profile, parent URL, container name, status string, host port). + ## Instance setup Operations against a workspace-instance Metabase. The setup wizard and API key creation are distinct endpoints — there is no shared body schema. diff --git a/src/commands/context.test.ts b/src/commands/context.test.ts index fbe1f14..781e34e 100644 --- a/src/commands/context.test.ts +++ b/src/commands/context.test.ts @@ -121,19 +121,19 @@ describe("resolveCommonFlags — maxBytes parsing", () => { it("throws ConfigError on negative value", () => { expect(() => resolveCommonFlags({ maxBytes: "-1" }, { isTty: true })).toThrow( - new ConfigError("invalid --max-bytes value: -1 (must be non-negative)"), + new ConfigError("invalid --max-bytes: -1 (must be ≥ 0)"), ); }); it("throws ConfigError on non-integer string", () => { expect(() => resolveCommonFlags({ maxBytes: "abc" }, { isTty: true })).toThrow( - new ConfigError(`invalid --max-bytes value: "abc" (expected non-negative integer)`), + new ConfigError(`invalid --max-bytes: "abc" (expected integer)`), ); }); it("throws ConfigError on float", () => { expect(() => resolveCommonFlags({ maxBytes: "1.5" }, { isTty: true })).toThrow( - new ConfigError(`invalid --max-bytes value: "1.5" (expected non-negative integer)`), + new ConfigError(`invalid --max-bytes: "1.5" (expected integer)`), ); }); }); diff --git a/src/commands/context.ts b/src/commands/context.ts index acc8a6a..a4c26e2 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -2,7 +2,9 @@ import { ConfigError } from "../core/errors"; import { resolveFormat } from "../output/format"; import { DEFAULT_MAX_BYTES, type Format } from "../output/types"; import { parseCsv } from "../runtime/csv"; + import type { connectionFlags, outputFlags, profileFlag } from "./flags"; +import { parseInteger } from "./parse-integer"; type FlagValue = T extends { type: "boolean" } ? boolean @@ -30,8 +32,6 @@ export interface ResolveOptions { isTty?: boolean; } -const INTEGER_PATTERN = /^-?\d+$/; - export function resolveCommonFlags(args: CommonArgs, options: ResolveOptions = {}): CommonContext { const isTty = options.isTty ?? Boolean(process.stdout.isTTY); const fields = parseFields(args.fields); @@ -59,13 +59,5 @@ function parseFields(value: string | undefined): string[] | undefined { } function parseMaxBytes(value: string | undefined): number { - const raw = value ?? String(DEFAULT_MAX_BYTES); - if (!INTEGER_PATTERN.test(raw)) { - throw new ConfigError(`invalid --max-bytes value: "${raw}" (expected non-negative integer)`); - } - const parsed = Number.parseInt(raw, 10); - if (parsed < 0) { - throw new ConfigError(`invalid --max-bytes value: ${parsed} (must be non-negative)`); - } - return parsed; + return parseInteger(value ?? String(DEFAULT_MAX_BYTES), { name: "--max-bytes", min: 0 }); } diff --git a/src/commands/parse-id.ts b/src/commands/parse-id.ts index f2cd2db..1bcf31b 100644 --- a/src/commands/parse-id.ts +++ b/src/commands/parse-id.ts @@ -1,15 +1,5 @@ -import { ConfigError } from "../core/errors"; - -const INTEGER_PATTERN = /^-?\d+$/; +import { parseInteger } from "./parse-integer"; export function parseId(value: string, name = "id"): number { - const trimmed = value.trim(); - if (!INTEGER_PATTERN.test(trimmed)) { - throw new ConfigError(`invalid ${name}: "${value}" (expected integer)`); - } - const parsed = Number.parseInt(trimmed, 10); - if (parsed < 1) { - throw new ConfigError(`invalid ${name}: ${parsed} (must be a positive integer)`); - } - return parsed; + return parseInteger(value, { name, min: 1 }); } diff --git a/src/commands/parse-integer.test.ts b/src/commands/parse-integer.test.ts new file mode 100644 index 0000000..91edb83 --- /dev/null +++ b/src/commands/parse-integer.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { ConfigError } from "../core/errors"; + +import { parseInteger, parseOptionalInteger } from "./parse-integer"; + +describe("parseInteger", () => { + it("returns the parsed value when in range", () => { + expect(parseInteger("3000", { name: "--port", min: 1 })).toBe(3000); + }); + + it("trims surrounding whitespace before parsing", () => { + expect(parseInteger(" 42 ", { name: "--port", min: 1 })).toBe(42); + }); + + it("accepts the minimum boundary inclusively", () => { + expect(parseInteger("0", { name: "--max-bytes", min: 0 })).toBe(0); + }); + + it("throws ConfigError below the minimum, naming the flag and bound", () => { + expect(() => parseInteger("0", { name: "--port", min: 1 })).toThrow( + new ConfigError("invalid --port: 0 (must be ≥ 1)"), + ); + }); + + it("throws ConfigError on a non-integer literal, preserving the raw string", () => { + expect(() => parseInteger("1.5", { name: "--tail", min: 0 })).toThrow( + new ConfigError(`invalid --tail: "1.5" (expected integer)`), + ); + }); + + it("throws ConfigError on a non-numeric string", () => { + expect(() => parseInteger("abc", { name: "--tail", min: 0 })).toThrow( + new ConfigError(`invalid --tail: "abc" (expected integer)`), + ); + }); +}); + +describe("parseOptionalInteger", () => { + it("returns null for undefined", () => { + expect(parseOptionalInteger(undefined, { name: "--port", min: 1 })).toBeNull(); + }); + + it("returns null for an empty string (omitted flag treated like absent)", () => { + expect(parseOptionalInteger("", { name: "--port", min: 1 })).toBeNull(); + }); + + it("delegates to parseInteger for non-empty values", () => { + expect(parseOptionalInteger("3100", { name: "--port", min: 1 })).toBe(3100); + }); + + it("propagates ConfigError from parseInteger when the value is invalid", () => { + expect(() => parseOptionalInteger("0", { name: "--port", min: 1 })).toThrow( + new ConfigError("invalid --port: 0 (must be ≥ 1)"), + ); + }); +}); diff --git a/src/commands/parse-integer.ts b/src/commands/parse-integer.ts new file mode 100644 index 0000000..e2e2ee7 --- /dev/null +++ b/src/commands/parse-integer.ts @@ -0,0 +1,30 @@ +import { ConfigError } from "../core/errors"; + +const INTEGER_PATTERN = /^-?\d+$/; + +export interface ParseIntegerOptions { + name: string; + min: number; +} + +export function parseInteger(value: string, options: ParseIntegerOptions): number { + const trimmed = value.trim(); + if (!INTEGER_PATTERN.test(trimmed)) { + throw new ConfigError(`invalid ${options.name}: "${value}" (expected integer)`); + } + const parsed = Number.parseInt(trimmed, 10); + if (parsed < options.min) { + throw new ConfigError(`invalid ${options.name}: ${parsed} (must be ≥ ${options.min})`); + } + return parsed; +} + +export function parseOptionalInteger( + value: string | undefined, + options: ParseIntegerOptions, +): number | null { + if (value === undefined || value === "") { + return null; + } + return parseInteger(value, options); +} diff --git a/src/commands/runtime.ts b/src/commands/runtime.ts index b0e5b0c..666b996 100644 --- a/src/commands/runtime.ts +++ b/src/commands/runtime.ts @@ -2,7 +2,7 @@ import { defineCommand } from "citty"; import type { ArgsDef, CommandDef, CommandMeta, ParsedArgs } from "citty"; import type { ZodType } from "zod"; -import { resolveConfig, type ConfigFlags } from "../core/config"; +import { resolveConfig, type ConfigFlags, type ResolvedConfig } from "../core/config"; import { createClient, type Client } from "../core/http/client"; import { reportError } from "../output/error"; import { setMetabaseAugment } from "../runtime/command-augment"; @@ -13,6 +13,7 @@ export interface MetabaseCommandContext { args: ParsedArgs; ctx: CommonContext; getClient: () => Promise; + getResolvedConfig: () => Promise; } export interface MetabaseCommandDef { @@ -32,16 +33,22 @@ export function defineMetabaseCommand( async run({ args }) { try { const ctx = resolveCommonFlags(pickCommonArgs(args)); - let cached: Client | null = null; + let cachedConfig: ResolvedConfig | null = null; + let cachedClient: Client | null = null; + const getResolvedConfig = async (): Promise => { + if (cachedConfig === null) { + cachedConfig = await resolveConfig(buildConfigFlags(ctx)); + } + return cachedConfig; + }; const getClient = async (): Promise => { - if (cached) { - return cached; + if (cachedClient === null) { + const resolved = await getResolvedConfig(); + cachedClient = createClient({ url: resolved.url, apiKey: resolved.apiKey }); } - const resolved = await resolveConfig(buildConfigFlags(ctx)); - cached = createClient({ url: resolved.url, apiKey: resolved.apiKey }); - return cached; + return cachedClient; }; - await def.run({ args, ctx, getClient }); + await def.run({ args, ctx, getClient, getResolvedConfig }); } catch (error) { reportError(error); } diff --git a/src/commands/workspace/index.ts b/src/commands/workspace/index.ts index cb5cedd..645cf41 100644 --- a/src/commands/workspace/index.ts +++ b/src/commands/workspace/index.ts @@ -8,5 +8,11 @@ export default defineCommand({ config: () => import("./config").then((mod) => mod.default), "metadata-export": () => import("./metadata-export").then((mod) => mod.default), database: () => import("./database").then((mod) => mod.default), + start: () => import("./start").then((mod) => mod.default), + stop: () => import("./stop").then((mod) => mod.default), + remove: () => import("./remove").then((mod) => mod.default), + logs: () => import("./logs").then((mod) => mod.default), + url: () => import("./url").then((mod) => mod.default), + ps: () => import("./ps").then((mod) => mod.default), }, }); diff --git a/src/commands/workspace/logs.ts b/src/commands/workspace/logs.ts new file mode 100644 index 0000000..d2bfa4b --- /dev/null +++ b/src/commands/workspace/logs.ts @@ -0,0 +1,55 @@ +import { + checkDockerReady, + containerLifecycleStatus, + containerNameFor, + streamLogs, +} from "../../core/docker"; +import { ConfigError } from "../../core/errors"; +import { outputFlags } from "../flags"; +import { parseId } from "../parse-id"; +import { parseInteger } from "../parse-integer"; +import { defineMetabaseCommand } from "../runtime"; + +const DEFAULT_TAIL = 200; + +export default defineMetabaseCommand({ + meta: { + name: "logs", + description: "Stream the local container's logs (passthrough to `docker logs`)", + }, + args: { + ...outputFlags, + id: { type: "positional", description: "Workspace id", required: true }, + follow: { + type: "boolean", + alias: "f", + description: "Follow log output (stream indefinitely; Ctrl-C to exit)", + default: false, + }, + tail: { + type: "string", + description: `Number of lines from the end of the logs (default: ${DEFAULT_TAIL})`, + default: String(DEFAULT_TAIL), + }, + }, + examples: [ + "metabase workspace logs 1", + "metabase workspace logs 1 --follow", + "metabase workspace logs 1 --tail 500", + ], + async run({ args }) { + const workspaceId = parseId(args.id); + const containerName = containerNameFor(workspaceId); + const tail = parseInteger(args.tail ?? String(DEFAULT_TAIL), { name: "--tail", min: 0 }); + + await checkDockerReady(); + const status = await containerLifecycleStatus(containerName); + if (status === "missing") { + throw new ConfigError( + `no container for workspace ${workspaceId} — run \`metabase workspace start ${workspaceId}\` first`, + ); + } + + await streamLogs(containerName, { follow: args.follow === true, tail }); + }, +}); diff --git a/src/commands/workspace/ps.ts b/src/commands/workspace/ps.ts new file mode 100644 index 0000000..7ec10e2 --- /dev/null +++ b/src/commands/workspace/ps.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +import { + CONTAINER_STATES, + checkDockerReady, + listWorkspaceContainers, + type ContainerState, +} from "../../core/docker"; +import { localUrl } from "../../core/url"; +import type { ResourceView } from "../../domain/view"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { outputFlags } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const LocalWorkspaceState = z.enum(CONTAINER_STATES); +export type LocalWorkspaceState = ContainerState; + +export const LocalWorkspace = z.object({ + workspace_id: z.number().int().positive(), + workspace_name: z.string(), + container_name: z.string(), + state: LocalWorkspaceState, + status: z.string(), + image: z.string(), + profile: z.string().nullable(), + parent_url: z.string().nullable(), + host_port: z.number().int().positive().nullable(), + url: z.string().nullable(), +}); +export type LocalWorkspace = z.infer; + +export const LocalWorkspaceCompact = LocalWorkspace.pick({ + workspace_id: true, + workspace_name: true, + state: true, + url: true, +}).strip(); +export type LocalWorkspaceCompact = z.infer; + +export const localWorkspaceView: ResourceView = { + compactPick: LocalWorkspaceCompact, + tableColumns: [ + { key: "workspace_id", label: "ID" }, + { key: "workspace_name", label: "Name" }, + { key: "state", label: "State" }, + { key: "url", label: "URL", format: (value) => (typeof value === "string" ? value : "—") }, + ], +}; + +export const LocalWorkspaceListEnvelope = listEnvelopeSchema(LocalWorkspaceCompact); + +export default defineMetabaseCommand({ + meta: { + name: "ps", + description: "List workspaces with a local container (running or stopped)", + }, + args: { ...outputFlags }, + outputSchema: LocalWorkspaceListEnvelope, + examples: ["metabase workspace ps", "metabase workspace ps --json"], + async run({ ctx }) { + await checkDockerReady(); + const summaries = await listWorkspaceContainers(); + const items: LocalWorkspace[] = summaries.map((summary) => ({ + workspace_id: summary.workspaceId, + workspace_name: summary.workspaceName, + container_name: summary.name, + state: summary.state, + status: summary.status, + image: summary.image, + profile: summary.profile, + parent_url: summary.parentUrl, + host_port: summary.hostPort, + url: + summary.hostPort !== null && summary.state === "running" + ? localUrl(summary.hostPort) + : null, + })); + items.sort((a, b) => a.workspace_id - b.workspace_id); + renderList(wrapList(items), localWorkspaceView, ctx); + }, +}); diff --git a/src/commands/workspace/remove.ts b/src/commands/workspace/remove.ts new file mode 100644 index 0000000..ccbe8dc --- /dev/null +++ b/src/commands/workspace/remove.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; + +import { + checkDockerReady, + containerNameFor, + removeContainer, + removeVolume, + volumeNameFor, +} from "../../core/docker"; +import type { ResourceView } from "../../domain/view"; +import { renderItem } from "../../output/render"; +import { promptConfirm } from "../../output/prompt"; +import { outputFlags } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export const RemoveResult = z.object({ + workspace_id: z.number().int().positive(), + container_name: z.string(), + volume_name: z.string(), + removed_container: z.boolean(), + removed_volume: z.boolean(), +}); +export type RemoveResult = z.infer; + +const removeResultView: ResourceView = { + compactPick: RemoveResult.pick({ + workspace_id: true, + removed_container: true, + removed_volume: true, + }).strip(), + tableColumns: [ + { key: "workspace_id", label: "ID" }, + { key: "container_name", label: "Container" }, + { key: "volume_name", label: "Volume" }, + { key: "removed_container", label: "Removed Container" }, + { key: "removed_volume", label: "Removed Volume" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { + name: "remove", + description: "Stop and remove the local container + app-db volume (does not affect remote)", + }, + args: { + ...outputFlags, + id: { type: "positional", description: "Workspace id", required: true }, + "keep-volume": { + type: "boolean", + description: "Keep the workspace's app-db volume (faster restart, app-db survives)", + default: false, + }, + yes: { type: "boolean", description: "Skip the confirmation prompt", default: false }, + }, + outputSchema: RemoveResult, + examples: [ + "metabase workspace remove 1 --yes", + "metabase workspace remove 1 --keep-volume --yes", + ], + async run({ args, ctx }) { + const workspaceId = parseId(args.id); + const containerName = containerNameFor(workspaceId); + const volumeName = volumeNameFor(workspaceId); + const shouldRemoveVolume = args["keep-volume"] !== true; + + await checkDockerReady(); + + if (!args.yes) { + const confirmed = await promptConfirm({ + message: shouldRemoveVolume + ? `Remove container ${containerName} and its app-db volume ${volumeName}?` + : `Remove container ${containerName}? (volume ${volumeName} will be kept)`, + }); + if (!confirmed) { + return; + } + } + + const removedContainer = await removeContainer(containerName); + const removedVolume = shouldRemoveVolume ? await removeVolume(volumeName) : false; + + const result: RemoveResult = { + workspace_id: workspaceId, + container_name: containerName, + volume_name: volumeName, + removed_container: removedContainer, + removed_volume: removedVolume, + }; + renderItem(result, removeResultView, ctx); + }, +}); diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts new file mode 100644 index 0000000..a144adc --- /dev/null +++ b/src/commands/workspace/start.ts @@ -0,0 +1,268 @@ +import { join } from "node:path"; + +import { z } from "zod"; + +import { resolveLicenseToken } from "../../core/config"; +import { + CONFIG_FILENAME, + METADATA_FILENAME, + checkDockerReady, + containerLifecycleStatus, + containerNameFor, + pullImage, + removeContainer, + runWorkspaceContainer, +} from "../../core/docker"; +import { ConfigError } from "../../core/errors"; +import type { Client } from "../../core/http/client"; +import { probeHealth } from "../../core/http/probe"; +import { localUrl } from "../../core/url"; +import type { ResourceView } from "../../domain/view"; +import { Workspace } from "../../domain/workspace"; +import { renderItem } from "../../output/render"; +import { findFreePort, isPortFree } from "../../runtime/port"; +import { pollUntil } from "../../runtime/poll"; +import { + mkSecureTempDir, + removeTempDir, + streamToSecureFile, + writeSecureFile, +} from "../../runtime/tempdir"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { parseInteger, parseOptionalInteger } from "../parse-integer"; +import { defineMetabaseCommand } from "../runtime"; + +const DEFAULT_IMAGE = "metabase/metabase-dev:feature-workspaces-v2"; +const DEFAULT_HOST_PORT = 3000; +const DEFAULT_HEALTH_TIMEOUT_MS = 180_000; +const HEALTH_INTERVAL_MS = 2_000; +const HEALTH_MAX_INTERVAL_MS = 10_000; +const HEALTH_PROBE_TIMEOUT_MS = 4_000; + +export const StartResult = z.object({ + workspace_id: z.number().int().positive(), + workspace_name: z.string(), + container_name: z.string(), + state: z.literal("running"), + host_port: z.number().int().positive(), + url: z.string(), + image: z.string(), +}); +export type StartResult = z.infer; + +const startResultView: ResourceView = { + compactPick: StartResult.pick({ + workspace_id: true, + workspace_name: true, + state: true, + url: true, + }).strip(), + tableColumns: [ + { key: "workspace_id", label: "ID" }, + { key: "workspace_name", label: "Name" }, + { key: "state", label: "State" }, + { key: "url", label: "URL" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { + name: "start", + description: "Start a local Docker container that serves as the workspace's dev instance", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Workspace id", required: true }, + port: { + type: "string", + description: `Host port to bind (default: ${DEFAULT_HOST_PORT}; auto-shifts up if taken)`, + }, + image: { + type: "string", + description: `Docker image to run (default: ${DEFAULT_IMAGE})`, + default: DEFAULT_IMAGE, + }, + timeout: { + type: "string", + description: `Health check deadline in ms (default: ${DEFAULT_HEALTH_TIMEOUT_MS})`, + default: String(DEFAULT_HEALTH_TIMEOUT_MS), + }, + pull: { + type: "boolean", + description: "Pull the image before starting", + default: true, + }, + metadata: { + type: "boolean", + description: "Fetch the workspace's warehouse metadata and mount it as metadata.json", + default: true, + }, + force: { + type: "boolean", + description: "If a container for this workspace already exists, remove it first", + default: false, + }, + }, + outputSchema: StartResult, + examples: [ + "metabase workspace start 1", + "metabase workspace start 1 --port 3100", + "metabase workspace start 1 --image metabase/metabase-dev:feature-workspaces-v2 --no-pull", + "metabase workspace start 1 --force", + ], + async run({ args, ctx, getClient, getResolvedConfig }) { + const workspaceId = parseId(args.id); + const containerName = containerNameFor(workspaceId); + const requestedPort = parseOptionalInteger(args.port, { name: "--port", min: 1 }); + const healthTimeoutMs = parseInteger(args.timeout ?? String(DEFAULT_HEALTH_TIMEOUT_MS), { + name: "--timeout", + min: 1000, + }); + + const client = await getClient(); + const resolved = await getResolvedConfig(); + const licenseToken = await resolveLicenseToken({}); + + await checkDockerReady(); + await ensureNoExistingContainer(containerName, args.force); + + // Kick off the image pull concurrently with the parent fetches; await before runContainer. + const pullPromise = args.pull ? pullImage(args.image) : Promise.resolve(); + + const workspace = await client.requestParsed( + Workspace, + `/api/ee/workspace-manager/${workspaceId}`, + ); + assertAllDatabasesProvisioned(workspace); + + const hostPort = await resolveHostPort(requestedPort); + + const tempDir = await mkSecureTempDir(); + try { + await Promise.all([ + writeConfigYaml(client, workspaceId, join(tempDir, CONFIG_FILENAME)), + args.metadata + ? streamMetadata(client, workspaceId, join(tempDir, METADATA_FILENAME)) + : Promise.resolve(), + ]); + + await pullPromise; + + await runWorkspaceContainer({ + workspaceId, + workspaceName: workspace.name, + profile: resolved.profile, + parentUrl: resolved.url, + image: args.image, + hostPort, + bootConfigDir: tempDir, + licenseToken, + includeMetadata: args.metadata, + }); + + await waitForHealth(hostPort, healthTimeoutMs); + } finally { + // config.yml carries DB credentials and the license token; scrub on + // every exit so secrets don't linger on disk. + await removeTempDir(tempDir); + } + + const result: StartResult = { + workspace_id: workspaceId, + workspace_name: workspace.name, + container_name: containerName, + state: "running", + host_port: hostPort, + url: localUrl(hostPort), + image: args.image, + }; + renderItem(result, startResultView, ctx); + }, +}); + +function assertAllDatabasesProvisioned(workspace: Workspace): void { + const databases = workspace.databases ?? []; + if (databases.length === 0) { + throw new ConfigError( + `workspace ${workspace.id} has no databases — provision at least one before starting`, + ); + } + const unready = databases.filter((entry) => entry.status !== "provisioned"); + if (unready.length > 0) { + const summary = unready + .map((entry) => `database ${entry.database_id}=${entry.status}`) + .join(", "); + throw new ConfigError( + `workspace ${workspace.id} is not ready: ${summary}. Wait for provisioning to finish.`, + ); + } +} + +async function ensureNoExistingContainer(containerName: string, force: boolean): Promise { + if (!force) { + const status = await containerLifecycleStatus(containerName); + if (status !== "missing") { + throw new ConfigError( + `container ${containerName} already exists (state=${status}). Use --force to recreate, or stop/remove it first.`, + ); + } + return; + } + await removeContainer(containerName); +} + +async function resolveHostPort(requested: number | null): Promise { + if (requested !== null) { + if (!(await isPortFree(requested))) { + throw new ConfigError(`port ${requested} is already in use`); + } + return requested; + } + if (await isPortFree(DEFAULT_HOST_PORT)) { + return DEFAULT_HOST_PORT; + } + return findFreePort(DEFAULT_HOST_PORT + 1); +} + +async function writeConfigYaml( + client: Client, + workspaceId: number, + destination: string, +): Promise { + // config.yml is small (one workspace + a handful of databases) — buffering is + // simpler than streaming and lets us write atomically through writeSecureFile. + const response = await client.requestRaw(`/api/ee/workspace-manager/${workspaceId}/config`, { + expectContentType: "binary", + }); + await writeSecureFile(destination, await response.text()); +} + +async function streamMetadata( + client: Client, + workspaceId: number, + destination: string, +): Promise { + // metadata.json can be tens of MB on a real warehouse; stream straight to disk + // instead of buffering twice (response.text + writeSecureFile). + const stream = await client.requestStream( + `/api/ee/workspace-manager/${workspaceId}/metadata/export`, + ); + await streamToSecureFile(stream, destination); +} + +async function waitForHealth(hostPort: number, timeoutMs: number): Promise { + const url = `${localUrl(hostPort)}/api/health`; + await pollUntil( + () => probeHealth(url, HEALTH_PROBE_TIMEOUT_MS), + (probe) => probe.ready, + { + intervalMs: HEALTH_INTERVAL_MS, + maxIntervalMs: HEALTH_MAX_INTERVAL_MS, + backoff: "exponential", + timeoutMs, + }, + ); +} diff --git a/src/commands/workspace/stop.ts b/src/commands/workspace/stop.ts new file mode 100644 index 0000000..e8f5d44 --- /dev/null +++ b/src/commands/workspace/stop.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +import { + checkDockerReady, + containerLifecycleStatus, + containerNameFor, + stopContainer, +} from "../../core/docker"; +import type { ResourceView } from "../../domain/view"; +import { renderItem } from "../../output/render"; +import { outputFlags } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { LocalWorkspaceState } from "./ps"; + +export const StopResult = z.object({ + workspace_id: z.number().int().positive(), + container_name: z.string(), + stopped: z.boolean(), + prior_state: LocalWorkspaceState.nullable(), +}); +export type StopResult = z.infer; + +const stopResultView: ResourceView = { + compactPick: StopResult.pick({ + workspace_id: true, + stopped: true, + prior_state: true, + }).strip(), + tableColumns: [ + { key: "workspace_id", label: "ID" }, + { key: "container_name", label: "Container" }, + { key: "stopped", label: "Stopped" }, + { key: "prior_state", label: "Prior State" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { + name: "stop", + description: "Stop the local Docker container for a workspace (does not remove it)", + }, + args: { + ...outputFlags, + id: { type: "positional", description: "Workspace id", required: true }, + }, + outputSchema: StopResult, + examples: ["metabase workspace stop 1", "metabase workspace stop 1 --json"], + async run({ args, ctx }) { + const workspaceId = parseId(args.id); + const containerName = containerNameFor(workspaceId); + + await checkDockerReady(); + const status = await containerLifecycleStatus(containerName); + const priorState = status === "missing" ? null : status; + + let stopped = false; + if (status === "running") { + await stopContainer(containerName); + stopped = true; + } + + const result: StopResult = { + workspace_id: workspaceId, + container_name: containerName, + stopped, + prior_state: priorState, + }; + renderItem(result, stopResultView, ctx); + }, +}); diff --git a/src/commands/workspace/url.ts b/src/commands/workspace/url.ts new file mode 100644 index 0000000..ae4ded7 --- /dev/null +++ b/src/commands/workspace/url.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +import { checkDockerReady, containerNameFor, inspectWorkspaceContainer } from "../../core/docker"; +import { ConfigError } from "../../core/errors"; +import { localUrl } from "../../core/url"; +import type { ResourceView } from "../../domain/view"; +import { renderItem } from "../../output/render"; +import { outputFlags } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export const UrlResult = z.object({ + workspace_id: z.number().int().positive(), + url: z.string(), +}); +export type UrlResult = z.infer; + +const urlResultView: ResourceView = { + compactPick: UrlResult.pick({ workspace_id: true, url: true }).strip(), + tableColumns: [ + { key: "workspace_id", label: "ID" }, + { key: "url", label: "URL" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { + name: "url", + description: "Print the local URL the workspace's container is bound to", + }, + args: { + ...outputFlags, + id: { type: "positional", description: "Workspace id", required: true }, + }, + outputSchema: UrlResult, + examples: ["metabase workspace url 1", "metabase workspace url 1 --json"], + async run({ args, ctx }) { + const workspaceId = parseId(args.id); + const containerName = containerNameFor(workspaceId); + + await checkDockerReady(); + const summary = await inspectWorkspaceContainer(containerName); + if (summary === null) { + throw new ConfigError( + `no container for workspace ${workspaceId} — run \`metabase workspace start ${workspaceId}\` first`, + ); + } + if (summary.hostPort === null) { + throw new ConfigError( + `container ${containerName} is missing the host-port label — likely created by a different tool`, + ); + } + + const result: UrlResult = { + workspace_id: workspaceId, + url: localUrl(summary.hostPort), + }; + renderItem(result, urlResultView, ctx); + }, +}); diff --git a/src/core/docker.test.ts b/src/core/docker.test.ts new file mode 100644 index 0000000..d48e084 --- /dev/null +++ b/src/core/docker.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { containerNameFor, parseContainerLine, parseContainerLines, volumeNameFor } from "./docker"; + +describe("containerNameFor / volumeNameFor", () => { + it("derives stable names from the workspace id", () => { + expect(containerNameFor(7)).toBe("metabase-workspace-7"); + expect(volumeNameFor(7)).toBe("metabase-workspace-7-appdb"); + }); +}); + +describe("parseContainerLine", () => { + it("extracts workspace fields from a docker ps json line", () => { + const line = JSON.stringify({ + ID: "abc123", + Names: "metabase-workspace-12", + State: "running", + Status: "Up 5 minutes", + Image: "metabase/metabase-dev:feature-workspaces-v2", + Ports: "0.0.0.0:3100->3000/tcp", + Labels: + "com.metabase.workspace.id=12,com.metabase.workspace.name=analytics," + + "com.metabase.workspace.profile=staging,com.metabase.workspace.parent=https://parent.example," + + "com.metabase.workspace.image=metabase/metabase-dev:feature-workspaces-v2," + + "com.metabase.workspace.host-port=3100", + }); + expect(parseContainerLine(line)).toEqual({ + containerId: "abc123", + name: "metabase-workspace-12", + state: "running", + status: "Up 5 minutes", + image: "metabase/metabase-dev:feature-workspaces-v2", + workspaceId: 12, + workspaceName: "analytics", + profile: "staging", + parentUrl: "https://parent.example", + hostPort: 3100, + }); + }); + + it("returns null when the workspace-id label is missing", () => { + const line = JSON.stringify({ + ID: "abc", + Names: "other-container", + State: "running", + Status: "Up", + Image: "alpine", + Ports: "", + Labels: "com.example.unrelated=yes", + }); + expect(parseContainerLine(line)).toBeNull(); + }); + + it("treats a missing host-port label as null (not 0)", () => { + const line = JSON.stringify({ + ID: "abc", + Names: "metabase-workspace-3", + State: "exited", + Status: "Exited (0) 2 minutes ago", + Image: "metabase/metabase-dev:feature-workspaces-v2", + Ports: "", + Labels: "com.metabase.workspace.id=3,com.metabase.workspace.name=demo", + }); + expect(parseContainerLine(line)).toMatchObject({ + workspaceId: 3, + workspaceName: "demo", + profile: null, + parentUrl: null, + hostPort: null, + }); + }); + + it("rejects a workspace-id label that is not a positive integer", () => { + const line = JSON.stringify({ + ID: "abc", + Names: "metabase-workspace-bad", + State: "running", + Status: "Up", + Image: "x", + Ports: "", + Labels: "com.metabase.workspace.id=not-a-number,com.metabase.workspace.name=w", + }); + expect(parseContainerLine(line)).toBeNull(); + }); + + it("throws when docker reports a state we do not recognize", () => { + const line = JSON.stringify({ + ID: "abc", + Names: "metabase-workspace-9", + State: "phantom", + Status: "?", + Image: "x", + Ports: "", + Labels: "com.metabase.workspace.id=9,com.metabase.workspace.name=w", + }); + expect(() => parseContainerLine(line)).toThrowError( + 'unknown docker container state: "phantom"', + ); + }); +}); + +describe("parseContainerLines", () => { + it("ignores blank lines and orders results as docker emitted them", () => { + const a = JSON.stringify({ + ID: "1", + Names: "metabase-workspace-1", + State: "running", + Status: "Up", + Image: "x", + Ports: "", + Labels: "com.metabase.workspace.id=1,com.metabase.workspace.name=a", + }); + const b = JSON.stringify({ + ID: "2", + Names: "metabase-workspace-2", + State: "exited", + Status: "Exited", + Image: "x", + Ports: "", + Labels: "com.metabase.workspace.id=2,com.metabase.workspace.name=b", + }); + const stdout = `${a}\n\n${b}\n`; + const summaries = parseContainerLines(stdout); + expect(summaries.map((s) => s.workspaceId)).toEqual([1, 2]); + }); +}); diff --git a/src/core/docker.ts b/src/core/docker.ts new file mode 100644 index 0000000..0b05c13 --- /dev/null +++ b/src/core/docker.ts @@ -0,0 +1,431 @@ +import { z } from "zod"; + +import { parseJson } from "../runtime/json"; +import { ProcessNotFoundError, runProcess, streamProcess } from "../runtime/process"; + +import { errorMessage } from "./errors"; + +const DOCKER_BIN = "docker"; + +const CONTAINER_NAME_PREFIX = "metabase-workspace-"; +const VOLUME_NAME_SUFFIX = "-appdb"; + +const LABEL_ID = "com.metabase.workspace.id"; +const LABEL_NAME = "com.metabase.workspace.name"; +const LABEL_PROFILE = "com.metabase.workspace.profile"; +const LABEL_PARENT = "com.metabase.workspace.parent"; +const LABEL_IMAGE = "com.metabase.workspace.image"; +const LABEL_HOST_PORT = "com.metabase.workspace.host-port"; + +export const WORKSPACE_CONTAINER_PORT = 3000; +const CONTAINER_CONFIG_DIR = "/mw-config"; +const CONTAINER_APP_DB_DIR = "/metabase-app-db"; +export const CONFIG_FILENAME = "config.yml"; +export const METADATA_FILENAME = "metadata.json"; + +const NO_SUCH_CONTAINER_PATTERN = /no such container/i; +const NO_SUCH_VOLUME_PATTERN = /no such volume/i; + +export const CONTAINER_STATES = [ + "running", + "exited", + "created", + "paused", + "restarting", + "removing", + "dead", +] as const; +export type ContainerState = (typeof CONTAINER_STATES)[number]; + +export type ContainerLifecycleStatus = ContainerState | "missing"; + +export class DockerError extends Error { + readonly exitCode: number | null; + readonly stderr: string; + constructor(message: string, exitCode: number | null, stderr: string) { + super(message); + this.name = "DockerError"; + this.exitCode = exitCode; + this.stderr = stderr; + } +} + +export class DockerNotInstalledError extends Error { + constructor() { + super( + "docker is not installed or not on PATH — install Docker Desktop / OrbStack / Colima and retry", + ); + this.name = "DockerNotInstalledError"; + } +} + +export class DockerNotRunningError extends Error { + readonly stderr: string; + constructor(stderr: string) { + super("docker is installed but the daemon is not responding — start Docker and retry"); + this.name = "DockerNotRunningError"; + this.stderr = stderr; + } +} + +export interface VolumeMount { + host: string; + container: string; + readOnly?: boolean; +} + +export interface NamedVolumeMount { + volume: string; + container: string; +} + +export interface PortMapping { + hostPort: number; + containerPort: number; +} + +export interface RunContainerOptions { + containerName: string; + image: string; + port: PortMapping; + bindMounts: readonly VolumeMount[]; + namedVolumes: readonly NamedVolumeMount[]; + envVars: Record; + labels: Record; +} + +export interface WorkspaceContainerSpec { + workspaceId: number; + workspaceName: string; + profile: string; + parentUrl: string; + image: string; + hostPort: number; + bootConfigDir: string; + licenseToken: string; + includeMetadata: boolean; +} + +export interface LogStreamOptions { + follow: boolean; + tail: number; +} + +const ContainerSummarySchema = z.object({ + ID: z.string(), + Names: z.string(), + State: z.string(), + Status: z.string(), + Image: z.string(), + Labels: z.string(), + Ports: z.string(), +}); + +export interface WorkspaceContainerSummary { + containerId: string; + name: string; + state: ContainerState; + status: string; + image: string; + workspaceId: number; + workspaceName: string; + profile: string | null; + parentUrl: string | null; + hostPort: number | null; +} + +export function containerNameFor(workspaceId: number): string { + return `${CONTAINER_NAME_PREFIX}${workspaceId}`; +} + +export function volumeNameFor(workspaceId: number): string { + return `${CONTAINER_NAME_PREFIX}${workspaceId}${VOLUME_NAME_SUFFIX}`; +} + +interface DockerExecResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +async function dockerExec( + args: readonly string[], + env?: NodeJS.ProcessEnv, +): Promise { + try { + return await runProcess(DOCKER_BIN, args, env ? { env } : {}); + } catch (error) { + if (error instanceof ProcessNotFoundError) { + throw new DockerNotInstalledError(); + } + throw error; + } +} + +export async function checkDockerReady(): Promise { + let result: DockerExecResult; + try { + result = await runProcess(DOCKER_BIN, ["version", "--format", "{{.Server.Version}}"]); + } catch (error) { + if (error instanceof ProcessNotFoundError) { + throw new DockerNotInstalledError(); + } + throw error; + } + if (result.exitCode !== 0) { + throw new DockerNotRunningError(result.stderr); + } +} + +export async function pullImage(image: string): Promise { + const code = await streamProcess(DOCKER_BIN, ["pull", image]); + if (code !== 0) { + throw new DockerError(`docker pull ${image} failed`, code, ""); + } +} + +export async function containerLifecycleStatus( + containerName: string, +): Promise { + const result = await dockerExec([ + "ps", + "-a", + "--filter", + `name=^${containerName}$`, + "--format", + "{{.State}}", + ]); + if (result.exitCode !== 0) { + throw new DockerError("docker ps failed", result.exitCode, result.stderr); + } + const trimmed = result.stdout.trim(); + if (trimmed.length === 0) { + return "missing"; + } + return parseContainerState(trimmed); +} + +function parseContainerState(raw: string): ContainerState { + const lower = raw.toLowerCase(); + for (const known of CONTAINER_STATES) { + if (known === lower) { + return known; + } + } + throw new DockerError(`unknown docker container state: ${JSON.stringify(raw)}`, null, ""); +} + +export async function runWorkspaceContainer(spec: WorkspaceContainerSpec): Promise { + await runContainer({ + containerName: containerNameFor(spec.workspaceId), + image: spec.image, + port: { hostPort: spec.hostPort, containerPort: WORKSPACE_CONTAINER_PORT }, + bindMounts: [{ host: spec.bootConfigDir, container: CONTAINER_CONFIG_DIR, readOnly: true }], + namedVolumes: [{ volume: volumeNameFor(spec.workspaceId), container: CONTAINER_APP_DB_DIR }], + envVars: workspaceContainerEnv(spec.licenseToken, spec.includeMetadata), + labels: workspaceContainerLabels(spec), + }); +} + +function workspaceContainerLabels(spec: WorkspaceContainerSpec): Record { + return { + [LABEL_ID]: String(spec.workspaceId), + [LABEL_NAME]: spec.workspaceName, + [LABEL_PROFILE]: spec.profile, + [LABEL_PARENT]: spec.parentUrl, + [LABEL_IMAGE]: spec.image, + [LABEL_HOST_PORT]: String(spec.hostPort), + }; +} + +function workspaceContainerEnv( + licenseToken: string, + includeMetadata: boolean, +): Record { + const env: Record = { + MB_CONFIG_FILE_PATH: `${CONTAINER_CONFIG_DIR}/${CONFIG_FILENAME}`, + MB_PREMIUM_EMBEDDING_TOKEN: licenseToken, + MB_DB_FILE: `${CONTAINER_APP_DB_DIR}/metabase.db`, + JAVA_OPTS: "-Xmx2g", + }; + if (includeMetadata) { + env["MB_DATABASE_METADATA_PATH"] = `${CONTAINER_CONFIG_DIR}/${METADATA_FILENAME}`; + } + return env; +} + +export async function runContainer(options: RunContainerOptions): Promise { + const args: string[] = [ + "run", + "-d", + "--name", + options.containerName, + "-p", + `${options.port.hostPort}:${options.port.containerPort}`, + ]; + for (const [key, value] of Object.entries(options.labels)) { + args.push("--label", `${key}=${value}`); + } + for (const mount of options.bindMounts) { + const suffix = mount.readOnly ? ":ro" : ""; + args.push("-v", `${mount.host}:${mount.container}${suffix}`); + } + for (const mount of options.namedVolumes) { + args.push("-v", `${mount.volume}:${mount.container}`); + } + for (const key of Object.keys(options.envVars)) { + args.push("-e", key); + } + args.push(options.image); + + const env: NodeJS.ProcessEnv = { ...process.env, ...options.envVars }; + const result = await dockerExec(args, env); + if (result.exitCode !== 0) { + throw new DockerError( + `docker run failed for ${options.containerName}`, + result.exitCode, + result.stderr, + ); + } +} + +export async function stopContainer(containerName: string): Promise { + const result = await dockerExec(["stop", containerName]); + if (result.exitCode !== 0 && !NO_SUCH_CONTAINER_PATTERN.test(result.stderr)) { + throw new DockerError(`docker stop ${containerName} failed`, result.exitCode, result.stderr); + } +} + +export async function removeContainer(containerName: string): Promise { + const result = await dockerExec(["rm", "-f", containerName]); + if (result.exitCode === 0) { + return true; + } + if (NO_SUCH_CONTAINER_PATTERN.test(result.stderr)) { + return false; + } + throw new DockerError(`docker rm ${containerName} failed`, result.exitCode, result.stderr); +} + +export async function removeVolume(volumeName: string): Promise { + const result = await dockerExec(["volume", "rm", volumeName]); + if (result.exitCode === 0) { + return true; + } + if (NO_SUCH_VOLUME_PATTERN.test(result.stderr)) { + return false; + } + throw new DockerError(`docker volume rm ${volumeName} failed`, result.exitCode, result.stderr); +} + +export async function listWorkspaceContainers(): Promise { + const result = await dockerExec([ + "ps", + "-a", + "--filter", + `label=${LABEL_ID}`, + "--format", + "{{json .}}", + ]); + if (result.exitCode !== 0) { + throw new DockerError("docker ps failed", result.exitCode, result.stderr); + } + return parseContainerLines(result.stdout); +} + +export async function inspectWorkspaceContainer( + containerName: string, +): Promise { + const result = await dockerExec([ + "ps", + "-a", + "--filter", + `name=^${containerName}$`, + "--filter", + `label=${LABEL_ID}`, + "--format", + "{{json .}}", + ]); + if (result.exitCode !== 0) { + throw new DockerError("docker ps failed", result.exitCode, result.stderr); + } + const summaries = parseContainerLines(result.stdout); + return summaries[0] ?? null; +} + +export function streamLogs( + containerName: string, + options: LogStreamOptions, +): Promise { + const args: string[] = ["logs", "--tail", String(options.tail)]; + if (options.follow) { + args.push("--follow"); + } + args.push(containerName); + return streamProcess(DOCKER_BIN, args); +} + +export function parseContainerLines(stdout: string): WorkspaceContainerSummary[] { + const lines = stdout.split("\n").filter((line) => line.trim().length > 0); + const summaries: WorkspaceContainerSummary[] = []; + for (const line of lines) { + const summary = parseContainerLine(line); + if (summary !== null) { + summaries.push(summary); + } + } + return summaries; +} + +export function parseContainerLine(line: string): WorkspaceContainerSummary | null { + let raw: unknown; + try { + raw = parseJson(line, z.unknown(), { source: "docker" }); + } catch (error) { + throw new DockerError(`could not parse docker output: ${errorMessage(error)}`, null, line); + } + const parsed = ContainerSummarySchema.safeParse(raw); + if (!parsed.success) { + return null; + } + const labels = parseLabels(parsed.data.Labels); + const idLabel = labels[LABEL_ID]; + const nameLabel = labels[LABEL_NAME]; + if (idLabel === undefined || nameLabel === undefined) { + return null; + } + const idNum = Number.parseInt(idLabel, 10); + if (!Number.isFinite(idNum) || idNum < 1) { + return null; + } + const portLabel = labels[LABEL_HOST_PORT]; + const portNum = portLabel !== undefined ? Number.parseInt(portLabel, 10) : Number.NaN; + return { + containerId: parsed.data.ID, + name: parsed.data.Names, + state: parseContainerState(parsed.data.State), + status: parsed.data.Status, + image: parsed.data.Image, + workspaceId: idNum, + workspaceName: nameLabel, + profile: labels[LABEL_PROFILE] ?? null, + parentUrl: labels[LABEL_PARENT] ?? null, + hostPort: Number.isFinite(portNum) ? portNum : null, + }; +} + +function parseLabels(raw: string): Record { + const out: Record = {}; + for (const pair of raw.split(",")) { + const eq = pair.indexOf("="); + if (eq === -1) { + continue; + } + const key = pair.slice(0, eq); + const value = pair.slice(eq + 1); + if (key.length > 0) { + out[key] = value; + } + } + return out; +} diff --git a/src/core/http/probe.ts b/src/core/http/probe.ts new file mode 100644 index 0000000..b15bf5f --- /dev/null +++ b/src/core/http/probe.ts @@ -0,0 +1,20 @@ +export interface ProbeResult { + ready: boolean; + status: number | null; +} + +export async function probeHealth(url: string, timeoutMs: number): Promise { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + return { ready: response.ok, status: response.status }; + } catch (error) { + // Connection refused, DNS, TLS, AbortError from the timeout — all valid + // "not ready yet" signals during boot. Re-throw anything that isn't a + // standard Error so genuine bugs (URL constructor failures, non-Error + // throws) don't get silently treated as "still booting forever". + if (error instanceof Error) { + return { ready: false, status: null }; + } + throw error; + } +} diff --git a/src/core/url.ts b/src/core/url.ts index 4019ad8..0866035 100644 --- a/src/core/url.ts +++ b/src/core/url.ts @@ -12,3 +12,7 @@ export function originOnly(input: string): string { parsed.password = ""; return parsed.origin; } + +export function localUrl(port: number): string { + return `http://localhost:${port}`; +} diff --git a/src/runtime/port.test.ts b/src/runtime/port.test.ts new file mode 100644 index 0000000..e9691a1 --- /dev/null +++ b/src/runtime/port.test.ts @@ -0,0 +1,76 @@ +import { createServer, type Server } from "node:net"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { findFreePort, isPortFree } from "./port"; + +describe("isPortFree", () => { + let occupied: Server | null = null; + + afterEach(async () => { + if (occupied !== null) { + const server = occupied; + occupied = null; + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("returns true for an unbound localhost port", async () => { + const port = await pickFreePortViaOS(); + expect(await isPortFree(port)).toBe(true); + }); + + it("returns false when a server is already bound to the port", async () => { + const { port, server } = await bindServer(); + occupied = server; + expect(await isPortFree(port)).toBe(false); + }); +}); + +describe("findFreePort", () => { + let occupied: Server | null = null; + + afterEach(async () => { + if (occupied !== null) { + const server = occupied; + occupied = null; + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("returns the start port when free", async () => { + const port = await pickFreePortViaOS(); + const result = await findFreePort(port); + expect(result).toBe(port); + }); + + it("skips a busy port and returns the next free one", async () => { + const { port, server } = await bindServer(); + occupied = server; + const result = await findFreePort(port); + expect(result).toBeGreaterThan(port); + }); +}); + +async function pickFreePortViaOS(): Promise { + const { port, server } = await bindServer(); + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +async function bindServer(): Promise<{ port: number; server: Server }> { + return await new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once("error", (error) => reject(error)); + server.once("listening", () => { + const address = server.address(); + if (typeof address === "object" && address !== null) { + resolve({ port: address.port, server }); + return; + } + reject(new Error("server.address() did not return an object")); + }); + server.listen(0, "127.0.0.1"); + }); +} diff --git a/src/runtime/port.ts b/src/runtime/port.ts new file mode 100644 index 0000000..aad99a3 --- /dev/null +++ b/src/runtime/port.ts @@ -0,0 +1,26 @@ +import { createServer } from "node:net"; + +import { ConfigError } from "../core/errors"; + +export const PORT_SCAN_LIMIT = 100; + +export function isPortFree(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.once("listening", () => { + server.close(() => resolve(true)); + }); + server.listen(port, "127.0.0.1"); + }); +} + +export async function findFreePort(start: number): Promise { + for (let port = start; port < start + PORT_SCAN_LIMIT; port++) { + if (await isPortFree(port)) { + return port; + } + } + throw new ConfigError(`no free port in range ${start}..${start + PORT_SCAN_LIMIT - 1}`); +} diff --git a/src/runtime/process.test.ts b/src/runtime/process.test.ts new file mode 100644 index 0000000..940b6e0 --- /dev/null +++ b/src/runtime/process.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { ProcessNotFoundError, runProcess, streamProcess } from "./process"; + +describe("runProcess", () => { + it("captures stdout and exit code 0", async () => { + const result = await runProcess("node", ["-e", "process.stdout.write('hello')"]); + expect(result).toEqual({ stdout: "hello", stderr: "", exitCode: 0 }); + }); + + it("captures stderr and a non-zero exit code", async () => { + const result = await runProcess("node", [ + "-e", + "process.stderr.write('boom'); process.exit(2)", + ]); + expect(result).toEqual({ stdout: "", stderr: "boom", exitCode: 2 }); + }); + + it("forwards stdin to the child", async () => { + const result = await runProcess( + "node", + ["-e", "let d=''; process.stdin.on('data',c=>d+=c).on('end',()=>process.stdout.write(d))"], + { stdin: "piped-input" }, + ); + expect(result).toEqual({ stdout: "piped-input", stderr: "", exitCode: 0 }); + }); + + it("throws ProcessNotFoundError when the binary does not exist", async () => { + await expect(runProcess("metabase-no-such-binary-xyz", [])).rejects.toBeInstanceOf( + ProcessNotFoundError, + ); + }); +}); + +describe("streamProcess", () => { + it("returns the child's exit code", async () => { + const code = await streamProcess("node", ["-e", "process.exit(7)"]); + expect(code).toBe(7); + }); + + it("throws ProcessNotFoundError when the binary does not exist", async () => { + await expect(streamProcess("metabase-no-such-binary-xyz", [])).rejects.toBeInstanceOf( + ProcessNotFoundError, + ); + }); +}); diff --git a/src/runtime/process.ts b/src/runtime/process.ts new file mode 100644 index 0000000..6246c46 --- /dev/null +++ b/src/runtime/process.ts @@ -0,0 +1,102 @@ +import { spawn } from "node:child_process"; + +export interface ProcessRunOptions { + env?: NodeJS.ProcessEnv; + cwd?: string; + stdin?: string; + timeoutMs?: number; +} + +export interface ProcessResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +export class ProcessNotFoundError extends Error { + readonly command: string; + constructor(command: string) { + super(`command not found: ${command}`); + this.name = "ProcessNotFoundError"; + this.command = command; + } +} + +export class ProcessTimeoutError extends Error { + readonly command: string; + readonly timeoutMs: number; + constructor(command: string, timeoutMs: number) { + super(`command timed out after ${timeoutMs}ms: ${command}`); + this.name = "ProcessTimeoutError"; + this.command = command; + this.timeoutMs = timeoutMs; + } +} + +export function runProcess( + command: string, + args: readonly string[], + options: ProcessRunOptions = {}, +): Promise { + const timeoutSignal = + options.timeoutMs !== undefined && options.timeoutMs > 0 + ? AbortSignal.timeout(options.timeoutMs) + : undefined; + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: ["pipe", "pipe", "pipe"], + env: options.env ?? process.env, + ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), + ...(timeoutSignal !== undefined ? { signal: timeoutSignal, killSignal: "SIGKILL" } : {}), + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + child.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") { + reject(new ProcessNotFoundError(command)); + return; + } + if (timeoutSignal?.aborted) { + reject(new ProcessTimeoutError(command, options.timeoutMs ?? 0)); + return; + } + reject(error); + }); + child.on("close", (code) => { + if (timeoutSignal?.aborted) { + reject(new ProcessTimeoutError(command, options.timeoutMs ?? 0)); + return; + } + resolve({ stdout, stderr, exitCode: code }); + }); + + if (options.stdin !== undefined) { + child.stdin.end(options.stdin); + } else { + child.stdin.end(); + } + }); +} + +export function streamProcess(command: string, args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: "inherit" }); + child.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") { + reject(new ProcessNotFoundError(command)); + return; + } + reject(error); + }); + child.on("close", (code) => resolve(code)); + }); +} diff --git a/src/runtime/tempdir.test.ts b/src/runtime/tempdir.test.ts new file mode 100644 index 0000000..dacddc5 --- /dev/null +++ b/src/runtime/tempdir.test.ts @@ -0,0 +1,29 @@ +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { mkSecureTempDir, removeTempDir, writeSecureFile } from "./tempdir"; + +describe("tempdir", () => { + it("creates a directory we can write to and then remove", async () => { + const dir = await mkSecureTempDir(); + try { + const target = join(dir, "config.yml"); + await writeSecureFile(target, "version: 1\n"); + expect(await readFile(target, "utf8")).toBe("version: 1\n"); + // Files written via writeSecureFile must not be world-readable. + const fileStat = await stat(target); + expect(fileStat.mode & 0o077).toBe(0); + } finally { + await removeTempDir(dir); + } + await expect(stat(dir)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("removeTempDir is idempotent on a missing path", async () => { + const dir = await mkSecureTempDir(); + await removeTempDir(dir); + await removeTempDir(dir); + }); +}); diff --git a/src/runtime/tempdir.ts b/src/runtime/tempdir.ts new file mode 100644 index 0000000..f328930 --- /dev/null +++ b/src/runtime/tempdir.ts @@ -0,0 +1,29 @@ +import { createWriteStream } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Writable } from "node:stream"; + +const TEMP_DIR_PREFIX = "metabase-workspace-"; +const SECURE_FILE_MODE = 0o600; + +// mkdtemp(3) creates the directory with mode 0700 by POSIX contract — no chmod needed. +export async function mkSecureTempDir(): Promise { + return await mkdtemp(join(tmpdir(), TEMP_DIR_PREFIX)); +} + +export async function writeSecureFile(path: string, content: string): Promise { + await writeFile(path, content, { mode: SECURE_FILE_MODE }); +} + +export async function streamToSecureFile( + source: ReadableStream, + path: string, +): Promise { + const writable = createWriteStream(path, { mode: SECURE_FILE_MODE }); + await source.pipeTo(Writable.toWeb(writable)); +} + +export async function removeTempDir(path: string): Promise { + await rm(path, { recursive: true, force: true }); +} diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index 4857a69..5c25863 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -88,14 +88,24 @@ describe("__manifest e2e", () => { "workspace database provision", "workspace database update", "workspace database deprovision", + "workspace start", + "workspace stop", + "workspace remove", + "workspace logs", + "workspace url", + "workspace ps", "setup", "api-key create", "eid translate", ]); // Streaming commands legitimately have no outputSchema — they pipe raw bytes - // (YAML / binary) to stdout rather than a typed JSON envelope. - const streamingCommands = new Set(["workspace config", "workspace metadata-export"]); + // (YAML / binary / docker logs) to stdout rather than a typed JSON envelope. + const streamingCommands = new Set([ + "workspace config", + "workspace metadata-export", + "workspace logs", + ]); for (const entry of manifest.commands) { expect(entry.examples.length, `missing examples for ${entry.command}`).toBeGreaterThan(0); diff --git a/tests/e2e/workspace-local.e2e.test.ts b/tests/e2e/workspace-local.e2e.test.ts new file mode 100644 index 0000000..1812506 --- /dev/null +++ b/tests/e2e/workspace-local.e2e.test.ts @@ -0,0 +1,267 @@ +import { readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { LocalWorkspaceListEnvelope } from "../../src/commands/workspace/ps"; +import { RemoveResult } from "../../src/commands/workspace/remove"; +import { StartResult } from "../../src/commands/workspace/start"; +import { StopResult } from "../../src/commands/workspace/stop"; +import { UrlResult } from "../../src/commands/workspace/url"; +import { Workspace } from "../../src/domain/workspace"; +import { parseJson } from "../../src/runtime/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { E2E_DATABASES } from "./seed/ids"; + +const ENABLE_FLAG = "METABASE_CLI_E2E_DOCKER"; +const dockerEnabled = process.env[ENABLE_FLAG] === "1"; +const licenseToken = process.env["MB_PREMIUM_EMBEDDING_TOKEN"]; +// The same image the e2e docker-compose uses; it's already pulled when the +// developer ran `bun run e2e:up`, so --no-pull is the right default for tests. +const TEST_IMAGE = + process.env["METABASE_CLI_E2E_LOCAL_IMAGE"] ?? "metabase/metabase-dev:feature-workspaces-v2"; +const TEST_HOST_PORT = "13100"; +const HEALTH_TIMEOUT_MS = 240_000; +const PROVISION_TIMEOUT_MS = 60_000; +const WORKSPACE_NAME = "e2e_local_workspace"; +const FIRST_WORKSPACE_ID = 1; +const ANALYTICS_SCHEMA = "analytics"; + +function resolveSkipReason(): string | null { + if (!dockerEnabled) { + return `set ${ENABLE_FLAG}=1 to opt into local-runtime e2e tests`; + } + if (!licenseToken) { + return "MB_PREMIUM_EMBEDDING_TOKEN is required for local-runtime e2e tests"; + } + return null; +} + +const skipReason = resolveSkipReason(); + +describe.skipIf(skipReason !== null)("workspace local-runtime e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterAll(async () => { + // The setup restore-each hook wipes parent state between tests, but it + // doesn't touch local docker. Tear down the container/volume that the + // test left behind so reruns start clean. + await runCli({ + args: ["workspace", "remove", String(FIRST_WORKSPACE_ID), "--yes", "--json"], + configHome: await pushConfigHome(), + env: authEnv(), + timeoutMs: 60_000, + }); + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function pushConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + METABASE_URL: bootstrap.baseUrl, + METABASE_API_KEY: bootstrap.adminApiKey, + }; + } + + async function provisionWorkspaceWithDatabase(): Promise { + const create = await runCli({ + args: ["workspace", "create", "--name", WORKSPACE_NAME, "--full", "--json"], + configHome: await pushConfigHome(), + env: authEnv(), + }); + expect(create.exitCode, create.stderr).toBe(0); + + const provision = await runCli({ + args: [ + "workspace", + "database", + "provision", + String(FIRST_WORKSPACE_ID), + "--database-id", + String(E2E_DATABASES.WAREHOUSE), + "--schemas", + ANALYTICS_SCHEMA, + "--wait", + "--full", + "--json", + ], + configHome: await pushConfigHome(), + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + expect(provision.exitCode, provision.stderr).toBe(0); + const workspace = parseJson(provision.stdout, Workspace); + const provisioned = workspace.databases?.find( + (entry) => entry.database_id === E2E_DATABASES.WAREHOUSE, + ); + expect(provisioned).toMatchObject({ + database_id: E2E_DATABASES.WAREHOUSE, + input_schemas: [ANALYTICS_SCHEMA], + status: "provisioned", + }); + } + + it( + "start spins up a healthy local container; ps + url + stop + remove cycle through it", + async () => { + if (!licenseToken) { + throw new Error("test reached body without a license token — skip guard is broken"); + } + + // The setupFile's restore-each hook wipes parent state before this test + // runs, so the workspace must be created here (not in beforeAll). + await provisionWorkspaceWithDatabase(); + + // 1. Stash the EE token so workspace start can resolve it from the keyring/file fallback. + const licenseHome = await pushConfigHome(); + const setLicense = await runCli({ + args: ["license", "set", "--json"], + configHome: licenseHome, + env: authEnv(), + stdin: licenseToken, + }); + expect(setLicense.exitCode, setLicense.stderr).toBe(0); + + // 2. Start the local container. --no-pull because the image is already + // on the developer's machine (the e2e parent uses the same image). + const start = await runCli({ + args: [ + "workspace", + "start", + String(FIRST_WORKSPACE_ID), + "--port", + TEST_HOST_PORT, + "--image", + TEST_IMAGE, + "--no-pull", + "--no-metadata", + "--full", + "--json", + ], + configHome: licenseHome, + env: authEnv(), + timeoutMs: HEALTH_TIMEOUT_MS, + }); + expect(start.exitCode, start.stderr).toBe(0); + const startResult = parseJson(start.stdout, StartResult); + expect(startResult).toEqual({ + workspace_id: FIRST_WORKSPACE_ID, + workspace_name: WORKSPACE_NAME, + container_name: `metabase-workspace-${FIRST_WORKSPACE_ID}`, + state: "running", + host_port: Number.parseInt(TEST_HOST_PORT, 10), + url: `http://localhost:${TEST_HOST_PORT}`, + image: TEST_IMAGE, + }); + + // 3. ps should show the workspace as running. + const ps = await runCli({ + args: ["workspace", "ps", "--json"], + configHome: await pushConfigHome(), + env: authEnv(), + }); + expect(ps.exitCode, ps.stderr).toBe(0); + const list = parseJson(ps.stdout, LocalWorkspaceListEnvelope); + const ours = list.data.find((entry) => entry.workspace_id === FIRST_WORKSPACE_ID); + expect(ours).toEqual({ + workspace_id: FIRST_WORKSPACE_ID, + workspace_name: WORKSPACE_NAME, + state: "running", + url: `http://localhost:${TEST_HOST_PORT}`, + }); + + // 4. url returns just the local URL. + const urlOut = await runCli({ + args: ["workspace", "url", String(FIRST_WORKSPACE_ID), "--full", "--json"], + configHome: await pushConfigHome(), + env: authEnv(), + }); + expect(urlOut.exitCode, urlOut.stderr).toBe(0); + expect(parseJson(urlOut.stdout, UrlResult)).toEqual({ + workspace_id: FIRST_WORKSPACE_ID, + url: `http://localhost:${TEST_HOST_PORT}`, + }); + + // 5. The boot config dir on the host must be gone — secrets should not linger. + expect(await listMetabaseTempDirs()).toEqual([]); + + // 6. Stop, then verify ps reflects the new state. + const stop = await runCli({ + args: ["workspace", "stop", String(FIRST_WORKSPACE_ID), "--full", "--json"], + configHome: await pushConfigHome(), + env: authEnv(), + timeoutMs: 60_000, + }); + expect(stop.exitCode, stop.stderr).toBe(0); + const stopResult = parseJson(stop.stdout, StopResult); + expect(stopResult).toEqual({ + workspace_id: FIRST_WORKSPACE_ID, + container_name: `metabase-workspace-${FIRST_WORKSPACE_ID}`, + stopped: true, + prior_state: "running", + }); + + const psAfterStop = await runCli({ + args: ["workspace", "ps", "--full", "--json"], + configHome: await pushConfigHome(), + env: authEnv(), + }); + expect(psAfterStop.exitCode, psAfterStop.stderr).toBe(0); + const afterStop = parseJson(psAfterStop.stdout, LocalWorkspaceListEnvelope).data; + const oursAfterStop = afterStop.find((entry) => entry.workspace_id === FIRST_WORKSPACE_ID); + expect(oursAfterStop).toEqual({ + workspace_id: FIRST_WORKSPACE_ID, + workspace_name: WORKSPACE_NAME, + state: "exited", + url: null, + }); + + // 7. Remove tears down the container + the app-db volume. + const remove = await runCli({ + args: ["workspace", "remove", String(FIRST_WORKSPACE_ID), "--yes", "--full", "--json"], + configHome: await pushConfigHome(), + env: authEnv(), + timeoutMs: 60_000, + }); + expect(remove.exitCode, remove.stderr).toBe(0); + const removeResult = parseJson(remove.stdout, RemoveResult); + expect(removeResult).toEqual({ + workspace_id: FIRST_WORKSPACE_ID, + container_name: `metabase-workspace-${FIRST_WORKSPACE_ID}`, + volume_name: `metabase-workspace-${FIRST_WORKSPACE_ID}-appdb`, + removed_container: true, + removed_volume: true, + }); + + // 8. ps should no longer list the workspace. + const psAfterRemove = await runCli({ + args: ["workspace", "ps", "--full", "--json"], + configHome: await pushConfigHome(), + env: authEnv(), + }); + expect(psAfterRemove.exitCode, psAfterRemove.stderr).toBe(0); + const afterRemove = parseJson(psAfterRemove.stdout, LocalWorkspaceListEnvelope).data; + expect( + afterRemove.find((entry) => entry.workspace_id === FIRST_WORKSPACE_ID), + ).toBeUndefined(); + }, + HEALTH_TIMEOUT_MS + 60_000, + ); +}); + +async function listMetabaseTempDirs(): Promise { + const entries = await readdir(tmpdir()); + return entries.filter((entry) => entry.startsWith("metabase-workspace-")); +} From 5f843a343b74fc32ced204f339e3fa6fc6d4e05f Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 6 May 2026 12:44:09 -0400 Subject: [PATCH 03/47] fix --- src/runtime/json.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runtime/json.test.ts b/src/runtime/json.test.ts index b39fb2d..3349049 100644 --- a/src/runtime/json.test.ts +++ b/src/runtime/json.test.ts @@ -166,11 +166,11 @@ describe("parseJsonResult on schema mismatch", () => { }); describe("parseJson property tests", () => { - it("property: round-trips any JSON.stringify(value) through parseJson with z.unknown()", () => { + it("property: parseJson agrees with JSON.parse on any valid JSON text", () => { fc.assert( fc.property(fc.jsonValue(), (value) => { const serialized = JSON.stringify(value); - expect(parseJson(serialized, z.unknown())).toEqual(value); + expect(parseJson(serialized, z.unknown())).toEqual(JSON.parse(serialized)); }), ); }); From 7d7eb50da2a989ef11cab489dc20cb4ccadcc748 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 6 May 2026 17:23:11 -0400 Subject: [PATCH 04/47] polish workspaces commands --- README.md | 36 +--- src/commands/workspace/config.ts | 24 --- src/commands/workspace/index.ts | 2 - src/commands/workspace/metadata-export.ts | 48 ----- src/commands/workspace/start.ts | 106 +++++----- src/core/auth/storage.ts | 11 +- src/core/docker.ts | 233 +++++++++++++--------- src/core/paths.test.ts | 51 +++++ src/core/paths.ts | 19 ++ src/runtime/process.ts | 8 +- src/runtime/tar.test.ts | 51 +++++ src/runtime/tar.ts | 153 ++++++++++++++ src/runtime/tempdir.test.ts | 29 --- src/runtime/tempdir.ts | 29 --- tests/e2e/manifest.e2e.test.ts | 10 +- tests/e2e/workspace-local.e2e.test.ts | 1 + tests/e2e/workspace.e2e.test.ts | 62 ------ 17 files changed, 477 insertions(+), 396 deletions(-) delete mode 100644 src/commands/workspace/config.ts delete mode 100644 src/commands/workspace/metadata-export.ts create mode 100644 src/core/paths.test.ts create mode 100644 src/core/paths.ts create mode 100644 src/runtime/tar.test.ts create mode 100644 src/runtime/tar.ts delete mode 100644 src/runtime/tempdir.test.ts delete mode 100644 src/runtime/tempdir.ts diff --git a/README.md b/README.md index 351e897..ad01596 100644 --- a/README.md +++ b/README.md @@ -598,30 +598,6 @@ metabase workspace create --file workspace.json | `--body ` | Inline JSON body. | | `--file ` | Path to JSON body file. | -### `metabase workspace config ` - -Stream the workspace's config file (raw bytes) to stdout. - -```sh -metabase workspace config 1 > config.yml -metabase workspace config 1 | yq . -``` - -### `metabase workspace metadata-export ` - -Stream the workspace's table metadata export (JSON) to stdout. The backend defaults all sections off; the CLI flips them on so the export is non-empty by default. - -```sh -metabase workspace metadata-export 1 > metadata.json -metabase workspace metadata-export 1 --no-with-fields > metadata.json -``` - -| Flag | Description | -| ------------------ | --------------------------------------- | -| `--with-databases` | Include database entries (default: on). | -| `--with-tables` | Include table entries (default: on). | -| `--with-fields` | Include field entries (default: on). | - ### `metabase workspace database provision ` Provision a database into a workspace. The backend kicks off the work asynchronously and returns the workspace with the new entry in `status: "provisioning"`. Pass `--wait` to poll until the entry reaches `status: "provisioned"` and surface the polled state instead of the initial response. @@ -679,26 +655,28 @@ metabase workspace database deprovision 1 5 --yes --wait These commands manage a Docker container that serves as the workspace's child Metabase instance. State lives in Docker labels and a named volume — there is no per-workspace local state directory. The container is named `metabase-workspace-`; the app-db volume is `metabase-workspace--appdb`. -`start` is the only command that talks to the parent: it fetches `config.yml` (and optionally the metadata export) into a 0700 temp directory, bind-mounts it read-only into the container, polls `/api/health`, and **scrubs the temp dir on exit (success or failure)** so the parent's connection credentials and the EE token don't linger on disk. - ### `metabase workspace start ` ```sh metabase workspace start 1 +metabase workspace start 1 --wait metabase workspace start 1 --port 3100 metabase workspace start 1 --image metabase/metabase-dev:feature-workspaces-v2 --no-pull metabase workspace start 1 --force ``` -Resolves the parent via the active profile (or `--profile`/`--url`/`--api-key`) and the EE license via `resolveLicenseToken` (the same path `metabase license set` writes to). The license is forwarded to the container as `MB_PREMIUM_EMBEDDING_TOKEN` via `-e KEY` (no value on argv). Refuses to start if the workspace has any database that isn't `status: "provisioned"`. +Resolves the parent via the active profile (or `--profile`/`--url`/`--api-key`) and the EE license via `resolveLicenseToken` (the same path `metabase license set` writes to). Refuses to start if the workspace has any database that isn't `status: "provisioned"`. + +By default `start` returns as soon as the container is started (`state: "starting"`); pass `--wait` to block until `/api/health` reports ready and the response reports `state: "running"`. | Flag | Description | | ---------------- | ---------------------------------------------------------------------------- | | `--port ` | Host port (default: 3000; auto-shifts up to 100 ports if taken). | | `--image ` | Docker image (default: `metabase/metabase-dev:feature-workspaces-v2`). | -| `--timeout ` | Health check deadline (default: 180000). | +| `--wait` | Block until `/api/health` is ready. Default: return as soon as started. | +| `--timeout ` | Health check deadline (default: 180000). Used with `--wait`. | | `--no-pull` | Skip `docker pull` (useful if the image is already present). | -| `--no-metadata` | Skip the metadata export — only mount `config.yml`. | +| `--no-metadata` | Skip the warehouse metadata export. | | `--force` | If a container for this workspace already exists, remove it before starting. | ### `metabase workspace stop ` diff --git a/src/commands/workspace/config.ts b/src/commands/workspace/config.ts deleted file mode 100644 index b9552c5..0000000 --- a/src/commands/workspace/config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { pipeToStdout } from "../../output/stream"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { parseId } from "../parse-id"; -import { defineMetabaseCommand } from "../runtime"; - -export default defineMetabaseCommand({ - meta: { - name: "config", - description: "Download a workspace's config file (raw stream to stdout)", - }, - args: { - ...outputFlags, - ...profileFlag, - ...connectionFlags, - id: { type: "positional", description: "Workspace id", required: true }, - }, - examples: ["metabase workspace config 1 > config.yml", "metabase workspace config 1 | yq ."], - async run({ args, getClient }) { - const id = parseId(args.id); - const client = await getClient(); - const stream = await client.requestStream(`/api/ee/workspace-manager/${id}/config`); - await pipeToStdout(stream); - }, -}); diff --git a/src/commands/workspace/index.ts b/src/commands/workspace/index.ts index 645cf41..c2bc4bb 100644 --- a/src/commands/workspace/index.ts +++ b/src/commands/workspace/index.ts @@ -5,8 +5,6 @@ export default defineCommand({ subCommands: { list: () => import("./list").then((mod) => mod.default), create: () => import("./create").then((mod) => mod.default), - config: () => import("./config").then((mod) => mod.default), - "metadata-export": () => import("./metadata-export").then((mod) => mod.default), database: () => import("./database").then((mod) => mod.default), start: () => import("./start").then((mod) => mod.default), stop: () => import("./stop").then((mod) => mod.default), diff --git a/src/commands/workspace/metadata-export.ts b/src/commands/workspace/metadata-export.ts deleted file mode 100644 index bd87437..0000000 --- a/src/commands/workspace/metadata-export.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { pipeToStdout } from "../../output/stream"; -import { connectionFlags, outputFlags, profileFlag } from "../flags"; -import { parseId } from "../parse-id"; -import { defineMetabaseCommand } from "../runtime"; - -export default defineMetabaseCommand({ - meta: { - name: "metadata-export", - description: "Download a workspace's table metadata (raw stream to stdout)", - }, - args: { - ...outputFlags, - ...profileFlag, - ...connectionFlags, - "with-databases": { - type: "boolean", - description: "Include database entries in the export", - default: true, - }, - "with-tables": { - type: "boolean", - description: "Include table entries in the export", - default: true, - }, - "with-fields": { - type: "boolean", - description: "Include field entries in the export", - default: true, - }, - id: { type: "positional", description: "Workspace id", required: true }, - }, - examples: [ - "metabase workspace metadata-export 1 > metadata.json", - "metabase workspace metadata-export 1 --no-with-fields > metadata.json", - ], - async run({ args, getClient }) { - const id = parseId(args.id); - const client = await getClient(); - const stream = await client.requestStream(`/api/ee/workspace-manager/${id}/metadata/export`, { - query: { - "with-databases": args["with-databases"], - "with-tables": args["with-tables"], - "with-fields": args["with-fields"], - }, - }); - await pipeToStdout(stream); - }, -}); diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts index a144adc..6faa7f3 100644 --- a/src/commands/workspace/start.ts +++ b/src/commands/workspace/start.ts @@ -1,33 +1,25 @@ -import { join } from "node:path"; - import { z } from "zod"; import { resolveLicenseToken } from "../../core/config"; import { - CONFIG_FILENAME, - METADATA_FILENAME, checkDockerReady, containerLifecycleStatus, containerNameFor, pullImage, removeContainer, runWorkspaceContainer, + scrubContainerConfig, } from "../../core/docker"; -import { ConfigError } from "../../core/errors"; +import { ConfigError, errorMessage } from "../../core/errors"; import type { Client } from "../../core/http/client"; import { probeHealth } from "../../core/http/probe"; import { localUrl } from "../../core/url"; import type { ResourceView } from "../../domain/view"; import { Workspace } from "../../domain/workspace"; +import { warn } from "../../output/notice"; import { renderItem } from "../../output/render"; import { findFreePort, isPortFree } from "../../runtime/port"; import { pollUntil } from "../../runtime/poll"; -import { - mkSecureTempDir, - removeTempDir, - streamToSecureFile, - writeSecureFile, -} from "../../runtime/tempdir"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { parseInteger, parseOptionalInteger } from "../parse-integer"; @@ -44,7 +36,7 @@ export const StartResult = z.object({ workspace_id: z.number().int().positive(), workspace_name: z.string(), container_name: z.string(), - state: z.literal("running"), + state: z.enum(["running", "starting"]), host_port: z.number().int().positive(), url: z.string(), image: z.string(), @@ -85,9 +77,15 @@ export default defineMetabaseCommand({ description: `Docker image to run (default: ${DEFAULT_IMAGE})`, default: DEFAULT_IMAGE, }, + wait: { + type: "boolean", + description: + "Block until /api/health is ready, then scrub the in-container config.yml. Default: return as soon as the container is started.", + default: false, + }, timeout: { type: "string", - description: `Health check deadline in ms (default: ${DEFAULT_HEALTH_TIMEOUT_MS})`, + description: `Health check deadline in ms (used with --wait; default: ${DEFAULT_HEALTH_TIMEOUT_MS})`, default: String(DEFAULT_HEALTH_TIMEOUT_MS), }, pull: { @@ -97,7 +95,7 @@ export default defineMetabaseCommand({ }, metadata: { type: "boolean", - description: "Fetch the workspace's warehouse metadata and mount it as metadata.json", + description: "Fetch the workspace's warehouse metadata and stage it inside the container", default: true, }, force: { @@ -109,6 +107,7 @@ export default defineMetabaseCommand({ outputSchema: StartResult, examples: [ "metabase workspace start 1", + "metabase workspace start 1 --wait", "metabase workspace start 1 --port 3100", "metabase workspace start 1 --image metabase/metabase-dev:feature-workspaces-v2 --no-pull", "metabase workspace start 1 --force", @@ -129,7 +128,6 @@ export default defineMetabaseCommand({ await checkDockerReady(); await ensureNoExistingContainer(containerName, args.force); - // Kick off the image pull concurrently with the parent fetches; await before runContainer. const pullPromise = args.pull ? pullImage(args.image) : Promise.resolve(); const workspace = await client.requestParsed( @@ -140,41 +138,46 @@ export default defineMetabaseCommand({ const hostPort = await resolveHostPort(requestedPort); - const tempDir = await mkSecureTempDir(); - try { - await Promise.all([ - writeConfigYaml(client, workspaceId, join(tempDir, CONFIG_FILENAME)), - args.metadata - ? streamMetadata(client, workspaceId, join(tempDir, METADATA_FILENAME)) - : Promise.resolve(), - ]); + // Boot bundle stays in process memory: no host-disk artifact for config.yml or + // metadata.json. The bytes are tar-streamed into the container by docker daemon + // and land on the container's overlay FS (root-only on the daemon host). + const [configYaml, metadataJson] = await Promise.all([ + fetchConfigYaml(client, workspaceId), + args.metadata ? fetchMetadataJson(client, workspaceId) : Promise.resolve(null), + ]); - await pullPromise; + await pullPromise; - await runWorkspaceContainer({ - workspaceId, - workspaceName: workspace.name, - profile: resolved.profile, - parentUrl: resolved.url, - image: args.image, - hostPort, - bootConfigDir: tempDir, - licenseToken, - includeMetadata: args.metadata, - }); + await runWorkspaceContainer({ + workspaceId, + workspaceName: workspace.name, + profile: resolved.profile, + parentUrl: resolved.url, + image: args.image, + hostPort, + configYaml, + metadataJson, + licenseToken, + }); + if (args.wait) { await waitForHealth(hostPort, healthTimeoutMs); - } finally { - // config.yml carries DB credentials and the license token; scrub on - // every exit so secrets don't linger on disk. - await removeTempDir(tempDir); + // After Metabase finishes its boot-time read of /mw-config/config.yml (signaled + // by /api/health going green), unlink the in-container copy too. The host + // never saw the file, so a scrub failure doesn't fail the start — but it's + // still surfaced to stderr so the operator knows the in-container copy lingered. + try { + await scrubContainerConfig(workspaceId); + } catch (error) { + warn(`could not scrub in-container config.yml: ${errorMessage(error)}`); + } } const result: StartResult = { workspace_id: workspaceId, workspace_name: workspace.name, container_name: containerName, - state: "running", + state: args.wait ? "running" : "starting", host_port: hostPort, url: localUrl(hostPort), image: args.image, @@ -227,30 +230,19 @@ async function resolveHostPort(requested: number | null): Promise { return findFreePort(DEFAULT_HOST_PORT + 1); } -async function writeConfigYaml( - client: Client, - workspaceId: number, - destination: string, -): Promise { - // config.yml is small (one workspace + a handful of databases) — buffering is - // simpler than streaming and lets us write atomically through writeSecureFile. +async function fetchConfigYaml(client: Client, workspaceId: number): Promise { const response = await client.requestRaw(`/api/ee/workspace-manager/${workspaceId}/config`, { expectContentType: "binary", }); - await writeSecureFile(destination, await response.text()); + return response.text(); } -async function streamMetadata( - client: Client, - workspaceId: number, - destination: string, -): Promise { - // metadata.json can be tens of MB on a real warehouse; stream straight to disk - // instead of buffering twice (response.text + writeSecureFile). - const stream = await client.requestStream( +async function fetchMetadataJson(client: Client, workspaceId: number): Promise { + const response = await client.requestRaw( `/api/ee/workspace-manager/${workspaceId}/metadata/export`, + { expectContentType: "binary" }, ); - await streamToSecureFile(stream, destination); + return new Uint8Array(await response.arrayBuffer()); } async function waitForHealth(hostPort: number, timeoutMs: number): Promise { diff --git a/src/core/auth/storage.ts b/src/core/auth/storage.ts index fe8c5cd..0188a1c 100644 --- a/src/core/auth/storage.ts +++ b/src/core/auth/storage.ts @@ -1,5 +1,4 @@ import { promises as fs } from "node:fs"; -import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { Entry } from "@napi-rs/keyring"; @@ -7,6 +6,7 @@ import { z } from "zod"; import { parseJson } from "../../runtime/json"; import { isNotFoundError } from "../errors"; +import { configDir } from "../paths"; const CredentialsFileSchema = z.record(z.string(), z.string()); @@ -47,15 +47,6 @@ export interface Profile { apiKey: string; } -function configDir(): string { - if (process.platform === "win32") { - const appData = process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"); - return join(appData, "metabase-cli"); - } - const xdg = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"); - return join(xdg, "metabase-cli"); -} - export function fallbackFilePath(): string { return join(configDir(), CREDENTIALS_FILE); } diff --git a/src/core/docker.ts b/src/core/docker.ts index 0b05c13..e645cff 100644 --- a/src/core/docker.ts +++ b/src/core/docker.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { parseJson } from "../runtime/json"; import { ProcessNotFoundError, runProcess, streamProcess } from "../runtime/process"; +import { buildTar, type TarEntry } from "../runtime/tar"; import { errorMessage } from "./errors"; @@ -19,9 +20,15 @@ const LABEL_HOST_PORT = "com.metabase.workspace.host-port"; export const WORKSPACE_CONTAINER_PORT = 3000; const CONTAINER_CONFIG_DIR = "/mw-config"; +const CONTAINER_CONFIG_DIR_BASENAME = CONTAINER_CONFIG_DIR.replace(/^\//, ""); const CONTAINER_APP_DB_DIR = "/metabase-app-db"; -export const CONFIG_FILENAME = "config.yml"; -export const METADATA_FILENAME = "metadata.json"; +const CONFIG_FILENAME = "config.yml"; +const METADATA_FILENAME = "metadata.json"; +// 0644 inside the container's namespace: files live only on the docker daemon's +// overlay FS (root-only on the host). The Metabase image starts as root and drops +// to a non-root user (uid 2000 by default, configurable via MUID), so the bytes +// must be world-readable for that user to read them. The host never sees them. +const BUNDLE_FILE_MODE = 0o644; const NO_SUCH_CONTAINER_PATTERN = /no such container/i; const NO_SUCH_VOLUME_PATTERN = /no such volume/i; @@ -68,12 +75,6 @@ export class DockerNotRunningError extends Error { } } -export interface VolumeMount { - host: string; - container: string; - readOnly?: boolean; -} - export interface NamedVolumeMount { volume: string; container: string; @@ -84,11 +85,10 @@ export interface PortMapping { containerPort: number; } -export interface RunContainerOptions { +export interface CreateContainerOptions { containerName: string; image: string; port: PortMapping; - bindMounts: readonly VolumeMount[]; namedVolumes: readonly NamedVolumeMount[]; envVars: Record; labels: Record; @@ -101,9 +101,9 @@ export interface WorkspaceContainerSpec { parentUrl: string; image: string; hostPort: number; - bootConfigDir: string; + configYaml: string; + metadataJson: Uint8Array | null; licenseToken: string; - includeMetadata: boolean; } export interface LogStreamOptions { @@ -148,12 +148,21 @@ interface DockerExecResult { exitCode: number | null; } +interface DockerExecOptions { + env?: NodeJS.ProcessEnv; + stdin?: Uint8Array | string; +} + +interface DockerRunOptions extends DockerExecOptions { + ignorePattern?: RegExp; +} + async function dockerExec( args: readonly string[], - env?: NodeJS.ProcessEnv, + options: DockerExecOptions = {}, ): Promise { try { - return await runProcess(DOCKER_BIN, args, env ? { env } : {}); + return await runProcess(DOCKER_BIN, args, options); } catch (error) { if (error instanceof ProcessNotFoundError) { throw new DockerNotInstalledError(); @@ -162,6 +171,22 @@ async function dockerExec( } } +async function runDocker( + args: readonly string[], + failureMessage: string, + options: DockerRunOptions = {}, +): Promise { + const { ignorePattern, ...execOptions } = options; + const result = await dockerExec(args, execOptions); + if (result.exitCode === 0) { + return result; + } + if (ignorePattern?.test(result.stderr)) { + return result; + } + throw new DockerError(failureMessage, result.exitCode, result.stderr); +} + export async function checkDockerReady(): Promise { let result: DockerExecResult; try { @@ -187,17 +212,10 @@ export async function pullImage(image: string): Promise { export async function containerLifecycleStatus( containerName: string, ): Promise { - const result = await dockerExec([ - "ps", - "-a", - "--filter", - `name=^${containerName}$`, - "--format", - "{{.State}}", - ]); - if (result.exitCode !== 0) { - throw new DockerError("docker ps failed", result.exitCode, result.stderr); - } + const result = await runDocker( + ["ps", "-a", "--filter", `name=^${containerName}$`, "--format", "{{.State}}"], + "docker ps failed", + ); const trimmed = result.stdout.trim(); if (trimmed.length === 0) { return "missing"; @@ -215,16 +233,56 @@ function parseContainerState(raw: string): ContainerState { throw new DockerError(`unknown docker container state: ${JSON.stringify(raw)}`, null, ""); } +// Boots without materializing the boot bundle on host disk: the tar streams through +// `docker cp -` into the container's /mw-config, which lives on the daemon's overlay +// FS (root-only on the docker host). export async function runWorkspaceContainer(spec: WorkspaceContainerSpec): Promise { - await runContainer({ - containerName: containerNameFor(spec.workspaceId), + const containerName = containerNameFor(spec.workspaceId); + await createContainer({ + containerName, image: spec.image, port: { hostPort: spec.hostPort, containerPort: WORKSPACE_CONTAINER_PORT }, - bindMounts: [{ host: spec.bootConfigDir, container: CONTAINER_CONFIG_DIR, readOnly: true }], namedVolumes: [{ volume: volumeNameFor(spec.workspaceId), container: CONTAINER_APP_DB_DIR }], - envVars: workspaceContainerEnv(spec.licenseToken, spec.includeMetadata), + envVars: workspaceContainerEnv(spec), labels: workspaceContainerLabels(spec), }); + try { + await copyTarToContainer(containerName, "/", buildBootBundleTar(spec)); + await startContainer(containerName); + } catch (error) { + // Reverse the create so the caller's `--force` path still finds a clean slate. + await removeContainer(containerName).catch(() => undefined); + throw error; + } +} + +export async function scrubContainerConfig(workspaceId: number): Promise { + const containerName = containerNameFor(workspaceId); + await runDocker( + ["exec", containerName, "rm", "-f", `${CONTAINER_CONFIG_DIR}/${CONFIG_FILENAME}`], + `docker exec rm config.yml failed for ${containerName}`, + ); +} + +function buildBootBundleTar(spec: WorkspaceContainerSpec): Uint8Array { + const entries: TarEntry[] = [ + { type: "directory", name: CONTAINER_CONFIG_DIR_BASENAME }, + { + type: "file", + name: `${CONTAINER_CONFIG_DIR_BASENAME}/${CONFIG_FILENAME}`, + content: spec.configYaml, + mode: BUNDLE_FILE_MODE, + }, + ]; + if (spec.metadataJson !== null) { + entries.push({ + type: "file", + name: `${CONTAINER_CONFIG_DIR_BASENAME}/${METADATA_FILENAME}`, + content: spec.metadataJson, + mode: BUNDLE_FILE_MODE, + }); + } + return buildTar(entries); } function workspaceContainerLabels(spec: WorkspaceContainerSpec): Record { @@ -238,26 +296,22 @@ function workspaceContainerLabels(spec: WorkspaceContainerSpec): Record { +function workspaceContainerEnv(spec: WorkspaceContainerSpec): Record { const env: Record = { MB_CONFIG_FILE_PATH: `${CONTAINER_CONFIG_DIR}/${CONFIG_FILENAME}`, - MB_PREMIUM_EMBEDDING_TOKEN: licenseToken, + MB_PREMIUM_EMBEDDING_TOKEN: spec.licenseToken, MB_DB_FILE: `${CONTAINER_APP_DB_DIR}/metabase.db`, JAVA_OPTS: "-Xmx2g", }; - if (includeMetadata) { + if (spec.metadataJson !== null) { env["MB_DATABASE_METADATA_PATH"] = `${CONTAINER_CONFIG_DIR}/${METADATA_FILENAME}`; } return env; } -export async function runContainer(options: RunContainerOptions): Promise { +async function createContainer(options: CreateContainerOptions): Promise { const args: string[] = [ - "run", - "-d", + "create", "--name", options.containerName, "-p", @@ -266,10 +320,6 @@ export async function runContainer(options: RunContainerOptions): Promise for (const [key, value] of Object.entries(options.labels)) { args.push("--label", `${key}=${value}`); } - for (const mount of options.bindMounts) { - const suffix = mount.readOnly ? ":ro" : ""; - args.push("-v", `${mount.host}:${mount.container}${suffix}`); - } for (const mount of options.namedVolumes) { args.push("-v", `${mount.volume}:${mount.container}`); } @@ -279,76 +329,71 @@ export async function runContainer(options: RunContainerOptions): Promise args.push(options.image); const env: NodeJS.ProcessEnv = { ...process.env, ...options.envVars }; - const result = await dockerExec(args, env); - if (result.exitCode !== 0) { - throw new DockerError( - `docker run failed for ${options.containerName}`, - result.exitCode, - result.stderr, - ); - } + await runDocker(args, `docker create failed for ${options.containerName}`, { env }); +} + +async function copyTarToContainer( + containerName: string, + destPath: string, + tarBytes: Uint8Array, +): Promise { + await runDocker( + ["cp", "-", `${containerName}:${destPath}`], + `docker cp into ${containerName}:${destPath} failed`, + { stdin: tarBytes }, + ); +} + +async function startContainer(containerName: string): Promise { + await runDocker(["start", containerName], `docker start failed for ${containerName}`); } export async function stopContainer(containerName: string): Promise { - const result = await dockerExec(["stop", containerName]); - if (result.exitCode !== 0 && !NO_SUCH_CONTAINER_PATTERN.test(result.stderr)) { - throw new DockerError(`docker stop ${containerName} failed`, result.exitCode, result.stderr); - } + await runDocker(["stop", containerName], `docker stop ${containerName} failed`, { + ignorePattern: NO_SUCH_CONTAINER_PATTERN, + }); } export async function removeContainer(containerName: string): Promise { - const result = await dockerExec(["rm", "-f", containerName]); - if (result.exitCode === 0) { - return true; - } - if (NO_SUCH_CONTAINER_PATTERN.test(result.stderr)) { - return false; - } - throw new DockerError(`docker rm ${containerName} failed`, result.exitCode, result.stderr); + const result = await runDocker(["rm", "-f", containerName], `docker rm ${containerName} failed`, { + ignorePattern: NO_SUCH_CONTAINER_PATTERN, + }); + return result.exitCode === 0; } export async function removeVolume(volumeName: string): Promise { - const result = await dockerExec(["volume", "rm", volumeName]); - if (result.exitCode === 0) { - return true; - } - if (NO_SUCH_VOLUME_PATTERN.test(result.stderr)) { - return false; - } - throw new DockerError(`docker volume rm ${volumeName} failed`, result.exitCode, result.stderr); + const result = await runDocker( + ["volume", "rm", volumeName], + `docker volume rm ${volumeName} failed`, + { ignorePattern: NO_SUCH_VOLUME_PATTERN }, + ); + return result.exitCode === 0; } export async function listWorkspaceContainers(): Promise { - const result = await dockerExec([ - "ps", - "-a", - "--filter", - `label=${LABEL_ID}`, - "--format", - "{{json .}}", - ]); - if (result.exitCode !== 0) { - throw new DockerError("docker ps failed", result.exitCode, result.stderr); - } + const result = await runDocker( + ["ps", "-a", "--filter", `label=${LABEL_ID}`, "--format", "{{json .}}"], + "docker ps failed", + ); return parseContainerLines(result.stdout); } export async function inspectWorkspaceContainer( containerName: string, ): Promise { - const result = await dockerExec([ - "ps", - "-a", - "--filter", - `name=^${containerName}$`, - "--filter", - `label=${LABEL_ID}`, - "--format", - "{{json .}}", - ]); - if (result.exitCode !== 0) { - throw new DockerError("docker ps failed", result.exitCode, result.stderr); - } + const result = await runDocker( + [ + "ps", + "-a", + "--filter", + `name=^${containerName}$`, + "--filter", + `label=${LABEL_ID}`, + "--format", + "{{json .}}", + ], + "docker ps failed", + ); const summaries = parseContainerLines(result.stdout); return summaries[0] ?? null; } diff --git a/src/core/paths.test.ts b/src/core/paths.test.ts new file mode 100644 index 0000000..b25c7e6 --- /dev/null +++ b/src/core/paths.test.ts @@ -0,0 +1,51 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { configDir } from "./paths"; + +const ENV_KEYS = ["XDG_CONFIG_HOME", "APPDATA"] as const; + +describe("configDir", () => { + let saved: Record; + + beforeEach(() => { + saved = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + const original = saved[key]; + if (original === undefined) { + delete process.env[key]; + } else { + process.env[key] = original; + } + } + }); + + it("honors XDG_CONFIG_HOME on non-windows platforms", () => { + if (process.platform === "win32") { + return; + } + process.env["XDG_CONFIG_HOME"] = "/tmp/xdg-test"; + expect(configDir()).toBe("/tmp/xdg-test/metabase-cli"); + }); + + it("falls back to ~/.config/metabase-cli when XDG_CONFIG_HOME is unset on non-windows", () => { + if (process.platform === "win32") { + return; + } + delete process.env["XDG_CONFIG_HOME"]; + expect(configDir()).toBe(join(homedir(), ".config", "metabase-cli")); + }); + + it("honors APPDATA on win32", () => { + if (process.platform !== "win32") { + return; + } + process.env["APPDATA"] = "C:\\Users\\test\\AppData\\Roaming"; + expect(configDir()).toBe("C:\\Users\\test\\AppData\\Roaming\\metabase-cli"); + }); +}); diff --git a/src/core/paths.ts b/src/core/paths.ts new file mode 100644 index 0000000..1f2e763 --- /dev/null +++ b/src/core/paths.ts @@ -0,0 +1,19 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +const APP_DIR_NAME = "metabase-cli"; + +// Resolves the per-user CLI config directory in a way that's both XDG/AppData-idiomatic +// and Docker Desktop-shareable on macOS and Windows (every supported OS routes through the +// user's home, which Docker Desktop shares out of the box; `os.tmpdir()` does not on macOS). +// +// macOS / Linux: $XDG_CONFIG_HOME/metabase-cli (default ~/.config/metabase-cli) +// Windows: %APPDATA%/metabase-cli (default ~/AppData/Roaming/metabase-cli) +export function configDir(): string { + if (process.platform === "win32") { + const appData = process.env["APPDATA"] ?? join(homedir(), "AppData", "Roaming"); + return join(appData, APP_DIR_NAME); + } + const xdg = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"); + return join(xdg, APP_DIR_NAME); +} diff --git a/src/runtime/process.ts b/src/runtime/process.ts index 6246c46..b25b7a4 100644 --- a/src/runtime/process.ts +++ b/src/runtime/process.ts @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; export interface ProcessRunOptions { env?: NodeJS.ProcessEnv; cwd?: string; - stdin?: string; + stdin?: string | Uint8Array; timeoutMs?: number; } @@ -79,10 +79,10 @@ export function runProcess( resolve({ stdout, stderr, exitCode: code }); }); - if (options.stdin !== undefined) { - child.stdin.end(options.stdin); - } else { + if (options.stdin === undefined) { child.stdin.end(); + } else { + child.stdin.end(options.stdin); } }); } diff --git a/src/runtime/tar.test.ts b/src/runtime/tar.test.ts new file mode 100644 index 0000000..a51e977 --- /dev/null +++ b/src/runtime/tar.test.ts @@ -0,0 +1,51 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { runProcess } from "./process"; +import { buildTar } from "./tar"; + +async function extractWithSystemTar(archive: Uint8Array, dest: string): Promise { + const result = await runProcess("tar", ["-xf", "-", "-C", dest], { stdin: archive }); + expect(result.exitCode, result.stderr).toBe(0); +} + +describe("buildTar", () => { + it("produces a ustar archive that the system tar binary can extract", async () => { + const dir = await mkdtemp(join(tmpdir(), "tar-test-")); + try { + const archive = buildTar([ + { type: "directory", name: "mw-config", mode: 0o755 }, + { type: "file", name: "mw-config/config.yml", content: "version: 1\n", mode: 0o600 }, + { type: "file", name: "mw-config/metadata.json", content: "{}\n", mode: 0o600 }, + ]); + + await extractWithSystemTar(archive, dir); + + expect(await readFile(join(dir, "mw-config/config.yml"), "utf8")).toBe("version: 1\n"); + expect(await readFile(join(dir, "mw-config/metadata.json"), "utf8")).toBe("{}\n"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("preserves binary content byte-for-byte", async () => { + const dir = await mkdtemp(join(tmpdir(), "tar-test-")); + try { + const payload = new Uint8Array(1024); + for (let i = 0; i < payload.length; i++) { + payload[i] = i & 0xff; + } + const archive = buildTar([{ type: "file", name: "blob.bin", content: payload }]); + + await extractWithSystemTar(archive, dir); + + const extracted = await readFile(join(dir, "blob.bin")); + expect(new Uint8Array(extracted)).toEqual(payload); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/runtime/tar.ts b/src/runtime/tar.ts new file mode 100644 index 0000000..2ae3567 --- /dev/null +++ b/src/runtime/tar.ts @@ -0,0 +1,153 @@ +const BLOCK_SIZE = 512; +const REGULAR_FILE_MODE = 0o644; +const DIR_MODE = 0o755; +const NAME_FIELD_LENGTH = 100; +const TYPE_FLAG_REGULAR = "0"; +const TYPE_FLAG_DIRECTORY = "5"; + +export interface TarFileEntry { + type: "file"; + name: string; + content: string | Uint8Array; + mode?: number; + mtime?: number; +} + +export interface TarDirectoryEntry { + type: "directory"; + name: string; + mode?: number; + mtime?: number; +} + +export type TarEntry = TarFileEntry | TarDirectoryEntry; + +const textEncoder = new TextEncoder(); + +function toBytes(content: string | Uint8Array): Uint8Array { + return typeof content === "string" ? textEncoder.encode(content) : content; +} + +// ustar octal: (length - 1) digits, NUL terminator. The chksum field uses a slightly +// different encoding (6 digits + NUL + space) and is written separately. +function writeOctal(target: Uint8Array, offset: number, length: number, value: number): void { + const digits = length - 1; + const octal = Math.trunc(value).toString(8).padStart(digits, "0"); + if (octal.length > digits) { + throw new Error(`tar value ${value} exceeds octal field width ${digits}`); + } + for (let i = 0; i < digits; i++) { + target[offset + i] = octal.charCodeAt(i); + } + target[offset + length - 1] = 0; +} + +function writeString(target: Uint8Array, offset: number, length: number, value: string): void { + const bytes = textEncoder.encode(value); + if (bytes.length > length) { + throw new Error(`tar string field of length ${length} cannot hold ${bytes.length} bytes`); + } + target.set(bytes, offset); +} + +function writeHeader( + out: Uint8Array, + offset: number, + name: string, + size: number, + mode: number, + mtime: number, + typeFlag: string, +): void { + if (textEncoder.encode(name).length > NAME_FIELD_LENGTH) { + throw new Error(`tar entry name exceeds ${NAME_FIELD_LENGTH} bytes: ${name}`); + } + writeString(out, offset, NAME_FIELD_LENGTH, name); + writeOctal(out, offset + 100, 8, mode & 0o7777); + writeOctal(out, offset + 108, 8, 0); // uid + writeOctal(out, offset + 116, 8, 0); // gid + writeOctal(out, offset + 124, 12, size); + writeOctal(out, offset + 136, 12, mtime); + // Chksum is computed over the whole header with the chksum field treated as 8 spaces. + for (let i = 148; i < 156; i++) { + out[offset + i] = 0x20; + } + out[offset + 156] = typeFlag.charCodeAt(0); + // ustar magic + version "00". + writeString(out, offset + 257, 6, "ustar"); + out[offset + 263] = 0x30; + out[offset + 264] = 0x30; + let sum = 0; + for (const byte of out.subarray(offset, offset + BLOCK_SIZE)) { + sum += byte; + } + const sumOctal = sum.toString(8).padStart(6, "0"); + for (let i = 0; i < 6; i++) { + out[offset + 148 + i] = sumOctal.charCodeAt(i); + } + out[offset + 154] = 0; + out[offset + 155] = 0x20; +} + +function paddedSize(size: number): number { + const remainder = size % BLOCK_SIZE; + return remainder === 0 ? 0 : BLOCK_SIZE - remainder; +} + +interface ResolvedEntry { + name: string; + mode: number; + mtime: number; + typeFlag: string; + content: Uint8Array | null; +} + +function resolveEntry(entry: TarEntry, fallbackMtime: number): ResolvedEntry { + if (entry.type === "directory") { + const name = entry.name.endsWith("/") ? entry.name : `${entry.name}/`; + return { + name, + mode: entry.mode ?? DIR_MODE, + mtime: entry.mtime ?? fallbackMtime, + typeFlag: TYPE_FLAG_DIRECTORY, + content: null, + }; + } + return { + name: entry.name, + mode: entry.mode ?? REGULAR_FILE_MODE, + mtime: entry.mtime ?? fallbackMtime, + typeFlag: TYPE_FLAG_REGULAR, + content: toBytes(entry.content), + }; +} + +// Builds a POSIX ustar archive in memory. Single allocation, single pass: headers, +// content, and inter-block padding are written directly into the output buffer +// (Uint8Array is zero-initialized at allocation, so padding writes are no-ops). +// The trailer is the final 1024 zero bytes of the buffer for the same reason. +export function buildTar(entries: readonly TarEntry[]): Uint8Array { + const fallbackMtime = Math.floor(Date.now() / 1000); + const resolved = entries.map((entry) => resolveEntry(entry, fallbackMtime)); + + let total = BLOCK_SIZE * 2; // POSIX-required two-block zero trailer. + for (const entry of resolved) { + total += BLOCK_SIZE; + if (entry.content !== null) { + total += entry.content.length + paddedSize(entry.content.length); + } + } + + const out = new Uint8Array(total); + let offset = 0; + for (const entry of resolved) { + const size = entry.content?.length ?? 0; + writeHeader(out, offset, entry.name, size, entry.mode, entry.mtime, entry.typeFlag); + offset += BLOCK_SIZE; + if (entry.content !== null) { + out.set(entry.content, offset); + offset += entry.content.length + paddedSize(entry.content.length); + } + } + return out; +} diff --git a/src/runtime/tempdir.test.ts b/src/runtime/tempdir.test.ts deleted file mode 100644 index dacddc5..0000000 --- a/src/runtime/tempdir.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { readFile, stat } from "node:fs/promises"; -import { join } from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { mkSecureTempDir, removeTempDir, writeSecureFile } from "./tempdir"; - -describe("tempdir", () => { - it("creates a directory we can write to and then remove", async () => { - const dir = await mkSecureTempDir(); - try { - const target = join(dir, "config.yml"); - await writeSecureFile(target, "version: 1\n"); - expect(await readFile(target, "utf8")).toBe("version: 1\n"); - // Files written via writeSecureFile must not be world-readable. - const fileStat = await stat(target); - expect(fileStat.mode & 0o077).toBe(0); - } finally { - await removeTempDir(dir); - } - await expect(stat(dir)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("removeTempDir is idempotent on a missing path", async () => { - const dir = await mkSecureTempDir(); - await removeTempDir(dir); - await removeTempDir(dir); - }); -}); diff --git a/src/runtime/tempdir.ts b/src/runtime/tempdir.ts deleted file mode 100644 index f328930..0000000 --- a/src/runtime/tempdir.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createWriteStream } from "node:fs"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Writable } from "node:stream"; - -const TEMP_DIR_PREFIX = "metabase-workspace-"; -const SECURE_FILE_MODE = 0o600; - -// mkdtemp(3) creates the directory with mode 0700 by POSIX contract — no chmod needed. -export async function mkSecureTempDir(): Promise { - return await mkdtemp(join(tmpdir(), TEMP_DIR_PREFIX)); -} - -export async function writeSecureFile(path: string, content: string): Promise { - await writeFile(path, content, { mode: SECURE_FILE_MODE }); -} - -export async function streamToSecureFile( - source: ReadableStream, - path: string, -): Promise { - const writable = createWriteStream(path, { mode: SECURE_FILE_MODE }); - await source.pipeTo(Writable.toWeb(writable)); -} - -export async function removeTempDir(path: string): Promise { - await rm(path, { recursive: true, force: true }); -} diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index 5c25863..ef164c2 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -83,8 +83,6 @@ describe("__manifest e2e", () => { "sync create-branch", "workspace list", "workspace create", - "workspace config", - "workspace metadata-export", "workspace database provision", "workspace database update", "workspace database deprovision", @@ -100,12 +98,8 @@ describe("__manifest e2e", () => { ]); // Streaming commands legitimately have no outputSchema — they pipe raw bytes - // (YAML / binary / docker logs) to stdout rather than a typed JSON envelope. - const streamingCommands = new Set([ - "workspace config", - "workspace metadata-export", - "workspace logs", - ]); + // (docker logs) to stdout rather than a typed JSON envelope. + const streamingCommands = new Set(["workspace logs"]); for (const entry of manifest.commands) { expect(entry.examples.length, `missing examples for ${entry.command}`).toBeGreaterThan(0); diff --git a/tests/e2e/workspace-local.e2e.test.ts b/tests/e2e/workspace-local.e2e.test.ts index 1812506..2f015eb 100644 --- a/tests/e2e/workspace-local.e2e.test.ts +++ b/tests/e2e/workspace-local.e2e.test.ts @@ -147,6 +147,7 @@ describe.skipIf(skipReason !== null)("workspace local-runtime e2e", () => { TEST_IMAGE, "--no-pull", "--no-metadata", + "--wait", "--full", "--json", ], diff --git a/tests/e2e/workspace.e2e.test.ts b/tests/e2e/workspace.e2e.test.ts index 656977b..7f3e268 100644 --- a/tests/e2e/workspace.e2e.test.ts +++ b/tests/e2e/workspace.e2e.test.ts @@ -223,68 +223,6 @@ describe("workspace e2e", () => { expect(after.databases ?? []).toEqual([]); }); - it("config streams a non-empty YAML file for a fully-provisioned workspace", async () => { - await createWorkspace(); - await provisionDatabase(FIRST_WORKSPACE_ID, [ANALYTICS_SCHEMA]); - - const result = await runCli({ - args: ["workspace", "config", String(FIRST_WORKSPACE_ID)], - configHome: await makeIsolatedConfigHome(), - env: authEnv(), - }); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout.length).toBeGreaterThan(0); - // Workspace config carries the workspace's databases — the warehouse - // we just provisioned should appear by id. - expect(result.stdout).toContain(String(E2E_DATABASES.WAREHOUSE)); - }); - - it("metadata-export streams JSON listing the warehouse database when --with-databases is on (default)", async () => { - await createWorkspace(); - await provisionDatabase(FIRST_WORKSPACE_ID, [ANALYTICS_SCHEMA]); - - const result = await runCli({ - args: ["workspace", "metadata-export", String(FIRST_WORKSPACE_ID)], - configHome: await makeIsolatedConfigHome(), - env: authEnv(), - timeoutMs: PROVISION_TIMEOUT_MS, - }); - - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout.length).toBeGreaterThan(0); - - // The export is JSON with a databases section when --with-databases is on. - // We don't pin the full schema (it's owned by the serdes module); just - // assert it parses as JSON (throws on malformed) and mentions our - // warehouse by its portable name (the export uses string ids). - JSON.parse(result.stdout); - expect(result.stdout).toContain(`"name":"Warehouse"`); - }); - - it("metadata-export with all sections off emits an effectively-empty payload", async () => { - await createWorkspace(); - await provisionDatabase(FIRST_WORKSPACE_ID, [ANALYTICS_SCHEMA]); - - const result = await runCli({ - args: [ - "workspace", - "metadata-export", - String(FIRST_WORKSPACE_ID), - "--no-with-databases", - "--no-with-tables", - "--no-with-fields", - ], - configHome: await makeIsolatedConfigHome(), - env: authEnv(), - timeoutMs: PROVISION_TIMEOUT_MS, - }); - - expect(result.exitCode, result.stderr).toBe(0); - JSON.parse(result.stdout); - expect(result.stdout).not.toContain(`"name":"Warehouse"`); - }); - it("database update rejects --database-id smuggled in --body (backend's UpdateDatabaseParams is closed)", async () => { await createWorkspace(); await provisionDatabase(FIRST_WORKSPACE_ID, [ANALYTICS_SCHEMA]); From d7ae16e886e6f3c85a48aff6ca36d874f1fdb26b Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 6 May 2026 18:24:07 -0400 Subject: [PATCH 05/47] fix --- README.md | 15 ++- bun.lock | 3 + package.json | 1 + src/commands/workspace/credentials.ts | 74 +++++++++++++ src/commands/workspace/index.ts | 1 + src/commands/workspace/start.ts | 44 +++++--- src/commands/workspace/url.ts | 20 +--- src/core/docker.ts | 110 ++++++++++++++++++- src/core/workspace-credentials.test.ts | 143 +++++++++++++++++++++++++ src/core/workspace-credentials.ts | 86 +++++++++++++++ src/runtime/process.test.ts | 22 +++- src/runtime/process.ts | 56 ++++++++-- src/runtime/tar.test.ts | 41 ++++++- src/runtime/tar.ts | 45 ++++++++ src/runtime/yaml.test.ts | 79 ++++++++++++++ src/runtime/yaml.ts | 58 ++++++++++ tests/e2e/manifest.e2e.test.ts | 1 + tests/e2e/workspace-local.e2e.test.ts | 25 ++++- 18 files changed, 773 insertions(+), 51 deletions(-) create mode 100644 src/commands/workspace/credentials.ts create mode 100644 src/core/workspace-credentials.test.ts create mode 100644 src/core/workspace-credentials.ts create mode 100644 src/runtime/yaml.test.ts create mode 100644 src/runtime/yaml.ts diff --git a/README.md b/README.md index ad01596..d610c55 100644 --- a/README.md +++ b/README.md @@ -667,13 +667,15 @@ metabase workspace start 1 --force Resolves the parent via the active profile (or `--profile`/`--url`/`--api-key`) and the EE license via `resolveLicenseToken` (the same path `metabase license set` writes to). Refuses to start if the workspace has any database that isn't `status: "provisioned"`. -By default `start` returns as soon as the container is started (`state: "starting"`); pass `--wait` to block until `/api/health` reports ready and the response reports `state: "running"`. +The boot bundle (`config.yml`, `credentials.json`, optional `metadata.json`) is built in process memory and tar-streamed into the container's `/mw-config/` directory through `docker cp -`; no host-disk artifact is created. The CLI generates a per-workspace admin user + API key, injects them into the YAML before shipping, and stores the same values in `credentials.json` for later retrieval via `metabase workspace credentials`. Once the child logs that it has read `config.yml`, the CLI scrubs the in-container copy (`docker exec rm /mw-config/config.yml`) so the warehouse credentials in `details.password` no longer linger; `credentials.json` stays. + +By default `start` returns once the bundle has been consumed by the child (`state: "starting"`); pass `--wait` to also block until `/api/health` reports ready and the response reports `state: "running"`. | Flag | Description | | ---------------- | ---------------------------------------------------------------------------- | | `--port ` | Host port (default: 3000; auto-shifts up to 100 ports if taken). | | `--image ` | Docker image (default: `metabase/metabase-dev:feature-workspaces-v2`). | -| `--wait` | Block until `/api/health` is ready. Default: return as soon as started. | +| `--wait` | Block until `/api/health` is ready. Default: return as soon as consumed. | | `--timeout ` | Health check deadline (default: 180000). Used with `--wait`. | | `--no-pull` | Skip `docker pull` (useful if the image is already present). | | `--no-metadata` | Skip the warehouse metadata export. | @@ -726,6 +728,15 @@ metabase workspace url 1 --json Prints `http://localhost:` for the workspace's container. Reads the host port from the container's `com.metabase.workspace.host-port` label. +### `metabase workspace credentials ` + +```sh +metabase workspace credentials 1 +metabase workspace credentials 1 --json +``` + +Reads the workspace child's admin credentials (email, password, admin API key) from `/mw-config/credentials.json` inside the container. The file is written by `workspace start` from CLI-generated, per-workspace values; the same values are injected into `config.yml`'s `:users` and `:api-keys` sections so they take effect on the child's first boot. Works against running and stopped containers (uses `docker cp`); errors clearly if no container exists for the given workspace id. Removing the container destroys the file — recover by `workspace start --force`. + ### `metabase workspace ps` ```sh diff --git a/bun.lock b/bun.lock index e4d6cfe..a3d699e 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@napi-rs/keyring": "^1.3.0", "citty": "^0.2.2", "cli-table3": "^0.6.5", + "yaml": "^2.8.4", "zod": "^4.0.0", }, "devDependencies": { @@ -587,6 +588,8 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], "zod": ["zod@4.4.2", "", {}, "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw=="], diff --git a/package.json b/package.json index c41fe52..6a90b6a 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@napi-rs/keyring": "^1.3.0", "citty": "^0.2.2", "cli-table3": "^0.6.5", + "yaml": "^2.8.4", "zod": "^4.0.0" }, "devDependencies": { diff --git a/src/commands/workspace/credentials.ts b/src/commands/workspace/credentials.ts new file mode 100644 index 0000000..a83a6d8 --- /dev/null +++ b/src/commands/workspace/credentials.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; + +import { + checkDockerReady, + readContainerCredentialsFile, + requireWorkspaceContainerLocation, +} from "../../core/docker"; +import { localUrl } from "../../core/url"; +import { WorkspaceCredentials } from "../../core/workspace-credentials"; +import type { ResourceView } from "../../domain/view"; +import { renderItem } from "../../output/render"; +import { parseJson } from "../../runtime/json"; +import { outputFlags } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export const WorkspaceCredentialsResult = z.object({ + workspace_id: z.number().int().positive(), + url: z.string(), + email: z.string(), + password: z.string(), + api_key_name: z.string(), + api_key: z.string(), +}); +export type WorkspaceCredentialsResult = z.infer; + +const credentialsView: ResourceView = { + compactPick: WorkspaceCredentialsResult, + tableColumns: [ + { key: "workspace_id", label: "ID" }, + { key: "url", label: "URL" }, + { key: "email", label: "Email" }, + { key: "password", label: "Password" }, + { key: "api_key_name", label: "API Key Name" }, + { key: "api_key", label: "API Key" }, + ], +}; + +const textDecoder = new TextDecoder("utf-8"); + +export default defineMetabaseCommand({ + meta: { + name: "credentials", + description: + "Read the workspace child instance's admin credentials (email + password + API key) from the running container", + }, + args: { + ...outputFlags, + id: { type: "positional", description: "Workspace id", required: true }, + }, + outputSchema: WorkspaceCredentialsResult, + examples: ["metabase workspace credentials 1", "metabase workspace credentials 1 --json"], + async run({ args, ctx }) { + const workspaceId = parseId(args.id); + + await checkDockerReady(); + const { containerName, hostPort } = await requireWorkspaceContainerLocation(workspaceId); + + const bytes = await readContainerCredentialsFile(workspaceId); + const credentials = parseJson(textDecoder.decode(bytes), WorkspaceCredentials, { + source: `${containerName}:credentials.json`, + }); + + const result: WorkspaceCredentialsResult = { + workspace_id: workspaceId, + url: localUrl(hostPort), + email: credentials.user.email, + password: credentials.user.password, + api_key_name: credentials.api_key.name, + api_key: credentials.api_key.key, + }; + renderItem(result, credentialsView, ctx); + }, +}); diff --git a/src/commands/workspace/index.ts b/src/commands/workspace/index.ts index c2bc4bb..6b6e445 100644 --- a/src/commands/workspace/index.ts +++ b/src/commands/workspace/index.ts @@ -11,6 +11,7 @@ export default defineCommand({ remove: () => import("./remove").then((mod) => mod.default), logs: () => import("./logs").then((mod) => mod.default), url: () => import("./url").then((mod) => mod.default), + credentials: () => import("./credentials").then((mod) => mod.default), ps: () => import("./ps").then((mod) => mod.default), }, }); diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts index 6faa7f3..e7a9064 100644 --- a/src/commands/workspace/start.ts +++ b/src/commands/workspace/start.ts @@ -9,11 +9,17 @@ import { removeContainer, runWorkspaceContainer, scrubContainerConfig, + waitForConfigConsumed, } from "../../core/docker"; import { ConfigError, errorMessage } from "../../core/errors"; import type { Client } from "../../core/http/client"; import { probeHealth } from "../../core/http/probe"; import { localUrl } from "../../core/url"; +import { + buildCredentialsJson, + generateWorkspaceCredentials, + injectCredentialsIntoConfig, +} from "../../core/workspace-credentials"; import type { ResourceView } from "../../domain/view"; import { Workspace } from "../../domain/workspace"; import { warn } from "../../output/notice"; @@ -28,6 +34,7 @@ import { defineMetabaseCommand } from "../runtime"; const DEFAULT_IMAGE = "metabase/metabase-dev:feature-workspaces-v2"; const DEFAULT_HOST_PORT = 3000; const DEFAULT_HEALTH_TIMEOUT_MS = 180_000; +const DEFAULT_CONFIG_CONSUMED_TIMEOUT_MS = 60_000; const HEALTH_INTERVAL_MS = 2_000; const HEALTH_MAX_INTERVAL_MS = 10_000; const HEALTH_PROBE_TIMEOUT_MS = 4_000; @@ -80,7 +87,7 @@ export default defineMetabaseCommand({ wait: { type: "boolean", description: - "Block until /api/health is ready, then scrub the in-container config.yml. Default: return as soon as the container is started.", + "Block until /api/health is ready before returning. Default: return as soon as the container has consumed config.yml.", default: false, }, timeout: { @@ -138,14 +145,19 @@ export default defineMetabaseCommand({ const hostPort = await resolveHostPort(requestedPort); - // Boot bundle stays in process memory: no host-disk artifact for config.yml or - // metadata.json. The bytes are tar-streamed into the container by docker daemon - // and land on the container's overlay FS (root-only on the daemon host). - const [configYaml, metadataJson] = await Promise.all([ + // Boot bundle stays in process memory: no host-disk artifact for config.yml, + // credentials.json, or metadata.json. The bytes are tar-streamed into the + // container by the docker daemon and land on the overlay FS (root-only on + // the daemon host). + const [parentConfigYaml, metadataJson] = await Promise.all([ fetchConfigYaml(client, workspaceId), args.metadata ? fetchMetadataJson(client, workspaceId) : Promise.resolve(null), ]); + const credentials = generateWorkspaceCredentials(workspaceId); + const configYaml = injectCredentialsIntoConfig(parentConfigYaml, credentials); + const credentialsJson = buildCredentialsJson(credentials); + await pullPromise; await runWorkspaceContainer({ @@ -156,21 +168,25 @@ export default defineMetabaseCommand({ image: args.image, hostPort, configYaml, + credentialsJson, metadataJson, licenseToken, }); + // The child reads config.yml during init; once it logs the consumed marker, the + // warehouse credentials inside that file are mirrored into its app db and the + // file itself is no longer needed. Scrubbing it here keeps the warehouse password + // out of the container's overlay FS for the rest of the instance's lifetime. + // credentials.json stays — `workspace credentials` reads it on demand. + await waitForConfigConsumed(workspaceId, DEFAULT_CONFIG_CONSUMED_TIMEOUT_MS); + try { + await scrubContainerConfig(workspaceId); + } catch (error) { + warn(`could not scrub in-container config.yml: ${errorMessage(error)}`); + } + if (args.wait) { await waitForHealth(hostPort, healthTimeoutMs); - // After Metabase finishes its boot-time read of /mw-config/config.yml (signaled - // by /api/health going green), unlink the in-container copy too. The host - // never saw the file, so a scrub failure doesn't fail the start — but it's - // still surfaced to stderr so the operator knows the in-container copy lingered. - try { - await scrubContainerConfig(workspaceId); - } catch (error) { - warn(`could not scrub in-container config.yml: ${errorMessage(error)}`); - } } const result: StartResult = { diff --git a/src/commands/workspace/url.ts b/src/commands/workspace/url.ts index ae4ded7..392ca9c 100644 --- a/src/commands/workspace/url.ts +++ b/src/commands/workspace/url.ts @@ -1,7 +1,6 @@ import { z } from "zod"; -import { checkDockerReady, containerNameFor, inspectWorkspaceContainer } from "../../core/docker"; -import { ConfigError } from "../../core/errors"; +import { checkDockerReady, requireWorkspaceContainerLocation } from "../../core/docker"; import { localUrl } from "../../core/url"; import type { ResourceView } from "../../domain/view"; import { renderItem } from "../../output/render"; @@ -16,7 +15,7 @@ export const UrlResult = z.object({ export type UrlResult = z.infer; const urlResultView: ResourceView = { - compactPick: UrlResult.pick({ workspace_id: true, url: true }).strip(), + compactPick: UrlResult, tableColumns: [ { key: "workspace_id", label: "ID" }, { key: "url", label: "URL" }, @@ -36,24 +35,13 @@ export default defineMetabaseCommand({ examples: ["metabase workspace url 1", "metabase workspace url 1 --json"], async run({ args, ctx }) { const workspaceId = parseId(args.id); - const containerName = containerNameFor(workspaceId); await checkDockerReady(); - const summary = await inspectWorkspaceContainer(containerName); - if (summary === null) { - throw new ConfigError( - `no container for workspace ${workspaceId} — run \`metabase workspace start ${workspaceId}\` first`, - ); - } - if (summary.hostPort === null) { - throw new ConfigError( - `container ${containerName} is missing the host-port label — likely created by a different tool`, - ); - } + const { hostPort } = await requireWorkspaceContainerLocation(workspaceId); const result: UrlResult = { workspace_id: workspaceId, - url: localUrl(summary.hostPort), + url: localUrl(hostPort), }; renderItem(result, urlResultView, ctx); }, diff --git a/src/core/docker.ts b/src/core/docker.ts index e645cff..9c004b2 100644 --- a/src/core/docker.ts +++ b/src/core/docker.ts @@ -1,10 +1,16 @@ import { z } from "zod"; import { parseJson } from "../runtime/json"; -import { ProcessNotFoundError, runProcess, streamProcess } from "../runtime/process"; -import { buildTar, type TarEntry } from "../runtime/tar"; +import { pollUntil } from "../runtime/poll"; +import { + ProcessNotFoundError, + runProcess, + runProcessBinary, + streamProcess, +} from "../runtime/process"; +import { buildTar, extractSingleFileFromTar, type TarEntry } from "../runtime/tar"; -import { errorMessage } from "./errors"; +import { ConfigError, errorMessage } from "./errors"; const DOCKER_BIN = "docker"; @@ -24,6 +30,19 @@ const CONTAINER_CONFIG_DIR_BASENAME = CONTAINER_CONFIG_DIR.replace(/^\//, ""); const CONTAINER_APP_DB_DIR = "/metabase-app-db"; const CONFIG_FILENAME = "config.yml"; const METADATA_FILENAME = "metadata.json"; +const CREDENTIALS_FILENAME = "credentials.json"; + +// Log line emitted by the child once it finishes applying the workspace config block. +// At that point the warehouse credentials in the file have been mirrored into the app +// db and the file itself is safe to delete. +const CONFIG_CONSUMED_MARKER = "Loaded workspace"; +// Treat this as a fatal signal and bail out of the wait early instead of timing out. +const INIT_FAILED_MARKER = "Metabase Initialization FAILED"; + +const CONFIG_CONSUMED_LOG_LINES = 500; +const CONFIG_CONSUMED_INTERVAL_MS = 1_000; +const CONFIG_CONSUMED_MAX_INTERVAL_MS = 3_000; +const INIT_FAILED_TAIL_LINES = 25; // 0644 inside the container's namespace: files live only on the docker daemon's // overlay FS (root-only on the host). The Metabase image starts as root and drops // to a non-root user (uid 2000 by default, configurable via MUID), so the bytes @@ -102,6 +121,7 @@ export interface WorkspaceContainerSpec { image: string; hostPort: number; configYaml: string; + credentialsJson: Uint8Array; metadataJson: Uint8Array | null; licenseToken: string; } @@ -264,6 +284,61 @@ export async function scrubContainerConfig(workspaceId: number): Promise { ); } +export async function waitForConfigConsumed(workspaceId: number, timeoutMs: number): Promise { + const containerName = containerNameFor(workspaceId); + await pollUntil( + async () => { + const result = await dockerExec([ + "logs", + "--tail", + String(CONFIG_CONSUMED_LOG_LINES), + containerName, + ]); + const haystack = `${result.stdout}\n${result.stderr}`; + if (haystack.includes(INIT_FAILED_MARKER)) { + const tail = haystack.split("\n").slice(-INIT_FAILED_TAIL_LINES).join("\n"); + throw new DockerError( + `workspace ${workspaceId} container failed Metabase initialization`, + null, + tail, + ); + } + return haystack.includes(CONFIG_CONSUMED_MARKER); + }, + (consumed) => consumed, + { + intervalMs: CONFIG_CONSUMED_INTERVAL_MS, + maxIntervalMs: CONFIG_CONSUMED_MAX_INTERVAL_MS, + backoff: "exponential", + timeoutMs, + }, + ); +} + +export async function readContainerCredentialsFile(workspaceId: number): Promise { + const containerName = containerNameFor(workspaceId); + const result = await runProcessBinary(DOCKER_BIN, [ + "cp", + `${containerName}:${CONTAINER_CONFIG_DIR}/${CREDENTIALS_FILENAME}`, + "-", + ]); + if (result.exitCode !== 0) { + if (NO_SUCH_CONTAINER_PATTERN.test(result.stderr)) { + throw new DockerError( + `no container for workspace ${workspaceId}`, + result.exitCode, + result.stderr, + ); + } + throw new DockerError( + `docker cp ${CREDENTIALS_FILENAME} from ${containerName} failed`, + result.exitCode, + result.stderr, + ); + } + return extractSingleFileFromTar(result.stdout, CREDENTIALS_FILENAME); +} + function buildBootBundleTar(spec: WorkspaceContainerSpec): Uint8Array { const entries: TarEntry[] = [ { type: "directory", name: CONTAINER_CONFIG_DIR_BASENAME }, @@ -273,6 +348,12 @@ function buildBootBundleTar(spec: WorkspaceContainerSpec): Uint8Array { content: spec.configYaml, mode: BUNDLE_FILE_MODE, }, + { + type: "file", + name: `${CONTAINER_CONFIG_DIR_BASENAME}/${CREDENTIALS_FILENAME}`, + content: spec.credentialsJson, + mode: BUNDLE_FILE_MODE, + }, ]; if (spec.metadataJson !== null) { entries.push({ @@ -398,6 +479,29 @@ export async function inspectWorkspaceContainer( return summaries[0] ?? null; } +export interface WorkspaceContainerLocation { + containerName: string; + hostPort: number; +} + +export async function requireWorkspaceContainerLocation( + workspaceId: number, +): Promise { + const containerName = containerNameFor(workspaceId); + const summary = await inspectWorkspaceContainer(containerName); + if (summary === null) { + throw new ConfigError( + `no container for workspace ${workspaceId} — run \`metabase workspace start ${workspaceId}\` first`, + ); + } + if (summary.hostPort === null) { + throw new ConfigError( + `container ${containerName} is missing the host-port label — likely created by a different tool`, + ); + } + return { containerName, hostPort: summary.hostPort }; +} + export function streamLogs( containerName: string, options: LogStreamOptions, diff --git a/src/core/workspace-credentials.test.ts b/src/core/workspace-credentials.test.ts new file mode 100644 index 0000000..388cd8e --- /dev/null +++ b/src/core/workspace-credentials.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { parseYaml } from "../runtime/yaml"; + +import { ConfigError } from "./errors"; +import { + API_KEY_GROUP, + API_KEY_NAME, + buildCredentialsJson, + generateWorkspaceCredentials, + injectCredentialsIntoConfig, +} from "./workspace-credentials"; + +const OVERWRITE_REFUSAL = + "config.yml already declares users or api-keys — refusing to overwrite parent-supplied credentials"; + +function captureThrown(fn: () => unknown): unknown { + try { + fn(); + } catch (caught) { + return caught; + } + throw new Error("expected the callback to throw"); +} + +const PARENT_CONFIG_YAML = `version: 1 +config: + databases: + - name: neondb + engine: postgres + details: + host: example.com + password: hunter2 + schema-filters-patterns: public + workspace: + name: my_ws + databases: + neondb: + input_schemas: + - public + output_schema: mb_ws_2 +`; + +describe("generateWorkspaceCredentials", () => { + it("produces the full deterministic + random shape; API key matches Metabase's bounded mb_ format", () => { + const credentials = generateWorkspaceCredentials(42); + expect(credentials).toEqual({ + workspace_id: 42, + user: { + first_name: "Workspace", + last_name: "Admin", + password: expect.stringMatching(/^[A-Za-z0-9_-]+$/), + email: "workspace-42@workspace.local", + }, + api_key: { + name: API_KEY_NAME, + group: API_KEY_GROUP, + creator: "workspace-42@workspace.local", + key: expect.stringMatching(/^mb_[A-Za-z0-9+/=]{8,251}$/), + }, + }); + }); + + it("generates fresh randomness on each call", () => { + const a = generateWorkspaceCredentials(1); + const b = generateWorkspaceCredentials(1); + expect(a.user.password).not.toBe(b.user.password); + expect(a.api_key.key).not.toBe(b.api_key.key); + }); +}); + +describe("buildCredentialsJson", () => { + it("emits UTF-8 bytes that JSON.parse round-trips into the original credentials", () => { + const credentials = generateWorkspaceCredentials(3); + const bytes = buildCredentialsJson(credentials); + const decoded = new TextDecoder().decode(bytes); + expect(JSON.parse(decoded)).toEqual(credentials); + }); + + it("ends with a trailing newline", () => { + const credentials = generateWorkspaceCredentials(1); + const decoded = new TextDecoder().decode(buildCredentialsJson(credentials)); + expect(decoded.endsWith("\n")).toBe(true); + }); +}); + +describe("injectCredentialsIntoConfig", () => { + it("adds users + api-keys under config: while preserving the parent fields", () => { + const credentials = generateWorkspaceCredentials(2); + const merged = injectCredentialsIntoConfig(PARENT_CONFIG_YAML, credentials); + + const parsed = parseYaml(merged, z.unknown()); + expect(parsed).toEqual({ + version: 1, + config: { + databases: [ + { + name: "neondb", + engine: "postgres", + details: { + host: "example.com", + password: "hunter2", + "schema-filters-patterns": "public", + }, + }, + ], + workspace: { + name: "my_ws", + databases: { + neondb: { input_schemas: ["public"], output_schema: "mb_ws_2" }, + }, + }, + users: [credentials.user], + "api-keys": [credentials.api_key], + }, + }); + }); + + const PARENT_CONFIG_WITH_USERS = `${PARENT_CONFIG_YAML} users: + - email: existing@example.com +`; + + const PARENT_CONFIG_WITH_API_KEYS = `${PARENT_CONFIG_YAML} api-keys: + - name: existing + key: mb_x + group: admin + creator: someone@example.com +`; + + it.each<[string, string]>([ + ["users", PARENT_CONFIG_WITH_USERS], + ["api-keys", PARENT_CONFIG_WITH_API_KEYS], + ])("refuses to overwrite a config that already declares %s", (_label, yaml) => { + const credentials = generateWorkspaceCredentials(1); + const thrown = captureThrown(() => injectCredentialsIntoConfig(yaml, credentials)); + expect(thrown).toBeInstanceOf(ConfigError); + if (!(thrown instanceof ConfigError)) { + throw new Error("expected ConfigError"); + } + expect(thrown.message).toBe(OVERWRITE_REFUSAL); + }); +}); diff --git a/src/core/workspace-credentials.ts b/src/core/workspace-credentials.ts new file mode 100644 index 0000000..34efcba --- /dev/null +++ b/src/core/workspace-credentials.ts @@ -0,0 +1,86 @@ +import { randomBytes } from "node:crypto"; + +import { z } from "zod"; + +import { ConfigError } from "./errors"; +import { parseYaml, stringifyYaml } from "../runtime/yaml"; + +export const API_KEY_NAME = "Workspace API Key"; +export const API_KEY_GROUP = "admin"; + +const PASSWORD_BYTE_LENGTH = 18; +const API_KEY_BYTE_LENGTH = 32; + +export const WorkspaceCredentials = z.object({ + workspace_id: z.number().int().positive(), + user: z.object({ + first_name: z.string().min(1), + last_name: z.string().min(1), + password: z.string().min(1), + email: z.string().min(1), + }), + api_key: z.object({ + name: z.string().min(1), + group: z.enum(["admin", "all-users"]), + creator: z.string().min(1), + key: z.string().regex(/^mb_[A-Za-z0-9+/=]+$/), + }), +}); +export type WorkspaceCredentials = z.infer; + +export function generateWorkspaceCredentials(workspaceId: number): WorkspaceCredentials { + const email = `workspace-${workspaceId}@workspace.local`; + return { + workspace_id: workspaceId, + user: { + first_name: "Workspace", + last_name: "Admin", + password: randomBase64Url(PASSWORD_BYTE_LENGTH), + email, + }, + api_key: { + name: API_KEY_NAME, + group: API_KEY_GROUP, + creator: email, + key: `mb_${randomBytes(API_KEY_BYTE_LENGTH).toString("base64")}`, + }, + }; +} + +const credentialsJsonEncoder = new TextEncoder(); + +export function buildCredentialsJson(credentials: WorkspaceCredentials): Uint8Array { + return credentialsJsonEncoder.encode(`${JSON.stringify(credentials, null, 2)}\n`); +} + +const ConfigEnvelopeShape = z + .object({ + version: z.number().int(), + config: z.looseObject({}), + }) + .loose(); + +export function injectCredentialsIntoConfig( + yamlInput: string, + credentials: WorkspaceCredentials, +): string { + const envelope = parseYaml(yamlInput, ConfigEnvelopeShape, { source: "config.yml" }); + if ("users" in envelope.config || "api-keys" in envelope.config) { + throw new ConfigError( + "config.yml already declares users or api-keys — refusing to overwrite parent-supplied credentials", + ); + } + const merged = { + ...envelope, + config: { + ...envelope.config, + users: [credentials.user], + "api-keys": [credentials.api_key], + }, + }; + return stringifyYaml(merged); +} + +function randomBase64Url(byteLength: number): string { + return randomBytes(byteLength).toString("base64url"); +} diff --git a/src/runtime/process.test.ts b/src/runtime/process.test.ts index 940b6e0..a6b1c8b 100644 --- a/src/runtime/process.test.ts +++ b/src/runtime/process.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ProcessNotFoundError, runProcess, streamProcess } from "./process"; +import { ProcessNotFoundError, runProcess, runProcessBinary, streamProcess } from "./process"; describe("runProcess", () => { it("captures stdout and exit code 0", async () => { @@ -32,6 +32,26 @@ describe("runProcess", () => { }); }); +describe("runProcessBinary", () => { + it("captures stdout as bytes preserving non-UTF8 sequences", async () => { + const result = await runProcessBinary("node", [ + "-e", + "process.stdout.write(Buffer.from([0,1,2,255,254,128,127]))", + ]); + expect(result).toEqual({ + stdout: new Uint8Array([0, 1, 2, 255, 254, 128, 127]), + stderr: "", + exitCode: 0, + }); + }); + + it("throws ProcessNotFoundError when the binary does not exist", async () => { + await expect(runProcessBinary("metabase-no-such-binary-xyz", [])).rejects.toBeInstanceOf( + ProcessNotFoundError, + ); + }); +}); + describe("streamProcess", () => { it("returns the child's exit code", async () => { const code = await streamProcess("node", ["-e", "process.exit(7)"]); diff --git a/src/runtime/process.ts b/src/runtime/process.ts index b25b7a4..a57539f 100644 --- a/src/runtime/process.ts +++ b/src/runtime/process.ts @@ -1,5 +1,7 @@ import { spawn } from "node:child_process"; +import { isNotFoundError } from "../core/errors"; + export interface ProcessRunOptions { env?: NodeJS.ProcessEnv; cwd?: string; @@ -13,6 +15,12 @@ export interface ProcessResult { exitCode: number | null; } +export interface ProcessBinaryResult { + stdout: Uint8Array; + stderr: string; + exitCode: number | null; +} + export class ProcessNotFoundError extends Error { readonly command: string; constructor(command: string) { @@ -33,11 +41,11 @@ export class ProcessTimeoutError extends Error { } } -export function runProcess( +function spawnAndCollect( command: string, args: readonly string[], - options: ProcessRunOptions = {}, -): Promise { + options: ProcessRunOptions, +): Promise { const timeoutSignal = options.timeoutMs !== undefined && options.timeoutMs > 0 ? AbortSignal.timeout(options.timeoutMs) @@ -51,17 +59,17 @@ export function runProcess( ...(timeoutSignal !== undefined ? { signal: timeoutSignal, killSignal: "SIGKILL" } : {}), }); - let stdout = ""; + const stdoutChunks: Buffer[] = []; let stderr = ""; child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString("utf8"); + stdoutChunks.push(chunk); }); child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); }); - child.on("error", (error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") { + child.on("error", (error: unknown) => { + if (isNotFoundError(error)) { reject(new ProcessNotFoundError(command)); return; } @@ -76,7 +84,12 @@ export function runProcess( reject(new ProcessTimeoutError(command, options.timeoutMs ?? 0)); return; } - resolve({ stdout, stderr, exitCode: code }); + const stdoutBuffer = Buffer.concat(stdoutChunks); + resolve({ + stdout: new Uint8Array(stdoutBuffer.buffer, stdoutBuffer.byteOffset, stdoutBuffer.length), + stderr, + exitCode: code, + }); }); if (options.stdin === undefined) { @@ -87,11 +100,34 @@ export function runProcess( }); } +const stdoutDecoder = new TextDecoder("utf-8"); + +export async function runProcess( + command: string, + args: readonly string[], + options: ProcessRunOptions = {}, +): Promise { + const result = await spawnAndCollect(command, args, options); + return { + stdout: stdoutDecoder.decode(result.stdout), + stderr: result.stderr, + exitCode: result.exitCode, + }; +} + +export function runProcessBinary( + command: string, + args: readonly string[], + options: ProcessRunOptions = {}, +): Promise { + return spawnAndCollect(command, args, options); +} + export function streamProcess(command: string, args: readonly string[]): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: "inherit" }); - child.on("error", (error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") { + child.on("error", (error: unknown) => { + if (isNotFoundError(error)) { reject(new ProcessNotFoundError(command)); return; } diff --git a/src/runtime/tar.test.ts b/src/runtime/tar.test.ts index a51e977..4b79bbf 100644 --- a/src/runtime/tar.test.ts +++ b/src/runtime/tar.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { runProcess } from "./process"; -import { buildTar } from "./tar"; +import { buildTar, extractSingleFileFromTar, TarParseError } from "./tar"; async function extractWithSystemTar(archive: Uint8Array, dest: string): Promise { const result = await runProcess("tar", ["-xf", "-", "-C", dest], { stdin: archive }); @@ -49,3 +49,42 @@ describe("buildTar", () => { } }); }); + +describe("extractSingleFileFromTar", () => { + it("round-trips ASCII content through buildTar + extract", () => { + const archive = buildTar([{ type: "file", name: "credentials.json", content: '{"x":1}\n' }]); + const extracted = extractSingleFileFromTar(archive, "credentials.json"); + expect(new TextDecoder().decode(extracted)).toBe('{"x":1}\n'); + }); + + it("round-trips binary content byte-for-byte", () => { + const payload = new Uint8Array(600); + for (let i = 0; i < payload.length; i++) { + payload[i] = (i * 7) & 0xff; + } + const archive = buildTar([{ type: "file", name: "blob.bin", content: payload }]); + const extracted = extractSingleFileFromTar(archive, "blob.bin"); + expect(extracted).toEqual(payload); + }); + + it("matches by name suffix to tolerate docker cp's directory prefix", () => { + const archive = buildTar([{ type: "file", name: "mw-config/credentials.json", content: "ok" }]); + const extracted = extractSingleFileFromTar(archive, "credentials.json"); + expect(new TextDecoder().decode(extracted)).toBe("ok"); + }); + + it("throws when the entry name does not match", () => { + const archive = buildTar([{ type: "file", name: "other.json", content: "ok" }]); + expect(() => extractSingleFileFromTar(archive, "credentials.json")).toThrow(TarParseError); + expect(() => extractSingleFileFromTar(archive, "credentials.json")).toThrow( + 'unexpected tar entry "other.json", expected to end with "credentials.json"', + ); + }); + + it("throws when the buffer is shorter than one block", () => { + expect(() => extractSingleFileFromTar(new Uint8Array(100), "any")).toThrow(TarParseError); + expect(() => extractSingleFileFromTar(new Uint8Array(100), "any")).toThrow( + "tar is shorter than one block: 100 bytes", + ); + }); +}); diff --git a/src/runtime/tar.ts b/src/runtime/tar.ts index 2ae3567..6ed6182 100644 --- a/src/runtime/tar.ts +++ b/src/runtime/tar.ts @@ -122,6 +122,51 @@ function resolveEntry(entry: TarEntry, fallbackMtime: number): ResolvedEntry { }; } +export class TarParseError extends Error { + constructor(message: string) { + super(message); + this.name = "TarParseError"; + } +} + +const SIZE_FIELD_OFFSET = 124; +const SIZE_FIELD_LENGTH = 12; + +const textDecoder = new TextDecoder("utf-8"); + +function readNullTerminatedString(buffer: Uint8Array, offset: number, length: number): string { + const slice = buffer.subarray(offset, offset + length); + const nul = slice.indexOf(0); + const end = nul === -1 ? slice.length : nul; + return textDecoder.decode(slice.subarray(0, end)); +} + +// Extracts the first regular-file entry from a single-file ustar archive (the shape +// `docker cp : -` produces). The expectedNameSuffix check defends +// against accidental misuse — `docker cp` may include a leading directory in the name. +export function extractSingleFileFromTar(tar: Uint8Array, expectedNameSuffix: string): Uint8Array { + if (tar.length < BLOCK_SIZE) { + throw new TarParseError(`tar is shorter than one block: ${tar.length} bytes`); + } + const nameField = readNullTerminatedString(tar, 0, NAME_FIELD_LENGTH); + if (!nameField.endsWith(expectedNameSuffix)) { + throw new TarParseError( + `unexpected tar entry ${JSON.stringify(nameField)}, expected to end with ${JSON.stringify(expectedNameSuffix)}`, + ); + } + const sizeField = readNullTerminatedString(tar, SIZE_FIELD_OFFSET, SIZE_FIELD_LENGTH).trim(); + const size = Number.parseInt(sizeField, 8); + if (!Number.isFinite(size) || size < 0) { + throw new TarParseError(`tar header has invalid size field: ${JSON.stringify(sizeField)}`); + } + if (tar.length < BLOCK_SIZE + size) { + throw new TarParseError( + `tar truncated: header reports ${size} content bytes but only ${tar.length - BLOCK_SIZE} bytes follow`, + ); + } + return tar.subarray(BLOCK_SIZE, BLOCK_SIZE + size); +} + // Builds a POSIX ustar archive in memory. Single allocation, single pass: headers, // content, and inter-block padding are written directly into the output buffer // (Uint8Array is zero-initialized at allocation, so padding writes are no-ops). diff --git a/src/runtime/yaml.test.ts b/src/runtime/yaml.test.ts new file mode 100644 index 0000000..0c6dc79 --- /dev/null +++ b/src/runtime/yaml.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { ConfigError, ValidationError } from "../core/errors"; +import { parseYaml, parseYamlResult, stringifyYaml } from "./yaml"; + +const Envelope = z.object({ + version: z.number(), + config: z.object({ name: z.string() }), +}); + +describe("parseYaml", () => { + it("parses valid YAML matching the schema", () => { + const yaml = "version: 1\nconfig:\n name: ws\n"; + expect(parseYaml(yaml, Envelope)).toEqual({ version: 1, config: { name: "ws" } }); + }); + + it("throws ConfigError mentioning the source on malformed YAML", () => { + const broken = "version: 1\nconfig: [unclosed"; + let thrown: unknown; + try { + parseYaml(broken, Envelope, { source: "config.yml" }); + } catch (caught) { + thrown = caught; + } + expect(thrown).toBeInstanceOf(ConfigError); + if (!(thrown instanceof ConfigError)) { + throw new Error("expected ConfigError"); + } + expect(thrown.message.startsWith("config.yml: invalid YAML: ")).toBe(true); + }); + + it("throws ValidationError when the schema rejects the parsed value", () => { + const yaml = "version: 1\nconfig:\n name: 99\n"; + let thrown: unknown; + try { + parseYaml(yaml, Envelope, { source: "config.yml" }); + } catch (caught) { + thrown = caught; + } + expect(thrown).toBeInstanceOf(ValidationError); + if (!(thrown instanceof ValidationError)) { + throw new Error("expected ValidationError"); + } + expect(thrown.developerDetail.source).toBe("config.yml"); + }); +}); + +describe("parseYamlResult", () => { + it("returns ok with the parsed value", () => { + expect(parseYamlResult("version: 2\nconfig:\n name: x\n", Envelope)).toEqual({ + ok: true, + value: { version: 2, config: { name: "x" } }, + }); + }); + + it("returns a ConfigError on broken YAML", () => { + const result = parseYamlResult("a: [b: c", Envelope, { source: "fixture" }); + expect(result.ok).toBe(false); + if (result.ok) { + throw new Error("expected failure"); + } + expect(result.error).toBeInstanceOf(ConfigError); + expect(result.error.message.startsWith("fixture: invalid YAML: ")).toBe(true); + }); +}); + +describe("stringifyYaml", () => { + it("round-trips simple structures", () => { + const value = { version: 1, config: { name: "ws", databases: [{ id: 1 }, { id: 2 }] } }; + const yaml = stringifyYaml(value); + expect(parseYamlResult(yaml, z.unknown())).toEqual({ ok: true, value }); + }); + + it("emits flow-style-free output suitable for human reading", () => { + const yaml = stringifyYaml({ a: 1, b: [1, 2] }); + expect(yaml).toBe("a: 1\nb:\n - 1\n - 2\n"); + }); +}); diff --git a/src/runtime/yaml.ts b/src/runtime/yaml.ts new file mode 100644 index 0000000..96f71b2 --- /dev/null +++ b/src/runtime/yaml.ts @@ -0,0 +1,58 @@ +import { parse, stringify, YAMLParseError } from "yaml"; +import type { ZodType } from "zod"; + +import { ConfigError, errorMessage, ValidationError } from "../core/errors"; + +export interface ParseYamlOptions { + source?: string; +} + +export type ParseYamlResult = + | { ok: true; value: T } + | { ok: false; error: ConfigError | ValidationError }; + +export function parseYaml(input: string, schema: ZodType, opts: ParseYamlOptions = {}): T { + const result = parseYamlResult(input, schema, opts); + if (!result.ok) { + throw result.error; + } + return result.value; +} + +export function parseYamlResult( + input: string, + schema: ZodType, + opts: ParseYamlOptions = {}, +): ParseYamlResult { + const sourcePrefix = opts.source ? `${opts.source}: ` : ""; + let raw: unknown; + try { + raw = parse(input); + } catch (error) { + if (error instanceof YAMLParseError) { + return { + ok: false, + error: new ConfigError(`${sourcePrefix}invalid YAML: ${error.message}`), + }; + } + return { + ok: false, + error: new ConfigError(`${sourcePrefix}invalid YAML: ${errorMessage(error)}`), + }; + } + const parsed = schema.safeParse(raw); + if (!parsed.success) { + return { + ok: false, + error: new ValidationError(`${sourcePrefix}value did not match expected schema`, { + source: opts.source ?? "", + zodIssues: parsed.error.issues, + }), + }; + } + return { ok: true, value: parsed.data }; +} + +export function stringifyYaml(value: unknown): string { + return stringify(value, { lineWidth: 0 }); +} diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index ef164c2..14bcfc9 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -91,6 +91,7 @@ describe("__manifest e2e", () => { "workspace remove", "workspace logs", "workspace url", + "workspace credentials", "workspace ps", "setup", "api-key create", diff --git a/tests/e2e/workspace-local.e2e.test.ts b/tests/e2e/workspace-local.e2e.test.ts index 2f015eb..128e7c8 100644 --- a/tests/e2e/workspace-local.e2e.test.ts +++ b/tests/e2e/workspace-local.e2e.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { WorkspaceCredentialsResult } from "../../src/commands/workspace/credentials"; import { LocalWorkspaceListEnvelope } from "../../src/commands/workspace/ps"; import { RemoveResult } from "../../src/commands/workspace/remove"; import { StartResult } from "../../src/commands/workspace/start"; @@ -195,10 +196,26 @@ describe.skipIf(skipReason !== null)("workspace local-runtime e2e", () => { url: `http://localhost:${TEST_HOST_PORT}`, }); - // 5. The boot config dir on the host must be gone — secrets should not linger. + // 5. credentials surfaces the CLI-injected admin user + API key. + const credentialsOut = await runCli({ + args: ["workspace", "credentials", String(FIRST_WORKSPACE_ID), "--full", "--json"], + configHome: await pushConfigHome(), + env: authEnv(), + }); + expect(credentialsOut.exitCode, credentialsOut.stderr).toBe(0); + expect(parseJson(credentialsOut.stdout, WorkspaceCredentialsResult)).toEqual({ + workspace_id: FIRST_WORKSPACE_ID, + url: `http://localhost:${TEST_HOST_PORT}`, + email: `workspace-${FIRST_WORKSPACE_ID}@workspace.local`, + password: expect.stringMatching(/^[A-Za-z0-9_-]+$/), + api_key_name: "Workspace API Key", + api_key: expect.stringMatching(/^mb_[A-Za-z0-9+/=]+$/), + }); + + // 6. The boot config dir on the host must be gone — secrets should not linger. expect(await listMetabaseTempDirs()).toEqual([]); - // 6. Stop, then verify ps reflects the new state. + // 7. Stop, then verify ps reflects the new state. const stop = await runCli({ args: ["workspace", "stop", String(FIRST_WORKSPACE_ID), "--full", "--json"], configHome: await pushConfigHome(), @@ -229,7 +246,7 @@ describe.skipIf(skipReason !== null)("workspace local-runtime e2e", () => { url: null, }); - // 7. Remove tears down the container + the app-db volume. + // 8. Remove tears down the container + the app-db volume. const remove = await runCli({ args: ["workspace", "remove", String(FIRST_WORKSPACE_ID), "--yes", "--full", "--json"], configHome: await pushConfigHome(), @@ -246,7 +263,7 @@ describe.skipIf(skipReason !== null)("workspace local-runtime e2e", () => { removed_volume: true, }); - // 8. ps should no longer list the workspace. + // 9. ps should no longer list the workspace. const psAfterRemove = await runCli({ args: ["workspace", "ps", "--full", "--json"], configHome: await pushConfigHome(), From 3ef891158ae780d92e67c5e083a6fcdaf8b43668 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Wed, 6 May 2026 20:05:47 -0400 Subject: [PATCH 06/47] commands --- README.md | 25 ++++-- src/commands/workspace/start.ts | 112 ++++++++++++++++++++++++- src/core/docker.ts | 13 +++ src/core/workspace-credentials.test.ts | 80 ++++++++++++++++++ src/core/workspace-credentials.ts | 47 +++++++++++ 5 files changed, 264 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d610c55..5f90a37 100644 --- a/README.md +++ b/README.md @@ -663,6 +663,8 @@ metabase workspace start 1 --wait metabase workspace start 1 --port 3100 metabase workspace start 1 --image metabase/metabase-dev:feature-workspaces-v2 --no-pull metabase workspace start 1 --force +metabase workspace start 1 --repo /path/to/sync-repo --wait +metabase workspace start 1 --repo /path/to/sync-repo --repo-branch dev --repo-mode read-only ``` Resolves the parent via the active profile (or `--profile`/`--url`/`--api-key`) and the EE license via `resolveLicenseToken` (the same path `metabase license set` writes to). Refuses to start if the workspace has any database that isn't `status: "provisioned"`. @@ -671,15 +673,20 @@ The boot bundle (`config.yml`, `credentials.json`, optional `metadata.json`) is By default `start` returns once the bundle has been consumed by the child (`state: "starting"`); pass `--wait` to also block until `/api/health` reports ready and the response reports `state: "running"`. -| Flag | Description | -| ---------------- | ---------------------------------------------------------------------------- | -| `--port ` | Host port (default: 3000; auto-shifts up to 100 ports if taken). | -| `--image ` | Docker image (default: `metabase/metabase-dev:feature-workspaces-v2`). | -| `--wait` | Block until `/api/health` is ready. Default: return as soon as consumed. | -| `--timeout ` | Health check deadline (default: 180000). Used with `--wait`. | -| `--no-pull` | Skip `docker pull` (useful if the image is already present). | -| `--no-metadata` | Skip the warehouse metadata export. | -| `--force` | If a container for this workspace already exists, remove it before starting. | +When `--repo ` is passed, the CLI bind-mounts the host directory at `/mnt/repo` inside the container and injects three settings into the workspace's `config.yml` so the child boots already wired to the repo: `remote-sync-url=file:///mnt/repo`, `remote-sync-branch=` (defaults to the current branch of the host repo, read via `git -C symbolic-ref --short HEAD`; override with `--repo-branch`), and `remote-sync-type=` (defaults to `read-write`; override with `--repo-mode read-only`, which also makes the bind mount read-only). The bind mount is set at container-create time only — to add or change it after the fact, run `start --force` again with the new flags. The host path must be an existing directory; the CLI does not create or `git init` it for you. + +| Flag | Description | +| ---------------------- | --------------------------------------------------------------------------------------------------------- | +| `--port ` | Host port (default: 3000; auto-shifts up to 100 ports if taken). | +| `--image ` | Docker image (default: `metabase/metabase-dev:feature-workspaces-v2`). | +| `--wait` | Block until `/api/health` is ready. Default: return as soon as consumed. | +| `--timeout ` | Health check deadline (default: 180000). Used with `--wait`. | +| `--no-pull` | Skip `docker pull` (useful if the image is already present). | +| `--no-metadata` | Skip the warehouse metadata export. | +| `--force` | If a container for this workspace already exists, remove it before starting. | +| `--repo ` | Bind-mount a host directory at `/mnt/repo` and set `remote-sync-url=file:///mnt/repo` in `config.yml`. | +| `--repo-branch ` | `remote-sync-branch` value (default: current branch of the host repo). | +| `--repo-mode ` | `remote-sync-type`: `read-write` (default) or `read-only`. Read-only also makes the bind mount read-only. | ### `metabase workspace stop ` diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts index e7a9064..13f4723 100644 --- a/src/commands/workspace/start.ts +++ b/src/commands/workspace/start.ts @@ -1,7 +1,12 @@ +import { stat } from "node:fs/promises"; +import { resolve as resolvePath } from "node:path"; + import { z } from "zod"; import { resolveLicenseToken } from "../../core/config"; import { + type BindMount, + CONTAINER_REPO_DIR, checkDockerReady, containerLifecycleStatus, containerNameFor, @@ -16,9 +21,13 @@ import type { Client } from "../../core/http/client"; import { probeHealth } from "../../core/http/probe"; import { localUrl } from "../../core/url"; import { + REPO_SYNC_MODES, + type RepoSettings, + RepoSyncMode, buildCredentialsJson, generateWorkspaceCredentials, injectCredentialsIntoConfig, + injectRepoSettingsIntoConfig, } from "../../core/workspace-credentials"; import type { ResourceView } from "../../domain/view"; import { Workspace } from "../../domain/workspace"; @@ -26,6 +35,7 @@ import { warn } from "../../output/notice"; import { renderItem } from "../../output/render"; import { findFreePort, isPortFree } from "../../runtime/port"; import { pollUntil } from "../../runtime/poll"; +import { runProcess } from "../../runtime/process"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { parseInteger, parseOptionalInteger } from "../parse-integer"; @@ -38,6 +48,8 @@ const DEFAULT_CONFIG_CONSUMED_TIMEOUT_MS = 60_000; const HEALTH_INTERVAL_MS = 2_000; const HEALTH_MAX_INTERVAL_MS = 10_000; const HEALTH_PROBE_TIMEOUT_MS = 4_000; +const DEFAULT_REPO_MODE: RepoSyncMode = "read-write"; +const REPO_FILE_URL = `file://${CONTAINER_REPO_DIR}`; export const StartResult = z.object({ workspace_id: z.number().int().positive(), @@ -110,6 +122,20 @@ export default defineMetabaseCommand({ description: "If a container for this workspace already exists, remove it first", default: false, }, + repo: { + type: "string", + description: `Bind-mount a host directory (typically a remote-sync git repo) into the container at ${CONTAINER_REPO_DIR}. Sets remote-sync-url=${REPO_FILE_URL} in the workspace config.yml so the child boots already wired to the repo.`, + }, + "repo-branch": { + type: "string", + description: + "Branch to set as remote-sync-branch (default: the current branch of the host repo, read from HEAD)", + }, + "repo-mode": { + type: "string", + description: "remote-sync-type: 'read-write' (default) or 'read-only'", + default: DEFAULT_REPO_MODE, + }, }, outputSchema: StartResult, examples: [ @@ -118,6 +144,8 @@ export default defineMetabaseCommand({ "metabase workspace start 1 --port 3100", "metabase workspace start 1 --image metabase/metabase-dev:feature-workspaces-v2 --no-pull", "metabase workspace start 1 --force", + "metabase workspace start 1 --repo /path/to/sync-repo --wait", + "metabase workspace start 1 --repo /path/to/sync-repo --repo-branch dev --repo-mode read-only", ], async run({ args, ctx, getClient, getResolvedConfig }) { const workspaceId = parseId(args.id); @@ -127,7 +155,6 @@ export default defineMetabaseCommand({ name: "--timeout", min: 1000, }); - const client = await getClient(); const resolved = await getResolvedConfig(); const licenseToken = await resolveLicenseToken({}); @@ -148,14 +175,23 @@ export default defineMetabaseCommand({ // Boot bundle stays in process memory: no host-disk artifact for config.yml, // credentials.json, or metadata.json. The bytes are tar-streamed into the // container by the docker daemon and land on the overlay FS (root-only on - // the daemon host). - const [parentConfigYaml, metadataJson] = await Promise.all([ + // the daemon host). Repo resolution overlaps with the parent fetches. + const [parentConfigYaml, metadataJson, repoOptions] = await Promise.all([ fetchConfigYaml(client, workspaceId), args.metadata ? fetchMetadataJson(client, workspaceId) : Promise.resolve(null), + resolveRepoOptions({ + hostPath: args.repo, + branch: args["repo-branch"], + mode: args["repo-mode"], + }), ]); const credentials = generateWorkspaceCredentials(workspaceId); - const configYaml = injectCredentialsIntoConfig(parentConfigYaml, credentials); + const configWithCredentials = injectCredentialsIntoConfig(parentConfigYaml, credentials); + const configYaml = + repoOptions !== null + ? injectRepoSettingsIntoConfig(configWithCredentials, repoOptions.repo) + : configWithCredentials; const credentialsJson = buildCredentialsJson(credentials); await pullPromise; @@ -171,6 +207,7 @@ export default defineMetabaseCommand({ credentialsJson, metadataJson, licenseToken, + bindMounts: repoOptions === null ? [] : [repoOptions.bindMount], }); // The child reads config.yml during init; once it logs the consumed marker, the @@ -274,3 +311,70 @@ async function waitForHealth(hostPort: number, timeoutMs: number): Promise }, ); } + +interface ResolvedRepoOptions { + bindMount: BindMount; + repo: RepoSettings; +} + +interface RepoOptionsInput { + hostPath: string | undefined; + branch: string | undefined; + mode: string | undefined; +} + +async function resolveRepoOptions(input: RepoOptionsInput): Promise { + if (input.hostPath === undefined || input.hostPath === "") { + const explicitBranch = input.branch !== undefined; + const explicitNonDefaultMode = input.mode !== undefined && input.mode !== DEFAULT_REPO_MODE; + if (explicitBranch || explicitNonDefaultMode) { + throw new ConfigError( + "--repo-branch and --repo-mode require --repo to point at a host repo path", + ); + } + return null; + } + const hostPath = resolvePath(input.hostPath); + const stats = await stat(hostPath).catch(() => null); + if (stats === null || !stats.isDirectory()) { + throw new ConfigError(`--repo path does not exist or is not a directory: ${hostPath}`); + } + const mode = parseRepoMode(input.mode); + const branch = input.branch ?? (await detectBranch(hostPath)); + return { + bindMount: { hostPath, containerPath: CONTAINER_REPO_DIR, readOnly: mode === "read-only" }, + repo: { url: REPO_FILE_URL, branch, mode }, + }; +} + +function parseRepoMode(raw: string | undefined): RepoSyncMode { + const result = RepoSyncMode.safeParse(raw ?? DEFAULT_REPO_MODE); + if (!result.success) { + throw new ConfigError( + `invalid --repo-mode: "${raw}" (expected one of: ${REPO_SYNC_MODES.join(", ")})`, + ); + } + return result.data; +} + +async function detectBranch(hostPath: string): Promise { + const result = await runProcess("git", ["-C", hostPath, "symbolic-ref", "--short", "HEAD"]).catch( + (error: unknown) => { + throw new ConfigError( + `--repo-branch not provided and could not detect a branch at ${hostPath}: ${errorMessage(error)}`, + ); + }, + ); + if (result.exitCode !== 0) { + throw new ConfigError( + `--repo-branch not provided and \`git symbolic-ref\` at ${hostPath} failed: ${result.stderr.trim() || "no output"}`, + ); + } + const branch = result.stdout.trim(); + if (branch === "") { + throw new ConfigError( + `--repo-branch not provided and HEAD at ${hostPath} resolved to an empty branch name`, + ); + } + return branch; +} diff --git a/src/core/docker.ts b/src/core/docker.ts index 9c004b2..a65c198 100644 --- a/src/core/docker.ts +++ b/src/core/docker.ts @@ -28,6 +28,7 @@ export const WORKSPACE_CONTAINER_PORT = 3000; const CONTAINER_CONFIG_DIR = "/mw-config"; const CONTAINER_CONFIG_DIR_BASENAME = CONTAINER_CONFIG_DIR.replace(/^\//, ""); const CONTAINER_APP_DB_DIR = "/metabase-app-db"; +export const CONTAINER_REPO_DIR = "/mnt/repo"; const CONFIG_FILENAME = "config.yml"; const METADATA_FILENAME = "metadata.json"; const CREDENTIALS_FILENAME = "credentials.json"; @@ -99,6 +100,12 @@ export interface NamedVolumeMount { container: string; } +export interface BindMount { + hostPath: string; + containerPath: string; + readOnly?: boolean; +} + export interface PortMapping { hostPort: number; containerPort: number; @@ -109,6 +116,7 @@ export interface CreateContainerOptions { image: string; port: PortMapping; namedVolumes: readonly NamedVolumeMount[]; + bindMounts: readonly BindMount[]; envVars: Record; labels: Record; } @@ -124,6 +132,7 @@ export interface WorkspaceContainerSpec { credentialsJson: Uint8Array; metadataJson: Uint8Array | null; licenseToken: string; + bindMounts: readonly BindMount[]; } export interface LogStreamOptions { @@ -263,6 +272,7 @@ export async function runWorkspaceContainer(spec: WorkspaceContainerSpec): Promi image: spec.image, port: { hostPort: spec.hostPort, containerPort: WORKSPACE_CONTAINER_PORT }, namedVolumes: [{ volume: volumeNameFor(spec.workspaceId), container: CONTAINER_APP_DB_DIR }], + bindMounts: spec.bindMounts, envVars: workspaceContainerEnv(spec), labels: workspaceContainerLabels(spec), }); @@ -404,6 +414,9 @@ async function createContainer(options: CreateContainerOptions): Promise { for (const mount of options.namedVolumes) { args.push("-v", `${mount.volume}:${mount.container}`); } + for (const bind of options.bindMounts) { + args.push("-v", `${bind.hostPath}:${bind.containerPath}:${bind.readOnly ? "ro" : "rw"}`); + } for (const key of Object.keys(options.envVars)) { args.push("-e", key); } diff --git a/src/core/workspace-credentials.test.ts b/src/core/workspace-credentials.test.ts index 388cd8e..326350b 100644 --- a/src/core/workspace-credentials.test.ts +++ b/src/core/workspace-credentials.test.ts @@ -10,6 +10,7 @@ import { buildCredentialsJson, generateWorkspaceCredentials, injectCredentialsIntoConfig, + injectRepoSettingsIntoConfig, } from "./workspace-credentials"; const OVERWRITE_REFUSAL = @@ -141,3 +142,82 @@ describe("injectCredentialsIntoConfig", () => { expect(thrown.message).toBe(OVERWRITE_REFUSAL); }); }); + +describe("injectRepoSettingsIntoConfig", () => { + const REPO = { + url: "file:///mnt/repo", + branch: "main", + mode: "read-write", + } as const; + + it("adds the three remote-sync keys under config.settings while preserving the parent fields", () => { + const merged = injectRepoSettingsIntoConfig(PARENT_CONFIG_YAML, REPO); + const parsed = parseYaml(merged, z.unknown()); + expect(parsed).toEqual({ + version: 1, + config: { + databases: [ + { + name: "neondb", + engine: "postgres", + details: { + host: "example.com", + password: "hunter2", + "schema-filters-patterns": "public", + }, + }, + ], + workspace: { + name: "my_ws", + databases: { + neondb: { input_schemas: ["public"], output_schema: "mb_ws_2" }, + }, + }, + settings: { + "remote-sync-url": "file:///mnt/repo", + "remote-sync-branch": "main", + "remote-sync-type": "read-write", + }, + }, + }); + }); + + it("merges into an existing settings block, leaving non-remote-sync keys alone", () => { + const yamlWithOtherSettings = `${PARENT_CONFIG_YAML} settings: + site-name: My Workspace + admin-email: ops@example.com +`; + const merged = injectRepoSettingsIntoConfig(yamlWithOtherSettings, REPO); + const parsed = parseYaml( + merged, + z.object({ + config: z.object({ + settings: z.record(z.string(), z.string()), + }), + }), + ); + expect(parsed.config.settings).toEqual({ + "site-name": "My Workspace", + "admin-email": "ops@example.com", + "remote-sync-url": "file:///mnt/repo", + "remote-sync-branch": "main", + "remote-sync-type": "read-write", + }); + }); + + it.each<[string, string]>([ + ["remote-sync-url", "remote-sync-url"], + ["remote-sync-branch", "remote-sync-branch"], + ["remote-sync-type", "remote-sync-type"], + ])("refuses to overwrite an existing %s", (_label, key) => { + const yamlWithRemoteSync = `${PARENT_CONFIG_YAML} settings: + ${key}: existing-value +`; + const thrown = captureThrown(() => injectRepoSettingsIntoConfig(yamlWithRemoteSync, REPO)); + expect(thrown).toBeInstanceOf(ConfigError); + if (!(thrown instanceof ConfigError)) { + throw new Error("expected ConfigError"); + } + expect(thrown.message).toContain(`already declares remote-sync settings (${key})`); + }); +}); diff --git a/src/core/workspace-credentials.ts b/src/core/workspace-credentials.ts index 34efcba..cd91b46 100644 --- a/src/core/workspace-credentials.ts +++ b/src/core/workspace-credentials.ts @@ -81,6 +81,53 @@ export function injectCredentialsIntoConfig( return stringifyYaml(merged); } +export const REPO_SYNC_MODES = ["read-write", "read-only"] as const; +export const RepoSyncMode = z.enum(REPO_SYNC_MODES); +export type RepoSyncMode = z.infer; + +export interface RepoSettings { + url: string; + branch: string; + mode: RepoSyncMode; +} + +const ConfigEnvelopeWithSettingsShape = z + .object({ + version: z.number().int(), + config: z + .object({ + settings: z.looseObject({}).optional(), + }) + .loose(), + }) + .loose(); + +const REMOTE_SYNC_KEYS = ["remote-sync-url", "remote-sync-branch", "remote-sync-type"] as const; + +export function injectRepoSettingsIntoConfig(yamlInput: string, repo: RepoSettings): string { + const envelope = parseYaml(yamlInput, ConfigEnvelopeWithSettingsShape, { source: "config.yml" }); + const existingSettings = envelope.config.settings ?? {}; + const conflicts = REMOTE_SYNC_KEYS.filter((key) => key in existingSettings); + if (conflicts.length > 0) { + throw new ConfigError( + `config.yml already declares remote-sync settings (${conflicts.join(", ")}) — refusing to overwrite parent-supplied values`, + ); + } + const merged = { + ...envelope, + config: { + ...envelope.config, + settings: { + ...existingSettings, + "remote-sync-url": repo.url, + "remote-sync-branch": repo.branch, + "remote-sync-type": repo.mode, + }, + }, + }; + return stringifyYaml(merged); +} + function randomBase64Url(byteLength: number): string { return randomBytes(byteLength).toString("base64url"); } From 5d0de2e6b17247c9b66d08d993598a7d043770f9 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Thu, 7 May 2026 01:41:10 -0400 Subject: [PATCH 07/47] strict querying --- README.md | 44 + bun.lock | 22 + package.json | 5 + scripts/sync-representations.ts | 95 + src/commands/card/create.ts | 8 +- src/commands/manifest.ts | 4 +- src/commands/query.ts | 94 + src/commands/transform/create.ts | 10 +- src/commands/transform/update.ts | 10 +- src/commands/validate-query.test.ts | 82 + src/commands/validate-query.ts | 19 + src/core/schema/data/LICENSE.txt | 1 + src/core/schema/data/schemas/common/id.json | 67 + .../schema/data/schemas/common/parameter.json | 218 +++ .../schema/data/schemas/common/query.json | 1606 +++++++++++++++++ src/core/schema/data/schemas/common/ref.json | 229 +++ .../schemas/common/temporal_bucketing.json | 45 + src/core/schema/validate.test.ts | 158 ++ src/core/schema/validate.ts | 134 ++ src/main.ts | 1 + src/output/manifest.test.ts | 42 - src/output/manifest.ts | 5 - src/output/render.test.ts | 16 +- src/output/render.ts | 8 + tests/e2e/manifest.e2e.test.ts | 1 + tests/e2e/query.e2e.test.ts | 185 ++ 26 files changed, 3056 insertions(+), 53 deletions(-) create mode 100644 scripts/sync-representations.ts create mode 100644 src/commands/query.ts create mode 100644 src/commands/validate-query.test.ts create mode 100644 src/commands/validate-query.ts create mode 100644 src/core/schema/data/LICENSE.txt create mode 100644 src/core/schema/data/schemas/common/id.json create mode 100644 src/core/schema/data/schemas/common/parameter.json create mode 100644 src/core/schema/data/schemas/common/query.json create mode 100644 src/core/schema/data/schemas/common/ref.json create mode 100644 src/core/schema/data/schemas/common/temporal_bucketing.json create mode 100644 src/core/schema/validate.test.ts create mode 100644 src/core/schema/validate.ts delete mode 100644 src/output/manifest.test.ts delete mode 100644 src/output/manifest.ts create mode 100644 tests/e2e/query.e2e.test.ts diff --git a/README.md b/README.md index 5f90a37..ca29a1c 100644 --- a/README.md +++ b/README.md @@ -810,6 +810,50 @@ metabase eid translate --body '{"entity_ids":{"card":["abc123XYZ"]}}' | `--body ` | Inline JSON body. | | `--file ` | Path to JSON body file. | +## Query + +### `metabase query` + +Run an MBQL 5 query with built-in schema validation. Three modes — discover the schema (`--print-schema`), validate without sending (`--dry-run`), run. + +Two MBQL flavors: + +- **Internal MBQL** (default) — numeric IDs (`database: 1`, `source-table: 7`). POSTs to `/api/dataset`. This is what every existing Metabase API endpoint accepts. +- **External MBQL** (`--external`) — string-id FKs (`database: "My DB"`, `source-table: ["My DB", null, "orders"]`). POSTs to `/api/dataset/external` (forward-looking representations endpoint). + +External and internal MBQL are structurally identical; only the ID types differ. The bundled query schema is synced from `@metabase/representations`; the internal validator overrides `id.yaml` to require positive integers for every ID `$def`. + +```sh +metabase query --print-schema # internal JSON Schema bundle +metabase query --print-schema --external # string-FK variant +cat q.json | metabase query --dry-run # validate, no network +metabase query --file q.json +metabase query --file q.json --external +``` + +Body sources: `--file`, `--body`, or stdin (exactly one). Body is JSON. + +Exit codes: + +- `0` — valid (and the query ran successfully when not in dry-run). +- `2` — validation failed, malformed body, or `ConfigError`. +- `1` — server-side error after a valid pre-flight (network, HTTP 4xx/5xx). + +Output by mode: + +- `--print-schema` — `{ mode, schema, defs: { "id.yaml", "parameter.yaml", "ref.yaml", "temporal_bucketing.yaml" } }`. The query schema's `$ref`s point into the `defs` namespace by file path; an agent can either feed the bundle directly into Ajv (`addSchema(defs["id.yaml"], "id.yaml")` etc., then `compile(schema)`) or read it as documentation. +- `--dry-run` — `{ ok: boolean, errors: { path: string, message: string }[] }`. `path` is a JSON Pointer into the body, `message` is the Ajv error string. +- Run failure (no `--dry-run`) — same `{ ok, errors }` envelope on stdout, exit 2, no request made. +- Run success — the streamed `CardQueryResult`. + +### MBQL 5 pre-flight in `card create` and `transform create`/`update` + +When the embedded query (`card.dataset_query`, or `transform.source.query` for `source.type: "query"`) is MBQL 5 (`lib/type: "mbql/query"`), it is pre-flight-validated against the same schema as `metabase query`. Validation failure: `{ ok, errors }` envelope on stdout, exit 2, request not made. MBQL 4 (legacy) bodies and Python transform sources skip validation — they're still accepted by the server and we don't ship a schema for them. + +Agent discovery path: `metabase __manifest` lists every command's args and description; the description for `card create` and `transform create`/`update` references `metabase query --print-schema` so an agent can fetch the validating schema directly. + +The bundled query schema is synced from a pinned `@metabase/representations` release via `bun run sync:representations`; CI guards against drift. + ## Environment variables | Variable | Effect | diff --git a/bun.lock b/bun.lock index a3d699e..06fd510 100644 --- a/bun.lock +++ b/bun.lock @@ -7,15 +7,19 @@ "dependencies": { "@clack/prompts": "^0.8.2", "@napi-rs/keyring": "^1.3.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "citty": "^0.2.2", "cli-table3": "^0.6.5", "yaml": "^2.8.4", "zod": "^4.0.0", }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.0", "execa": "^9.5.2", "fast-check": "^4.7.0", + "js-yaml": "^4.1.0", "oxfmt": "^0.47.0", "oxlint": "^1.62.0", "publint": "^0.3.0", @@ -342,6 +346,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], "@valibot/to-json-schema": ["@valibot/to-json-schema@1.0.0", "", { "peerDependencies": { "valibot": "^1.0.0" } }, "sha512-/9crJgPptVsGCL6X+JPDQyaJwkalSZ/52WuF8DiRUxJgcmpNdzYRfZ+gqMEP8W3CTVfuMWPqqvIgfwJ97f9Etw=="], @@ -362,10 +368,16 @@ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansis": ["ansis@3.17.0", "", {}, "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-kit": ["ast-kit@1.4.3", "", { "dependencies": { "@babel/parser": "^7.27.0", "pathe": "^2.0.3" } }, "sha512-MdJqjpodkS5J149zN0Po+HPshkTdUyrvF7CKTafUgv69vBSPtncrj+3IiUgqdd7ElIEkbeXCsEouBUwLrw9Ilg=="], @@ -414,6 +426,10 @@ "fast-check": ["fast-check@4.7.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], @@ -440,8 +456,12 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -512,6 +532,8 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "rolldown": ["rolldown@1.0.0-beta.8-commit.151352b", "", { "dependencies": { "@oxc-project/types": "0.66.0", "@valibot/to-json-schema": "1.0.0", "ansis": "^3.17.0", "valibot": "1.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-darwin-x64": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-freebsd-x64": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.8-commit.151352b", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.8-commit.151352b" }, "peerDependencies": { "@oxc-project/runtime": "0.66.0" }, "optionalPeers": ["@oxc-project/runtime"], "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-TCb6GVaFBk4wB0LERofFDxTO5X1/Sgahr7Yn5UA9XjuFtCwL1CyEhUHX5lUIstcMxjbkLjn2z4TAGwisr6Blvw=="], diff --git a/package.json b/package.json index 6a90b6a..7a6134b 100644 --- a/package.json +++ b/package.json @@ -35,20 +35,25 @@ "lint:fix": "oxlint --fix", "format": "oxfmt", "format:check": "oxfmt --check", + "sync:representations": "bun run scripts/sync-representations.ts", "prepublishOnly": "tsdown && publint" }, "dependencies": { "@clack/prompts": "^0.8.2", "@napi-rs/keyring": "^1.3.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "citty": "^0.2.2", "cli-table3": "^0.6.5", "yaml": "^2.8.4", "zod": "^4.0.0" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.0", "execa": "^9.5.2", "fast-check": "^4.7.0", + "js-yaml": "^4.1.0", "oxfmt": "^0.47.0", "oxlint": "^1.62.0", "publint": "^0.3.0", diff --git a/scripts/sync-representations.ts b/scripts/sync-representations.ts new file mode 100644 index 0000000..18b792c --- /dev/null +++ b/scripts/sync-representations.ts @@ -0,0 +1,95 @@ +// Idempotent — CI runs this and asserts no diff. Requires `npm` and `tar` on PATH. +import { execFileSync } from "node:child_process"; +import { promises as fs } from "node:fs"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import yaml from "js-yaml"; +import { z } from "zod"; + +import { isNotFoundError } from "../src/core/errors"; + +const YamlObject = z.record(z.string(), z.unknown()); + +const REPRESENTATIONS_VERSION = "1.1.7"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(SCRIPT_DIR, ".."); +const DATA_DIR = resolve(REPO_ROOT, "src/core/schema/data"); +const COMMON_DIR = resolve(DATA_DIR, "schemas/common"); + +async function main(): Promise { + const tarball = await npmPack(REPRESENTATIONS_VERSION); + const extracted = await extractTarball(tarball); + try { + await syncCommonSchemas(extracted); + await copyLicense(extracted); + } finally { + await cleanupDir(extracted); + } + // eslint-disable-next-line no-console -- script + console.log(`Synced @metabase/representations@${REPRESENTATIONS_VERSION}`); +} + +async function syncCommonSchemas(packageRoot: string): Promise { + const sourceDir = resolve(packageRoot, "core-spec/v1/schemas/common"); + await fs.rm(resolve(DATA_DIR, "schemas"), { recursive: true, force: true }); + await fs.mkdir(COMMON_DIR, { recursive: true }); + + const files = await fs.readdir(sourceDir); + await Promise.all(files.filter((f) => f.endsWith(".yaml")).map((f) => convertOne(sourceDir, f))); +} + +async function convertOne(sourceDir: string, filename: string): Promise { + const text = await fs.readFile(join(sourceDir, filename), "utf8"); + const parsed = YamlObject.parse(yaml.load(text)); + const { $schema: _ignored, ...body } = parsed; + const targetName = filename.replace(/\.yaml$/u, ".json"); + await fs.writeFile(join(COMMON_DIR, targetName), JSON.stringify(body, null, 2) + "\n", "utf8"); +} + +async function copyLicense(packageRoot: string): Promise { + const sourceLicense = join(packageRoot, "LICENSE.txt"); + let text: string; + try { + text = await fs.readFile(sourceLicense, "utf8"); + } catch (error) { + if (isNotFoundError(error)) { + return; + } + throw error; + } + await fs.writeFile(join(DATA_DIR, "LICENSE.txt"), text, "utf8"); +} + +async function cleanupDir(dir: string): Promise { + try { + await fs.rm(dir, { recursive: true, force: true }); + } catch (error) { + // eslint-disable-next-line no-console -- script + console.warn(`failed to clean up ${dir}: ${error instanceof Error ? error.message : error}`); + } +} + +async function npmPack(version: string): Promise { + const dir = mkdtempSync(join(tmpdir(), "representations-pack-")); + const stdout = execFileSync("npm", ["pack", `@metabase/representations@${version}`, "--silent"], { + cwd: dir, + encoding: "utf8", + }); + const filename = stdout.trim().split("\n").pop(); + if (filename === undefined || filename === "") { + throw new Error(`npm pack produced no output for @metabase/representations@${version}`); + } + return join(dir, filename); +} + +async function extractTarball(tarballPath: string): Promise { + const dir = mkdtempSync(join(tmpdir(), "representations-extract-")); + execFileSync("tar", ["-xzf", tarballPath, "-C", dir]); + return join(dir, "package"); +} + +await main(); diff --git a/src/commands/card/create.ts b/src/commands/card/create.ts index 762d1eb..ecb67de 100644 --- a/src/commands/card/create.ts +++ b/src/commands/card/create.ts @@ -4,9 +4,14 @@ import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { preflightInternalMbql5Query } from "../validate-query"; export default defineMetabaseCommand({ - meta: { name: "create", description: "Create a card from a JSON spec" }, + meta: { + name: "create", + description: + "Create a card from a JSON spec; if dataset_query is MBQL 5 (lib/type: mbql/query) it is pre-flight-validated against the same schema as `metabase query` (see `metabase query --print-schema`)", + }, args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, outputSchema: Card, examples: [ @@ -16,6 +21,7 @@ export default defineMetabaseCommand({ ], async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, CardCreateInput); + preflightInternalMbql5Query(body.dataset_query, "card.dataset_query validation failed"); const client = await getClient(); const created = await client.requestParsed(Card, "/api/card", { method: "POST", diff --git a/src/commands/manifest.ts b/src/commands/manifest.ts index 144e716..f08d42c 100644 --- a/src/commands/manifest.ts +++ b/src/commands/manifest.ts @@ -1,7 +1,7 @@ import { defineCommand } from "citty"; import type { CommandDef } from "citty"; -import { writeManifest } from "../output/manifest"; +import { writeJson } from "../output/render"; import { buildManifest } from "../runtime/manifest"; export function createManifestCommand(root: CommandDef): CommandDef { @@ -14,7 +14,7 @@ export function createManifestCommand(root: CommandDef): CommandDef { args: {}, async run() { const manifest = await buildManifest(root); - writeManifest(manifest); + writeJson(manifest); }, }); } diff --git a/src/commands/query.ts b/src/commands/query.ts new file mode 100644 index 0000000..7ea877a --- /dev/null +++ b/src/commands/query.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; + +import { ConfigError } from "../core/errors"; +import { + getQuerySchemaBundle, + validateExternalQuery, + validateInternalQuery, +} from "../core/schema/validate"; +import { CardQueryResult, cardQueryView } from "../domain/card"; +import { renderItem, writeJson } from "../output/render"; +import { readBody } from "../runtime/body"; + +import { bodyInputFlags } from "./body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "./flags"; +import { defineMetabaseCommand } from "./runtime"; + +const QueryBody = z.unknown(); + +const INTERNAL = { + mode: "internal", + validate: validateInternalQuery, + endpoint: "/api/dataset", +} as const; +const EXTERNAL = { + mode: "external", + validate: validateExternalQuery, + endpoint: "/api/dataset/external", +} as const; + +export default defineMetabaseCommand({ + meta: { + name: "query", + description: + "Run an MBQL 5 query (validates against the bundled schema first); --print-schema emits the schema for agent discovery, --dry-run validates without sending. Default is internal MBQL (numeric IDs); pass --external for the representations / string-FK form.", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + external: { + type: "boolean", + description: + "Validate as external MBQL (string FKs) and POST to /api/dataset/external; default is internal (numeric IDs) → /api/dataset", + }, + "dry-run": { + type: "boolean", + description: "Validate the body and exit without sending the query", + }, + "print-schema": { + type: "boolean", + description: + "Emit the bundled MBQL 5 query JSON Schema (with --external for the string-FK variant) and exit; no body required", + }, + }, + outputSchema: CardQueryResult, + examples: [ + "metabase query --print-schema", + "metabase query --print-schema --external", + "cat q.json | metabase query --dry-run", + "metabase query --file q.json", + "metabase query --file q.json --external", + ], + async run({ args, ctx, getClient }) { + const mode = args.external === true ? EXTERNAL : INTERNAL; + + if (args["print-schema"] === true) { + writeJson(getQuerySchemaBundle(mode.mode)); + return; + } + + const dryRun = args["dry-run"] === true; + const body = await readBody({ flag: args.body, file: args.file }, QueryBody); + const outcome = mode.validate(body); + + if (!outcome.ok) { + writeJson(outcome); + const hint = dryRun ? "" : " — pass --dry-run to validate without sending"; + throw new ConfigError(`validation failed: ${outcome.errors.length} error(s)${hint}`); + } + + if (dryRun) { + writeJson(outcome); + return; + } + + const client = await getClient(); + const queryResult = await client.requestParsed(CardQueryResult, mode.endpoint, { + method: "POST", + body, + }); + renderItem(queryResult, cardQueryView, ctx); + }, +}); diff --git a/src/commands/transform/create.ts b/src/commands/transform/create.ts index 615986d..aababc8 100644 --- a/src/commands/transform/create.ts +++ b/src/commands/transform/create.ts @@ -4,9 +4,14 @@ import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { preflightInternalMbql5Query } from "../validate-query"; export default defineMetabaseCommand({ - meta: { name: "create", description: "Create a transform" }, + meta: { + name: "create", + description: + "Create a transform; if source.type is `query` and source.query is MBQL 5 (lib/type: mbql/query) it is pre-flight-validated against the same schema as `metabase query` (see `metabase query --print-schema`)", + }, args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, outputSchema: Transform, examples: [ @@ -15,6 +20,9 @@ export default defineMetabaseCommand({ ], async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, TransformCreateInput); + if (body.source.type === "query") { + preflightInternalMbql5Query(body.source.query, "transform.source.query validation failed"); + } const client = await getClient(); const created = await client.requestParsed(Transform, "/api/transform", { method: "POST", diff --git a/src/commands/transform/update.ts b/src/commands/transform/update.ts index cc37bd4..d55037a 100644 --- a/src/commands/transform/update.ts +++ b/src/commands/transform/update.ts @@ -5,9 +5,14 @@ import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { preflightInternalMbql5Query } from "../validate-query"; export default defineMetabaseCommand({ - meta: { name: "update", description: "Update a transform by id" }, + meta: { + name: "update", + description: + "Update a transform by id; if source is provided with type `query` and source.query is MBQL 5 (lib/type: mbql/query) it is pre-flight-validated against the same schema as `metabase query` (see `metabase query --print-schema`)", + }, args: { ...outputFlags, ...profileFlag, @@ -24,6 +29,9 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, TransformUpdateInput); + if (body.source !== undefined && body.source.type === "query") { + preflightInternalMbql5Query(body.source.query, "transform.source.query validation failed"); + } const client = await getClient(); const updated = await client.requestParsed(Transform, `/api/transform/${id}`, { method: "PUT", diff --git a/src/commands/validate-query.test.ts b/src/commands/validate-query.test.ts new file mode 100644 index 0000000..20e8720 --- /dev/null +++ b/src/commands/validate-query.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ConfigError } from "../core/errors"; +import { ValidationOutcome } from "../core/schema/validate"; +import { parseJson } from "../runtime/json"; + +import { preflightInternalMbql5Query } from "./validate-query"; + +interface Streams { + stdout: string; + stderr: string; +} + +let streams: Streams; + +beforeEach(() => { + streams = { stdout: "", stderr: "" }; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + streams.stdout += String(chunk); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + streams.stderr += String(chunk); + return true; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("preflightInternalMbql5Query", () => { + it("returns silently when the body is not MBQL 5 (legacy MBQL 4)", () => { + preflightInternalMbql5Query( + { type: "query", database: 1, query: { "source-table": 5 } }, + "card.dataset_query validation failed", + ); + expect(streams.stdout).toBe(""); + expect(streams.stderr).toBe(""); + }); + + it("returns silently when the body is undefined / null / non-object", () => { + preflightInternalMbql5Query(undefined, "x"); + preflightInternalMbql5Query(null, "x"); + preflightInternalMbql5Query("native sql", "x"); + expect(streams.stdout).toBe(""); + }); + + it("returns silently when the MBQL 5 body validates", () => { + preflightInternalMbql5Query( + { + "lib/type": "mbql/query", + database: 1, + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], + }, + "card.dataset_query validation failed", + ); + expect(streams.stdout).toBe(""); + expect(streams.stderr).toBe(""); + }); + + it("writes the structured envelope and throws ConfigError when MBQL 5 validation fails", () => { + expect(() => + preflightInternalMbql5Query( + { + "lib/type": "mbql/query", + database: "oops", + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], + }, + "card.dataset_query validation failed", + ), + ).toThrow( + new ConfigError( + "card.dataset_query validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", + ), + ); + expect(parseJson(streams.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/database", message: "must be integer" }], + }); + }); +}); diff --git a/src/commands/validate-query.ts b/src/commands/validate-query.ts new file mode 100644 index 0000000..d0beb94 --- /dev/null +++ b/src/commands/validate-query.ts @@ -0,0 +1,19 @@ +import { ConfigError } from "../core/errors"; +import { isMbql5Query, validateInternalQuery } from "../core/schema/validate"; +import { writeJson } from "../output/render"; + +// Skips MBQL 4 / native — we only have a schema for MBQL 5 today, and the +// legacy formats are still accepted by the server. +export function preflightInternalMbql5Query(query: unknown, contextLabel: string): void { + if (!isMbql5Query(query)) { + return; + } + const outcome = validateInternalQuery(query); + if (outcome.ok) { + return; + } + writeJson(outcome); + throw new ConfigError( + `${contextLabel}: ${outcome.errors.length} error(s) — pass valid MBQL 5 or use the legacy format`, + ); +} diff --git a/src/core/schema/data/LICENSE.txt b/src/core/schema/data/LICENSE.txt new file mode 100644 index 0000000..a62aac1 --- /dev/null +++ b/src/core/schema/data/LICENSE.txt @@ -0,0 +1 @@ +Source code in this repository is licensed under the GNU Affero General Public License (AGPL). diff --git a/src/core/schema/data/schemas/common/id.json b/src/core/schema/data/schemas/common/id.json new file mode 100644 index 0000000..f2a5765 --- /dev/null +++ b/src/core/schema/data/schemas/common/id.json @@ -0,0 +1,67 @@ +{ + "title": "ID", + "description": "Shared type definitions for entity identifiers and foreign keys.", + "$defs": { + "entity_id": { + "type": "string", + "description": "NanoID identifier (21 characters, alphabet A-Za-z0-9_-)", + "pattern": "^[A-Za-z0-9_-]{21}$" + }, + "user_id": { + "type": "string", + "description": "User FK (email address)", + "format": "email" + }, + "database_id": { + "type": "string", + "description": "Database FK (database name)" + }, + "table_id": { + "type": "array", + "description": "Table FK [database, schema, table]", + "prefixItems": [ + { + "type": "string", + "description": "Database name" + }, + { + "type": ["string", "null"], + "description": "Schema name (null for schemaless databases)" + }, + { + "type": "string", + "description": "Table name" + } + ], + "minItems": 3, + "maxItems": 3 + }, + "field_id": { + "type": "array", + "description": "Field FK [database, schema, table, field] for regular fields, or [database, schema, table, parent, child, ...] for JSON-unfolded fields.\n", + "prefixItems": [ + { + "type": "string", + "description": "Database name" + }, + { + "type": ["string", "null"], + "description": "Schema name (null for schemaless databases)" + }, + { + "type": "string", + "description": "Table name" + }, + { + "type": "string", + "description": "Field name (or first JSON path segment)" + } + ], + "items": { + "type": "string", + "description": "Additional JSON path segments" + }, + "minItems": 4 + } + } +} diff --git a/src/core/schema/data/schemas/common/parameter.json b/src/core/schema/data/schemas/common/parameter.json new file mode 100644 index 0000000..4767fc3 --- /dev/null +++ b/src/core/schema/data/schemas/common/parameter.json @@ -0,0 +1,218 @@ +{ + "title": "Parameter", + "description": "A filter control parameter for cards and dashboards. Parameters allow users to interactively filter data via UI controls.\n", + "type": "object", + "required": ["id", "name", "slug", "type"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Unique identifier within the dashboard or card. UUIDs are recommended, but Metabase accepts any unique non-empty string.\n" + }, + "name": { + "type": "string", + "description": "Display name" + }, + "slug": { + "type": "string", + "description": "URL-friendly identifier" + }, + "type": { + "type": "string", + "description": "Filter widget type: string/=, string/!=, string/contains, string/starts-with, string/ends-with, number/=, number/!=, number/>=, number/<=, number/between, date/single, date/range, date/month-year, date/quarter-year, date/relative, date/all-options, boolean/=, temporal-unit\n" + }, + "default": { + "description": "Default value (type depends on filter type)" + }, + "required": { + "type": "boolean", + "description": "Whether a value is required" + }, + "sectionId": { + "type": "string", + "description": "UI section grouping. Restricts which columns are available for mapping. For id — only PK and FK columns. For location — only location columns (country, city, etc.). Normally matches the first part of the parameter type.\n", + "enum": ["string", "number", "date", "boolean", "temporal-unit", "id", "location"] + }, + "temporal_units": { + "type": "array", + "description": "Allowed temporal units (for temporal-unit type)", + "items": { + "type": "string", + "enum": ["minute", "hour", "day", "week", "month", "quarter", "year"] + } + }, + "values_query_type": { + "type": "string", + "enum": ["list", "search", "none"] + }, + "values_source_type": { + "type": ["string", "null"], + "enum": [null, "card", "static-list"] + }, + "values_source_config": { + "type": "object", + "description": "Source configuration. For static-list: {values: [[val, label], ...]}. For card: {card_id, value_field, label_field}.\n", + "properties": { + "values": { + "type": "array", + "description": "Static list of [value, label] pairs" + }, + "card_id": { + "type": "string", + "description": "Card entity_id to source values from" + }, + "value_field": { + "description": "Field clause for extracting values from card results" + }, + "label_field": { + "description": "Field clause for extracting labels from card results" + } + }, + "additionalProperties": true + } + }, + "$defs": { + "parameter_target": { + "description": "Parameter target for dashboard parameter mappings. Uses legacy MBQL format: [field, Field-FK, null-or-options].\n", + "type": "array", + "minItems": 2, + "allOf": [ + { + "if": { + "prefixItems": [ + { + "const": "dimension" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "dimension" + }, + { + "type": "array", + "minItems": 2, + "allOf": [ + { + "if": { + "prefixItems": [ + { + "const": "field" + } + ] + }, + "then": { + "$ref": "ref.yaml#/$defs/legacy_field_ref" + } + }, + { + "if": { + "prefixItems": [ + { + "const": "expression" + } + ] + }, + "then": { + "$ref": "ref.yaml#/$defs/legacy_expression_ref" + } + }, + { + "if": { + "prefixItems": [ + { + "const": "template-tag" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "template-tag" + }, + { + "type": "string" + } + ], + "minItems": 2, + "maxItems": 2 + } + } + ] + } + ], + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "stage-number": { + "type": "integer" + } + } + }, + { + "type": "null" + } + ] + }, + "maxItems": 3 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "variable" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "variable" + }, + { + "type": "array", + "prefixItems": [ + { + "const": "template-tag" + }, + { + "type": "string" + } + ], + "minItems": 2, + "maxItems": 2 + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "text-tag" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "text-tag" + }, + { + "type": "string" + } + ], + "minItems": 2, + "maxItems": 2 + } + } + ] + } + } +} diff --git a/src/core/schema/data/schemas/common/query.json b/src/core/schema/data/schemas/common/query.json new file mode 100644 index 0000000..cdde591 --- /dev/null +++ b/src/core/schema/data/schemas/common/query.json @@ -0,0 +1,1606 @@ +{ + "title": "Query", + "description": "A query definition in serialized pMBQL format. Contains a flat list of stages (no recursive source-query nesting). Each stage is either an MBQL structured stage or a native SQL stage.\n", + "type": "object", + "required": ["lib/type", "database", "stages"], + "properties": { + "lib/type": { + "type": "string", + "const": "mbql/query" + }, + "database": { + "$ref": "id.yaml#/$defs/database_id" + }, + "stages": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/stage" + } + } + }, + "$defs": { + "options": { + "description": "Base options object for pMBQL clauses. Always present as the second element of every clause array. Empty {} when no options apply.\n", + "type": "object", + "properties": { + "base-type": { + "type": "string" + }, + "effective-type": { + "type": "string" + }, + "lib/uuid": { + "type": "string", + "format": "uuid" + }, + "lib/expression-name": { + "type": "string" + } + } + }, + "case_sensitive_options": { + "description": "Options for string filter operators.", + "allOf": [ + { + "$ref": "#/$defs/options" + }, + { + "type": "object", + "properties": { + "case-sensitive": { + "type": "boolean" + } + } + } + ] + }, + "time_interval_options": { + "description": "Options for the time-interval operator.", + "allOf": [ + { + "$ref": "#/$defs/options" + }, + { + "type": "object", + "properties": { + "include-current": { + "type": "boolean" + } + } + } + ] + }, + "datetime_parse_options": { + "description": "Options for the datetime parsing operator.", + "allOf": [ + { + "$ref": "#/$defs/options" + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "iso", + "simple", + "unix-seconds", + "unix-milliseconds", + "unix-microseconds", + "unix-nanoseconds", + "iso-bytes", + "simple-bytes" + ] + } + } + } + ] + }, + "stage": { + "type": "object", + "required": ["lib/type"], + "allOf": [ + { + "if": { + "properties": { + "lib/type": { + "const": "mbql.stage/mbql" + } + } + }, + "then": { + "$ref": "#/$defs/mbql_stage" + } + }, + { + "if": { + "properties": { + "lib/type": { + "const": "mbql.stage/native" + } + } + }, + "then": { + "$ref": "#/$defs/native_stage" + } + } + ] + }, + "mbql_stage": { + "type": "object", + "properties": { + "lib/type": { + "const": "mbql.stage/mbql" + }, + "source-table": { + "$ref": "id.yaml#/$defs/table_id" + }, + "source-card": { + "$ref": "id.yaml#/$defs/entity_id" + }, + "joins": { + "type": "array", + "items": { + "$ref": "#/$defs/join" + } + }, + "expressions": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + } + }, + "fields": { + "type": "array", + "items": { + "type": "array", + "allOf": [ + { + "if": { + "prefixItems": [ + { + "const": "field" + } + ] + }, + "then": { + "$ref": "ref.yaml#/$defs/field_ref" + } + }, + { + "if": { + "prefixItems": [ + { + "const": "expression" + } + ] + }, + "then": { + "$ref": "ref.yaml#/$defs/expression_ref" + } + } + ] + } + }, + "filters": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + } + }, + "aggregation": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + } + }, + "breakout": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + } + }, + "order-by": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + } + }, + "limit": { + "type": ["integer", "null"] + } + } + }, + "expression": { + "description": "An MBQL expression clause. Either a literal value or an array [operator, options, ...args] where options is always an object.\n", + "type": ["string", "number", "boolean", "null", "array"], + "if": { + "type": "array" + }, + "then": { + "minItems": 1, + "allOf": [ + { + "if": { + "prefixItems": [ + { + "const": "field" + } + ] + }, + "then": { + "$ref": "ref.yaml#/$defs/field_ref" + } + }, + { + "if": { + "prefixItems": [ + { + "const": "expression" + } + ] + }, + "then": { + "$ref": "ref.yaml#/$defs/expression_ref" + } + }, + { + "if": { + "prefixItems": [ + { + "const": "aggregation" + } + ] + }, + "then": { + "$ref": "ref.yaml#/$defs/aggregation_ref" + } + }, + { + "if": { + "prefixItems": [ + { + "const": "metric" + } + ] + }, + "then": { + "$ref": "ref.yaml#/$defs/metric_ref" + } + }, + { + "if": { + "prefixItems": [ + { + "const": "measure" + } + ] + }, + "then": { + "$ref": "ref.yaml#/$defs/measure_ref" + } + }, + { + "if": { + "prefixItems": [ + { + "const": "segment" + } + ] + }, + "then": { + "$ref": "ref.yaml#/$defs/segment_ref" + } + }, + { + "if": { + "prefixItems": [ + { + "const": "value" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "value" + }, + { + "$ref": "#/$defs/options" + }, + { + "type": ["string", "number", "boolean"] + } + ], + "minItems": 3, + "maxItems": 3 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["+", "-", "*", "/"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["+", "-", "*", "/"] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + } + ], + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 3 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["and", "or"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["and", "or"] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + } + ], + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "not" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "not" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 3, + "maxItems": 3 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["=", "!=", "<", ">", "<=", ">="] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["=", "!=", "<", ">", "<=", ">="] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + } + ], + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["in", "not-in"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["in", "not-in"] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + } + ], + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "between" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "between" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 5, + "maxItems": 5 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "inside" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "inside" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "minItems": 8, + "maxItems": 8 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["is-null", "not-null", "is-empty", "not-empty"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["is-null", "not-null", "is-empty", "not-empty"] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 3, + "maxItems": 3 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["contains", "does-not-contain", "starts-with", "ends-with"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["contains", "does-not-contain", "starts-with", "ends-with"] + }, + { + "$ref": "#/$defs/case_sensitive_options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + } + ], + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "time-interval" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "time-interval" + }, + { + "$ref": "#/$defs/time_interval_options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "if": { + "type": "integer" + }, + "then": { + "type": "integer" + }, + "else": { + "enum": ["current", "last", "next"] + } + }, + { + "$ref": "temporal_bucketing.yaml#/$defs/datetime_truncation_unit" + } + ], + "minItems": 5, + "maxItems": 5 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "relative-time-interval" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "relative-time-interval" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "type": "integer" + }, + { + "$ref": "temporal_bucketing.yaml#/$defs/datetime_truncation_unit" + }, + { + "type": "integer" + }, + { + "$ref": "temporal_bucketing.yaml#/$defs/datetime_truncation_unit" + } + ], + "minItems": 7, + "maxItems": 7 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["now", "today"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["now", "today"] + }, + { + "$ref": "#/$defs/options" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "interval" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "interval" + }, + { + "$ref": "#/$defs/options" + }, + { + "type": "integer" + }, + { + "$ref": "temporal_bucketing.yaml#/$defs/datetime_truncation_unit" + } + ], + "minItems": 4, + "maxItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": [ + "abs", + "ceil", + "floor", + "round", + "sqrt", + "exp", + "log", + "length", + "trim", + "ltrim", + "rtrim", + "upper", + "lower", + "host", + "domain", + "subdomain", + "path", + "get-year", + "get-quarter", + "get-month", + "get-day", + "get-hour", + "get-minute", + "get-second", + "text", + "integer", + "float", + "day-name", + "month-name", + "quarter-name", + "date" + ] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": [ + "abs", + "ceil", + "floor", + "round", + "sqrt", + "exp", + "log", + "length", + "trim", + "ltrim", + "rtrim", + "upper", + "lower", + "host", + "domain", + "subdomain", + "path", + "get-year", + "get-quarter", + "get-month", + "get-day", + "get-hour", + "get-minute", + "get-second", + "text", + "integer", + "float", + "day-name", + "month-name", + "quarter-name", + "date" + ] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 3, + "maxItems": 3 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "datetime" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "datetime" + }, + { + "$ref": "#/$defs/datetime_parse_options" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 3, + "maxItems": 3 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["get-day-of-week", "get-week"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["get-day-of-week", "get-week"] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "type": "string", + "enum": ["iso", "us", "instance"] + } + ], + "minItems": 3, + "maxItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "power" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "power" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 4, + "maxItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["replace", "split-part"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["replace", "split-part"] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 5, + "maxItems": 5 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "substring" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "substring" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 4, + "maxItems": 5 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "collate" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "collate" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "type": "string" + } + ], + "minItems": 4, + "maxItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "regex-match-first" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "regex-match-first" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "type": "string" + } + ], + "minItems": 4, + "maxItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "concat" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "concat" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + } + ], + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "coalesce" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "coalesce" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + } + ], + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["datetime-add", "datetime-subtract"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["datetime-add", "datetime-subtract"] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "type": "integer" + }, + { + "$ref": "temporal_bucketing.yaml#/$defs/datetime_truncation_unit" + } + ], + "minItems": 5, + "maxItems": 5 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "datetime-diff" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "datetime-diff" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/datetime_diff_unit" + } + ], + "minItems": 5, + "maxItems": 5 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "convert-timezone" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "convert-timezone" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "type": "string" + }, + { + "type": "string" + } + ], + "minItems": 4, + "maxItems": 5 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "temporal-extract" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "temporal-extract" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "temporal_bucketing.yaml#/$defs/datetime_extraction_unit" + }, + { + "type": "string", + "enum": ["iso", "us", "instance"] + } + ], + "minItems": 4, + "maxItems": 5 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["count", "cum-count"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["count", "cum-count"] + }, + { + "$ref": "#/$defs/options" + } + ], + "items": { + "$ref": "#/$defs/expression" + }, + "minItems": 2, + "maxItems": 3 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": [ + "sum", + "avg", + "min", + "max", + "distinct", + "stddev", + "var", + "median", + "cum-sum" + ] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": [ + "sum", + "avg", + "min", + "max", + "distinct", + "stddev", + "var", + "median", + "cum-sum" + ] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 3, + "maxItems": 3 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "percentile" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "percentile" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "type": "number" + } + ], + "minItems": 4, + "maxItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["count-where", "share"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["count-where", "share"] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 3, + "maxItems": 3 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["sum-where", "distinct-where"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["sum-where", "distinct-where"] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 4, + "maxItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["case", "if"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["case", "if"] + }, + { + "$ref": "#/$defs/options" + }, + { + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/expression" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 2, + "maxItems": 2 + } + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 3, + "maxItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "const": "offset" + } + ] + }, + "then": { + "prefixItems": [ + { + "const": "offset" + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + }, + { + "type": "integer" + } + ], + "minItems": 4, + "maxItems": 4 + } + }, + { + "if": { + "prefixItems": [ + { + "enum": ["asc", "desc"] + } + ] + }, + "then": { + "prefixItems": [ + { + "enum": ["asc", "desc"] + }, + { + "$ref": "#/$defs/options" + }, + { + "$ref": "#/$defs/expression" + } + ], + "minItems": 3, + "maxItems": 3 + } + } + ] + } + }, + "datetime_diff_unit": { + "type": "string", + "enum": ["second", "minute", "hour", "day", "week", "month", "quarter", "year"] + }, + "join": { + "type": "object", + "required": ["stages", "conditions", "alias"], + "properties": { + "stages": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/stage" + } + }, + "conditions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/expression" + } + }, + "alias": { + "type": "string" + }, + "strategy": { + "type": "string", + "enum": ["left-join", "right-join", "inner-join", "full-join"] + }, + "fields": { + "if": { + "type": "string" + }, + "then": { + "enum": ["all", "none"] + }, + "else": { + "type": "array", + "items": { + "$ref": "#/$defs/expression" + } + } + } + } + }, + "native_stage": { + "type": "object", + "required": ["lib/type", "native"], + "properties": { + "lib/type": { + "const": "mbql.stage/native" + }, + "native": { + "type": "string" + }, + "template-tags": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/template_tag" + } + } + } + }, + "template_tag": { + "type": "object", + "required": ["type", "name", "id"], + "properties": { + "type": { + "type": "string", + "enum": [ + "text", + "number", + "date", + "boolean", + "dimension", + "temporal-unit", + "card", + "snippet", + "table" + ] + }, + "name": { + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "display-name": { + "type": "string" + }, + "default": { + "description": "Default value (type depends on tag type)" + }, + "required": { + "type": "boolean" + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "dimension" + } + } + }, + "then": { + "required": ["dimension", "widget-type"], + "properties": { + "dimension": { + "$ref": "#/$defs/expression" + }, + "widget-type": { + "type": "string" + }, + "options": { + "type": "object" + }, + "alias": { + "type": "string" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "temporal-unit" + } + } + }, + "then": { + "required": ["dimension"], + "properties": { + "dimension": { + "$ref": "#/$defs/expression" + }, + "alias": { + "type": "string" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "card" + } + } + }, + "then": { + "required": ["card-id"], + "properties": { + "card-id": { + "$ref": "id.yaml#/$defs/entity_id" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "snippet" + } + } + }, + "then": { + "required": ["snippet-name", "snippet-id"], + "properties": { + "snippet-name": { + "type": "string" + }, + "snippet-id": { + "$ref": "id.yaml#/$defs/entity_id" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "table" + } + } + }, + "then": { + "required": ["table-id"], + "properties": { + "table-id": { + "$ref": "id.yaml#/$defs/table_id" + }, + "emit-alias": { + "type": "boolean" + } + } + } + } + ] + } + } +} diff --git a/src/core/schema/data/schemas/common/ref.json b/src/core/schema/data/schemas/common/ref.json new file mode 100644 index 0000000..ab5fb5b --- /dev/null +++ b/src/core/schema/data/schemas/common/ref.json @@ -0,0 +1,229 @@ +{ + "title": "Ref", + "description": "Column and entity reference types used in MBQL expressions, parameter mappings, and visualization settings. Options are always the second element (an object, never null).\n", + "$defs": { + "field_options": { + "description": "Options for field refs.", + "allOf": [ + { + "$ref": "query.yaml#/$defs/options" + }, + { + "type": "object", + "properties": { + "temporal-unit": { + "$ref": "temporal_bucketing.yaml#/$defs/datetime_bucketing_unit" + }, + "join-alias": { + "type": "string" + }, + "binning": { + "type": "object", + "required": ["strategy"], + "properties": { + "strategy": { + "type": "string", + "enum": ["default", "num-bins", "bin-width"] + } + }, + "allOf": [ + { + "if": { + "properties": { + "strategy": { + "const": "bin-width" + } + } + }, + "then": { + "required": ["bin-width"], + "properties": { + "bin-width": { + "type": "number", + "exclusiveMinimum": 0 + } + } + } + }, + { + "if": { + "properties": { + "strategy": { + "const": "num-bins" + } + } + }, + "then": { + "required": ["num-bins"], + "properties": { + "num-bins": { + "type": "integer", + "minimum": 1 + } + } + } + } + ] + }, + "source-field": { + "$ref": "id.yaml#/$defs/field_id" + }, + "source-field-name": { + "type": "string" + }, + "source-field-join-alias": { + "type": "string" + } + } + } + ] + }, + "field_ref": { + "description": "[field, options, Field-FK-or-name]", + "type": "array", + "prefixItems": [ + { + "const": "field" + }, + { + "$ref": "#/$defs/field_options" + }, + { + "if": { + "type": "array" + }, + "then": { + "$ref": "id.yaml#/$defs/field_id" + } + } + ], + "minItems": 3, + "maxItems": 3 + }, + "expression_ref": { + "description": "[expression, options, name]", + "type": "array", + "prefixItems": [ + { + "const": "expression" + }, + { + "$ref": "query.yaml#/$defs/options" + }, + { + "type": "string" + } + ], + "minItems": 3, + "maxItems": 3 + }, + "aggregation_ref": { + "description": "[aggregation, options, uuid] — uuid matches the lib/uuid on the aggregation clause", + "type": "array", + "prefixItems": [ + { + "const": "aggregation" + }, + { + "$ref": "query.yaml#/$defs/options" + }, + { + "type": "string", + "format": "uuid" + } + ], + "minItems": 3, + "maxItems": 3 + }, + "metric_ref": { + "description": "[metric, options, entity_id]", + "type": "array", + "prefixItems": [ + { + "const": "metric" + }, + { + "$ref": "query.yaml#/$defs/options" + }, + { + "type": "string" + } + ], + "minItems": 3, + "maxItems": 3 + }, + "measure_ref": { + "description": "[measure, options, entity_id]", + "type": "array", + "prefixItems": [ + { + "const": "measure" + }, + { + "$ref": "query.yaml#/$defs/options" + }, + { + "type": "string" + } + ], + "minItems": 3, + "maxItems": 3 + }, + "segment_ref": { + "description": "[segment, options, entity_id]", + "type": "array", + "prefixItems": [ + { + "const": "segment" + }, + { + "$ref": "query.yaml#/$defs/options" + }, + { + "type": "string" + } + ], + "minItems": 3, + "maxItems": 3 + }, + "legacy_field_ref": { + "description": "[field, Field-FK-or-name, null-or-options] — legacy format used in parameter targets", + "type": "array", + "prefixItems": [ + { + "const": "field" + }, + { + "if": { + "type": "array" + }, + "then": { + "$ref": "id.yaml#/$defs/field_id" + } + }, + { + "type": ["object", "null"] + } + ], + "minItems": 3, + "maxItems": 3 + }, + "legacy_expression_ref": { + "description": "[expression, name] or [expression, name, null-or-options] — legacy format used in parameter targets", + "type": "array", + "prefixItems": [ + { + "const": "expression" + }, + { + "type": "string" + }, + { + "type": ["object", "null"] + } + ], + "minItems": 2, + "maxItems": 3 + } + } +} diff --git a/src/core/schema/data/schemas/common/temporal_bucketing.json b/src/core/schema/data/schemas/common/temporal_bucketing.json new file mode 100644 index 0000000..0333c35 --- /dev/null +++ b/src/core/schema/data/schemas/common/temporal_bucketing.json @@ -0,0 +1,45 @@ +{ + "title": "Temporal Bucketing", + "description": "Datetime truncation, extraction, and bucketing unit definitions.", + "$defs": { + "datetime_truncation_unit": { + "description": "Truncation units — truncate a datetime to a boundary. Also used for intervals and datetime arithmetic.\n", + "type": "string", + "enum": ["millisecond", "second", "minute", "hour", "day", "week", "month", "quarter", "year"] + }, + "datetime_extraction_unit": { + "description": "Extraction units — extract a numeric component from a datetime.\n", + "type": "string", + "enum": [ + "second-of-minute", + "minute-of-hour", + "hour-of-day", + "day-of-week", + "day-of-week-iso", + "day-of-month", + "day-of-year", + "week-of-year", + "week-of-year-iso", + "week-of-year-us", + "week-of-year-instance", + "month-of-year", + "quarter-of-year", + "year-of-era" + ] + }, + "datetime_bucketing_unit": { + "description": "All datetime units — truncation + extraction + default. Used for field temporal-unit option.\n", + "anyOf": [ + { + "const": "default" + }, + { + "$ref": "#/$defs/datetime_truncation_unit" + }, + { + "$ref": "#/$defs/datetime_extraction_unit" + } + ] + } + } +} diff --git a/src/core/schema/validate.test.ts b/src/core/schema/validate.test.ts new file mode 100644 index 0000000..92f4e3b --- /dev/null +++ b/src/core/schema/validate.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; + +import { + getQuerySchemaBundle, + isMbql5Query, + validateExternalQuery, + validateInternalQuery, +} from "./validate"; + +const VALID_EXTERNAL = { + "lib/type": "mbql/query", + database: "My DB", + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": ["My DB", null, "orders"], + }, + ], +}; + +const VALID_INTERNAL = { + "lib/type": "mbql/query", + database: 1, + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": 7, + }, + ], +}; + +describe("validateExternalQuery", () => { + it("accepts a structurally valid external-MBQL body", () => { + expect(validateExternalQuery(VALID_EXTERNAL)).toEqual({ ok: true, errors: [] }); + }); + + it("rejects integer database (would belong to internal MBQL)", () => { + expect(validateExternalQuery(VALID_INTERNAL)).toEqual({ + ok: false, + errors: [ + { path: "/database", message: "must be string" }, + { path: "/stages/0/source-table", message: "must be array" }, + { path: "/stages/0", message: 'must match "then" schema' }, + ], + }); + }); + + it("rejects an empty stages array", () => { + expect( + validateExternalQuery({ + "lib/type": "mbql/query", + database: "My DB", + stages: [], + }), + ).toEqual({ + ok: false, + errors: [{ path: "/stages", message: "must NOT have fewer than 1 items" }], + }); + }); + + it("rejects a missing top-level lib/type", () => { + const outcome = validateExternalQuery({ + database: "My DB", + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": ["My DB", null, "orders"] }], + }); + expect(outcome.ok).toBe(false); + expect(outcome.errors).toContainEqual({ + path: "/", + message: "must have required property 'lib/type'", + }); + }); +}); + +describe("validateInternalQuery", () => { + it("accepts a structurally valid internal-MBQL body", () => { + expect(validateInternalQuery(VALID_INTERNAL)).toEqual({ ok: true, errors: [] }); + }); + + it("rejects string database / FK-tuple source-table (would belong to external MBQL)", () => { + expect(validateInternalQuery(VALID_EXTERNAL)).toEqual({ + ok: false, + errors: [ + { path: "/database", message: "must be integer" }, + { path: "/stages/0/source-table", message: "must be integer" }, + { path: "/stages/0", message: 'must match "then" schema' }, + ], + }); + }); + + it("rejects zero or negative database id", () => { + const outcome = validateInternalQuery({ + "lib/type": "mbql/query", + database: 0, + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], + }); + expect(outcome.ok).toBe(false); + expect(outcome.errors).toContainEqual({ path: "/database", message: "must be >= 1" }); + }); +}); + +describe("isMbql5Query", () => { + it("returns true for an object with lib/type: mbql/query", () => { + expect(isMbql5Query({ "lib/type": "mbql/query" })).toBe(true); + }); + + it("returns false for an object with a different lib/type", () => { + expect(isMbql5Query({ "lib/type": "mbql.stage/mbql" })).toBe(false); + }); + + it("returns false for an object missing lib/type", () => { + expect(isMbql5Query({ type: "query", database: 1, query: { "source-table": 7 } })).toBe(false); + }); + + it("returns false for null, primitives, and arrays", () => { + expect(isMbql5Query(null)).toBe(false); + expect(isMbql5Query(undefined)).toBe(false); + expect(isMbql5Query("mbql/query")).toBe(false); + expect(isMbql5Query(42)).toBe(false); + expect(isMbql5Query([{ "lib/type": "mbql/query" }])).toBe(false); + }); +}); + +describe("getQuerySchemaBundle", () => { + it("external mode bundles the query schema with the string-FK id schema and the other 3 common defs", () => { + const bundle = getQuerySchemaBundle("external"); + expect(bundle.mode).toBe("external"); + expect(bundle.schema).toBe(getQuerySchemaBundle("external").schema); + expect(Object.keys(bundle.defs)).toEqual([ + "id.yaml", + "parameter.yaml", + "ref.yaml", + "temporal_bucketing.yaml", + ]); + }); + + it("external mode's id schema describes database_id as a string", () => { + const bundle = getQuerySchemaBundle("external"); + expect(bundle.defs["id.yaml"]).toMatchObject({ + $defs: { database_id: { type: "string" } }, + }); + }); + + it("internal mode's id schema describes every id $def as a positive integer", () => { + const bundle = getQuerySchemaBundle("internal"); + expect(bundle.mode).toBe("internal"); + expect(bundle.defs["id.yaml"]).toEqual({ + title: "ID (internal)", + description: "Internal-MBQL identifier overrides — every ID is a positive integer.", + $defs: { + entity_id: { type: "integer", minimum: 1 }, + user_id: { type: "integer", minimum: 1 }, + database_id: { type: "integer", minimum: 1 }, + table_id: { type: "integer", minimum: 1 }, + field_id: { type: "integer", minimum: 1 }, + }, + }); + }); +}); diff --git a/src/core/schema/validate.ts b/src/core/schema/validate.ts new file mode 100644 index 0000000..d3c39c8 --- /dev/null +++ b/src/core/schema/validate.ts @@ -0,0 +1,134 @@ +import Ajv2020 from "ajv/dist/2020.js"; +import addFormats from "ajv-formats"; +import type { ValidateFunction } from "ajv"; +import { z } from "zod"; + +import idSchema from "./data/schemas/common/id.json" with { type: "json" }; +import parameterSchema from "./data/schemas/common/parameter.json" with { type: "json" }; +import querySchema from "./data/schemas/common/query.json" with { type: "json" }; +import refSchema from "./data/schemas/common/ref.json" with { type: "json" }; +import temporalSchema from "./data/schemas/common/temporal_bucketing.json" with { type: "json" }; + +export const ValidationIssue = z.object({ + path: z.string(), + message: z.string(), +}); +export type ValidationIssue = z.infer; + +export const ValidationOutcome = z.object({ + ok: z.boolean(), + errors: z.array(ValidationIssue), +}); +export type ValidationOutcome = z.infer; + +// Internal MBQL is structurally identical to external MBQL except every ID +// field is a positive integer instead of a portable string / FK tuple. We +// override the bundled id.yaml's five $defs to express that. +const POSITIVE_INTEGER = { type: "integer", minimum: 1 } as const; +const internalIdSchema = { + title: "ID (internal)", + description: "Internal-MBQL identifier overrides — every ID is a positive integer.", + $defs: { + entity_id: POSITIVE_INTEGER, + user_id: POSITIVE_INTEGER, + database_id: POSITIVE_INTEGER, + table_id: POSITIVE_INTEGER, + field_id: POSITIVE_INTEGER, + }, +}; + +let externalValidator: ValidateFunction | null = null; +let internalValidator: ValidateFunction | null = null; + +function buildAjv(idVariant: typeof idSchema | typeof internalIdSchema): ValidateFunction { + const ajv = new Ajv2020({ + allErrors: true, + strictTuples: false, + allowUnionTypes: true, + }); + addFormats(ajv); + ajv.addSchema(idVariant, "id.yaml"); + ajv.addSchema(parameterSchema, "parameter.yaml"); + ajv.addSchema(refSchema, "ref.yaml"); + ajv.addSchema(temporalSchema, "temporal_bucketing.yaml"); + ajv.addSchema(querySchema, "query.yaml"); + const compiled = ajv.getSchema("query.yaml"); + if (compiled === undefined) { + throw new Error("internal: query.yaml validator not registered"); + } + return compiled; +} + +function getExternalValidator(): ValidateFunction { + if (externalValidator === null) { + externalValidator = buildAjv(idSchema); + } + return externalValidator; +} + +function getInternalValidator(): ValidateFunction { + if (internalValidator === null) { + internalValidator = buildAjv(internalIdSchema); + } + return internalValidator; +} + +function runValidator(validator: ValidateFunction, value: unknown): ValidationOutcome { + if (validator(value)) { + return { ok: true, errors: [] }; + } + const issues = validator.errors ?? []; + const errors = issues.map((issue) => { + if (issue.message === undefined) { + throw new Error(`Ajv issue at ${issue.instancePath} has no message`); + } + return { + path: issue.instancePath === "" ? "/" : issue.instancePath, + message: issue.message, + }; + }); + return { ok: false, errors }; +} + +export function validateExternalQuery(value: unknown): ValidationOutcome { + return runValidator(getExternalValidator(), value); +} + +export function validateInternalQuery(value: unknown): ValidationOutcome { + return runValidator(getInternalValidator(), value); +} + +export function isMbql5Query(value: unknown): boolean { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + return "lib/type" in value && value["lib/type"] === "mbql/query"; +} + +export const SchemaMode = z.enum(["external", "internal"]); +export type SchemaMode = z.infer; + +export const QuerySchemaBundle = z.object({ + mode: SchemaMode, + schema: z.unknown(), + defs: z.object({ + "id.yaml": z.unknown(), + "parameter.yaml": z.unknown(), + "ref.yaml": z.unknown(), + "temporal_bucketing.yaml": z.unknown(), + }), +}); +export type QuerySchemaBundle = z.infer; + +export function getQuerySchemaBundle(mode: SchemaMode): QuerySchemaBundle { + return { + mode, + schema: querySchema, + defs: { + "id.yaml": mode === "internal" ? internalIdSchema : idSchema, + "parameter.yaml": parameterSchema, + "ref.yaml": refSchema, + "temporal_bucketing.yaml": temporalSchema, + }, + }; +} diff --git a/src/main.ts b/src/main.ts index 6dfd4fb..8668cfe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,6 +26,7 @@ const main: CommandDef = defineCommand({ setup: () => import("./commands/setup").then((mod) => mod.default), "api-key": () => import("./commands/api-key").then((mod) => mod.default), eid: () => import("./commands/eid").then((mod) => mod.default), + query: () => import("./commands/query").then((mod) => mod.default), __manifest: (): Promise => import("./commands/manifest").then((mod) => mod.createManifestCommand(main)), }, diff --git a/src/output/manifest.test.ts b/src/output/manifest.test.ts deleted file mode 100644 index 5c69c40..0000000 --- a/src/output/manifest.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { Manifest } from "../runtime/manifest"; -import { parseJson } from "../runtime/json"; - -import { writeManifest } from "./manifest"; - -describe("writeManifest", () => { - let chunks: string[]; - - beforeEach(() => { - chunks = []; - vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { - chunks.push(String(chunk)); - return true; - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("writes pretty-printed JSON terminated by a single newline", () => { - const manifest: Manifest = { - version: 1, - commands: [ - { - command: "auth status", - description: "show status", - examples: [], - args: [], - outputSchema: null, - }, - ], - }; - - writeManifest(manifest); - const out = chunks.join(""); - expect(out.endsWith("\n")).toBe(true); - expect(parseJson(out, Manifest)).toEqual(manifest); - }); -}); diff --git a/src/output/manifest.ts b/src/output/manifest.ts deleted file mode 100644 index 65ea2b5..0000000 --- a/src/output/manifest.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Manifest } from "../runtime/manifest"; - -export function writeManifest(manifest: Manifest): void { - process.stdout.write(JSON.stringify(manifest, null, 2) + "\n"); -} diff --git a/src/output/render.test.ts b/src/output/render.test.ts index e294bba..c79078f 100644 --- a/src/output/render.test.ts +++ b/src/output/render.test.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import type { ResourceView } from "../domain/view"; import { parseJson } from "../runtime/json"; import { capListEnvelope } from "./cap"; -import { renderItem, renderList } from "./render"; +import { renderItem, renderList, writeJson, writeText } from "./render"; import type { ListEnvelope, RenderOptions } from "./types"; const Card = z.object({ @@ -272,3 +272,17 @@ describe("renderList — text format", () => { ); }); }); + +describe("writeJson", () => { + it("emits the value pretty-printed with a trailing newline", () => { + writeJson({ a: 1, b: ["x", "y"] }); + expect(streams.stdout).toBe('{\n "a": 1,\n "b": [\n "x",\n "y"\n ]\n}\n'); + }); +}); + +describe("writeText", () => { + it("appends a single trailing newline to the input", () => { + writeText("hello\nworld"); + expect(streams.stdout).toBe("hello\nworld\n"); + }); +}); diff --git a/src/output/render.ts b/src/output/render.ts index c6b22cc..ed7d824 100644 --- a/src/output/render.ts +++ b/src/output/render.ts @@ -6,6 +6,14 @@ import { applyProjection, isPlainObject } from "./projection"; import { formatCell, formatScalar, renderTable } from "./table"; import type { ListEnvelope, RenderOptions } from "./types"; +export function writeJson(value: unknown): void { + process.stdout.write(JSON.stringify(value, null, 2) + "\n"); +} + +export function writeText(text: string): void { + process.stdout.write(text + "\n"); +} + type KeyValuePair = readonly [label: string, value: string]; export function renderItem(item: T, view: ResourceView, opts: RenderOptions): void { diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index 14bcfc9..b236ddd 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -96,6 +96,7 @@ describe("__manifest e2e", () => { "setup", "api-key create", "eid translate", + "query", ]); // Streaming commands legitimately have no outputSchema — they pipe raw bytes diff --git a/tests/e2e/query.e2e.test.ts b/tests/e2e/query.e2e.test.ts new file mode 100644 index 0000000..c60625c --- /dev/null +++ b/tests/e2e/query.e2e.test.ts @@ -0,0 +1,185 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + getQuerySchemaBundle, + QuerySchemaBundle, + ValidationOutcome, +} from "../../src/core/schema/validate"; +import { parseJson } from "../../src/runtime/json"; + +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; + +const VALID_EXTERNAL = { + "lib/type": "mbql/query", + database: "My DB", + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": ["My DB", null, "orders"], + }, + ], +}; + +const VALID_INTERNAL = { + "lib/type": "mbql/query", + database: 1, + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": 7, + }, + ], +}; + +const EMPTY_STAGES_QUERY = { + "lib/type": "mbql/query", + database: 1, + stages: [], +}; + +describe("query e2e", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + it("--print-schema (default) emits the internal-mode bundle with all 4 common defs", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--print-schema"], + configHome, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, QuerySchemaBundle)).toEqual(getQuerySchemaBundle("internal")); + }); + + it("--print-schema --external emits the external-mode bundle", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--print-schema", "--external"], + configHome, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, QuerySchemaBundle)).toEqual(getQuerySchemaBundle("external")); + }); + + it("--dry-run (default = internal) with a valid numeric-IDs body returns ok and exits 0", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--dry-run"], + stdin: JSON.stringify(VALID_INTERNAL), + configHome, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ ok: true, errors: [] }); + }); + + it("--external --dry-run with a valid string-FK body returns ok and exits 0", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--external", "--dry-run"], + stdin: JSON.stringify(VALID_EXTERNAL), + configHome, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ ok: true, errors: [] }); + }); + + it("--dry-run (default = internal) rejects external-shaped IDs (string database, FK-tuple source-table)", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--dry-run"], + stdin: JSON.stringify(VALID_EXTERNAL), + configHome, + }); + + expect(result.exitCode).toBe(2); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [ + { path: "/database", message: "must be integer" }, + { path: "/stages/0/source-table", message: "must be integer" }, + { path: "/stages/0", message: 'must match "then" schema' }, + ], + }); + expect(result.stderr).toContain("validation failed: 3 error(s)"); + }); + + it("--external --dry-run rejects internal-shaped IDs (integer database, integer source-table)", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--external", "--dry-run"], + stdin: JSON.stringify(VALID_INTERNAL), + configHome, + }); + + expect(result.exitCode).toBe(2); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [ + { path: "/database", message: "must be string" }, + { path: "/stages/0/source-table", message: "must be array" }, + { path: "/stages/0", message: 'must match "then" schema' }, + ], + }); + expect(result.stderr).toContain("validation failed: 3 error(s)"); + }); + + it("--dry-run with an empty stages array reports the structural error and exits 2", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--dry-run"], + stdin: JSON.stringify(EMPTY_STAGES_QUERY), + configHome, + }); + + expect(result.exitCode).toBe(2); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/stages", message: "must NOT have fewer than 1 items" }], + }); + expect(result.stderr).toContain("validation failed: 1 error(s)"); + }); + + it("run (no --dry-run) with an invalid body refuses to send and points at --dry-run", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query"], + stdin: JSON.stringify(EMPTY_STAGES_QUERY), + configHome, + }); + + expect(result.exitCode).toBe(2); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/stages", message: "must NOT have fewer than 1 items" }], + }); + expect(result.stderr).toContain( + "validation failed: 1 error(s) — pass --dry-run to validate without sending", + ); + }); + + it("--dry-run with malformed JSON exits 2 with a ConfigError", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--dry-run"], + stdin: "not json", + configHome, + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("request body: invalid JSON:"); + expect(result.stdout).toBe(""); + }); +}); From ab07e252da5063ed60c1d3cda45b790f8014cf9e Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Thu, 7 May 2026 01:58:54 -0400 Subject: [PATCH 08/47] tests --- tests/e2e/card.e2e.test.ts | 36 ++++++++++++++++- tests/e2e/query.e2e.test.ts | 45 ++++++++++++++++++++- tests/e2e/search.e2e.test.ts | 2 +- tests/e2e/transform.e2e.test.ts | 70 ++++++++++++++++++++++++++++++++- 4 files changed, 149 insertions(+), 4 deletions(-) diff --git a/tests/e2e/card.e2e.test.ts b/tests/e2e/card.e2e.test.ts index 4d523d4..72742fc 100644 --- a/tests/e2e/card.e2e.test.ts +++ b/tests/e2e/card.e2e.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { CardListEnvelope } from "../../src/commands/card/list"; +import { ValidationOutcome } from "../../src/core/schema/validate"; import { Card, CardCompact, CardQueryResult } from "../../src/domain/card"; import { parseJson } from "../../src/runtime/json"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { E2E_CARDS, E2E_COLLECTIONS, E2E_DATABASES } from "./seed/ids"; +import { E2E_CARDS, E2E_COLLECTIONS, E2E_DATABASES, E2E_TABLES } from "./seed/ids"; const FIRST_NEW_CARD_ID = 95; const NEW_CARD_NAME = "e2e_card_new"; @@ -314,6 +315,39 @@ describe("card e2e", () => { expect(result.stdout).toBe(""); }); + it("create with invalid MBQL 5 dataset_query fails pre-flight before sending", async () => { + const result = await runCli({ + args: ["card", "create", "--json"], + stdin: JSON.stringify({ + name: "preflight-fail", + display: "table", + visualization_settings: {}, + collection_id: E2E_COLLECTIONS.DEFAULT, + dataset_query: { + "lib/type": "mbql/query", + database: "oops not an integer", + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": E2E_TABLES.ORDERS, + }, + ], + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/database", message: "must be integer" }], + }); + expect(result.stderr).toContain( + "card.dataset_query validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", + ); + }); + it("archive with a non-integer id fails fast with ConfigError", async () => { const result = await runCli({ args: ["card", "archive", "abc", "--json"], diff --git a/tests/e2e/query.e2e.test.ts b/tests/e2e/query.e2e.test.ts index c60625c..2b2ca6d 100644 --- a/tests/e2e/query.e2e.test.ts +++ b/tests/e2e/query.e2e.test.ts @@ -1,13 +1,16 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { getQuerySchemaBundle, QuerySchemaBundle, ValidationOutcome, } from "../../src/core/schema/validate"; +import { CardQueryResult } from "../../src/domain/card"; import { parseJson } from "../../src/runtime/json"; +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { E2E_DATABASES, E2E_TABLES } from "./seed/ids"; const VALID_EXTERNAL = { "lib/type": "mbql/query", @@ -38,8 +41,13 @@ const EMPTY_STAGES_QUERY = { }; describe("query e2e", () => { + let bootstrap: E2EBootstrap; const tempDirs: string[] = []; + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + afterEach(async () => { await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); }); @@ -50,6 +58,13 @@ describe("query e2e", () => { return dir; } + function authEnv(): Record { + return { + METABASE_URL: bootstrap.baseUrl, + METABASE_API_KEY: bootstrap.adminApiKey, + }; + } + it("--print-schema (default) emits the internal-mode bundle with all 4 common defs", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ @@ -182,4 +197,32 @@ describe("query e2e", () => { expect(result.stderr).toContain("request body: invalid JSON:"); expect(result.stdout).toBe(""); }); + + it("run (default = internal) executes a valid MBQL 5 query against /api/dataset and returns rows", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--json"], + stdin: JSON.stringify({ + "lib/type": "mbql/query", + database: E2E_DATABASES.WAREHOUSE, + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": E2E_TABLES.ORDERS, + limit: 3, + }, + ], + }), + configHome, + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const queryResult = parseJson(result.stdout, CardQueryResult); + expect(queryResult.status).toBe("completed"); + if (queryResult.status === "completed") { + expect(queryResult.row_count).toBe(3); + expect(queryResult.data.rows).toHaveLength(3); + } + }); }); diff --git a/tests/e2e/search.e2e.test.ts b/tests/e2e/search.e2e.test.ts index c1fdd1e..f9d28a1 100644 --- a/tests/e2e/search.e2e.test.ts +++ b/tests/e2e/search.e2e.test.ts @@ -99,7 +99,7 @@ describe("search e2e", () => { }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("invalid --limit: 0 (must be a positive integer)"); + expect(result.stderr).toContain("invalid --limit: 0 (must be ≥ 1)"); expect(result.stdout).toBe(""); }); diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index 927abef..fc6b5d2 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -6,13 +6,14 @@ import { DeleteResult } from "../../src/commands/delete-runtime"; import { TransformListEnvelope } from "../../src/commands/transform/list"; import { TransformRunResult } from "../../src/commands/transform/run"; import { createClient, type Client } from "../../src/core/http/client"; +import { ValidationOutcome } from "../../src/core/schema/validate"; import { TransformCompact } from "../../src/domain/transform"; import { parseJson } from "../../src/runtime/json"; import { pollUntil } from "../../src/runtime/poll"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { E2E_DATABASES } from "./seed/ids"; +import { E2E_DATABASES, E2E_TABLES } from "./seed/ids"; const FIRST_TRANSFORM_ID = 1; const TRANSFORM_NAME = "e2e_transform"; @@ -287,6 +288,73 @@ describe("transform e2e", () => { expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); }); + it("create with invalid MBQL 5 source.query fails pre-flight before sending", async () => { + const result = await runCli({ + args: ["transform", "create", "--json"], + stdin: JSON.stringify({ + name: "preflight-fail", + source: { + type: "query", + query: { + "lib/type": "mbql/query", + database: "oops not an integer", + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": E2E_TABLES.ORDERS, + }, + ], + }, + }, + target: { + type: "table", + database: E2E_DATABASES.WAREHOUSE, + schema: "public", + name: "preflight_fail_target", + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/database", message: "must be integer" }], + }); + expect(result.stderr).toContain( + "transform.source.query validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", + ); + }); + + it("update with invalid MBQL 5 source.query fails pre-flight before sending", async () => { + await createSeedTransform(); + const result = await runCli({ + args: ["transform", "update", String(FIRST_TRANSFORM_ID), "--json"], + stdin: JSON.stringify({ + source: { + type: "query", + query: { + "lib/type": "mbql/query", + database: "oops", + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 99 }], + }, + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/database", message: "must be integer" }], + }); + expect(result.stderr).toContain( + "transform.source.query validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", + ); + }); + it("delete without --yes and without TTY stdin fails with ConfigError", async () => { const result = await runCli({ args: ["transform", "delete", "1", "--json"], From 9af92d7ba3d4666709b7c29979326dba49867acb Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Thu, 7 May 2026 02:26:00 -0400 Subject: [PATCH 09/47] skip --- README.md | 5 +++++ src/commands/card/create.ts | 15 ++++++++++++--- src/commands/query.ts | 29 +++++++++++++++++++---------- src/commands/transform/create.ts | 15 ++++++++++++--- src/commands/transform/update.ts | 8 ++++++-- src/commands/validate-query.test.ts | 23 ++++++++++++++++++++--- src/commands/validate-query.ts | 21 ++++++++++++++++++++- tests/e2e/card.e2e.test.ts | 26 ++++++++++++++++++++++++++ tests/e2e/query.e2e.test.ts | 29 +++++++++++++++++++++++++++++ tests/e2e/transform.e2e.test.ts | 29 +++++++++++++++++++++++++++++ 10 files changed, 178 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ca29a1c..4e6ba90 100644 --- a/README.md +++ b/README.md @@ -829,10 +829,13 @@ metabase query --print-schema --external # string-FK variant cat q.json | metabase query --dry-run # validate, no network metabase query --file q.json metabase query --file q.json --external +metabase query --file q.json --skip-validate # bypass pre-flight; let server reject ``` Body sources: `--file`, `--body`, or stdin (exactly one). Body is JSON. +`--skip-validate` is an escape hatch when the bundled schema disagrees with what the server actually accepts (drift, false negative, edge case). Validation is skipped entirely and the body is sent as-is. Mutually exclusive with `--dry-run` (which is itself the validation mode). + Exit codes: - `0` — valid (and the query ran successfully when not in dry-run). @@ -850,6 +853,8 @@ Output by mode: When the embedded query (`card.dataset_query`, or `transform.source.query` for `source.type: "query"`) is MBQL 5 (`lib/type: "mbql/query"`), it is pre-flight-validated against the same schema as `metabase query`. Validation failure: `{ ok, errors }` envelope on stdout, exit 2, request not made. MBQL 4 (legacy) bodies and Python transform sources skip validation — they're still accepted by the server and we don't ship a schema for them. +Pass `--skip-validate` to bypass the pre-flight on `card create`, `transform create`, or `transform update` — the body is sent as-is and the server is the authority. Same escape hatch as on `metabase query`; use only when the bundled schema disagrees with what the server actually accepts. + Agent discovery path: `metabase __manifest` lists every command's args and description; the description for `card create` and `transform create`/`update` references `metabase query --print-schema` so an agent can fetch the validating schema directly. The bundled query schema is synced from a pinned `@metabase/representations` release via `bun run sync:representations`; CI guards against drift. diff --git a/src/commands/card/create.ts b/src/commands/card/create.ts index ecb67de..36ff4a9 100644 --- a/src/commands/card/create.ts +++ b/src/commands/card/create.ts @@ -4,7 +4,7 @@ import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { preflightInternalMbql5Query } from "../validate-query"; +import { preflightInternalMbql5Query, skipValidateFlag } from "../validate-query"; export default defineMetabaseCommand({ meta: { @@ -12,16 +12,25 @@ export default defineMetabaseCommand({ description: "Create a card from a JSON spec; if dataset_query is MBQL 5 (lib/type: mbql/query) it is pre-flight-validated against the same schema as `metabase query` (see `metabase query --print-schema`)", }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + ...skipValidateFlag, + }, outputSchema: Card, examples: [ "cat card.json | metabase card create", "metabase card create --file card.json", 'metabase card create --body \'{"name":"x","display":"table","dataset_query":{...},"visualization_settings":{}}\'', + "metabase card create --file card.json --skip-validate", ], async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, CardCreateInput); - preflightInternalMbql5Query(body.dataset_query, "card.dataset_query validation failed"); + preflightInternalMbql5Query(body.dataset_query, "card.dataset_query validation failed", { + skip: args["skip-validate"] === true, + }); const client = await getClient(); const created = await client.requestParsed(Card, "/api/card", { method: "POST", diff --git a/src/commands/query.ts b/src/commands/query.ts index 7ea877a..71359a2 100644 --- a/src/commands/query.ts +++ b/src/commands/query.ts @@ -13,6 +13,7 @@ import { readBody } from "../runtime/body"; import { bodyInputFlags } from "./body-flags"; import { connectionFlags, outputFlags, profileFlag } from "./flags"; import { defineMetabaseCommand } from "./runtime"; +import { skipValidateFlag } from "./validate-query"; const QueryBody = z.unknown(); @@ -52,6 +53,7 @@ export default defineMetabaseCommand({ description: "Emit the bundled MBQL 5 query JSON Schema (with --external for the string-FK variant) and exit; no body required", }, + ...skipValidateFlag, }, outputSchema: CardQueryResult, examples: [ @@ -60,6 +62,7 @@ export default defineMetabaseCommand({ "cat q.json | metabase query --dry-run", "metabase query --file q.json", "metabase query --file q.json --external", + "metabase query --file q.json --skip-validate", ], async run({ args, ctx, getClient }) { const mode = args.external === true ? EXTERNAL : INTERNAL; @@ -70,18 +73,24 @@ export default defineMetabaseCommand({ } const dryRun = args["dry-run"] === true; - const body = await readBody({ flag: args.body, file: args.file }, QueryBody); - const outcome = mode.validate(body); - - if (!outcome.ok) { - writeJson(outcome); - const hint = dryRun ? "" : " — pass --dry-run to validate without sending"; - throw new ConfigError(`validation failed: ${outcome.errors.length} error(s)${hint}`); + const skipValidation = args["skip-validate"] === true; + if (dryRun && skipValidation) { + throw new ConfigError("--skip-validate cannot be combined with --dry-run"); } - if (dryRun) { - writeJson(outcome); - return; + const body = await readBody({ flag: args.body, file: args.file }, QueryBody); + + if (!skipValidation) { + const outcome = mode.validate(body); + if (!outcome.ok) { + writeJson(outcome); + const hint = dryRun ? "" : " — pass --dry-run to validate without sending"; + throw new ConfigError(`validation failed: ${outcome.errors.length} error(s)${hint}`); + } + if (dryRun) { + writeJson(outcome); + return; + } } const client = await getClient(); diff --git a/src/commands/transform/create.ts b/src/commands/transform/create.ts index aababc8..50fc1f0 100644 --- a/src/commands/transform/create.ts +++ b/src/commands/transform/create.ts @@ -4,7 +4,7 @@ import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { preflightInternalMbql5Query } from "../validate-query"; +import { preflightInternalMbql5Query, skipValidateFlag } from "../validate-query"; export default defineMetabaseCommand({ meta: { @@ -12,16 +12,25 @@ export default defineMetabaseCommand({ description: "Create a transform; if source.type is `query` and source.query is MBQL 5 (lib/type: mbql/query) it is pre-flight-validated against the same schema as `metabase query` (see `metabase query --print-schema`)", }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + ...skipValidateFlag, + }, outputSchema: Transform, examples: [ "cat transform.json | metabase transform create", "metabase transform create --file transform.json", + "metabase transform create --file transform.json --skip-validate", ], async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, TransformCreateInput); if (body.source.type === "query") { - preflightInternalMbql5Query(body.source.query, "transform.source.query validation failed"); + preflightInternalMbql5Query(body.source.query, "transform.source.query validation failed", { + skip: args["skip-validate"] === true, + }); } const client = await getClient(); const created = await client.requestParsed(Transform, "/api/transform", { diff --git a/src/commands/transform/update.ts b/src/commands/transform/update.ts index d55037a..2d3f2eb 100644 --- a/src/commands/transform/update.ts +++ b/src/commands/transform/update.ts @@ -5,7 +5,7 @@ import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { preflightInternalMbql5Query } from "../validate-query"; +import { preflightInternalMbql5Query, skipValidateFlag } from "../validate-query"; export default defineMetabaseCommand({ meta: { @@ -19,18 +19,22 @@ export default defineMetabaseCommand({ ...connectionFlags, ...bodyInputFlags, id: { type: "positional", description: "Transform id", required: true }, + ...skipValidateFlag, }, outputSchema: Transform, examples: [ "cat patch.json | metabase transform update 1", "metabase transform update 1 --file patch.json", 'metabase transform update 1 --body \'{"name":"renamed"}\'', + "metabase transform update 1 --file patch.json --skip-validate", ], async run({ args, ctx, getClient }) { const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, TransformUpdateInput); if (body.source !== undefined && body.source.type === "query") { - preflightInternalMbql5Query(body.source.query, "transform.source.query validation failed"); + preflightInternalMbql5Query(body.source.query, "transform.source.query validation failed", { + skip: args["skip-validate"] === true, + }); } const client = await getClient(); const updated = await client.requestParsed(Transform, `/api/transform/${id}`, { diff --git a/src/commands/validate-query.test.ts b/src/commands/validate-query.test.ts index 20e8720..cde8a0e 100644 --- a/src/commands/validate-query.test.ts +++ b/src/commands/validate-query.test.ts @@ -34,15 +34,16 @@ describe("preflightInternalMbql5Query", () => { preflightInternalMbql5Query( { type: "query", database: 1, query: { "source-table": 5 } }, "card.dataset_query validation failed", + { skip: false }, ); expect(streams.stdout).toBe(""); expect(streams.stderr).toBe(""); }); it("returns silently when the body is undefined / null / non-object", () => { - preflightInternalMbql5Query(undefined, "x"); - preflightInternalMbql5Query(null, "x"); - preflightInternalMbql5Query("native sql", "x"); + preflightInternalMbql5Query(undefined, "x", { skip: false }); + preflightInternalMbql5Query(null, "x", { skip: false }); + preflightInternalMbql5Query("native sql", "x", { skip: false }); expect(streams.stdout).toBe(""); }); @@ -54,6 +55,7 @@ describe("preflightInternalMbql5Query", () => { stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], }, "card.dataset_query validation failed", + { skip: false }, ); expect(streams.stdout).toBe(""); expect(streams.stderr).toBe(""); @@ -68,6 +70,7 @@ describe("preflightInternalMbql5Query", () => { stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], }, "card.dataset_query validation failed", + { skip: false }, ), ).toThrow( new ConfigError( @@ -79,4 +82,18 @@ describe("preflightInternalMbql5Query", () => { errors: [{ path: "/database", message: "must be integer" }], }); }); + + it("returns silently when skip is true, regardless of body validity", () => { + preflightInternalMbql5Query( + { + "lib/type": "mbql/query", + database: "oops", + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], + }, + "card.dataset_query validation failed", + { skip: true }, + ); + expect(streams.stdout).toBe(""); + expect(streams.stderr).toBe(""); + }); }); diff --git a/src/commands/validate-query.ts b/src/commands/validate-query.ts index d0beb94..ce6fae4 100644 --- a/src/commands/validate-query.ts +++ b/src/commands/validate-query.ts @@ -2,9 +2,28 @@ import { ConfigError } from "../core/errors"; import { isMbql5Query, validateInternalQuery } from "../core/schema/validate"; import { writeJson } from "../output/render"; +export const skipValidateFlag = { + "skip-validate": { + type: "boolean", + description: + "Skip the local MBQL 5 pre-flight validation; let the server be the authority. Use only when the bundled schema disagrees with what the server accepts.", + }, +} as const; + +export interface PreflightOptions { + readonly skip: boolean; +} + // Skips MBQL 4 / native — we only have a schema for MBQL 5 today, and the // legacy formats are still accepted by the server. -export function preflightInternalMbql5Query(query: unknown, contextLabel: string): void { +export function preflightInternalMbql5Query( + query: unknown, + contextLabel: string, + options: PreflightOptions, +): void { + if (options.skip) { + return; + } if (!isMbql5Query(query)) { return; } diff --git a/tests/e2e/card.e2e.test.ts b/tests/e2e/card.e2e.test.ts index 72742fc..517f76c 100644 --- a/tests/e2e/card.e2e.test.ts +++ b/tests/e2e/card.e2e.test.ts @@ -348,6 +348,32 @@ describe("card e2e", () => { ); }); + it("create --skip-validate bypasses the MBQL 5 pre-flight (server is the authority)", async () => { + const result = await runCli({ + args: ["card", "create", "--skip-validate", "--json"], + stdin: JSON.stringify({ + name: "skip-validate-bypass", + display: "table", + visualization_settings: {}, + collection_id: E2E_COLLECTIONS.DEFAULT, + dataset_query: { + "lib/type": "mbql/query", + database: "oops not an integer", + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": E2E_TABLES.ORDERS }], + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + // Pre-flight is bypassed; the server then rejects the malformed body with an HttpError (exit 1). + // The card-create endpoint surfaces the underlying app-DB constraint message via the response + // envelope; we assert a stable substring of that surfaced error. + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('NULL not allowed for column "DATABASE_ID"'); + expect(result.stdout).toBe(""); + }); + it("archive with a non-integer id fails fast with ConfigError", async () => { const result = await runCli({ args: ["card", "archive", "abc", "--json"], diff --git a/tests/e2e/query.e2e.test.ts b/tests/e2e/query.e2e.test.ts index 2b2ca6d..15d060f 100644 --- a/tests/e2e/query.e2e.test.ts +++ b/tests/e2e/query.e2e.test.ts @@ -198,6 +198,35 @@ describe("query e2e", () => { expect(result.stdout).toBe(""); }); + it("--skip-validate combined with --dry-run is rejected with ConfigError", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--skip-validate", "--dry-run"], + stdin: JSON.stringify(VALID_INTERNAL), + configHome, + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("--skip-validate cannot be combined with --dry-run"); + expect(result.stdout).toBe(""); + }); + + it("--skip-validate sends an invalid body and surfaces the server-side error (HttpError, exit 1)", async () => { + const configHome = await makeIsolatedConfigHome(); + // External-shaped body in default (internal) mode would fail pre-flight; with --skip-validate the + // body reaches the server, which rejects it with an HTTP error. + const result = await runCli({ + args: ["query", "--skip-validate", "--json"], + stdin: JSON.stringify(VALID_EXTERNAL), + configHome, + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Metabase returned 400"); + expect(result.stdout).toBe(""); + }); + it("run (default = internal) executes a valid MBQL 5 query against /api/dataset and returns rows", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index fc6b5d2..8011134 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -355,6 +355,35 @@ describe("transform e2e", () => { ); }); + it("create --skip-validate bypasses the MBQL 5 pre-flight (server is the authority)", async () => { + const result = await runCli({ + args: ["transform", "create", "--skip-validate", "--json"], + stdin: JSON.stringify({ + name: "skip-validate-bypass", + source: { + type: "query", + query: { + "lib/type": "mbql/query", + database: "oops", + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": E2E_TABLES.ORDERS }], + }, + }, + target: { + type: "table", + database: E2E_DATABASES.WAREHOUSE, + schema: "public", + name: "skip_validate_bypass_target", + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Metabase returned 400"); + expect(result.stdout).toBe(""); + }); + it("delete without --yes and without TTY stdin fails with ConfigError", async () => { const result = await runCli({ args: ["transform", "delete", "1", "--json"], From 5ab9ca657f087920fb684bc2e01b96aa83b79d62 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Thu, 7 May 2026 13:01:44 -0400 Subject: [PATCH 10/47] metadata --- src/commands/workspace/start.ts | 5 ++++- src/core/docker.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts index 13f4723..b4baccd 100644 --- a/src/commands/workspace/start.ts +++ b/src/commands/workspace/start.ts @@ -293,7 +293,10 @@ async function fetchConfigYaml(client: Client, workspaceId: number): Promise { const response = await client.requestRaw( `/api/ee/workspace-manager/${workspaceId}/metadata/export`, - { expectContentType: "binary" }, + { + expectContentType: "binary", + query: { "with-databases": true, "with-tables": true, "with-fields": true }, + }, ); return new Uint8Array(await response.arrayBuffer()); } diff --git a/src/core/docker.ts b/src/core/docker.ts index a65c198..0df6c74 100644 --- a/src/core/docker.ts +++ b/src/core/docker.ts @@ -395,7 +395,7 @@ function workspaceContainerEnv(spec: WorkspaceContainerSpec): Record Date: Thu, 7 May 2026 21:15:22 -0400 Subject: [PATCH 11/47] bug fixes --- src/commands/setting/get.ts | 6 +- src/commands/sync/poll-task.ts | 6 +- src/runtime/json.test.ts | 160 ++++++++++++++++++++++----------- src/runtime/json.ts | 13 +++ tests/e2e/setting.e2e.test.ts | 31 +++++++ 5 files changed, 158 insertions(+), 58 deletions(-) diff --git a/src/commands/setting/get.ts b/src/commands/setting/get.ts index c59b484..0776a72 100644 --- a/src/commands/setting/get.ts +++ b/src/commands/setting/get.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { Client } from "../../core/http/client"; import { SettingValue, settingValueView } from "../../domain/setting"; import { renderItem } from "../../output/render"; -import { parseJson } from "../../runtime/json"; +import { parseJsonOrPlain } from "../../runtime/json"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; @@ -37,5 +37,7 @@ async function fetchSettingValue(client: Client, key: string): Promise return null; } const text = await response.text(); - return parseJson(text, z.unknown(), { source: response.url }); + return parseJsonOrPlain(text, response.headers.get("content-type"), z.unknown(), { + source: response.url, + }); } diff --git a/src/commands/sync/poll-task.ts b/src/commands/sync/poll-task.ts index b1137b4..08f81dd 100644 --- a/src/commands/sync/poll-task.ts +++ b/src/commands/sync/poll-task.ts @@ -3,7 +3,7 @@ import { z, type ZodType } from "zod"; import { SyncTask, type SyncTaskStatus } from "../../domain/remote-sync"; import type { Client } from "../../core/http/client"; import type { ResourceView } from "../../domain/view"; -import { parseJson } from "../../runtime/json"; +import { parseJsonOrPlain } from "../../runtime/json"; import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS, @@ -81,7 +81,9 @@ export async function fetchOptionalParsed( return null; } const text = await response.text(); - return parseJson(text, schema, { source: response.url }); + return parseJsonOrPlain(text, response.headers.get("content-type"), schema, { + source: response.url, + }); } export async function fetchCurrentTask(client: Client): Promise { diff --git a/src/runtime/json.test.ts b/src/runtime/json.test.ts index 3349049..ebb83ba 100644 --- a/src/runtime/json.test.ts +++ b/src/runtime/json.test.ts @@ -1,9 +1,9 @@ import * as fc from "fast-check"; -import { describe, expect, it } from "vitest"; +import { assert, describe, expect, it } from "vitest"; import { z } from "zod"; import { ConfigError, ValidationError } from "../core/errors"; -import { parseJson, parseJsonResult } from "./json"; +import { parseJson, parseJsonOrPlain, parseJsonResult } from "./json"; const Person = z.object({ id: z.number(), name: z.string() }); @@ -23,10 +23,7 @@ describe("parseJson", () => { it("throws ConfigError mentioning the source on malformed JSON", () => { const error = captureThrown(() => parseJson("{ not json }", Person, { source: "--body" })); - expect(error).toBeInstanceOf(ConfigError); - if (!(error instanceof ConfigError)) { - throw new Error("expected ConfigError"); - } + assert(error instanceof ConfigError, "expected ConfigError"); expect(error.message).toContain("--body: invalid JSON: "); }); @@ -34,10 +31,7 @@ describe("parseJson", () => { const error = captureThrown(() => parseJson('{"id":"not-a-number","name":"x"}', Person, { source: "--body" }), ); - expect(error).toBeInstanceOf(ValidationError); - if (!(error instanceof ValidationError)) { - throw new Error("expected ValidationError"); - } + assert(error instanceof ValidationError, "expected ValidationError"); expect(error.message).toContain("--body"); expect(error.developerDetail).toEqual({ source: "--body", @@ -54,10 +48,7 @@ describe("parseJson", () => { it("uses as the default source when none is provided", () => { const error = captureThrown(() => parseJson('{"id":"x","name":"y"}', Person)); - expect(error).toBeInstanceOf(ValidationError); - if (!(error instanceof ValidationError)) { - throw new Error("expected ValidationError"); - } + assert(error instanceof ValidationError, "expected ValidationError"); expect(error.developerDetail.source).toBe(""); }); @@ -76,21 +67,15 @@ describe("parseJsonResult", () => { it("returns a ConfigError on malformed JSON", () => { const result = parseJsonResult("{ not json }", Person, { source: "--body" }); - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("expected failure"); - } - expect(result.error).toBeInstanceOf(ConfigError); + assert(!result.ok, "expected failure"); + assert(result.error instanceof ConfigError, "expected ConfigError"); expect(result.error.message).toContain("--body: invalid JSON: "); }); it("returns a ValidationError when the schema rejects the value", () => { const result = parseJsonResult('{"id":"x","name":"y"}', Person, { source: "--body" }); - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("expected failure"); - } - expect(result.error).toBeInstanceOf(ValidationError); + assert(!result.ok, "expected failure"); + assert(result.error instanceof ValidationError, "expected ValidationError"); expect(result.error.message).toContain("--body"); }); @@ -124,11 +109,8 @@ describe("parseJsonResult on non-JSON strings", () => { "returns full ConfigError envelope for %j", (input) => { const result = parseJsonResult(input, z.unknown(), { source: "fixture" }); - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("expected failure"); - } - expect(result.error).toBeInstanceOf(ConfigError); + assert(!result.ok, "expected failure"); + assert(result.error instanceof ConfigError, "expected ConfigError"); expect(result.error.message).toContain("fixture: invalid JSON: "); }, ); @@ -143,13 +125,8 @@ describe("parseJsonResult on schema mismatch", () => { ['{"a":1}', "Invalid input: expected string, received object"], ])("returns full ValidationError envelope for %j vs z.string()", (input, expectedMessage) => { const result = parseJsonResult(input, z.string(), { source: "fixture" }); - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("expected failure"); - } - if (!(result.error instanceof ValidationError)) { - throw new Error("expected ValidationError"); - } + assert(!result.ok, "expected failure"); + assert(result.error instanceof ValidationError, "expected ValidationError"); expect(result.error.message).toBe("fixture: value did not match expected schema"); expect(result.error.developerDetail).toEqual({ source: "fixture", @@ -186,11 +163,8 @@ describe("parseJson property tests", () => { } fc.pre(!isValidJson); const result = parseJsonResult(input, z.unknown(), { source: "fixture" }); - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("expected failure"); - } - expect(result.error).toBeInstanceOf(ConfigError); + assert(!result.ok, "expected failure"); + assert(result.error instanceof ConfigError, "expected ConfigError"); expect(result.error.message.startsWith("fixture: invalid JSON: ")).toBe(true); }), ); @@ -201,14 +175,8 @@ describe("parseJson property tests", () => { fc.property(fc.integer(), (value) => { const serialized = JSON.stringify(value); const result = parseJsonResult(serialized, z.string(), { source: "fixture" }); - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("expected failure"); - } - expect(result.error).toBeInstanceOf(ValidationError); - if (!(result.error instanceof ValidationError)) { - throw new Error("expected ValidationError"); - } + assert(!result.ok, "expected failure"); + assert(result.error instanceof ValidationError, "expected ValidationError"); expect(result.error.developerDetail.source).toBe("fixture"); }), ); @@ -220,11 +188,8 @@ describe("parseJson property tests", () => { fc.string({ minLength: 1 }).filter((value) => !isParseableJson(value)), (input) => { const result = parseJsonResult(input, z.unknown()); - expect(result.ok).toBe(false); - if (result.ok) { - throw new Error("expected failure"); - } - expect(result.error).toBeInstanceOf(ConfigError); + assert(!result.ok, "expected failure"); + assert(result.error instanceof ConfigError, "expected ConfigError"); expect(result.error.message.startsWith("invalid JSON: ")).toBe(true); }, ), @@ -240,3 +205,90 @@ function isParseableJson(input: string): boolean { return false; } } + +describe("parseJsonOrPlain", () => { + it("parses JSON when content-type is application/json", () => { + expect(parseJsonOrPlain('{"id":1,"name":"x"}', "application/json", Person)).toEqual({ + id: 1, + name: "x", + }); + }); + + it("parses JSON when content-type carries charset alongside application/json", () => { + expect( + parseJsonOrPlain('{"id":1,"name":"x"}', "application/json; charset=utf-8", Person), + ).toEqual({ id: 1, name: "x" }); + }); + + it("treats text/plain bare strings as JSON string literals", () => { + expect(parseJsonOrPlain("agent/shipments-analysis", "text/plain", z.string())).toBe( + "agent/shipments-analysis", + ); + }); + + it("treats a missing content-type as a bare string", () => { + expect(parseJsonOrPlain("agent/shipments-analysis", null, z.string())).toBe( + "agent/shipments-analysis", + ); + }); + + it("treats an empty body with text/plain as an empty string", () => { + expect(parseJsonOrPlain("", "text/plain", z.string())).toBe(""); + }); + + it("preserves bare strings that contain JSON-special characters", () => { + expect(parseJsonOrPlain('he said "hi"\nline2', "text/plain", z.string())).toBe( + 'he said "hi"\nline2', + ); + }); + + it("rejects schema mismatches on JSON content with ValidationError", () => { + const error = captureThrown(() => + parseJsonOrPlain('{"id":"x","name":"y"}', "application/json", Person, { source: "fixture" }), + ); + assert(error instanceof ValidationError, "expected ValidationError"); + expect(error.message).toBe("fixture: value did not match expected schema"); + expect(error.developerDetail).toEqual({ + source: "fixture", + zodIssues: [ + { + code: "invalid_type", + expected: "number", + path: ["id"], + message: "Invalid input: expected number, received string", + }, + ], + }); + }); + + it("rejects malformed JSON content with ConfigError", () => { + const error = captureThrown(() => + parseJsonOrPlain("{ not json }", "application/json", Person, { source: "fixture" }), + ); + assert(error instanceof ConfigError, "expected ConfigError"); + expect(error.message).toContain("fixture: invalid JSON: "); + }); + + it("rejects schema mismatches on plain-text content with ValidationError", () => { + const error = captureThrown(() => + parseJsonOrPlain("not-an-object", "text/plain", Person, { source: "fixture" }), + ); + assert(error instanceof ValidationError, "expected ValidationError"); + expect(error.message).toBe("fixture: value did not match expected schema"); + expect(error.developerDetail).toEqual({ + source: "fixture", + zodIssues: [ + { + code: "invalid_type", + expected: "object", + path: [], + message: "Invalid input: expected object, received string", + }, + ], + }); + }); + + it("does not treat application/json-prefixed but other content as JSON when prefix is absent", () => { + expect(parseJsonOrPlain("read-write", "text/plain", z.string())).toBe("read-write"); + }); +}); diff --git a/src/runtime/json.ts b/src/runtime/json.ts index 6b9269e..5d6941c 100644 --- a/src/runtime/json.ts +++ b/src/runtime/json.ts @@ -45,3 +45,16 @@ export function parseJsonResult( } return { ok: true, value: parsed.data }; } + +const JSON_CONTENT_TYPE = "application/json"; + +export function parseJsonOrPlain( + text: string, + contentType: string | null, + schema: ZodType, + opts: ParseJsonOptions = {}, +): T { + const isJson = contentType !== null && contentType.includes(JSON_CONTENT_TYPE); + const payload = isJson ? text : JSON.stringify(text); + return parseJson(payload, schema, opts); +} diff --git a/tests/e2e/setting.e2e.test.ts b/tests/e2e/setting.e2e.test.ts index b936c8c..a890392 100644 --- a/tests/e2e/setting.e2e.test.ts +++ b/tests/e2e/setting.e2e.test.ts @@ -218,6 +218,37 @@ describe("setting e2e", () => { expect(result.stdout).toBe(""); }); + it("get --json on a string-valued setting wraps the bare server response", async () => { + const STRING_KEY = "site-name"; + const ORIGINAL = "Metabase"; + const TARGET = "metabase-cli e2e site name"; + try { + await adminClient.requestRaw(`/api/setting/${STRING_KEY}`, { + method: "PUT", + body: { value: TARGET }, + expectContentType: "binary", + }); + + const result = await runCli({ + args: ["setting", "get", STRING_KEY, "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SettingValue)).toEqual({ + key: STRING_KEY, + value: TARGET, + }); + } finally { + await adminClient.requestRaw(`/api/setting/${STRING_KEY}`, { + method: "PUT", + body: { value: ORIGINAL }, + expectContentType: "binary", + }); + } + }); + it("get with an invalid setting key (regex fail) fails with ConfigError", async () => { const result = await runCli({ args: ["setting", "get", "..bad..", "--json"], From 2af0be92fc556d5f0f3b227c66ae050830d4f187 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Thu, 7 May 2026 21:27:20 -0400 Subject: [PATCH 12/47] update command --- README.md | 27 ++++++- src/commands/card/index.ts | 1 + src/commands/card/update.ts | 46 ++++++++++++ src/domain/card.ts | 25 +++++++ tests/e2e/card.e2e.test.ts | 129 +++++++++++++++++++++++++++++++++ tests/e2e/manifest.e2e.test.ts | 1 + 6 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 src/commands/card/update.ts diff --git a/README.md b/README.md index 4e6ba90..c12116e 100644 --- a/README.md +++ b/README.md @@ -279,9 +279,28 @@ metabase card create --body '{"name":"x","display":"table","dataset_query":{...} | `--body ` | Inline JSON body. | | `--file ` | Path to JSON body file. | +### `metabase card update ` + +Patch a card. Body is a partial subset of the create shape (`name`, `display`, `dataset_query`, `visualization_settings`, `description`, `archived`, `collection_id`, `dashboard_id`, `cache_ttl`, `parameters`, `parameter_mappings`, etc.). Only the keys you send are touched. If `dataset_query` is MBQL 5 (`lib/type: "mbql/query"`) it goes through the same pre-flight validation as `card create` and `metabase query`; pass `--skip-validate` to bypass. + +```sh +cat patch.json | metabase card update 1 +metabase card update 1 --file patch.json +metabase card update 1 --body '{"name":"renamed"}' +metabase card update 1 --body '{"display":"bar"}' +metabase card update 1 --body '{"archived":true}' +metabase card update 1 --file patch.json --skip-validate +``` + +| Flag | Description | +| ----------------- | -------------------------------------------------------------------------------------------------------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | +| `--skip-validate` | Skip the local MBQL 5 pre-flight validation; let the server be the authority. Use only when the bundled schema disagrees with what the server accepts. | + ### `metabase card archive ` -Soft-delete a card by setting `archived: true`. The archived card stays available via `card list --filter archived` and `card get ` until permanently deleted server-side. +Soft-delete a card by setting `archived: true`. The archived card stays available via `card list --filter archived` and `card get ` until permanently deleted server-side. To unarchive (or otherwise toggle the flag) use `metabase card update --body '{"archived":false}'`. ```sh metabase card archive 1 @@ -849,13 +868,13 @@ Output by mode: - Run failure (no `--dry-run`) — same `{ ok, errors }` envelope on stdout, exit 2, no request made. - Run success — the streamed `CardQueryResult`. -### MBQL 5 pre-flight in `card create` and `transform create`/`update` +### MBQL 5 pre-flight in `card create`/`update` and `transform create`/`update` When the embedded query (`card.dataset_query`, or `transform.source.query` for `source.type: "query"`) is MBQL 5 (`lib/type: "mbql/query"`), it is pre-flight-validated against the same schema as `metabase query`. Validation failure: `{ ok, errors }` envelope on stdout, exit 2, request not made. MBQL 4 (legacy) bodies and Python transform sources skip validation — they're still accepted by the server and we don't ship a schema for them. -Pass `--skip-validate` to bypass the pre-flight on `card create`, `transform create`, or `transform update` — the body is sent as-is and the server is the authority. Same escape hatch as on `metabase query`; use only when the bundled schema disagrees with what the server actually accepts. +Pass `--skip-validate` to bypass the pre-flight on `card create`, `card update`, `transform create`, or `transform update` — the body is sent as-is and the server is the authority. Same escape hatch as on `metabase query`; use only when the bundled schema disagrees with what the server actually accepts. -Agent discovery path: `metabase __manifest` lists every command's args and description; the description for `card create` and `transform create`/`update` references `metabase query --print-schema` so an agent can fetch the validating schema directly. +Agent discovery path: `metabase __manifest` lists every command's args and description; the description for `card create`/`update` and `transform create`/`update` references `metabase query --print-schema` so an agent can fetch the validating schema directly. The bundled query schema is synced from a pinned `@metabase/representations` release via `bun run sync:representations`; CI guards against drift. diff --git a/src/commands/card/index.ts b/src/commands/card/index.ts index 3c3e730..3514eed 100644 --- a/src/commands/card/index.ts +++ b/src/commands/card/index.ts @@ -7,6 +7,7 @@ export default defineCommand({ get: () => import("./get").then((mod) => mod.default), query: () => import("./query").then((mod) => mod.default), create: () => import("./create").then((mod) => mod.default), + update: () => import("./update").then((mod) => mod.default), archive: () => import("./archive").then((mod) => mod.default), }, }); diff --git a/src/commands/card/update.ts b/src/commands/card/update.ts new file mode 100644 index 0000000..a77330d --- /dev/null +++ b/src/commands/card/update.ts @@ -0,0 +1,46 @@ +import { Card, CardUpdateInput, cardView } from "../../domain/card"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; +import { preflightInternalMbql5Query, skipValidateFlag } from "../validate-query"; + +export default defineMetabaseCommand({ + meta: { + name: "update", + description: + "Update a card by id; if dataset_query is MBQL 5 (lib/type: mbql/query) it is pre-flight-validated against the same schema as `metabase query` (see `metabase query --print-schema`)", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + ...skipValidateFlag, + id: { type: "positional", description: "Card id", required: true }, + }, + outputSchema: Card, + examples: [ + "cat patch.json | metabase card update 1", + "metabase card update 1 --file patch.json", + 'metabase card update 1 --body \'{"name":"renamed"}\'', + 'metabase card update 1 --body \'{"display":"bar"}\'', + "metabase card update 1 --body '{\"archived\":true}'", + "metabase card update 1 --file patch.json --skip-validate", + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const body = await readBody({ flag: args.body, file: args.file }, CardUpdateInput); + preflightInternalMbql5Query(body.dataset_query, "card.dataset_query validation failed", { + skip: args["skip-validate"] === true, + }); + const client = await getClient(); + const updated = await client.requestParsed(Card, `/api/card/${id}`, { + method: "PUT", + body, + }); + renderItem(updated, cardView, ctx); + }, +}); diff --git a/src/domain/card.ts b/src/domain/card.ts index f166002..17c993e 100644 --- a/src/domain/card.ts +++ b/src/domain/card.ts @@ -69,6 +69,31 @@ export const CardCreateInput = z .loose(); export type CardCreateInput = z.infer; +export const CardUpdateInput = z + .object({ + name: z.string().min(1).optional(), + type: CardType.optional(), + dataset_query: z.unknown().optional(), + display: z.string().min(1).optional(), + visualization_settings: z.unknown().optional(), + description: z.string().nullable().optional(), + archived: z.boolean().optional(), + enable_embedding: z.boolean().optional(), + embedding_type: z.string().optional(), + embedding_params: z.unknown().optional(), + collection_id: z.number().int().positive().nullable().optional(), + collection_position: z.number().int().positive().nullable().optional(), + collection_preview: z.boolean().optional(), + cache_ttl: z.number().int().positive().nullable().optional(), + dashboard_id: z.number().int().positive().nullable().optional(), + dashboard_tab_id: z.number().int().positive().nullable().optional(), + parameters: z.array(z.unknown()).optional(), + parameter_mappings: z.array(z.unknown()).optional(), + result_metadata: z.array(z.unknown()).nullable().optional(), + }) + .loose(); +export type CardUpdateInput = z.infer; + const QueryColumn = z .object({ name: z.string(), diff --git a/tests/e2e/card.e2e.test.ts b/tests/e2e/card.e2e.test.ts index 517f76c..7465729 100644 --- a/tests/e2e/card.e2e.test.ts +++ b/tests/e2e/card.e2e.test.ts @@ -385,4 +385,133 @@ describe("card e2e", () => { expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); expect(result.stdout).toBe(""); }); + + it("update renames the card and the compact view reflects the new name", async () => { + const result = await runCli({ + args: ["card", "update", String(E2E_CARDS.ORDERS_BY_STATUS), "--json"], + stdin: JSON.stringify({ name: "Orders by status (renamed)" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, CardCompact)).toEqual({ + ...ORDERS_BY_STATUS_COMPACT, + name: "Orders by status (renamed)", + }); + }); + + it("update flips archived to true and the archived list reflects it", async () => { + const updateResult = await runCli({ + args: ["card", "update", String(E2E_CARDS.ORDERS_BY_STATUS), "--json"], + stdin: JSON.stringify({ archived: true }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(updateResult.exitCode, updateResult.stderr).toBe(0); + expect(parseJson(updateResult.stdout, CardCompact)).toEqual({ + ...ORDERS_BY_STATUS_COMPACT, + archived: true, + }); + + const archivedListResult = await runCli({ + args: ["card", "list", "--filter", "archived", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(archivedListResult.exitCode, archivedListResult.stderr).toBe(0); + expect(parseJson(archivedListResult.stdout, CardListEnvelope)).toEqual({ + data: [{ ...ORDERS_BY_STATUS_COMPACT, archived: true }], + returned: 1, + total: 1, + }); + }); + + it("update changes display from table to bar without disturbing other fields", async () => { + const result = await runCli({ + args: ["card", "update", String(E2E_CARDS.ORDERS_BY_STATUS), "--json"], + stdin: JSON.stringify({ display: "bar" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, CardCompact)).toEqual({ + ...ORDERS_BY_STATUS_COMPACT, + display: "bar", + }); + }); + + it("update with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["card", "update", "abc", "--json"], + stdin: JSON.stringify({ name: "x" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("update against a missing card id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["card", "update", "9999999", "--json"], + stdin: JSON.stringify({ name: "x" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("update with invalid MBQL 5 dataset_query fails pre-flight before sending", async () => { + const result = await runCli({ + args: ["card", "update", String(E2E_CARDS.ORDERS_BY_STATUS), "--json"], + stdin: JSON.stringify({ + dataset_query: { + "lib/type": "mbql/query", + database: "oops not an integer", + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": E2E_TABLES.ORDERS, + }, + ], + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/database", message: "must be integer" }], + }); + expect(result.stderr).toContain( + "card.dataset_query validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", + ); + }); + + it("update --skip-validate bypasses the MBQL 5 pre-flight (server is the authority)", async () => { + const result = await runCli({ + args: ["card", "update", String(E2E_CARDS.ORDERS_BY_STATUS), "--skip-validate", "--json"], + stdin: JSON.stringify({ + dataset_query: { + "lib/type": "mbql/query", + database: "oops not an integer", + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": E2E_TABLES.ORDERS }], + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + // Pre-flight is bypassed; the server then rejects the malformed body with an HttpError (exit 1). + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + }); }); diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index b236ddd..bda6f99 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -46,6 +46,7 @@ describe("__manifest e2e", () => { "card get", "card query", "card create", + "card update", "card archive", "dashboard list", "dashboard get", From 5e12c24a322c441714e3bd40b02bb5afe7128fa2 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Thu, 7 May 2026 22:10:52 -0400 Subject: [PATCH 13/47] fixes --- README.md | 65 +++++++++-------- src/commands/auth/logout.ts | 6 +- src/commands/dashboard/create.ts | 21 +++++- src/commands/delete-runtime.ts | 6 +- src/commands/license/remove.ts | 6 +- src/commands/sync/export.ts | 11 +++ src/commands/validate-query.test.ts | 39 ++++++++++ src/commands/validate-query.ts | 13 +++- src/commands/workspace/remove.ts | 2 +- src/core/schema/validate.test.ts | 106 ++++++++++++++++++++++++++++ src/core/schema/validate.ts | 99 ++++++++++++++++++++++++-- src/domain/dashboard.ts | 2 + src/output/help.test.ts | 15 ++++ src/output/help.ts | 11 ++- src/output/projection.ts | 5 +- src/runtime/input.test.ts | 10 +++ src/runtime/input.ts | 3 + src/runtime/predicates.ts | 3 + tests/e2e/auth.e2e.test.ts | 11 +-- tests/e2e/transform-job.e2e.test.ts | 15 ++-- tests/e2e/transform.e2e.test.ts | 15 ++-- 21 files changed, 391 insertions(+), 73 deletions(-) create mode 100644 src/runtime/predicates.ts diff --git a/README.md b/README.md index c12116e..a1fc6d9 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,10 @@ metabase auth logout --yes metabase auth logout --profile staging --yes ``` -| Flag | Description | -| ------------------ | --------------------------------------- | -| `--profile ` | Profile to clear (default: `default`). | -| `--yes` | Skip confirmation. Required on non-TTY. | +| Flag | Description | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| `--profile ` | Profile to clear (default: `default`). | +| `--yes` | Skip the interactive confirmation prompt. In non-TTY contexts the prompt is skipped automatically (kubectl/gh/docker convention). | ## License @@ -113,9 +113,9 @@ Clear the stored license. metabase license remove --yes ``` -| Flag | Description | -| ------- | --------------------------------------- | -| `--yes` | Skip confirmation. Required on non-TTY. | +| Flag | Description | +| ------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `--yes` | Skip the interactive confirmation prompt. In non-TTY contexts the prompt is skipped automatically (kubectl/gh/docker convention). | Common output flags (`--json`, `--format`, `--detail`, `--fields`, `--max-bytes`) are accepted; the result payload is rendered through the standard output layer. @@ -162,9 +162,9 @@ Same `--body` / `--file` resolution as `create`. Stdin is auto-detected when not metabase transform delete 1 --yes ``` -| Flag | Description | -| ------- | --------------------------------------- | -| `--yes` | Skip confirmation. Required on non-TTY. | +| Flag | Description | +| ------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `--yes` | Skip the interactive confirmation prompt. In non-TTY contexts the prompt is skipped automatically (kubectl/gh/docker convention). | ### `metabase transform run ` @@ -220,9 +220,9 @@ metabase transform-job update 1 --body '{"schedule":"0 0 6 * * ?"}' metabase transform-job delete 1 --yes ``` -| Flag | Description | -| ------- | --------------------------------------- | -| `--yes` | Skip confirmation. Required on non-TTY. | +| Flag | Description | +| ------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `--yes` | Skip the interactive confirmation prompt. In non-TTY contexts the prompt is skipped automatically (kubectl/gh/docker convention). | ## Cards @@ -292,10 +292,10 @@ metabase card update 1 --body '{"archived":true}' metabase card update 1 --file patch.json --skip-validate ``` -| Flag | Description | -| ----------------- | -------------------------------------------------------------------------------------------------------------------- | -| `--body ` | Inline JSON body. | -| `--file ` | Path to JSON body file. | +| Flag | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | | `--skip-validate` | Skip the local MBQL 5 pre-flight validation; let the server be the authority. Use only when the bundled schema disagrees with what the server accepts. | ### `metabase card archive ` @@ -344,16 +344,19 @@ metabase dashboard cards 1 --json ### `metabase dashboard create` +The body accepts the same dashboard-level fields as the underlying `POST /api/dashboard` (`name`, `description`, `parameters`, `cache_ttl`, `collection_id`, `collection_position`). It also accepts optional `dashcards` and `tabs`: when either is present, the CLI chains a `PUT /api/dashboard/:id` after the create and returns the updated dashboard with its dashcards/tabs applied. Use a negative `id` on a dashcard to indicate one the server should newly create. + ```sh cat dashboard.json | metabase dashboard create metabase dashboard create --file dashboard.json metabase dashboard create --body '{"name":"My Dashboard","collection_id":4}' +metabase dashboard create --body '{"name":"D","dashcards":[{"id":-1,"card_id":42,"row":0,"col":0,"size_x":12,"size_y":6}]}' ``` -| Flag | Description | -| --------------- | ----------------------- | -| `--body ` | Inline JSON body. | -| `--file ` | Path to JSON body file. | +| Flag | Description | +| --------------- | --------------------------------------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. Use `-` to read from stdin. | ### `metabase dashboard update ` @@ -663,12 +666,12 @@ metabase workspace database deprovision 1 5 --yes metabase workspace database deprovision 1 5 --yes --wait ``` -| Flag | Description | -| ----------------- | ------------------------------------------------------------ | -| `--yes` | Skip confirmation. Required on non-TTY. | -| `--wait` | Poll until the database entry is removed from the workspace. | -| `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | -| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | +| Flag | Description | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `--yes` | Skip the interactive confirmation prompt. In non-TTY contexts the prompt is skipped automatically (kubectl/gh/docker convention). | +| `--wait` | Poll until the database entry is removed from the workspace. | +| `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | +| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | ### Local runtime @@ -725,10 +728,10 @@ metabase workspace remove 1 --keep-volume --yes Stops and removes the container. By default, also removes the app-db volume — pass `--keep-volume` to preserve it across rebuilds. **Does not affect the remote workspace** on the parent. -| Flag | Description | -| --------------- | ------------------------------------------------------------- | -| `--yes` | Skip confirmation. Required on non-TTY. | -| `--keep-volume` | Preserve the app-db volume (`metabase-workspace--appdb`). | +| Flag | Description | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `--yes` | Skip the interactive confirmation prompt. In non-TTY contexts the prompt is skipped automatically (kubectl/gh/docker convention). | +| `--keep-volume` | Preserve the app-db volume (`metabase-workspace--appdb`). | ### `metabase workspace logs ` diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 5f54530..86f15ac 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -2,7 +2,6 @@ import { z } from "zod"; import { clearProfile } from "../../core/auth/storage"; import { resolveProfileName } from "../../core/config"; -import { ConfigError } from "../../core/errors"; import type { ResourceView } from "../../domain/view"; import { promptConfirm } from "../../output/prompt"; import { renderItem } from "../../output/render"; @@ -37,10 +36,7 @@ export default defineMetabaseCommand({ async run({ args, ctx }) { const profileName = resolveProfileName(args.profile); - if (!args.yes) { - if (!process.stdin.isTTY) { - throw new ConfigError("--yes required to clear credentials non-interactively"); - } + if (!args.yes && process.stdin.isTTY === true) { const ok = await promptConfirm({ message: `Clear stored credentials for profile "${profileName}"?`, initialValue: false, diff --git a/src/commands/dashboard/create.ts b/src/commands/dashboard/create.ts index d61c2e4..42094f1 100644 --- a/src/commands/dashboard/create.ts +++ b/src/commands/dashboard/create.ts @@ -1,4 +1,9 @@ -import { Dashboard, DashboardCreateInput, dashboardView } from "../../domain/dashboard"; +import { + Dashboard, + DashboardCreateInput, + DashboardDetail, + dashboardView, +} from "../../domain/dashboard"; import { renderItem } from "../../output/render"; import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; @@ -13,14 +18,24 @@ export default defineMetabaseCommand({ "cat dashboard.json | metabase dashboard create", "metabase dashboard create --file dashboard.json", 'metabase dashboard create --body \'{"name":"My Dashboard","collection_id":4}\'', + 'metabase dashboard create --body \'{"name":"D","dashcards":[{"id":-1,"card_id":42,"row":0,"col":0,"size_x":12,"size_y":6}]}\'', ], async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, DashboardCreateInput); + const { dashcards, tabs, ...createOnly } = body; const client = await getClient(); const created = await client.requestParsed(Dashboard, "/api/dashboard", { method: "POST", - body, + body: createOnly, }); - renderItem(created, dashboardView, ctx); + if (dashcards === undefined && tabs === undefined) { + renderItem(created, dashboardView, ctx); + return; + } + const updated = await client.requestParsed(DashboardDetail, `/api/dashboard/${created.id}`, { + method: "PUT", + body: { dashcards, tabs }, + }); + renderItem(updated, dashboardView, ctx); }, }); diff --git a/src/commands/delete-runtime.ts b/src/commands/delete-runtime.ts index 41f46d3..1427d48 100644 --- a/src/commands/delete-runtime.ts +++ b/src/commands/delete-runtime.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { ConfigError } from "../core/errors"; import type { Client } from "../core/http/client"; import type { ResourceView } from "../domain/view"; import { promptConfirm } from "../output/prompt"; @@ -35,10 +34,7 @@ export interface ConfirmAndDeleteArgs { } export async function confirmAndDelete(args: ConfirmAndDeleteArgs): Promise { - if (!args.yes) { - if (!process.stdin.isTTY) { - throw new ConfigError("--yes required to delete non-interactively"); - } + if (!args.yes && process.stdin.isTTY === true) { const ok = await promptConfirm({ message: args.promptMessage, initialValue: false, diff --git a/src/commands/license/remove.ts b/src/commands/license/remove.ts index d5f6752..cb2c82b 100644 --- a/src/commands/license/remove.ts +++ b/src/commands/license/remove.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { clearLicense } from "../../core/auth/storage"; -import { ConfigError } from "../../core/errors"; import type { ResourceView } from "../../domain/view"; import { promptConfirm } from "../../output/prompt"; import { renderItem } from "../../output/render"; @@ -31,10 +30,7 @@ export default defineMetabaseCommand({ outputSchema: LicenseRemoveResult, examples: ["metabase license remove --yes"], async run({ args, ctx }) { - if (!args.yes) { - if (!process.stdin.isTTY) { - throw new ConfigError("--yes required to remove license non-interactively"); - } + if (!args.yes && process.stdin.isTTY === true) { const ok = await promptConfirm({ message: "Remove stored license token?", initialValue: false, diff --git a/src/commands/sync/export.ts b/src/commands/sync/export.ts index 7afc6cc..454d869 100644 --- a/src/commands/sync/export.ts +++ b/src/commands/sync/export.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { SyncTask } from "../../domain/remote-sync"; import type { ResourceView } from "../../domain/view"; import { renderItem } from "../../output/render"; +import type { CommonContext } from "../context"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; @@ -90,6 +91,7 @@ export default defineMetabaseCommand({ if (!args.wait) { const result: SyncExportResult = { message: kickoff.message, task_id: kickoff.task_id }; renderItem(result, syncExportView, ctx); + emitRealignHint(ctx); return; } @@ -101,5 +103,14 @@ export default defineMetabaseCommand({ }; renderItem(result, syncExportView, ctx); throwIfFailedTask(final, "export"); + emitRealignHint(ctx); }, }); + +function emitRealignHint(ctx: CommonContext): void { + if (ctx.format !== "text") return; + process.stderr.write( + "\nNote: if exporting to a host-bound repo, realign the host working tree with:\n" + + " git -C restore --staged --worktree .\n", + ); +} diff --git a/src/commands/validate-query.test.ts b/src/commands/validate-query.test.ts index cde8a0e..4427ed7 100644 --- a/src/commands/validate-query.test.ts +++ b/src/commands/validate-query.test.ts @@ -96,4 +96,43 @@ describe("preflightInternalMbql5Query", () => { expect(streams.stdout).toBe(""); expect(streams.stderr).toBe(""); }); + + it("rejects MBQL 5 nested inside a legacy MBQL 4 envelope with a targeted message", () => { + const doubleWrapped = { + type: "query", + database: 2, + query: { + "lib/type": "mbql/query", + database: 2, + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], + }, + }; + expect(() => + preflightInternalMbql5Query(doubleWrapped, "card.dataset_query validation failed", { + skip: false, + }), + ).toThrow( + new ConfigError( + 'card.dataset_query validation failed: MBQL 5 query nested inside a legacy {type:"query", query:…} envelope. ' + + "For MBQL 5, dataset_query is the mbql/query value itself: " + + '{"lib/type":"mbql/query", database:N, stages:[…]}.', + ), + ); + expect(streams.stdout).toBe(""); + expect(streams.stderr).toBe(""); + }); + + it("legacy-envelope detection is bypassed by skip", () => { + preflightInternalMbql5Query( + { + type: "query", + database: 2, + query: { "lib/type": "mbql/query", database: 2, stages: [] }, + }, + "card.dataset_query validation failed", + { skip: true }, + ); + expect(streams.stdout).toBe(""); + expect(streams.stderr).toBe(""); + }); }); diff --git a/src/commands/validate-query.ts b/src/commands/validate-query.ts index ce6fae4..6576af5 100644 --- a/src/commands/validate-query.ts +++ b/src/commands/validate-query.ts @@ -1,5 +1,9 @@ import { ConfigError } from "../core/errors"; -import { isMbql5Query, validateInternalQuery } from "../core/schema/validate"; +import { + isLegacyEnvelopeWrappingMbql5, + isMbql5Query, + validateInternalQuery, +} from "../core/schema/validate"; import { writeJson } from "../output/render"; export const skipValidateFlag = { @@ -24,6 +28,13 @@ export function preflightInternalMbql5Query( if (options.skip) { return; } + if (isLegacyEnvelopeWrappingMbql5(query)) { + throw new ConfigError( + `${contextLabel}: MBQL 5 query nested inside a legacy {type:"query", query:…} envelope. ` + + `For MBQL 5, dataset_query is the mbql/query value itself: ` + + `{"lib/type":"mbql/query", database:N, stages:[…]}.`, + ); + } if (!isMbql5Query(query)) { return; } diff --git a/src/commands/workspace/remove.ts b/src/commands/workspace/remove.ts index ccbe8dc..dc246b5 100644 --- a/src/commands/workspace/remove.ts +++ b/src/commands/workspace/remove.ts @@ -66,7 +66,7 @@ export default defineMetabaseCommand({ await checkDockerReady(); - if (!args.yes) { + if (!args.yes && process.stdin.isTTY === true) { const confirmed = await promptConfirm({ message: shouldRemoveVolume ? `Remove container ${containerName} and its app-db volume ${volumeName}?` diff --git a/src/core/schema/validate.test.ts b/src/core/schema/validate.test.ts index 92f4e3b..cc1aa61 100644 --- a/src/core/schema/validate.test.ts +++ b/src/core/schema/validate.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getQuerySchemaBundle, + isLegacyEnvelopeWrappingMbql5, isMbql5Query, validateExternalQuery, validateInternalQuery, @@ -120,6 +121,111 @@ describe("isMbql5Query", () => { }); }); +describe("isLegacyEnvelopeWrappingMbql5", () => { + it("returns true for an MBQL 5 query nested inside a legacy MBQL 4 envelope", () => { + expect( + isLegacyEnvelopeWrappingMbql5({ + type: "query", + database: 2, + query: { "lib/type": "mbql/query", database: 2, stages: [] }, + }), + ).toBe(true); + }); + + it("returns false for a plain legacy MBQL 4 envelope", () => { + expect( + isLegacyEnvelopeWrappingMbql5({ type: "query", database: 2, query: { "source-table": 7 } }), + ).toBe(false); + }); + + it("returns false for a top-level MBQL 5 query", () => { + expect(isLegacyEnvelopeWrappingMbql5(VALID_INTERNAL)).toBe(false); + }); + + it("returns false for non-objects, arrays, and null", () => { + expect(isLegacyEnvelopeWrappingMbql5(null)).toBe(false); + expect(isLegacyEnvelopeWrappingMbql5("query")).toBe(false); + expect(isLegacyEnvelopeWrappingMbql5([])).toBe(false); + expect( + isLegacyEnvelopeWrappingMbql5({ type: "native", query: { "lib/type": "mbql/query" } }), + ).toBe(false); + }); +}); + +describe("ref-clause error messages", () => { + it("rewrites 'must be string' on aggregation_ref's UUID slot and reports the cascading 'then' shape errors verbatim", () => { + const outcome = validateInternalQuery({ + "lib/type": "mbql/query", + database: 1, + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": 7, + aggregations: [["count", { "lib/uuid": "11111111-1111-1111-1111-111111111111" }]], + "order-by": [ + [ + "asc", + { "lib/uuid": "22222222-2222-2222-2222-222222222222" }, + ["aggregation", { "lib/uuid": "33333333-3333-3333-3333-333333333333" }, 0], + ], + ], + }, + ], + }); + expect(outcome).toEqual({ + ok: false, + errors: [ + { + path: "/stages/0/order-by/0/2/2", + message: "must be the target aggregation's lib/uuid (string), not a numeric position", + }, + { path: "/stages/0/order-by/0/2", message: 'must match "then" schema' }, + { path: "/stages/0/order-by/0/2", message: 'must match "then" schema' }, + { path: "/stages/0/order-by/0", message: 'must match "then" schema' }, + { path: "/stages/0/order-by/0", message: 'must match "then" schema' }, + { path: "/stages/0", message: 'must match "then" schema' }, + ], + }); + }); + + it("rewrites the message for expression refs to reference the name contract", () => { + const outcome = validateInternalQuery({ + "lib/type": "mbql/query", + database: 1, + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": 7, + fields: [["expression", { "lib/uuid": "44444444-4444-4444-4444-444444444444" }, 0]], + }, + ], + }); + expect(outcome).toEqual({ + ok: false, + errors: [ + { + path: "/stages/0/fields/0/2", + message: "must be the target expression's name (string), not a numeric position", + }, + { path: "/stages/0/fields/0", message: 'must match "then" schema' }, + { path: "/stages/0", message: 'must match "then" schema' }, + ], + }); + }); + + it("does not rewrite non-string-typed errors (only 'must be string' at ref-third positions is enriched)", () => { + const outcome = validateInternalQuery({ + "lib/type": "mbql/query", + database: "oops", + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], + }); + expect(outcome).toEqual({ + ok: false, + errors: [{ path: "/database", message: "must be integer" }], + }); + }); +}); + describe("getQuerySchemaBundle", () => { it("external mode bundles the query schema with the string-FK id schema and the other 3 common defs", () => { const bundle = getQuerySchemaBundle("external"); diff --git a/src/core/schema/validate.ts b/src/core/schema/validate.ts index d3c39c8..bee3238 100644 --- a/src/core/schema/validate.ts +++ b/src/core/schema/validate.ts @@ -3,6 +3,8 @@ import addFormats from "ajv-formats"; import type { ValidateFunction } from "ajv"; import { z } from "zod"; +import { isPlainObject } from "../../runtime/predicates"; + import idSchema from "./data/schemas/common/id.json" with { type: "json" }; import parameterSchema from "./data/schemas/common/parameter.json" with { type: "json" }; import querySchema from "./data/schemas/common/query.json" with { type: "json" }; @@ -77,19 +79,86 @@ function runValidator(validator: ValidateFunction, value: unknown): ValidationOu if (validator(value)) { return { ok: true, errors: [] }; } + const refHints = collectRefShapeHints(value); const issues = validator.errors ?? []; const errors = issues.map((issue) => { if (issue.message === undefined) { throw new Error(`Ajv issue at ${issue.instancePath} has no message`); } - return { - path: issue.instancePath === "" ? "/" : issue.instancePath, - message: issue.message, - }; + const path = issue.instancePath === "" ? "/" : issue.instancePath; + const enrichedMessage = refHints.get(path); + return { path, message: enrichedMessage ?? issue.message }; }); return { ok: false, errors }; } +// Walks the candidate query and identifies ref-clause arrays whose third +// element violates its kind-specific contract. Ajv reports these as bare +// "must be string", which doesn't tell the caller *which* string is meant +// (target aggregation's lib/uuid? expression's name?). We carry the kind in +// from the parent so the swapped message names the contract directly. +function collectRefShapeHints(root: unknown): Map { + const hints = new Map(); + visit(root, ""); + return hints; + + function visit(node: unknown, path: string): void { + if (Array.isArray(node)) { + const refMessage = refShapeMessage(node); + if (refMessage !== null) { + hints.set(`${path}/2`, refMessage); + } + for (let index = 0; index < node.length; index += 1) { + visit(node[index], `${path}/${index}`); + } + return; + } + if (!isPlainObject(node)) { + return; + } + for (const key of Object.keys(node)) { + const segment = key.replace(/~/g, "~0").replace(/\//g, "~1"); + visit(node[key], `${path}/${segment}`); + } + } +} + +function refShapeMessage(clause: readonly unknown[]): string | null { + if (clause.length !== 3) { + return null; + } + const kind = clause[0]; + if (typeof kind !== "string") { + return null; + } + const hint = refHintForKind(kind); + if (hint === null) { + return null; + } + if (typeof clause[2] === "string") { + return null; + } + return hint; +} + +// Only `aggregation` and `expression` refs have unambiguously string-typed +// third elements. `metric`, `measure`, and `segment` refs accept entity ids +// that may be integer or string depending on the resource, so a "must be +// string" rewrite for those would mislead — leave Ajv's bare message alone. +function refHintForKind(kind: string): string | null { + switch (kind) { + case "aggregation": { + return "must be the target aggregation's lib/uuid (string), not a numeric position"; + } + case "expression": { + return "must be the target expression's name (string), not a numeric position"; + } + default: { + return null; + } + } +} + export function validateExternalQuery(value: unknown): ValidationOutcome { return runValidator(getExternalValidator(), value); } @@ -105,6 +174,28 @@ export function isMbql5Query(value: unknown): boolean { return "lib/type" in value && value["lib/type"] === "mbql/query"; } +// Detects the double-wrap footgun: an MBQL 5 query (`{lib/type: "mbql/query", …}`) +// nested inside a legacy MBQL 4 envelope (`{type: "query", database: N, query: {…}}`). +// The server stores this without complaint and only fails at run time with +// "Initial MBQL stage must have either :source-table or :source-card", because +// the legacy normalizer descends into `query` expecting legacy shape. +export function isLegacyEnvelopeWrappingMbql5(value: unknown): boolean { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + if (!("type" in value) || value["type"] !== "query") { + return false; + } + if (!("query" in value)) { + return false; + } + const inner = value["query"]; + if (typeof inner !== "object" || inner === null || Array.isArray(inner)) { + return false; + } + return "lib/type" in inner && inner["lib/type"] === "mbql/query"; +} + export const SchemaMode = z.enum(["external", "internal"]); export type SchemaMode = z.infer; diff --git a/src/domain/dashboard.ts b/src/domain/dashboard.ts index aa24deb..25f6b3b 100644 --- a/src/domain/dashboard.ts +++ b/src/domain/dashboard.ts @@ -112,6 +112,8 @@ export const DashboardCreateInput = z cache_ttl: z.number().int().positive().optional(), collection_id: z.number().int().positive().nullable().optional(), collection_position: z.number().int().positive().nullable().optional(), + dashcards: z.array(z.unknown()).optional(), + tabs: z.array(DashboardTab.partial()).optional(), }) .loose(); export type DashboardCreateInput = z.infer; diff --git a/src/output/help.test.ts b/src/output/help.test.ts index c942f1f..7c0f461 100644 --- a/src/output/help.test.ts +++ b/src/output/help.test.ts @@ -83,4 +83,19 @@ describe("showUsage", () => { const out = chunks.join(""); expect(out).not.toContain("EXAMPLES"); }); + + it("appends a SCHEMA section pointing to __manifest on every help page", async () => { + const cmd = defineCommand({ + meta: { name: "demo", description: "demo cmd" }, + args: {}, + run() { + return; + }, + }); + + await showUsage(cmd); + const out = chunks.join(""); + expect(out).toContain("SCHEMA"); + expect(out).toContain("metabase __manifest"); + }); }); diff --git a/src/output/help.ts b/src/output/help.ts index 2a79db2..c7a8140 100644 --- a/src/output/help.ts +++ b/src/output/help.ts @@ -19,7 +19,7 @@ export async function showUsage( const stripped = first === undefined ? "" : first.replace(BREADCRUMB_SUFFIX, "$1"); const body = [stripped, ...rest].join("\n"); const examples = getMetabaseAugment(cmd)?.examples ?? []; - process.stdout.write(body + renderExamples(examples) + "\n"); + process.stdout.write(body + renderExamples(examples) + renderSchemaHint() + "\n"); } function renderExamples(examples: readonly string[]): string { @@ -32,3 +32,12 @@ function renderExamples(examples: readonly string[]): string { } return lines.join("\n"); } + +function renderSchemaHint(): string { + return [ + "", + "SCHEMA", + "", + " metabase __manifest # machine-readable command tree (flags, output, examples)", + ].join("\n"); +} diff --git a/src/output/projection.ts b/src/output/projection.ts index 1b93b67..094789e 100644 --- a/src/output/projection.ts +++ b/src/output/projection.ts @@ -1,5 +1,6 @@ import { ConfigError } from "../core/errors"; import type { ResourceView } from "../domain/view"; +import { isPlainObject } from "../runtime/predicates"; export function applyProjection( value: T, @@ -68,6 +69,4 @@ function setPath(target: Record, parts: string[], value: unknow } } -export function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +export { isPlainObject }; diff --git a/src/runtime/input.test.ts b/src/runtime/input.test.ts index 05ad61b..d0b44a1 100644 --- a/src/runtime/input.test.ts +++ b/src/runtime/input.test.ts @@ -139,4 +139,14 @@ describe("readInput precedence", () => { } expect(error.message).toBe(`--file not found: ${missing}`); }); + + it("treats --file - as stdin", async () => { + setStdin(piped("from-stdin-via-dash")); + expect(await readInput({ file: "-" })).toBe("from-stdin-via-dash"); + }); + + it("--file - returns empty string when stdin is empty", async () => { + setStdin(piped("")); + expect(await readInput({ file: "-" })).toBe(""); + }); }); diff --git a/src/runtime/input.ts b/src/runtime/input.ts index 3f74fe5..668cd14 100644 --- a/src/runtime/input.ts +++ b/src/runtime/input.ts @@ -38,6 +38,9 @@ export async function readInput(sources: InputSources): Promise { } async function readFileSource(path: string): Promise { + if (path === "-") { + return await readStdin(); + } try { return await readFile(path, "utf8"); } catch (error) { diff --git a/src/runtime/predicates.ts b/src/runtime/predicates.ts new file mode 100644 index 0000000..4101929 --- /dev/null +++ b/src/runtime/predicates.ts @@ -0,0 +1,3 @@ +export function isPlainObject(value: unknown): value is { readonly [key: string]: unknown } { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/tests/e2e/auth.e2e.test.ts b/tests/e2e/auth.e2e.test.ts index 17c6269..6eec1b2 100644 --- a/tests/e2e/auth.e2e.test.ts +++ b/tests/e2e/auth.e2e.test.ts @@ -144,7 +144,7 @@ describe("auth e2e", () => { }); }); - it("logout fails with ConfigError exit code without --yes when stdin is not a TTY", async () => { + it("logout proceeds without --yes when stdin is not a TTY (non-interactive auto-confirm)", async () => { const configHome = await makeIsolatedConfigHome(); const logout = await runCli({ @@ -153,8 +153,11 @@ describe("auth e2e", () => { stdin: "", }); - expect(logout.exitCode).toBe(2); - expect(logout.stderr).toContain("--yes required to clear credentials non-interactively"); - expect(logout.stdout).toBe(""); + expect(logout.exitCode, logout.stderr).toBe(0); + expect(parseJson(logout.stdout, LogoutResult)).toEqual({ + profile: "default", + cleared: false, + aborted: false, + }); }); }); diff --git a/tests/e2e/transform-job.e2e.test.ts b/tests/e2e/transform-job.e2e.test.ts index 054c296..b81da42 100644 --- a/tests/e2e/transform-job.e2e.test.ts +++ b/tests/e2e/transform-job.e2e.test.ts @@ -220,15 +220,20 @@ describe("transform-job e2e", () => { expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); }); - it("delete without --yes and without TTY stdin fails with ConfigError", async () => { + it("delete without --yes proceeds in non-TTY (auto-confirm matches kubectl/gh/docker convention)", async () => { + await createSeedJob(); + const result = await runCli({ - args: ["transform-job", "delete", "1", "--json"], + args: ["transform-job", "delete", String(FIRST_USER_JOB_ID), "--json"], stdin: "", configHome: await makeIsolatedConfigHome(), env: authEnv(), }); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("--yes required to delete non-interactively"); - expect(result.stdout).toBe(""); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, DeleteResult)).toEqual({ + deleted: true, + aborted: false, + id: FIRST_USER_JOB_ID, + }); }); }); diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index 8011134..b63b2ff 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -384,15 +384,20 @@ describe("transform e2e", () => { expect(result.stdout).toBe(""); }); - it("delete without --yes and without TTY stdin fails with ConfigError", async () => { + it("delete without --yes proceeds in non-TTY (auto-confirm matches kubectl/gh/docker convention)", async () => { + await createSeedTransform(); + const result = await runCli({ - args: ["transform", "delete", "1", "--json"], + args: ["transform", "delete", String(FIRST_TRANSFORM_ID), "--json"], stdin: "", configHome: await makeIsolatedConfigHome(), env: authEnv(), }); - expect(result.exitCode).toBe(2); - expect(result.stderr).toContain("--yes required to delete non-interactively"); - expect(result.stdout).toBe(""); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, DeleteResult)).toEqual({ + deleted: true, + aborted: false, + id: FIRST_TRANSFORM_ID, + }); }); }); From eaec321351919dddc9cff45be6cc6fd0718fbe79 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Thu, 7 May 2026 22:41:48 -0400 Subject: [PATCH 14/47] fixes --- README.md | 13 +++++ src/commands/auth/index.ts | 1 + src/commands/auth/list.test.ts | 94 +++++++++++++++++++++++++++++++++ src/commands/auth/list.ts | 48 +++++++++++++++++ src/commands/sync/export.ts | 9 ++-- src/core/auth/storage.test.ts | 75 ++++++++++++++++++++++++++ src/core/auth/storage.ts | 96 +++++++++++++++++++++++++++++++++- tests/e2e/manifest.e2e.test.ts | 1 + tests/e2e/profiles.e2e.test.ts | 52 ++++++++++++++++++ 9 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 src/commands/auth/list.test.ts create mode 100644 src/commands/auth/list.ts diff --git a/README.md b/README.md index a1fc6d9..5eaee3d 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,19 @@ metabase auth status --profile staging | `--profile ` | Profile to inspect (default: `default`). | | `--json` | Emit JSON. Auto-enabled on non-TTY. | +### `metabase auth list` + +List configured authentication profiles. The index is maintained at `/profiles.json` and updated on every `auth login` / `auth logout`. Profiles whose URL/API key were stored in the OS keychain before the index existed are picked up by a one-time backfill from `credentials.json`; profiles that exist only in the keyring (no entry in `credentials.json`) appear after the next `auth login` or `auth logout` against them. + +```sh +metabase auth list +metabase auth list --json +``` + +| Flag | Description | +| -------- | ----------------------------------- | +| `--json` | Emit JSON. Auto-enabled on non-TTY. | + ### `metabase auth logout` Clear stored credentials for a profile. diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index d3607d9..ea28ec2 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -6,6 +6,7 @@ export default defineCommand({ subCommands: { login: () => import("./login").then((m) => m.default), status: () => import("./status").then((m) => m.default), + list: () => import("./list").then((m) => m.default), logout: () => import("./logout").then((m) => m.default), }, }); diff --git a/src/commands/auth/list.test.ts b/src/commands/auth/list.test.ts new file mode 100644 index 0000000..f8e3f67 --- /dev/null +++ b/src/commands/auth/list.test.ts @@ -0,0 +1,94 @@ +import { runCommand } from "citty"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ZodType } from "zod"; + +import { parseJson } from "../../runtime/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 authListCommand, { AuthProfileListEnvelope } from "./list"; +import { clearProfile, writeProfile } from "../../core/auth/storage"; +import { setupTempConfigHome, type TempConfigHome } from "../../core/auth/temp-config-home"; + +interface CapturedStdout { + chunks: string[]; + parse: (schema: ZodType) => T; +} + +function captureStdout(): CapturedStdout { + const chunks: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + if (typeof chunk === "string") { + chunks.push(chunk); + } else if (chunk instanceof Uint8Array) { + chunks.push(Buffer.from(chunk).toString("utf8")); + } + return true; + }); + return { + chunks, + parse: (schema: ZodType) => parseJson(chunks.join(""), schema, { source: "stdout" }), + }; +} + +describe("auth list command", () => { + let home: TempConfigHome; + + beforeEach(() => { + hoisted.store.clear(); + home = setupTempConfigHome(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + home.cleanup(); + }); + + it("emits an empty envelope when no profiles are stored", async () => { + const capture = captureStdout(); + await runCommand(authListCommand, { rawArgs: ["--json"] }); + expect(capture.parse(AuthProfileListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("lists every stored profile with sanitized URL and present=true", async () => { + await writeProfile({ url: "https://staging.example.com/path?x=1", apiKey: "k1" }, "staging"); + await writeProfile({ url: "https://prod.example.com", apiKey: "k2" }, "prod"); + + const capture = captureStdout(); + await runCommand(authListCommand, { rawArgs: ["--json"] }); + expect(capture.parse(AuthProfileListEnvelope)).toEqual({ + data: [ + { profile: "prod", url: "https://prod.example.com", present: true }, + { profile: "staging", url: "https://staging.example.com", present: true }, + ], + returned: 2, + total: 2, + }); + }); + + it("drops a profile from the list after clearProfile", async () => { + await writeProfile({ url: "https://a.example.com", apiKey: "a" }, "a"); + await writeProfile({ url: "https://b.example.com", apiKey: "b" }, "b"); + await clearProfile("a"); + + const capture = captureStdout(); + await runCommand(authListCommand, { rawArgs: ["--json"] }); + expect(capture.parse(AuthProfileListEnvelope)).toEqual({ + data: [{ profile: "b", url: "https://b.example.com", present: true }], + returned: 1, + total: 1, + }); + }); +}); diff --git a/src/commands/auth/list.ts b/src/commands/auth/list.ts new file mode 100644 index 0000000..6da37d8 --- /dev/null +++ b/src/commands/auth/list.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +import { listProfileNames, readProfile } from "../../core/auth/storage"; +import { originOnly } from "../../core/url"; +import type { ResourceView } from "../../domain/view"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { outputFlags } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const AuthProfile = z.object({ + profile: z.string(), + url: z.string().nullable(), + present: z.boolean(), +}); +export type AuthProfileJson = z.infer; + +export const AuthProfileListEnvelope = listEnvelopeSchema(AuthProfile); + +const authProfileView: ResourceView = { + compactPick: AuthProfile, + tableColumns: [ + { key: "profile", label: "Profile" }, + { key: "url", label: "URL" }, + { key: "present", label: "Authenticated" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List configured authentication profiles" }, + args: { ...outputFlags }, + outputSchema: AuthProfileListEnvelope, + examples: ["metabase auth list", "metabase auth list --json"], + async run({ ctx }) { + const names = await listProfileNames(); + const items = await Promise.all( + names.map(async (name): Promise => { + const profile = await readProfile(name); + return { + profile: name, + url: profile === null ? null : originOnly(profile.url), + present: profile !== null, + }; + }), + ); + renderList(wrapList(items), authProfileView, ctx); + }, +}); diff --git a/src/commands/sync/export.ts b/src/commands/sync/export.ts index 454d869..81775d6 100644 --- a/src/commands/sync/export.ts +++ b/src/commands/sync/export.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { SyncTask } from "../../domain/remote-sync"; import type { ResourceView } from "../../domain/view"; +import { warn } from "../../output/notice"; import { renderItem } from "../../output/render"; import type { CommonContext } from "../context"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; @@ -108,9 +109,11 @@ export default defineMetabaseCommand({ }); function emitRealignHint(ctx: CommonContext): void { - if (ctx.format !== "text") return; - process.stderr.write( + if (ctx.format !== "text") { + return; + } + warn( "\nNote: if exporting to a host-bound repo, realign the host working tree with:\n" + - " git -C restore --staged --worktree .\n", + " git -C restore --staged --worktree .", ); } diff --git a/src/core/auth/storage.test.ts b/src/core/auth/storage.test.ts index 15842bf..f8e8c38 100644 --- a/src/core/auth/storage.test.ts +++ b/src/core/auth/storage.test.ts @@ -24,6 +24,8 @@ const { clearProfile, credentials, fallbackFilePath, + listProfileNames, + profileIndexPath, readLicense, readProfile, writeLicense, @@ -206,6 +208,79 @@ describe("profiles", () => { }); }); +describe("profile index", () => { + let home: TempConfigHome; + + beforeEach(() => { + hoisted.store.clear(); + hoisted.controls.broken = false; + home = setupTempConfigHome(); + }); + + afterEach(() => { + home.cleanup(); + }); + + it("returns an empty list when no profiles exist", async () => { + expect(await listProfileNames()).toEqual([]); + }); + + it("adds a profile to the index on writeProfile", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "k" }, "staging"); + expect(await listProfileNames()).toEqual(["staging"]); + }); + + it("stores the index as JSON in profiles.json on the filesystem", async () => { + await writeProfile({ url: "https://m.example.com", apiKey: "k" }, "staging"); + await writeProfile({ url: "https://p.example.com", apiKey: "p" }, "prod"); + const stored = JSON.parse(readFileSync(profileIndexPath(), "utf8")); + expect(stored).toEqual(["prod", "staging"]); + }); + + it("keeps the index sorted and deduplicated across multiple writes", async () => { + await writeProfile({ url: "https://1.example.com", apiKey: "k1" }, "zeta"); + await writeProfile({ url: "https://2.example.com", apiKey: "k2" }, "alpha"); + await writeProfile({ url: "https://3.example.com", apiKey: "k3" }, "alpha"); + expect(await listProfileNames()).toEqual(["alpha", "zeta"]); + }); + + it("removes a profile from the index on clearProfile", async () => { + await writeProfile({ url: "https://a.example.com", apiKey: "a" }, "a"); + await writeProfile({ url: "https://b.example.com", apiKey: "b" }, "b"); + await clearProfile("a"); + expect(await listProfileNames()).toEqual(["b"]); + }); + + it("deletes profiles.json when the last profile is cleared", async () => { + if (process.platform === "win32") { + return; + } + await writeProfile({ url: "https://m.example.com", apiKey: "k" }, "only"); + await clearProfile("only"); + expect(() => statSync(profileIndexPath())).toThrow(/ENOENT/); + }); + + it("backfills the index from credentials.json on first read when keyring is broken", async () => { + hoisted.controls.broken = true; + await credentials.set(account.profileUrl("backfilled"), "https://b.example.com"); + await credentials.set(account.profileApiKey("backfilled"), "k"); + hoisted.controls.broken = false; + + expect(await listProfileNames()).toEqual(["backfilled"]); + const stored = JSON.parse(readFileSync(profileIndexPath(), "utf8")); + expect(stored).toEqual(["backfilled"]); + }); + + it("writes the index to its own file with 0600 perms", async () => { + if (process.platform === "win32") { + return; + } + await writeProfile({ url: "https://m.example.com", apiKey: "k" }, "only"); + const mode = statSync(profileIndexPath()).mode & 0o777; + expect(mode).toBe(0o600); + }); +}); + describe("METABASE_CLI_DISABLE_KEYRING", () => { let home: TempConfigHome; diff --git a/src/core/auth/storage.ts b/src/core/auth/storage.ts index 0188a1c..8a38fb9 100644 --- a/src/core/auth/storage.ts +++ b/src/core/auth/storage.ts @@ -12,6 +12,7 @@ const CredentialsFileSchema = z.record(z.string(), z.string()); const KEYRING_SERVICE = "metabase-cli"; const CREDENTIALS_FILE = "credentials.json"; +const PROFILE_INDEX_FILE = "profiles.json"; export const DEFAULT_PROFILE = "default"; const CREDENTIALS_FILE_MODE = 0o600; @@ -28,6 +29,10 @@ export const account = { license: "license", } as const; +const ProfileIndexSchema = z.array(z.string()); +const FILE_STORE_PROFILE_URL_PREFIX = "profile:"; +const FILE_STORE_PROFILE_URL_SUFFIX = ":url"; + export interface KeyringLocation { backend: "keyring"; service: string; @@ -51,6 +56,10 @@ export function fallbackFilePath(): string { return join(configDir(), CREDENTIALS_FILE); } +export function profileIndexPath(): string { + return join(configDir(), PROFILE_INDEX_FILE); +} + function keyringEnabled(): boolean { return process.env["METABASE_CLI_DISABLE_KEYRING"] !== "1"; } @@ -198,15 +207,100 @@ export async function writeProfile( name: string = DEFAULT_PROFILE, ): Promise { await credentials.set(account.profileUrl(name), profile.url); - return credentials.set(account.profileApiKey(name), profile.apiKey); + const location = await credentials.set(account.profileApiKey(name), profile.apiKey); + await addToProfileIndex(name); + return location; } export async function clearProfile(name: string = DEFAULT_PROFILE): Promise { const removedUrl = await credentials.remove(account.profileUrl(name)); const removedKey = await credentials.remove(account.profileApiKey(name)); + await removeFromProfileIndex(name); return removedUrl || removedKey; } +export async function listProfileNames(): Promise { + const stored = await readProfileIndex(); + if (stored !== null) { + return stored; + } + const backfilled = await backfillProfileIndexFromFile(); + if (backfilled.length > 0) { + await writeProfileIndex(backfilled); + } + return backfilled; +} + +async function readProfileIndex(): Promise { + const path = profileIndexPath(); + let raw: string; + try { + raw = await fs.readFile(path, "utf8"); + } catch (error) { + if (isNotFoundError(error)) { + return null; + } + throw error; + } + return parseJson(raw, ProfileIndexSchema, { source: path }); +} + +async function writeProfileIndex(names: string[]): Promise { + const path = profileIndexPath(); + const unique = [...new Set(names)].toSorted(); + await fs.mkdir(dirname(path), { recursive: true, mode: CREDENTIALS_DIR_MODE }); + await fs.writeFile(path, JSON.stringify(unique, null, 2) + "\n", { mode: CREDENTIALS_FILE_MODE }); + if (process.platform !== "win32") { + await fs.chmod(path, CREDENTIALS_FILE_MODE); + } +} + +async function deleteProfileIndex(): Promise { + await fs.unlink(profileIndexPath()).catch(() => undefined); +} + +async function addToProfileIndex(name: string): Promise { + const current = await listProfileNames(); + if (current.includes(name)) { + return; + } + await writeProfileIndex([...current, name]); +} + +async function removeFromProfileIndex(name: string): Promise { + const current = await listProfileNames(); + const next = current.filter((entry) => entry !== name); + if (next.length === current.length) { + return; + } + if (next.length === 0) { + await deleteProfileIndex(); + return; + } + await writeProfileIndex(next); +} + +async function backfillProfileIndexFromFile(): Promise { + const store = await readFileStore(); + const names = new Set(); + for (const key of Object.keys(store)) { + if (!key.startsWith(FILE_STORE_PROFILE_URL_PREFIX)) { + continue; + } + if (!key.endsWith(FILE_STORE_PROFILE_URL_SUFFIX)) { + continue; + } + const name = key.slice( + FILE_STORE_PROFILE_URL_PREFIX.length, + key.length - FILE_STORE_PROFILE_URL_SUFFIX.length, + ); + if (name.length > 0) { + names.add(name); + } + } + return [...names].toSorted(); +} + export async function readLicense(): Promise { return credentials.read(account.license); } diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index bda6f99..e08d76a 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -33,6 +33,7 @@ describe("__manifest e2e", () => { expect(commandPaths).toEqual([ "auth login", "auth status", + "auth list", "auth logout", "license set", "license status", diff --git a/tests/e2e/profiles.e2e.test.ts b/tests/e2e/profiles.e2e.test.ts index 54658d5..fd34295 100644 --- a/tests/e2e/profiles.e2e.test.ts +++ b/tests/e2e/profiles.e2e.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { AuthProfileListEnvelope } from "../../src/commands/auth/list"; import { LoginResult } from "../../src/commands/auth/login"; import { LogoutResult } from "../../src/commands/auth/logout"; import { AuthStatus } from "../../src/commands/auth/status"; @@ -210,6 +211,57 @@ describe("profiles e2e", () => { expect(limitedQuery.stdout).toBe(""); }); + it("auth list returns empty when no profiles are stored", async () => { + const configHome = await makeIsolatedConfigHome(); + + const result = await runCli({ + args: ["auth", "list", "--json"], + configHome, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, AuthProfileListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("auth list reflects login and logout activity for the same config home", async () => { + const configHome = await makeIsolatedConfigHome(); + await loginProfile(configHome, "staging"); + await loginProfile(configHome, "prod"); + + const afterLogin = await runCli({ + args: ["auth", "list", "--json"], + configHome, + }); + expect(afterLogin.exitCode, afterLogin.stderr).toBe(0); + expect(parseJson(afterLogin.stdout, AuthProfileListEnvelope)).toEqual({ + data: [ + { profile: "prod", url: bootstrap.baseUrl, present: true }, + { profile: "staging", url: bootstrap.baseUrl, present: true }, + ], + returned: 2, + total: 2, + }); + + await runCli({ + args: ["auth", "logout", "--profile", "prod", "--yes", "--json"], + configHome, + }); + + const afterLogout = await runCli({ + args: ["auth", "list", "--json"], + configHome, + }); + expect(afterLogout.exitCode, afterLogout.stderr).toBe(0); + expect(parseJson(afterLogout.stdout, AuthProfileListEnvelope)).toEqual({ + data: [{ profile: "staging", url: bootstrap.baseUrl, present: true }], + returned: 1, + total: 1, + }); + }); + it("db list --profile pointing at an unknown profile fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); From 66774cc3a525cff396da337f82c5259fd6c42892 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Thu, 7 May 2026 22:50:34 -0400 Subject: [PATCH 15/47] fixes --- src/commands/sync/export.ts | 20 +++++++++---------- src/commands/workspace/start.ts | 2 +- src/core/auth/storage.ts | 18 ++++------------- src/core/docker.test.ts | 34 ++++++++++++++++++++++++++++++++- src/core/docker.ts | 34 ++++++++++++++++++++++++++++----- src/core/errors.ts | 1 + src/runtime/port.test.ts | 6 +++--- src/runtime/port.ts | 5 ++++- 8 files changed, 84 insertions(+), 36 deletions(-) diff --git a/src/commands/sync/export.ts b/src/commands/sync/export.ts index 81775d6..f768f77 100644 --- a/src/commands/sync/export.ts +++ b/src/commands/sync/export.ts @@ -92,18 +92,16 @@ export default defineMetabaseCommand({ if (!args.wait) { const result: SyncExportResult = { message: kickoff.message, task_id: kickoff.task_id }; renderItem(result, syncExportView, ctx); - emitRealignHint(ctx); - return; + } else { + const final = await pollSyncTask(client, { timeoutMs, intervalMs }); + const result: SyncExportResult = { + message: kickoff.message, + task_id: kickoff.task_id, + final, + }; + renderItem(result, syncExportView, ctx); + throwIfFailedTask(final, "export"); } - - const final = await pollSyncTask(client, { timeoutMs, intervalMs }); - const result: SyncExportResult = { - message: kickoff.message, - task_id: kickoff.task_id, - final, - }; - renderItem(result, syncExportView, ctx); - throwIfFailedTask(final, "export"); emitRealignHint(ctx); }, }); diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts index b4baccd..bde0960 100644 --- a/src/commands/workspace/start.ts +++ b/src/commands/workspace/start.ts @@ -89,7 +89,7 @@ export default defineMetabaseCommand({ id: { type: "positional", description: "Workspace id", required: true }, port: { type: "string", - description: `Host port to bind (default: ${DEFAULT_HOST_PORT}; auto-shifts up if taken)`, + description: `Host port to bind (default: ${DEFAULT_HOST_PORT}; auto-shifts up when this flag is omitted, fails on collision when set explicitly)`, }, image: { type: "string", diff --git a/src/core/auth/storage.ts b/src/core/auth/storage.ts index 8a38fb9..9231c82 100644 --- a/src/core/auth/storage.ts +++ b/src/core/auth/storage.ts @@ -30,8 +30,7 @@ export const account = { } as const; const ProfileIndexSchema = z.array(z.string()); -const FILE_STORE_PROFILE_URL_PREFIX = "profile:"; -const FILE_STORE_PROFILE_URL_SUFFIX = ":url"; +const FILE_STORE_PROFILE_URL_PATTERN = /^profile:(.+):url$/; export interface KeyringLocation { backend: "keyring"; @@ -284,21 +283,12 @@ async function backfillProfileIndexFromFile(): Promise { const store = await readFileStore(); const names = new Set(); for (const key of Object.keys(store)) { - if (!key.startsWith(FILE_STORE_PROFILE_URL_PREFIX)) { - continue; - } - if (!key.endsWith(FILE_STORE_PROFILE_URL_SUFFIX)) { - continue; - } - const name = key.slice( - FILE_STORE_PROFILE_URL_PREFIX.length, - key.length - FILE_STORE_PROFILE_URL_SUFFIX.length, - ); - if (name.length > 0) { + const name = FILE_STORE_PROFILE_URL_PATTERN.exec(key)?.[1]; + if (name !== undefined) { names.add(name); } } - return [...names].toSorted(); + return [...names]; } export async function readLicense(): Promise { diff --git a/src/core/docker.test.ts b/src/core/docker.test.ts index d48e084..188b412 100644 --- a/src/core/docker.test.ts +++ b/src/core/docker.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { containerNameFor, parseContainerLine, parseContainerLines, volumeNameFor } from "./docker"; +import { + containerNameFor, + DockerError, + parseContainerLine, + parseContainerLines, + volumeNameFor, +} from "./docker"; +import { MetabaseError } from "./errors"; describe("containerNameFor / volumeNameFor", () => { it("derives stable names from the workspace id", () => { @@ -124,3 +131,28 @@ describe("parseContainerLines", () => { expect(summaries.map((s) => s.workspaceId)).toEqual([1, 2]); }); }); + +describe("DockerError", () => { + it("is a MetabaseError with category=docker, exitCode=1", () => { + const error = new DockerError("docker start failed for x", 125, ""); + expect(error).toBeInstanceOf(MetabaseError); + expect(error.category).toBe("docker"); + expect(error.exitCode).toBe(1); + expect(error.developerDetail).toEqual({ dockerExitCode: 125, stderr: "" }); + }); + + it("userMessage falls back to the wrapper when stderr is empty", () => { + const error = new DockerError("docker start failed for x", 125, ""); + expect(error.userMessage).toBe("docker start failed for x"); + }); + + it("userMessage indents trimmed stderr beneath the wrapper", () => { + const stderr = + "docker: Error response from daemon: driver failed programming external connectivity: Bind for 0.0.0.0:3000 failed: port is already allocated.\n"; + const error = new DockerError("docker create failed for metabase-workspace-1", 125, stderr); + expect(error.userMessage).toBe( + "docker create failed for metabase-workspace-1\n" + + " docker: Error response from daemon: driver failed programming external connectivity: Bind for 0.0.0.0:3000 failed: port is already allocated.", + ); + }); +}); diff --git a/src/core/docker.ts b/src/core/docker.ts index 0df6c74..ea39a00 100644 --- a/src/core/docker.ts +++ b/src/core/docker.ts @@ -10,7 +10,7 @@ import { } from "../runtime/process"; import { buildTar, extractSingleFileFromTar, type TarEntry } from "../runtime/tar"; -import { ConfigError, errorMessage } from "./errors"; +import { ConfigError, errorMessage, MetabaseError } from "./errors"; const DOCKER_BIN = "docker"; @@ -66,15 +66,39 @@ export type ContainerState = (typeof CONTAINER_STATES)[number]; export type ContainerLifecycleStatus = ContainerState | "missing"; -export class DockerError extends Error { - readonly exitCode: number | null; +export interface DockerErrorDetail { + dockerExitCode: number | null; + stderr: string; +} + +export class DockerError extends MetabaseError { + readonly category = "docker"; + readonly isRetryable = false; + readonly exitCode = 1; + readonly developerDetail: DockerErrorDetail; readonly stderr: string; - constructor(message: string, exitCode: number | null, stderr: string) { + + constructor(message: string, dockerExitCode: number | null, stderr: string) { super(message); this.name = "DockerError"; - this.exitCode = exitCode; + this.developerDetail = { dockerExitCode, stderr }; this.stderr = stderr; } + + override get userMessage(): string { + const trimmed = this.stderr.trim(); + if (trimmed === "") { + return this.message; + } + return `${this.message}\n${indentLines(trimmed)}`; + } +} + +function indentLines(text: string): string { + return text + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); } export class DockerNotInstalledError extends Error { diff --git a/src/core/errors.ts b/src/core/errors.ts index ed5c25d..11d3568 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -8,6 +8,7 @@ export type ErrorCategory = | "timeout" | "config" | "abort" + | "docker" | "unknown"; export interface NetworkErrorDetail { diff --git a/src/runtime/port.test.ts b/src/runtime/port.test.ts index e9691a1..4186528 100644 --- a/src/runtime/port.test.ts +++ b/src/runtime/port.test.ts @@ -15,12 +15,12 @@ describe("isPortFree", () => { } }); - it("returns true for an unbound localhost port", async () => { + it("returns true for an unbound port", async () => { const port = await pickFreePortViaOS(); expect(await isPortFree(port)).toBe(true); }); - it("returns false when a server is already bound to the port", async () => { + it("returns false when a server is already bound to the wildcard interface", async () => { const { port, server } = await bindServer(); occupied = server; expect(await isPortFree(port)).toBe(false); @@ -71,6 +71,6 @@ async function bindServer(): Promise<{ port: number; server: Server }> { } reject(new Error("server.address() did not return an object")); }); - server.listen(0, "127.0.0.1"); + server.listen(0, "0.0.0.0"); }); } diff --git a/src/runtime/port.ts b/src/runtime/port.ts index aad99a3..bf8ca07 100644 --- a/src/runtime/port.ts +++ b/src/runtime/port.ts @@ -12,7 +12,10 @@ export function isPortFree(port: number): Promise { server.once("listening", () => { server.close(() => resolve(true)); }); - server.listen(port, "127.0.0.1"); + // 0.0.0.0 (not 127.0.0.1) — docker publishes container ports on the + // wildcard address, and a 127.0.0.1-only probe can return "free" while + // docker holds the port at 0.0.0.0. + server.listen(port, "0.0.0.0"); }); } From 78747d16118f01e2e70915b927c740f3ff65ff73 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 8 May 2026 13:22:21 -0400 Subject: [PATCH 16/47] more commands --- README.md | 72 ++++ src/commands/collection/create.ts | 26 ++ src/commands/collection/get.ts | 36 ++ src/commands/collection/index.ts | 15 + src/commands/collection/items.ts | 82 ++++ src/commands/collection/list.ts | 47 +++ src/commands/collection/parse-ref.test.ts | 66 +++ src/commands/collection/parse-ref.ts | 21 + src/commands/collection/tree.ts | 27 ++ src/commands/search.ts | 31 +- src/domain/collection.ts | 167 ++++++++ src/main.ts | 1 + src/runtime/csv.ts | 51 +++ tests/e2e/collection.e2e.test.ts | 481 ++++++++++++++++++++++ tests/e2e/manifest.e2e.test.ts | 5 + 15 files changed, 1099 insertions(+), 29 deletions(-) create mode 100644 src/commands/collection/create.ts create mode 100644 src/commands/collection/get.ts create mode 100644 src/commands/collection/index.ts create mode 100644 src/commands/collection/items.ts create mode 100644 src/commands/collection/list.ts create mode 100644 src/commands/collection/parse-ref.test.ts create mode 100644 src/commands/collection/parse-ref.ts create mode 100644 src/commands/collection/tree.ts create mode 100644 src/domain/collection.ts create mode 100644 tests/e2e/collection.e2e.test.ts diff --git a/README.md b/README.md index 5eaee3d..a892b0a 100644 --- a/README.md +++ b/README.md @@ -403,6 +403,78 @@ cat patch.json | metabase dashboard update-dashcard 1 5 The patch must contain at least one field; an empty object is rejected before the network round-trip. +## Collections + +Read collections on `/api/collection`. Collections are the folders that contain cards, dashboards, and other collections. The list endpoint surfaces a virtual root collection (id `"root"`) alongside regular numeric ids; the get endpoint accepts only the numeric id. + +### `metabase collection list` + +```sh +metabase collection list +metabase collection list --json +metabase collection list --filter archived --json +``` + +| Flag | Description | +| ------------------- | --------------------------------------------------------------------------------------------------------------- | +| `--filter ` | One of `all` (default), `archived` (returns the trash collection only), `personal` (only personal collections). | + +### `metabase collection get ` + +`` accepts any of: a positive integer collection id, the literal `root` (the virtual "Our analytics" root), the literal `trash` (the trash collection), or a 21-character entity id (NanoID). Anything else is rejected with a `ConfigError` before any HTTP call. + +```sh +metabase collection get 4 +metabase collection get root --json +metabase collection get trash --json +metabase collection get voo1If9y8Sld0lXej6xl0 --json +metabase collection get 4 --json --full +``` + +`--full` returns the full hydrated collection including `slug`, `entity_id`, `can_write`, `namespace`, and `personal_owner_id`. The default compact view returns `id`, `name`, `description`, `archived`, `location`, `parent_id`, `type`, `authority_level`, and `is_personal`. The root collection has a stripped-down shape — `archived`, `description`, `location`, `type`, etc. are absent rather than `null`. + +### `metabase collection items ` + +List the cards, dashboards, sub-collections, and other content stored inside a collection. The CLI drains all pages of `/api/collection/:id/items`; pass `--limit` to cap the result. `` accepts the same forms as `collection get` — including `root` for top-level content (items there have `collection_id: null`). + +```sh +metabase collection items 4 +metabase collection items root --json +metabase collection items 4 --models card,dashboard --json +metabase collection items 4 --pinned-state is_pinned --json +``` + +| Flag | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `--models ` | Restrict to one or more models (`card`, `dataset`, `metric`, `dashboard`, `snippet`, `collection`, `document`, …). | +| `--archived` | Return archived items instead of unarchived. | +| `--pinned-state ` | One of `all`, `is_pinned`, `is_not_pinned`. | +| `--limit ` | Cap total items returned. Default: drain all pages. | + +### `metabase collection tree` + +Fetch the full collection hierarchy as a nested tree. Output is always JSON — the recursive structure does not render meaningfully as a key/value table. + +```sh +metabase collection tree +metabase collection tree --json +``` + +### `metabase collection create` + +Create a collection from a JSON spec. The body accepts the same fields as `POST /api/collection`: `name` (required), `description`, `parent_id` (omit or `null` for the root), `namespace`, and `authority_level`. + +```sh +cat collection.json | metabase collection create +metabase collection create --file collection.json +metabase collection create --body '{"name":"My Collection","parent_id":4}' +``` + +| Flag | Description | +| --------------- | --------------------------------------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. Use `-` to read from stdin. | + ## Settings Read and write Metabase instance settings via `/api/setting`. Listing all settings requires admin privileges; per-key reads/writes additionally enforce per-setting access. Setting values are always JSON — `"main"` is the string `main`, `42` is a number, `null` deletes the override and resets the value to its default. diff --git a/src/commands/collection/create.ts b/src/commands/collection/create.ts new file mode 100644 index 0000000..6ea7ec9 --- /dev/null +++ b/src/commands/collection/create.ts @@ -0,0 +1,26 @@ +import { Collection, CollectionCreateInput, collectionView } from "../../domain/collection"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "create", description: "Create a collection from a JSON spec" }, + args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, + outputSchema: Collection, + examples: [ + "cat collection.json | metabase collection create", + "metabase collection create --file collection.json", + 'metabase collection create --body \'{"name":"My Collection","parent_id":4}\'', + ], + async run({ args, ctx, getClient }) { + const body = await readBody({ flag: args.body, file: args.file }, CollectionCreateInput); + const client = await getClient(); + const created = await client.requestParsed(Collection, "/api/collection", { + method: "POST", + body, + }); + renderItem(created, collectionView, ctx); + }, +}); diff --git a/src/commands/collection/get.ts b/src/commands/collection/get.ts new file mode 100644 index 0000000..695dee9 --- /dev/null +++ b/src/commands/collection/get.ts @@ -0,0 +1,36 @@ +import { Collection, collectionView } from "../../domain/collection"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +import { parseCollectionRef } from "./parse-ref"; + +export default defineMetabaseCommand({ + meta: { + name: "get", + description: 'Get a collection by id, 21-char entity id, or "root"/"trash"', + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { + type: "positional", + description: 'Collection id, 21-char entity id, or one of: "root", "trash"', + required: true, + }, + }, + outputSchema: Collection, + examples: [ + "metabase collection get 4", + "metabase collection get root --json", + "metabase collection get trash --json", + "metabase collection get voo1If9y8Sld0lXej6xl0 --json", + ], + async run({ args, ctx, getClient }) { + const ref = parseCollectionRef(args.id); + const client = await getClient(); + const collection = await client.requestParsed(Collection, `/api/collection/${ref}`); + renderItem(collection, collectionView, ctx); + }, +}); diff --git a/src/commands/collection/index.ts b/src/commands/collection/index.ts new file mode 100644 index 0000000..f269078 --- /dev/null +++ b/src/commands/collection/index.ts @@ -0,0 +1,15 @@ +import { defineCommand } from "citty"; + +export default defineCommand({ + meta: { + name: "collection", + description: "Browse Metabase collections", + }, + subCommands: { + list: () => import("./list").then((mod) => mod.default), + get: () => import("./get").then((mod) => mod.default), + items: () => import("./items").then((mod) => mod.default), + tree: () => import("./tree").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + }, +}); diff --git a/src/commands/collection/items.ts b/src/commands/collection/items.ts new file mode 100644 index 0000000..4f216e0 --- /dev/null +++ b/src/commands/collection/items.ts @@ -0,0 +1,82 @@ +import { + COLLECTION_ITEM_MODELS, + COLLECTION_PINNED_STATES, + CollectionItem, + CollectionItemCompact, + CollectionItemModel, + CollectionPinnedState, + collectionItemView, +} from "../../domain/collection"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, type ListEnvelope } from "../../output/types"; +import { parseEnum, parseEnumCsv } from "../../runtime/csv"; +import { collectPaginated } from "../../runtime/paginate"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { parseCollectionRef } from "./parse-ref"; + +export const CollectionItemListEnvelope = listEnvelopeSchema(CollectionItemCompact); + +export default defineMetabaseCommand({ + meta: { name: "items", description: "List items inside a collection" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { + type: "positional", + description: 'Collection id, 21-char entity id, or one of: "root", "trash"', + required: true, + }, + models: { + type: "string", + description: `Comma-separated model filter: ${COLLECTION_ITEM_MODELS.join(",")}`, + alias: "m", + }, + archived: { + type: "boolean", + description: "Return archived items instead of unarchived", + default: false, + }, + "pinned-state": { + type: "string", + description: `Pinned filter: ${COLLECTION_PINNED_STATES.join("|")}`, + }, + limit: { + type: "string", + description: "Cap total items returned (default: drain all pages)", + }, + }, + outputSchema: CollectionItemListEnvelope, + examples: [ + "metabase collection items 4", + "metabase collection items root --json", + "metabase collection items 4 --models card,dashboard --json", + "metabase collection items 4 --pinned-state is_pinned --json", + ], + async run({ args, ctx, getClient }) { + const ref = parseCollectionRef(args.id); + const models = parseEnumCsv(args.models, CollectionItemModel, "--models"); + const pinnedState = parseEnum(args["pinned-state"], CollectionPinnedState, "--pinned-state"); + const max = args.limit === undefined ? undefined : parseId(args.limit, "--limit"); + const client = await getClient(); + + const items = await collectPaginated(client, `/api/collection/${ref}/items`, CollectionItem, { + query: { + models, + archived: args.archived ? true : undefined, + pinned_state: pinnedState, + }, + ...(max !== undefined && { max }), + }); + + const envelope: ListEnvelope = { + data: items, + returned: items.length, + ...(max === undefined ? { total: items.length } : { limit: max }), + }; + renderList(envelope, collectionItemView, ctx); + }, +}); diff --git a/src/commands/collection/list.ts b/src/commands/collection/list.ts new file mode 100644 index 0000000..cbfc5ed --- /dev/null +++ b/src/commands/collection/list.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +import { Collection, CollectionCompact, collectionView } from "../../domain/collection"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +const CollectionApiList = z.array(Collection); + +const CollectionListFilter = z.enum(["all", "archived", "personal"]); + +const COLLECTION_LIST_QUERY = { + all: {}, + archived: { archived: true }, + personal: { "personal-only": true }, +} as const; + +export const CollectionListEnvelope = listEnvelopeSchema(CollectionCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List collections" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + filter: { + type: "string", + description: `Filter preset: ${CollectionListFilter.options.join("|")}`, + default: "all", + }, + }, + outputSchema: CollectionListEnvelope, + examples: [ + "metabase collection list", + "metabase collection list --json", + "metabase collection list --filter archived --json", + ], + async run({ args, ctx, getClient }) { + const filter = CollectionListFilter.parse(args.filter); + const client = await getClient(); + const items = await client.requestParsed(CollectionApiList, "/api/collection", { + query: COLLECTION_LIST_QUERY[filter], + }); + renderList(wrapList(items), collectionView, ctx); + }, +}); diff --git a/src/commands/collection/parse-ref.test.ts b/src/commands/collection/parse-ref.test.ts new file mode 100644 index 0000000..992b3be --- /dev/null +++ b/src/commands/collection/parse-ref.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { ConfigError } from "../../core/errors"; + +import { parseCollectionRef } from "./parse-ref"; + +describe("parseCollectionRef", () => { + it("accepts a positive integer string and returns the trimmed digits", () => { + expect(parseCollectionRef("42")).toBe("42"); + expect(parseCollectionRef(" 42 ")).toBe("42"); + expect(parseCollectionRef("1")).toBe("1"); + }); + + it('accepts "root" and "trash" as canonical literals', () => { + expect(parseCollectionRef("root")).toBe("root"); + expect(parseCollectionRef("trash")).toBe("trash"); + }); + + it("accepts a 21-character NanoID-shaped entity id", () => { + expect(parseCollectionRef("voo1If9y8Sld0lXej6xl0")).toBe("voo1If9y8Sld0lXej6xl0"); + expect(parseCollectionRef("trashtrashtrashtrasht")).toBe("trashtrashtrashtrasht"); + expect(parseCollectionRef("A_B-c0123456789defghi")).toBe("A_B-c0123456789defghi"); + }); + + it("rejects an empty string with ConfigError citing the accepted formats", () => { + expect(() => parseCollectionRef("")).toThrow(ConfigError); + expect(() => parseCollectionRef("")).toThrow( + 'invalid id: "" (expected integer, "root", "trash", or 21-char entity id)', + ); + }); + + it("rejects zero and negative integers", () => { + expect(() => parseCollectionRef("0")).toThrow(ConfigError); + expect(() => parseCollectionRef("0")).toThrow( + 'invalid id: "0" (expected integer, "root", "trash", or 21-char entity id)', + ); + expect(() => parseCollectionRef("-1")).toThrow(ConfigError); + expect(() => parseCollectionRef("-1")).toThrow( + 'invalid id: "-1" (expected integer, "root", "trash", or 21-char entity id)', + ); + }); + + it("rejects 21-char strings with disallowed characters", () => { + expect(() => parseCollectionRef("voo1If9y8Sld0lXej6xl@")).toThrow(ConfigError); + expect(() => parseCollectionRef("voo1If9y8Sld0lXej6xl@")).toThrow( + 'invalid id: "voo1If9y8Sld0lXej6xl@" (expected integer, "root", "trash", or 21-char entity id)', + ); + }); + + it.each([ + ["rooty", 'invalid id: "rooty" (expected integer, "root", "trash", or 21-char entity id)'], + ["trashy", 'invalid id: "trashy" (expected integer, "root", "trash", or 21-char entity id)'], + ["abc", 'invalid id: "abc" (expected integer, "root", "trash", or 21-char entity id)'], + [ + "voo1If9y8Sld0lXej6xl", + 'invalid id: "voo1If9y8Sld0lXej6xl" (expected integer, "root", "trash", or 21-char entity id)', + ], + [ + "voo1If9y8Sld0lXej6xl00", + 'invalid id: "voo1If9y8Sld0lXej6xl00" (expected integer, "root", "trash", or 21-char entity id)', + ], + ])("rejects %j with the canonical format-hint message", (input, expectedMessage) => { + expect(() => parseCollectionRef(input)).toThrow(ConfigError); + expect(() => parseCollectionRef(input)).toThrow(expectedMessage); + }); +}); diff --git a/src/commands/collection/parse-ref.ts b/src/commands/collection/parse-ref.ts new file mode 100644 index 0000000..43e378c --- /dev/null +++ b/src/commands/collection/parse-ref.ts @@ -0,0 +1,21 @@ +import { ConfigError } from "../../core/errors"; + +const SPECIAL_TOKENS: ReadonlySet = new Set(["root", "trash"]); +const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/; +const NANO_ID_PATTERN = /^[A-Za-z0-9_-]{21}$/; + +const FORMAT_HINT = 'expected integer, "root", "trash", or 21-char entity id'; + +export function parseCollectionRef(raw: string): string { + const trimmed = raw.trim(); + if (trimmed === "") { + throw new ConfigError(`invalid id: ${JSON.stringify(trimmed)} (${FORMAT_HINT})`); + } + if (SPECIAL_TOKENS.has(trimmed)) { + return trimmed; + } + if (POSITIVE_INTEGER_PATTERN.test(trimmed) || NANO_ID_PATTERN.test(trimmed)) { + return trimmed; + } + throw new ConfigError(`invalid id: ${JSON.stringify(raw)} (${FORMAT_HINT})`); +} diff --git a/src/commands/collection/tree.ts b/src/commands/collection/tree.ts new file mode 100644 index 0000000..307faca --- /dev/null +++ b/src/commands/collection/tree.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +import { ConfigError } from "../../core/errors"; +import { CollectionTreeNode } from "../../domain/collection"; +import { writeJson } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const CollectionTreeResponse = z.array(CollectionTreeNode); + +export default defineMetabaseCommand({ + meta: { + name: "tree", + description: "Fetch the collection hierarchy as a nested tree (JSON only)", + }, + args: { ...outputFlags, ...profileFlag, ...connectionFlags }, + outputSchema: CollectionTreeResponse, + examples: ["metabase collection tree", "metabase collection tree --json"], + async run({ ctx, getClient }) { + if (ctx.format === "text") { + throw new ConfigError("collection tree output is JSON-only; --format text is not supported"); + } + const client = await getClient(); + const tree = await client.requestParsed(CollectionTreeResponse, "/api/collection/tree"); + writeJson(tree); + }, +}); diff --git a/src/commands/search.ts b/src/commands/search.ts index 036a6a1..25b8824 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { ConfigError } from "../core/errors"; import { SEARCH_MODELS, SearchModel, @@ -10,7 +9,7 @@ import { } from "../domain/search"; import { renderList } from "../output/render"; import { listEnvelopeSchema, type ListEnvelope } from "../output/types"; -import { parseCsv } from "../runtime/csv"; +import { parseEnumCsv } from "../runtime/csv"; import { connectionFlags, outputFlags, profileFlag } from "./flags"; import { parseId } from "./parse-id"; @@ -77,7 +76,7 @@ export default defineMetabaseCommand({ const limit = parseId(args.limit, "--limit"); const tableDbIdRaw = args["table-db-id"]; const tableDbId = tableDbIdRaw ? parseId(tableDbIdRaw, "--table-db-id") : undefined; - const models = parseModels(args.models); + const models = parseEnumCsv(args.models, SearchModel, "--models"); const client = await getClient(); const response = await client.requestParsed(SearchApiResponse, "/api/search", { @@ -108,29 +107,3 @@ function nonEmpty(value: string | undefined): string | undefined { const trimmed = value.trim(); return trimmed.length === 0 ? undefined : trimmed; } - -function parseModels(raw: string | undefined): SearchModel[] | undefined { - if (raw === undefined || raw === "") { - return undefined; - } - const parts = parseCsv(raw); - if (parts.length === 0) { - return undefined; - } - const accepted: SearchModel[] = []; - const rejected: string[] = []; - for (const part of parts) { - const result = SearchModel.safeParse(part); - if (result.success) { - accepted.push(result.data); - } else { - rejected.push(part); - } - } - if (rejected.length > 0) { - throw new ConfigError( - `invalid --models value: ${rejected.join(", ")} (expected one of: ${SEARCH_MODELS.join(", ")})`, - ); - } - return accepted; -} diff --git a/src/domain/collection.ts b/src/domain/collection.ts new file mode 100644 index 0000000..e1b2463 --- /dev/null +++ b/src/domain/collection.ts @@ -0,0 +1,167 @@ +import { z } from "zod"; + +import type { ResourceView } from "./view"; + +export const CollectionId = z.union([z.number().int(), z.string()]); +export type CollectionId = z.infer; + +const CollectionAuthorityLevel = z.enum(["official"]); + +const CollectionType = z.enum([ + "instance-analytics", + "trash", + "remote-synced", + "library", + "library-data", + "library-metrics", + "shared-tenant-collection", + "tenant-specific-root-collection", +]); + +const CollectionNamespace = z.enum([ + "snippets", + "transforms", + "analytics", + "tenant-specific", + "shared-tenant-collection", +]); + +export const COLLECTION_ITEM_MODELS = [ + "card", + "dataset", + "metric", + "dashboard", + "snippet", + "collection", + "indexed-entity", + "document", + "table", + "transform", + "measure", + "pulse", + "timeline", + "no_models", +] as const; +export const CollectionItemModel = z.enum(COLLECTION_ITEM_MODELS); +export type CollectionItemModel = z.infer; + +export const COLLECTION_PINNED_STATES = ["all", "is_pinned", "is_not_pinned"] as const; +export const CollectionPinnedState = z.enum(COLLECTION_PINNED_STATES); +export type CollectionPinnedState = z.infer; + +export const Collection = z + .object({ + id: CollectionId, + name: z.string(), + description: z.string().nullable().optional(), + archived: z.boolean().optional(), + location: z.string().nullable().optional(), + parent_id: CollectionId.nullable().optional(), + personal_owner_id: z.number().int().nullable().optional(), + is_personal: z.boolean().optional(), + authority_level: CollectionAuthorityLevel.nullable().optional(), + type: CollectionType.nullable().optional(), + namespace: CollectionNamespace.nullable().optional(), + entity_id: z.string().nullable().optional(), + slug: z.string().optional(), + can_write: z.boolean().optional(), + }) + .loose(); +export type Collection = z.infer; + +export const CollectionCompact = Collection.pick({ + id: true, + name: true, + description: true, + archived: true, + location: true, + parent_id: true, + type: true, + authority_level: true, + is_personal: true, +}).strip(); +export type CollectionCompact = z.infer; + +export const collectionView: ResourceView = { + compactPick: CollectionCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "location", label: "Location" }, + { key: "type", label: "Type" }, + { key: "authority_level", label: "Authority" }, + { key: "archived", label: "Archived" }, + ], +}; + +export const CollectionItem = z + .object({ + id: z.number().int(), + model: CollectionItemModel, + name: z.string(), + description: z.string().nullable().optional(), + archived: z.boolean(), + collection_id: CollectionId.nullable().optional(), + collection_position: z.number().int().nullable().optional(), + display: z.string().optional(), + location: z.string().nullable().optional(), + entity_id: z.string().nullable().optional(), + database_id: z.number().int().nullable().optional(), + dashboard_id: z.number().int().nullable().optional(), + }) + .loose(); +export type CollectionItem = z.infer; + +export const CollectionItemCompact = CollectionItem.pick({ + id: true, + model: true, + name: true, + description: true, + archived: true, + collection_id: true, +}).strip(); +export type CollectionItemCompact = z.infer; + +export const collectionItemView: ResourceView = { + compactPick: CollectionItemCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "model", label: "Model" }, + { key: "name", label: "Name" }, + { key: "collection_id", label: "Collection" }, + { key: "archived", label: "Archived" }, + ], +}; + +const CollectionTreeNodeBase = z + .object({ + id: CollectionId, + name: z.string(), + description: z.string().nullable().optional(), + archived: z.boolean().optional(), + location: z.string().nullable().optional(), + type: CollectionType.nullable().optional(), + authority_level: CollectionAuthorityLevel.nullable().optional(), + here: z.array(CollectionItemModel).optional(), + below: z.array(CollectionItemModel).optional(), + }) + .loose(); + +export type CollectionTreeNode = z.infer & { + children: CollectionTreeNode[]; +}; + +export const CollectionTreeNode: z.ZodType = CollectionTreeNodeBase.extend({ + children: z.lazy(() => z.array(CollectionTreeNode)), +}); + +export const CollectionCreateInput = z + .object({ + name: z.string().min(1), + description: z.string().nullable().optional(), + parent_id: z.number().int().positive().nullable().optional(), + namespace: CollectionNamespace.nullable().optional(), + authority_level: CollectionAuthorityLevel.nullable().optional(), + }) + .loose(); +export type CollectionCreateInput = z.infer; diff --git a/src/main.ts b/src/main.ts index 8668cfe..4fb41cd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,7 @@ const main: CommandDef = defineCommand({ field: () => import("./commands/field").then((mod) => mod.default), card: () => import("./commands/card").then((mod) => mod.default), dashboard: () => import("./commands/dashboard").then((mod) => mod.default), + collection: () => import("./commands/collection").then((mod) => mod.default), transform: () => import("./commands/transform").then((mod) => mod.default), "transform-job": () => import("./commands/transform-job").then((mod) => mod.default), setting: () => import("./commands/setting").then((mod) => mod.default), diff --git a/src/runtime/csv.ts b/src/runtime/csv.ts index c45b30d..448193b 100644 --- a/src/runtime/csv.ts +++ b/src/runtime/csv.ts @@ -1,6 +1,57 @@ +import type { ZodEnum } from "zod"; + +import { ConfigError } from "../core/errors"; + export function parseCsv(raw: string): string[] { return raw .split(",") .map((part) => part.trim()) .filter((part) => part.length > 0); } + +export function parseEnumCsv( + raw: string | undefined, + schema: ZodEnum>, + flagName: string, +): T[] | undefined { + if (raw === undefined || raw === "") { + return undefined; + } + const parts = parseCsv(raw); + if (parts.length === 0) { + return undefined; + } + const accepted: T[] = []; + const rejected: string[] = []; + for (const part of parts) { + const result = schema.safeParse(part); + if (result.success) { + accepted.push(result.data); + } else { + rejected.push(part); + } + } + if (rejected.length > 0) { + const allowed = Object.values(schema.enum).join(", "); + throw new ConfigError( + `invalid ${flagName} value: ${rejected.join(", ")} (expected one of: ${allowed})`, + ); + } + return accepted; +} + +export function parseEnum( + raw: string | undefined, + schema: ZodEnum>, + flagName: string, +): T | undefined { + if (raw === undefined || raw === "") { + return undefined; + } + const result = schema.safeParse(raw); + if (!result.success) { + const allowed = Object.values(schema.enum).join(", "); + throw new ConfigError(`invalid ${flagName} value: "${raw}" (expected one of: ${allowed})`); + } + return result.data; +} diff --git a/tests/e2e/collection.e2e.test.ts b/tests/e2e/collection.e2e.test.ts new file mode 100644 index 0000000..ffbb5d6 --- /dev/null +++ b/tests/e2e/collection.e2e.test.ts @@ -0,0 +1,481 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { CollectionItemListEnvelope } from "../../src/commands/collection/items"; +import { CollectionListEnvelope } from "../../src/commands/collection/list"; +import { CollectionTreeResponse } from "../../src/commands/collection/tree"; +import { Collection, CollectionCompact } from "../../src/domain/collection"; +import { parseJson } from "../../src/runtime/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { E2E_CARDS, E2E_COLLECTIONS, E2E_DASHBOARDS } from "./seed/ids"; + +const DEFAULT_COLLECTION_NAME = "E2E Default"; + +const DEFAULT_COMPACT = { + id: E2E_COLLECTIONS.DEFAULT, + name: DEFAULT_COLLECTION_NAME, + description: null, + archived: false, + location: "/", + parent_id: null, + type: null, + authority_level: null, + is_personal: false, +} as const; + +const ROOT_COMPACT = { + id: "root", + name: "Our analytics", + parent_id: null, + authority_level: null, + is_personal: false, +} as const; + +const TRASH_COMPACT = { + id: 1, + name: "Trash", + description: null, + archived: false, + location: "/", + parent_id: null, + type: "trash", + authority_level: null, + is_personal: false, +} as const; + +describe("collection e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + METABASE_URL: bootstrap.baseUrl, + METABASE_API_KEY: bootstrap.adminApiKey, + }; + } + + it("list returns the virtual root and the seeded E2E Default collection in compact form", async () => { + const result = await runCli({ + args: ["collection", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, CollectionListEnvelope)).toEqual({ + data: [ROOT_COMPACT, DEFAULT_COMPACT], + returned: 2, + total: 2, + }); + }); + + it("list --filter archived returns the trash collection by itself", async () => { + const result = await runCli({ + args: ["collection", "list", "--filter", "archived", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, CollectionListEnvelope)).toEqual({ + data: [TRASH_COMPACT], + returned: 1, + total: 1, + }); + }); + + it("list --filter personal returns no rows for the synthetic api-key user", async () => { + const result = await runCli({ + args: ["collection", "list", "--filter", "personal", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, CollectionListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("get returns the seeded collection by id in compact form", async () => { + const result = await runCli({ + args: ["collection", "get", String(E2E_COLLECTIONS.DEFAULT), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, CollectionCompact)).toEqual(DEFAULT_COMPACT); + }); + + it("get --full surfaces slug, can_write, and namespace beyond the compact projection", async () => { + const result = await runCli({ + args: ["collection", "get", String(E2E_COLLECTIONS.DEFAULT), "--json", "--full"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const collection = parseJson(result.stdout, Collection); + expect({ + id: collection.id, + name: collection.name, + slug: collection.slug, + can_write: collection.can_write, + namespace: collection.namespace, + personal_owner_id: collection.personal_owner_id, + }).toEqual({ + id: E2E_COLLECTIONS.DEFAULT, + name: DEFAULT_COLLECTION_NAME, + slug: "e2e_default", + can_write: true, + namespace: null, + personal_owner_id: null, + }); + }); + + it("get --format text renders the compact key/value pairs", async () => { + const result = await runCli({ + args: ["collection", "get", String(E2E_COLLECTIONS.DEFAULT), "--format", "text"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const labelPadding = "Authority".length; + const expected = [ + `${"ID".padEnd(labelPadding)} ${E2E_COLLECTIONS.DEFAULT}`, + `${"Name".padEnd(labelPadding)} ${DEFAULT_COLLECTION_NAME}`, + `${"Location".padEnd(labelPadding)} /`, + `${"Type".padEnd(labelPadding)} `, + `${"Authority".padEnd(labelPadding)} `, + `${"Archived".padEnd(labelPadding)} false`, + ].join("\n"); + expect(result.stdout.trim()).toBe(expected); + }); + + it("get with an unrecognized ref fails fast with ConfigError citing the accepted formats", async () => { + const result = await runCli({ + args: ["collection", "get", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + 'invalid id: "abc" (expected integer, "root", "trash", or 21-char entity id)', + ); + expect(result.stdout).toBe(""); + }); + + it("get root returns the virtual root collection from /api/collection/root", async () => { + const result = await runCli({ + args: ["collection", "get", "root", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, CollectionCompact)).toEqual(ROOT_COMPACT); + }); + + it("get trash returns the trash collection from /api/collection/trash", async () => { + const result = await runCli({ + args: ["collection", "get", "trash", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, CollectionCompact)).toEqual(TRASH_COMPACT); + }); + + it("get with a 21-char entity id resolves to the same collection as the integer id", async () => { + const fetchByEntityId = await runCli({ + args: ["collection", "get", String(E2E_COLLECTIONS.DEFAULT), "--json", "--full"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(fetchByEntityId.exitCode, fetchByEntityId.stderr).toBe(0); + const viaInt = parseJson(fetchByEntityId.stdout, Collection); + if (typeof viaInt.entity_id !== "string") { + throw new Error( + `expected entity_id to be a string on the seeded collection, got ${String(viaInt.entity_id)}`, + ); + } + + const fetchAgain = await runCli({ + args: ["collection", "get", viaInt.entity_id, "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(fetchAgain.exitCode, fetchAgain.stderr).toBe(0); + expect(parseJson(fetchAgain.stdout, CollectionCompact)).toEqual(DEFAULT_COMPACT); + }); + + it("get against a missing collection id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["collection", "get", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("items lists the seeded card and dashboard inside the default collection", async () => { + const result = await runCli({ + args: ["collection", "items", String(E2E_COLLECTIONS.DEFAULT), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, CollectionItemListEnvelope); + const sortedById = [...envelope.data].toSorted((left, right) => left.id - right.id); + expect({ ...envelope, data: sortedById }).toEqual({ + data: [ + { + id: E2E_DASHBOARDS.ORDERS_OVERVIEW, + model: "dashboard", + name: "Orders Overview", + description: "E2E seeded dashboard with one orders dashcard.", + archived: false, + collection_id: E2E_COLLECTIONS.DEFAULT, + }, + { + id: E2E_CARDS.ORDERS_BY_STATUS, + model: "card", + name: "Orders by status", + description: null, + archived: false, + collection_id: E2E_COLLECTIONS.DEFAULT, + }, + ], + returned: 2, + total: 2, + }); + }); + + it("items --models card filters the result to cards only", async () => { + const result = await runCli({ + args: ["collection", "items", String(E2E_COLLECTIONS.DEFAULT), "--models", "card", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, CollectionItemListEnvelope)).toEqual({ + data: [ + { + id: E2E_CARDS.ORDERS_BY_STATUS, + model: "card", + name: "Orders by status", + description: null, + archived: false, + collection_id: E2E_COLLECTIONS.DEFAULT, + }, + ], + returned: 1, + total: 1, + }); + }); + + it("items --limit caps the returned page", async () => { + const result = await runCli({ + args: ["collection", "items", String(E2E_COLLECTIONS.DEFAULT), "--limit", "1", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, CollectionItemListEnvelope); + const { data, ...meta } = envelope; + expect(data).toHaveLength(1); + expect(meta).toEqual({ returned: 1, limit: 1 }); + }); + + it("items --models rejects an unknown model with ConfigError", async () => { + const result = await runCli({ + args: ["collection", "items", String(E2E_COLLECTIONS.DEFAULT), "--models", "bogus", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("invalid --models value: bogus"); + expect(result.stdout).toBe(""); + }); + + it("items --pinned-state rejects an unknown preset with ConfigError", async () => { + const result = await runCli({ + args: [ + "collection", + "items", + String(E2E_COLLECTIONS.DEFAULT), + "--pinned-state", + "bogus", + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid --pinned-state value: "bogus"'); + expect(result.stdout).toBe(""); + }); + + it("items with an unrecognized ref fails fast with ConfigError citing the accepted formats", async () => { + const result = await runCli({ + args: ["collection", "items", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + 'invalid id: "abc" (expected integer, "root", "trash", or 21-char entity id)', + ); + expect(result.stdout).toBe(""); + }); + + it("items root surfaces the seeded collection at the root level with collection_id null", async () => { + const result = await runCli({ + args: ["collection", "items", "root", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, CollectionItemListEnvelope)).toEqual({ + data: [ + { + id: E2E_COLLECTIONS.DEFAULT, + model: "collection", + name: DEFAULT_COLLECTION_NAME, + description: null, + archived: false, + collection_id: null, + }, + ], + returned: 1, + total: 1, + }); + }); + + it("tree returns the seeded collection at the root level with empty children", async () => { + const result = await runCli({ + args: ["collection", "tree"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const tree = parseJson(result.stdout, CollectionTreeResponse); + const seeded = tree.find((node) => node.id === E2E_COLLECTIONS.DEFAULT); + if (seeded === undefined) { + throw new Error( + `expected E2E Default in tree, got ids ${tree.map((node) => node.id).join(", ")}`, + ); + } + expect({ + id: seeded.id, + name: seeded.name, + location: seeded.location, + type: seeded.type, + childrenLength: seeded.children.length, + here: seeded.here, + }).toEqual({ + id: E2E_COLLECTIONS.DEFAULT, + name: DEFAULT_COLLECTION_NAME, + location: "/", + type: null, + childrenLength: 0, + here: ["card"], + }); + }); + + it("create round-trips a new collection and surfaces it on the list", async () => { + const createResult = await runCli({ + args: ["collection", "create", "--json"], + stdin: JSON.stringify({ + name: "e2e_new_collection", + description: "created in test", + parent_id: E2E_COLLECTIONS.DEFAULT, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(createResult.exitCode, createResult.stderr).toBe(0); + const created = parseJson(createResult.stdout, Collection); + expect({ + name: created.name, + description: created.description, + archived: created.archived, + location: created.location, + type: created.type, + authority_level: created.authority_level, + }).toEqual({ + name: "e2e_new_collection", + description: "created in test", + archived: false, + location: `/${E2E_COLLECTIONS.DEFAULT}/`, + type: null, + authority_level: null, + }); + + const listResult = await runCli({ + args: ["collection", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(listResult.exitCode, listResult.stderr).toBe(0); + const listEnvelope = parseJson(listResult.stdout, CollectionListEnvelope); + const newRow = listEnvelope.data.find((row) => row.id === created.id); + expect(newRow).toEqual({ + id: created.id, + name: "e2e_new_collection", + description: "created in test", + archived: false, + location: `/${E2E_COLLECTIONS.DEFAULT}/`, + parent_id: E2E_COLLECTIONS.DEFAULT, + type: null, + authority_level: null, + is_personal: false, + }); + }); + + it("create with a body missing the required name field fails on Zod validation", async () => { + const result = await runCli({ + args: ["collection", "create", "--json"], + stdin: JSON.stringify({ description: "no name here" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stdout).toBe(""); + }); +}); diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index e08d76a..601ea94 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -55,6 +55,11 @@ describe("__manifest e2e", () => { "dashboard create", "dashboard update", "dashboard update-dashcard", + "collection list", + "collection get", + "collection items", + "collection tree", + "collection create", "transform list", "transform get", "transform create", From 4f5cd01b75a07e150ce44412b961898600c56b9b Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 8 May 2026 14:21:42 -0400 Subject: [PATCH 17/47] fix --- src/commands/transform/run.ts | 18 +++++++---- src/domain/transform.ts | 11 +++++++ tests/e2e/transform.e2e.test.ts | 53 +++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/commands/transform/run.ts b/src/commands/transform/run.ts index 3066cc0..03ebee4 100644 --- a/src/commands/transform/run.ts +++ b/src/commands/transform/run.ts @@ -20,7 +20,7 @@ const TransformRunKickoff = z.object({ export const TransformRunResult = z.object({ message: z.string(), run_id: z.number().int().positive().nullable(), - final: TransformRun.nullable().optional(), + final: TransformRun.nullable(), }); export type TransformRunResultJson = z.infer; @@ -51,11 +51,20 @@ export default defineMetabaseCommand({ method: "POST", }); - if (!wait.enabled || kickoff.run_id === null) { - renderItem({ message: kickoff.message, run_id: kickoff.run_id }, transformRunView, ctx); + if (!wait.enabled) { + renderItem( + { message: kickoff.message, run_id: kickoff.run_id, final: null }, + transformRunView, + ctx, + ); return; } + if (kickoff.run_id === null) { + renderItem({ message: kickoff.message, run_id: null, final: null }, transformRunView, ctx); + throw new Error(`transform run did not start: ${kickoff.message}`); + } + const runId = kickoff.run_id; const final = await pollUntil( @@ -67,8 +76,7 @@ export default defineMetabaseCommand({ renderItem({ message: kickoff.message, run_id: runId, final }, transformRunView, ctx); if (RUN_FAILURE_STATUSES.has(final.status)) { - const detail = final.message ? `: ${final.message}` : ""; - throw new Error(`transform run ${runId} ${final.status}${detail}`); + throw new Error(`transform run ${runId} ${final.status}`); } }, }); diff --git a/src/domain/transform.ts b/src/domain/transform.ts index 11f6cfa..a2f49fb 100644 --- a/src/domain/transform.ts +++ b/src/domain/transform.ts @@ -112,6 +112,7 @@ export const TransformCompact = Transform.pick({ name: true, description: true, source_type: true, + target: true, target_db_id: true, }).strip(); export type TransformCompact = z.infer; @@ -122,11 +123,21 @@ export const transformView: ResourceView = { { key: "id", label: "ID" }, { key: "name", label: "Name" }, { key: "source_type", label: "Source" }, + { key: "target", label: "Target", format: (value) => formatTarget(value) }, { key: "target_db_id", label: "Target DB" }, { key: "description", label: "Description" }, ], }; +function formatTarget(value: unknown): string { + const parsed = TransformTarget.safeParse(value); + if (!parsed.success) { + return ""; + } + const { schema, name } = parsed.data; + return schema ? `${schema}.${name}` : name; +} + export const TransformCreateInput = z .object({ name: z.string().min(1), diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index b63b2ff..fb707d4 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -66,6 +66,12 @@ const TRANSFORM_COMPACT = { name: TRANSFORM_NAME, description: null, source_type: "native", + target: { + type: "table", + database: E2E_DATABASES.WAREHOUSE, + schema: "public", + name: TRANSFORM_TARGET_TABLE, + }, target_db_id: E2E_DATABASES.WAREHOUSE, } as const; @@ -206,6 +212,53 @@ describe("transform e2e", () => { expect(parsed.final?.status).toBe("succeeded"); }); + it("run --wait --json on a failing transform exits 1 with a stderr summary that does not duplicate final.message", async () => { + const failName = "e2e_transform_fail"; + const failingBody: TransformBody = { + name: failName, + source: { + type: "query", + query: { + type: "native", + database: E2E_DATABASES.WAREHOUSE, + native: { query: "SELECT 1 FROM does_not_exist" }, + }, + }, + target: { + type: "table", + database: E2E_DATABASES.WAREHOUSE, + schema: "public", + name: failName, + }, + }; + + const createResult = await runCli({ + args: ["transform", "create", "--json"], + stdin: JSON.stringify(failingBody), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(createResult.exitCode, createResult.stderr).toBe(0); + const created = parseJson(createResult.stdout, TransformCompact); + + const runResult = await runCli({ + args: ["transform", "run", String(created.id), "--wait", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(runResult.exitCode).toBe(1); + const parsed = parseJson(runResult.stdout, TransformRunResult); + const finalRun = parsed.final; + if (finalRun === null) throw new Error("expected final run to be populated when --wait is set"); + const failureDetail = finalRun.message; + if (failureDetail === null) throw new Error("expected failed run to carry a message"); + + expect(finalRun.status).toBe("failed"); + expect(runResult.stderr).toContain(`transform run ${parsed.run_id} failed`); + expect(runResult.stderr).not.toContain(failureDetail); + }); + it("run returns a run_id for the created transform", async () => { await createSeedTransform(); From 17254982ff679b3a17b822c065baeae0b546ccfe Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 8 May 2026 14:22:27 -0400 Subject: [PATCH 18/47] lint --- tests/e2e/transform.e2e.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index fb707d4..8edc029 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -250,9 +250,13 @@ describe("transform e2e", () => { expect(runResult.exitCode).toBe(1); const parsed = parseJson(runResult.stdout, TransformRunResult); const finalRun = parsed.final; - if (finalRun === null) throw new Error("expected final run to be populated when --wait is set"); + if (finalRun === null) { + throw new Error("expected final run to be populated when --wait is set"); + } const failureDetail = finalRun.message; - if (failureDetail === null) throw new Error("expected failed run to carry a message"); + if (failureDetail === null) { + throw new Error("expected failed run to carry a message"); + } expect(finalRun.status).toBe("failed"); expect(runResult.stderr).toContain(`transform run ${parsed.run_id} failed`); From a3b3a5f60e4f6757356e3cd78d591375014e166b Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 8 May 2026 16:17:01 -0400 Subject: [PATCH 19/47] more commands --- README.md | 20 +++++++++ src/commands/sync/add-collection.ts | 59 ++++++++++++++++++++++++++ src/commands/sync/index.ts | 2 + src/commands/sync/poll-task.ts | 1 + src/commands/sync/remove-collection.ts | 34 +++++++++++++++ tests/e2e/manifest.e2e.test.ts | 2 + tests/e2e/sync.e2e.test.ts | 55 ++++++++++++++++++++++++ 7 files changed, 173 insertions(+) create mode 100644 src/commands/sync/add-collection.ts create mode 100644 src/commands/sync/remove-collection.ts diff --git a/README.md b/README.md index a892b0a..f614c94 100644 --- a/README.md +++ b/README.md @@ -680,6 +680,26 @@ metabase sync create-branch feat/dashboards metabase sync create-branch feat/x --json ``` +### `metabase sync add-collection ` + +Mark a collection as remote-synced. The toggle cascades to every descendant by `location` prefix, so flagging a parent flags the whole subtree. Returns `{ success, task_id? }`; `task_id` only appears when the toggle triggers a follow-up task (e.g. a finalization import after switching to read-only mode). + +```sh +metabase sync add-collection 12 +metabase sync add-collection 12 --json --profile prod +``` + +The server rejects toggles while `remote-sync-type` is `read-only` (the install default). Switch first with `metabase setting set remote-sync-type '"read-write"'`. + +### `metabase sync remove-collection ` + +Unmark a collection as remote-synced. Same cascade and same `read-only` precondition as `add-collection`. + +```sh +metabase sync remove-collection 12 +metabase sync remove-collection 12 --json --profile prod +``` + ## Workspaces CRUD on `/api/ee/workspace-manager`. Run against the workspace-manager parent instance. diff --git a/src/commands/sync/add-collection.ts b/src/commands/sync/add-collection.ts new file mode 100644 index 0000000..74f35e6 --- /dev/null +++ b/src/commands/sync/add-collection.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +import type { Client } from "../../core/http/client"; +import type { ResourceView } from "../../domain/view"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { REMOTE_SYNC_PATHS } from "./poll-task"; + +export const SyncSettingsUpdateResult = z.object({ + success: z.boolean(), + task_id: z.number().int().positive().optional(), +}); +export type SyncSettingsUpdateResult = z.infer; + +export const syncSettingsUpdateView: ResourceView = { + compactPick: SyncSettingsUpdateResult, + tableColumns: [ + { key: "success", label: "Success" }, + { key: "task_id", label: "Task ID" }, + ], +}; + +export async function setCollectionRemoteSynced( + client: Client, + collectionId: number, + synced: boolean, +): Promise { + return await client.requestParsed(SyncSettingsUpdateResult, REMOTE_SYNC_PATHS.settings, { + method: "PUT", + body: { collections: { [collectionId]: synced } }, + }); +} + +export default defineMetabaseCommand({ + meta: { + name: "add-collection", + description: "Mark a collection as remote-synced; cascades to descendants by location prefix", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Collection id (positive integer)", required: true }, + }, + outputSchema: SyncSettingsUpdateResult, + examples: [ + "metabase sync add-collection 12", + "metabase sync add-collection 12 --json --profile prod", + ], + async run({ args, ctx, getClient }) { + const collectionId = parseId(args.id, "id"); + const client = await getClient(); + const result = await setCollectionRemoteSynced(client, collectionId, true); + renderItem(result, syncSettingsUpdateView, ctx); + }, +}); diff --git a/src/commands/sync/index.ts b/src/commands/sync/index.ts index b78eb2d..ea50e17 100644 --- a/src/commands/sync/index.ts +++ b/src/commands/sync/index.ts @@ -15,5 +15,7 @@ export default defineCommand({ stash: () => import("./stash").then((mod) => mod.default), branches: () => import("./branches").then((mod) => mod.default), "create-branch": () => import("./create-branch").then((mod) => mod.default), + "add-collection": () => import("./add-collection").then((mod) => mod.default), + "remove-collection": () => import("./remove-collection").then((mod) => mod.default), }, }); diff --git a/src/commands/sync/poll-task.ts b/src/commands/sync/poll-task.ts index 08f81dd..2baa491 100644 --- a/src/commands/sync/poll-task.ts +++ b/src/commands/sync/poll-task.ts @@ -32,6 +32,7 @@ export const REMOTE_SYNC_PATHS = { stash: "/api/ee/remote-sync/stash", branches: "/api/ee/remote-sync/branches", createBranch: "/api/ee/remote-sync/create-branch", + settings: "/api/ee/remote-sync/settings", } as const; export const SyncTaskIdle = z.object({ status: z.literal("idle") }); diff --git a/src/commands/sync/remove-collection.ts b/src/commands/sync/remove-collection.ts new file mode 100644 index 0000000..430e05c --- /dev/null +++ b/src/commands/sync/remove-collection.ts @@ -0,0 +1,34 @@ +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +import { + setCollectionRemoteSynced, + SyncSettingsUpdateResult, + syncSettingsUpdateView, +} from "./add-collection"; + +export default defineMetabaseCommand({ + meta: { + name: "remove-collection", + description: "Unmark a collection as remote-synced; cascades to descendants by location prefix", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Collection id (positive integer)", required: true }, + }, + outputSchema: SyncSettingsUpdateResult, + examples: [ + "metabase sync remove-collection 12", + "metabase sync remove-collection 12 --json --profile prod", + ], + async run({ args, ctx, getClient }) { + const collectionId = parseId(args.id, "id"); + const client = await getClient(); + const result = await setCollectionRemoteSynced(client, collectionId, false); + renderItem(result, syncSettingsUpdateView, ctx); + }, +}); diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index 601ea94..a5558e4 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -88,6 +88,8 @@ describe("__manifest e2e", () => { "sync stash", "sync branches", "sync create-branch", + "sync add-collection", + "sync remove-collection", "workspace list", "workspace create", "workspace database provision", diff --git a/tests/e2e/sync.e2e.test.ts b/tests/e2e/sync.e2e.test.ts index 101cae9..0c94bd5 100644 --- a/tests/e2e/sync.e2e.test.ts +++ b/tests/e2e/sync.e2e.test.ts @@ -77,6 +77,39 @@ describe("sync arg validation e2e (no Metabase contact required)", () => { expect(result.stderr).toContain("invalid name: branch name must not be blank"); expect(result.stdout).toBe(""); }); + + it("add-collection with non-integer positional fails with ConfigError", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["sync", "add-collection", "abc", "--json"], + configHome, + }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("add-collection with zero positional fails with ConfigError", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["sync", "add-collection", "0", "--json"], + configHome, + }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("invalid id: 0 (must be ≥ 1)"); + expect(result.stdout).toBe(""); + }); + + it("remove-collection with negative positional fails with ConfigError", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["sync", "remove-collection", "--", "-3", "--json"], + configHome, + }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("invalid id: -3 (must be ≥ 1)"); + expect(result.stdout).toBe(""); + }); }); describe("sync e2e against EE remote-sync endpoints", () => { @@ -232,4 +265,26 @@ describe("sync e2e against EE remote-sync endpoints", () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain("Failed to clone git repository"); }); + + it("add-collection surfaces a 400 HttpError in the default config (read-only or paywall)", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["sync", "add-collection", "1", "--json"], + configHome, + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Metabase returned 400"); + }); + + it("remove-collection surfaces a 400 HttpError in the default config (read-only or paywall)", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["sync", "remove-collection", "1", "--json"], + configHome, + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Metabase returned 400"); + }); }); From 645461e125690618c42b5f4a563f1ef48055c09f Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 8 May 2026 16:34:16 -0400 Subject: [PATCH 20/47] update contract --- README.md | 4 +-- .../workspace/database/parse-schemas.ts | 11 ++++++ src/commands/workspace/database/provision.ts | 20 ++++------- src/commands/workspace/database/update.ts | 15 ++------ src/core/workspace-credentials.test.ts | 8 ++--- src/domain/workspace.ts | 36 +++++++++++++++---- tests/e2e/workspace-local.e2e.test.ts | 2 +- tests/e2e/workspace.e2e.test.ts | 10 +++--- 8 files changed, 62 insertions(+), 44 deletions(-) create mode 100644 src/commands/workspace/database/parse-schemas.ts diff --git a/README.md b/README.md index f614c94..0f1e091 100644 --- a/README.md +++ b/README.md @@ -747,7 +747,7 @@ metabase workspace database provision 1 --file provision.json ### `metabase workspace database update ` -Update a workspace's provisioned database (server-side this is deprovision + provision). Body accepts only `input_schemas` — the database id comes from the URL. +Update a workspace's provisioned database (server-side this is deprovision + provision). Body accepts only `input` — the database id comes from the URL. ```sh metabase workspace database update 1 5 --schemas analytics,github @@ -758,7 +758,7 @@ metabase workspace database update 1 5 --file update.json | Flag | Description | | ----------------- | ----------------------------------------------------------------- | | `--schemas ` | Comma-separated input schemas. Shortcut for body. | -| `--body ` | Inline JSON body (`{"input_schemas":[...]}`). | +| `--body ` | Inline JSON body (`{"input":[{"schema":"..."}]}`). | | `--file ` | Path to JSON body file. | | `--wait` | Poll until the database entry returns to `status: "provisioned"`. | | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | diff --git a/src/commands/workspace/database/parse-schemas.ts b/src/commands/workspace/database/parse-schemas.ts new file mode 100644 index 0000000..7cb85f6 --- /dev/null +++ b/src/commands/workspace/database/parse-schemas.ts @@ -0,0 +1,11 @@ +import { ConfigError } from "../../../core/errors"; +import type { WorkspaceInputNamespace } from "../../../domain/workspace"; +import { parseCsv } from "../../../runtime/csv"; + +export function parseSchemasCsv(raw: string): WorkspaceInputNamespace[] { + const parts = parseCsv(raw); + if (parts.length === 0) { + throw new ConfigError("--schemas must contain at least one schema name"); + } + return parts.map((schema) => ({ schema })); +} diff --git a/src/commands/workspace/database/provision.ts b/src/commands/workspace/database/provision.ts index de78fc8..2abf384 100644 --- a/src/commands/workspace/database/provision.ts +++ b/src/commands/workspace/database/provision.ts @@ -2,13 +2,13 @@ import { Workspace, WorkspaceProvisionInput, workspaceView } from "../../../doma import { ConfigError } from "../../../core/errors"; import { renderItem } from "../../../output/render"; import { readBody } from "../../../runtime/body"; -import { parseCsv } from "../../../runtime/csv"; import { bodyInputFlags } from "../../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../../flags"; import { parseId } from "../../parse-id"; import { defineMetabaseCommand } from "../../runtime"; import { parseWaitFlags, waitFlags } from "../../wait-flags"; +import { parseSchemasCsv } from "./parse-schemas"; import { waitForDatabaseProvisioned } from "./wait"; export default defineMetabaseCommand({ @@ -44,8 +44,11 @@ export default defineMetabaseCommand({ let body: WorkspaceProvisionInput; if (databaseIdFlag !== undefined && databaseIdFlag !== "") { const databaseId = parseId(databaseIdFlag, "--database-id"); - const schemas = parseSchemas(schemasFlag); - body = WorkspaceProvisionInput.parse({ database_id: databaseId, input_schemas: schemas }); + if (schemasFlag === undefined || schemasFlag === "") { + throw new ConfigError("--schemas is required when using --database-id"); + } + const input = parseSchemasCsv(schemasFlag); + body = WorkspaceProvisionInput.parse({ database_id: databaseId, input }); } else { body = await readBody({ flag: args.body, file: args.file }, WorkspaceProvisionInput); } @@ -63,14 +66,3 @@ export default defineMetabaseCommand({ renderItem(final, workspaceView, ctx); }, }); - -function parseSchemas(raw: string | undefined): string[] { - if (raw === undefined || raw === "") { - throw new ConfigError("--schemas is required when using --database-id"); - } - const parts = parseCsv(raw); - if (parts.length === 0) { - throw new ConfigError("--schemas must contain at least one schema name"); - } - return parts; -} diff --git a/src/commands/workspace/database/update.ts b/src/commands/workspace/database/update.ts index 7a179f3..3a24341 100644 --- a/src/commands/workspace/database/update.ts +++ b/src/commands/workspace/database/update.ts @@ -1,14 +1,13 @@ import { Workspace, WorkspaceUpdateDatabaseInput, workspaceView } from "../../../domain/workspace"; -import { ConfigError } from "../../../core/errors"; import { renderItem } from "../../../output/render"; import { readBody } from "../../../runtime/body"; -import { parseCsv } from "../../../runtime/csv"; import { bodyInputFlags } from "../../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../../flags"; import { parseId } from "../../parse-id"; import { defineMetabaseCommand } from "../../runtime"; import { parseWaitFlags, waitFlags } from "../../wait-flags"; +import { parseSchemasCsv } from "./parse-schemas"; import { waitForDatabaseProvisioned } from "./wait"; export default defineMetabaseCommand({ @@ -44,8 +43,8 @@ export default defineMetabaseCommand({ let body: WorkspaceUpdateDatabaseInput; if (schemasFlag !== undefined && schemasFlag !== "") { - const schemas = parseSchemas(schemasFlag); - body = WorkspaceUpdateDatabaseInput.parse({ input_schemas: schemas }); + const input = parseSchemasCsv(schemasFlag); + body = WorkspaceUpdateDatabaseInput.parse({ input }); } else { body = await readBody({ flag: args.body, file: args.file }, WorkspaceUpdateDatabaseInput); } @@ -63,11 +62,3 @@ export default defineMetabaseCommand({ renderItem(final, workspaceView, ctx); }, }); - -function parseSchemas(raw: string): string[] { - const parts = parseCsv(raw); - if (parts.length === 0) { - throw new ConfigError("--schemas must contain at least one schema name"); - } - return parts; -} diff --git a/src/core/workspace-credentials.test.ts b/src/core/workspace-credentials.test.ts index 326350b..e60212a 100644 --- a/src/core/workspace-credentials.test.ts +++ b/src/core/workspace-credentials.test.ts @@ -38,8 +38,8 @@ config: name: my_ws databases: neondb: - input_schemas: - - public + input: + - schema: public output_schema: mb_ws_2 `; @@ -109,7 +109,7 @@ describe("injectCredentialsIntoConfig", () => { workspace: { name: "my_ws", databases: { - neondb: { input_schemas: ["public"], output_schema: "mb_ws_2" }, + neondb: { input: [{ schema: "public" }], output_schema: "mb_ws_2" }, }, }, users: [credentials.user], @@ -170,7 +170,7 @@ describe("injectRepoSettingsIntoConfig", () => { workspace: { name: "my_ws", databases: { - neondb: { input_schemas: ["public"], output_schema: "mb_ws_2" }, + neondb: { input: [{ schema: "public" }], output_schema: "mb_ws_2" }, }, }, settings: { diff --git a/src/domain/workspace.ts b/src/domain/workspace.ts index dad9717..aaf16c2 100644 --- a/src/domain/workspace.ts +++ b/src/domain/workspace.ts @@ -9,11 +9,22 @@ const WorkspaceDatabaseStatus = z.enum([ "deprovisioning", ]); +export const WorkspaceInputNamespace = z + .object({ + db: z.string().min(1).optional(), + schema: z.string().min(1).optional(), + }) + .loose() + .refine((value) => value.db !== undefined || value.schema !== undefined, { + message: "input namespace must specify at least one of db or schema", + }); +export type WorkspaceInputNamespace = z.infer; + export const WorkspaceDatabase = z .object({ database_id: z.number().int(), output_schema: z.string(), - input_schemas: z.array(z.string()), + input: z.array(WorkspaceInputNamespace), status: WorkspaceDatabaseStatus, }) .loose(); @@ -73,14 +84,14 @@ export type WorkspaceCreateInput = z.infer; export const WorkspaceProvisionInput = z .object({ database_id: z.number().int().positive(), - input_schemas: z.array(z.string().min(1)).min(1), + input: z.array(WorkspaceInputNamespace).min(1), }) .loose(); export type WorkspaceProvisionInput = z.infer; export const WorkspaceUpdateDatabaseInput = z .object({ - input_schemas: z.array(z.string().min(1)).min(1), + input: z.array(WorkspaceInputNamespace).min(1), }) .loose(); export type WorkspaceUpdateDatabaseInput = z.infer; @@ -95,9 +106,22 @@ function formatDatabases(value: unknown): string { } return parsed.data .map((entry) => { - const schemaList = - entry.input_schemas.length === 0 ? "" : ` [${entry.input_schemas.join(", ")}]`; - return `${entry.database_id} (${entry.status})${schemaList}`; + const inputList = + entry.input.length === 0 ? "" : ` [${entry.input.map(formatInputNamespace).join(", ")}]`; + return `${entry.database_id} (${entry.status})${inputList}`; }) .join("; "); } + +function formatInputNamespace(namespace: WorkspaceInputNamespace): string { + if (namespace.db !== undefined && namespace.schema !== undefined) { + return `${namespace.db}.${namespace.schema}`; + } + if (namespace.schema !== undefined) { + return namespace.schema; + } + if (namespace.db !== undefined) { + return namespace.db; + } + throw new Error("WorkspaceInputNamespace must specify db or schema"); +} diff --git a/tests/e2e/workspace-local.e2e.test.ts b/tests/e2e/workspace-local.e2e.test.ts index 128e7c8..38ae68e 100644 --- a/tests/e2e/workspace-local.e2e.test.ts +++ b/tests/e2e/workspace-local.e2e.test.ts @@ -109,7 +109,7 @@ describe.skipIf(skipReason !== null)("workspace local-runtime e2e", () => { ); expect(provisioned).toMatchObject({ database_id: E2E_DATABASES.WAREHOUSE, - input_schemas: [ANALYTICS_SCHEMA], + input: [{ schema: ANALYTICS_SCHEMA }], status: "provisioned", }); } diff --git a/tests/e2e/workspace.e2e.test.ts b/tests/e2e/workspace.e2e.test.ts index 7f3e268..9903466 100644 --- a/tests/e2e/workspace.e2e.test.ts +++ b/tests/e2e/workspace.e2e.test.ts @@ -123,12 +123,12 @@ describe("workspace e2e", () => { const entry = findWarehouseDatabase(provisioned); expect({ database_id: entry.database_id, - input_schemas: entry.input_schemas, + input: entry.input, status: entry.status, hasOutputSchema: entry.output_schema.length > 0, }).toEqual({ database_id: E2E_DATABASES.WAREHOUSE, - input_schemas: [ANALYTICS_SCHEMA], + input: [{ schema: ANALYTICS_SCHEMA }], status: "provisioned", hasOutputSchema: true, }); @@ -187,10 +187,10 @@ describe("workspace e2e", () => { const updated = parseJson(updateResult.stdout, Workspace); const entry = findWarehouseDatabase(updated); expect({ - input_schemas: entry.input_schemas, + input: entry.input, status: entry.status, }).toEqual({ - input_schemas: [PUBLIC_SCHEMA], + input: [{ schema: PUBLIC_SCHEMA }], status: "provisioned", }); }); @@ -237,7 +237,7 @@ describe("workspace e2e", () => { "--body", JSON.stringify({ database_id: E2E_DATABASES.WAREHOUSE, - input_schemas: [PUBLIC_SCHEMA], + input: [{ schema: PUBLIC_SCHEMA }], }), "--json", ], From 4acbfab74a25d90d677673f6011fb3ebb57d718f Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 8 May 2026 17:40:02 -0400 Subject: [PATCH 21/47] transforms runs --- README.md | 32 ++++ src/commands/transform/cancel.ts | 42 +++++ src/commands/transform/get-run.ts | 23 +++ src/commands/transform/index.ts | 3 + src/commands/transform/run.ts | 14 +- src/commands/transform/runs.ts | 52 ++++++ src/domain/transform.ts | 34 +++- tests/e2e/manifest.e2e.test.ts | 3 + tests/e2e/transform.e2e.test.ts | 283 +++++++++++++++++++++++++++++- 9 files changed, 475 insertions(+), 11 deletions(-) create mode 100644 src/commands/transform/cancel.ts create mode 100644 src/commands/transform/get-run.ts create mode 100644 src/commands/transform/runs.ts diff --git a/README.md b/README.md index 0f1e091..6955ab7 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,38 @@ metabase transform run 1 --wait --json | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | | `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | +### `metabase transform cancel ` + +Cancel the currently-running run for a transform. Exits 0 with `{canceled: true, id}` on success; exits 1 with a 404 if the transform has no active run. + +```sh +metabase transform cancel 1 +metabase transform cancel 1 --json +``` + +### `metabase transform get-run ` + +Fetch a single run by run id (not transform id). Same compact / `--full` projection convention as `transform get`. + +```sh +metabase transform get-run 1 --json +``` + +### `metabase transform runs` + +List recent transform runs across all transforms, or filter to one. Drains all pages by default; pass `--limit` to cap. + +```sh +metabase transform runs +metabase transform runs --transform-id 1 --json +metabase transform runs --limit 10 --json +``` + +| Flag | Description | +| --------------------- | --------------------------------------------------- | +| `--transform-id ` | Filter to runs of a single transform id. | +| `--limit ` | Cap total runs returned (default: drain all pages). | + ## Transform jobs CRUD on `/api/transform-job`. Bodies for `create` / `update` follow the same `--body` / `--file` / stdin pattern as transforms. diff --git a/src/commands/transform/cancel.ts b/src/commands/transform/cancel.ts new file mode 100644 index 0000000..c7b43a8 --- /dev/null +++ b/src/commands/transform/cancel.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +import type { ResourceView } from "../../domain/view"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export const TransformCancelResult = z.object({ + canceled: z.boolean(), + id: z.number().int(), +}); +export type TransformCancelResultJson = z.infer; + +const transformCancelView: ResourceView = { + compactPick: TransformCancelResult, + tableColumns: [ + { key: "id", label: "Transform" }, + { key: "canceled", label: "Canceled" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { name: "cancel", description: "Cancel the current run for a transform" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Transform id", required: true }, + }, + outputSchema: TransformCancelResult, + examples: ["metabase transform cancel 1", "metabase transform cancel 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + await client.requestRaw(`/api/transform/${id}/cancel`, { + method: "POST", + expectContentType: "binary", + }); + renderItem({ canceled: true, id }, transformCancelView, ctx); + }, +}); diff --git a/src/commands/transform/get-run.ts b/src/commands/transform/get-run.ts new file mode 100644 index 0000000..476f40d --- /dev/null +++ b/src/commands/transform/get-run.ts @@ -0,0 +1,23 @@ +import { TransformRun, transformRunView } from "../../domain/transform"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "get-run", description: "Get a transform run by id" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Run id", required: true }, + }, + outputSchema: TransformRun, + examples: ["metabase transform get-run 1", "metabase transform get-run 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id, "run id"); + const client = await getClient(); + const run = await client.requestParsed(TransformRun, `/api/transform/run/${id}`); + renderItem(run, transformRunView, ctx); + }, +}); diff --git a/src/commands/transform/index.ts b/src/commands/transform/index.ts index 59dce45..cebaf17 100644 --- a/src/commands/transform/index.ts +++ b/src/commands/transform/index.ts @@ -10,5 +10,8 @@ export default defineCommand({ delete: () => import("./delete").then((mod) => mod.default), "delete-table": () => import("./delete-table").then((mod) => mod.default), run: () => import("./run").then((mod) => mod.default), + cancel: () => import("./cancel").then((mod) => mod.default), + "get-run": () => import("./get-run").then((mod) => mod.default), + runs: () => import("./runs").then((mod) => mod.default), }, }); diff --git a/src/commands/transform/run.ts b/src/commands/transform/run.ts index 03ebee4..c676041 100644 --- a/src/commands/transform/run.ts +++ b/src/commands/transform/run.ts @@ -9,7 +9,7 @@ import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; import { parseWaitFlags, waitFlags } from "../wait-flags"; -const RUN_TERMINAL_STATUSES = new Set(["succeeded", "failed", "timeout", "canceled"]); +export const RUN_TERMINAL_STATUSES = new Set(["succeeded", "failed", "timeout", "canceled"]); const RUN_FAILURE_STATUSES = new Set(["failed", "timeout", "canceled"]); const TransformRunKickoff = z.object({ @@ -24,7 +24,7 @@ export const TransformRunResult = z.object({ }); export type TransformRunResultJson = z.infer; -const transformRunView: ResourceView = { +const transformRunResultView: ResourceView = { compactPick: TransformRunResult, tableColumns: [ { key: "run_id", label: "Run ID" }, @@ -54,14 +54,18 @@ export default defineMetabaseCommand({ if (!wait.enabled) { renderItem( { message: kickoff.message, run_id: kickoff.run_id, final: null }, - transformRunView, + transformRunResultView, ctx, ); return; } if (kickoff.run_id === null) { - renderItem({ message: kickoff.message, run_id: null, final: null }, transformRunView, ctx); + renderItem( + { message: kickoff.message, run_id: null, final: null }, + transformRunResultView, + ctx, + ); throw new Error(`transform run did not start: ${kickoff.message}`); } @@ -73,7 +77,7 @@ export default defineMetabaseCommand({ wait.schedule, ); - renderItem({ message: kickoff.message, run_id: runId, final }, transformRunView, ctx); + renderItem({ message: kickoff.message, run_id: runId, final }, transformRunResultView, ctx); if (RUN_FAILURE_STATUSES.has(final.status)) { throw new Error(`transform run ${runId} ${final.status}`); diff --git a/src/commands/transform/runs.ts b/src/commands/transform/runs.ts new file mode 100644 index 0000000..1bfc00b --- /dev/null +++ b/src/commands/transform/runs.ts @@ -0,0 +1,52 @@ +import { TransformRun, TransformRunCompact, transformRunView } from "../../domain/transform"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, type ListEnvelope } from "../../output/types"; +import { collectPaginated } from "../../runtime/paginate"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export const TransformRunListEnvelope = listEnvelopeSchema(TransformRunCompact); + +export default defineMetabaseCommand({ + meta: { name: "runs", description: "List recent transform runs" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + "transform-id": { + type: "string", + description: "Filter to runs of a single transform id", + }, + limit: { + type: "string", + description: "Cap total runs returned (default: drain all pages)", + }, + }, + outputSchema: TransformRunListEnvelope, + examples: [ + "metabase transform runs", + "metabase transform runs --transform-id 1 --json", + "metabase transform runs --limit 10 --json", + ], + async run({ args, ctx, getClient }) { + const transformId = + args["transform-id"] === undefined + ? undefined + : parseId(args["transform-id"], "--transform-id"); + const max = args.limit === undefined ? undefined : parseId(args.limit, "--limit"); + const client = await getClient(); + + const items = await collectPaginated(client, "/api/transform/run", TransformRun, { + query: { "transform-ids": transformId }, + ...(max !== undefined && { max }), + }); + + const envelope: ListEnvelope = { + data: items, + returned: items.length, + ...(max === undefined ? { total: items.length } : { limit: max }), + }; + renderList(envelope, transformRunView, ctx); + }, +}); diff --git a/src/domain/transform.ts b/src/domain/transform.ts index a2f49fb..b1533ab 100644 --- a/src/domain/transform.ts +++ b/src/domain/transform.ts @@ -56,6 +56,11 @@ const TransformTarget = z.discriminatedUnion("type", [ TransformTableIncrementalTarget, ]); +const TransformTargetCompact = z.discriminatedUnion("type", [ + TransformTableTarget.strip(), + TransformTableIncrementalTarget.strip(), +]); + const TransformLastRun = z .object({ id: z.number().int(), @@ -84,6 +89,30 @@ export const TransformRun = z .loose(); export type TransformRun = z.infer; +export const TransformRunCompact = TransformRun.pick({ + id: true, + transform_id: true, + status: true, + run_method: true, + start_time: true, + end_time: true, + message: true, +}).strip(); +export type TransformRunCompact = z.infer; + +export const transformRunView: ResourceView = { + compactPick: TransformRunCompact, + tableColumns: [ + { key: "id", label: "Run ID" }, + { key: "transform_id", label: "Transform" }, + { key: "status", label: "Status" }, + { key: "run_method", label: "Method" }, + { key: "start_time", label: "Started" }, + { key: "end_time", label: "Ended" }, + { key: "message", label: "Message" }, + ], +}; + export const Transform = z .object({ id: z.number().int(), @@ -112,9 +141,10 @@ export const TransformCompact = Transform.pick({ name: true, description: true, source_type: true, - target: true, target_db_id: true, -}).strip(); +}) + .strip() + .extend({ target: TransformTargetCompact }); export type TransformCompact = z.infer; export const transformView: ResourceView = { diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index a5558e4..aaae160 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -67,6 +67,9 @@ describe("__manifest e2e", () => { "transform delete", "transform delete-table", "transform run", + "transform cancel", + "transform get-run", + "transform runs", "transform-job list", "transform-job get", "transform-job create", diff --git a/tests/e2e/transform.e2e.test.ts b/tests/e2e/transform.e2e.test.ts index 8edc029..f31f4c8 100644 --- a/tests/e2e/transform.e2e.test.ts +++ b/tests/e2e/transform.e2e.test.ts @@ -3,11 +3,13 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { z } from "zod"; import { DeleteResult } from "../../src/commands/delete-runtime"; +import { TransformCancelResult } from "../../src/commands/transform/cancel"; import { TransformListEnvelope } from "../../src/commands/transform/list"; -import { TransformRunResult } from "../../src/commands/transform/run"; +import { RUN_TERMINAL_STATUSES, TransformRunResult } from "../../src/commands/transform/run"; +import { TransformRunListEnvelope } from "../../src/commands/transform/runs"; import { createClient, type Client } from "../../src/core/http/client"; import { ValidationOutcome } from "../../src/core/schema/validate"; -import { TransformCompact } from "../../src/domain/transform"; +import { TransformCompact, TransformRun, TransformRunCompact } from "../../src/domain/transform"; import { parseJson } from "../../src/runtime/json"; import { pollUntil } from "../../src/runtime/poll"; @@ -31,8 +33,6 @@ interface TransformBody { target: { type: "table"; database: number; schema: string; name: string }; } -const RUN_TERMINAL_STATUSES = new Set(["succeeded", "failed", "timeout", "canceled"]); - const RunStatusResponse = z.object({ status: z.string() }).loose(); async function waitForRunComplete(client: Client, runId: number): Promise { @@ -43,6 +43,14 @@ async function waitForRunComplete(client: Client, runId: number): Promise ); } +async function waitForRunStarted(client: Client, runId: number): Promise { + await pollUntil( + async () => client.requestParsed(RunStatusResponse, `/api/transform/run/${runId}`), + (run) => run.status === "started", + { intervalMs: 200, timeoutMs: 15_000 }, + ); +} + const TRANSFORM_BODY: TransformBody = { name: TRANSFORM_NAME, source: { @@ -441,6 +449,273 @@ describe("transform e2e", () => { expect(result.stdout).toBe(""); }); + it("get-run returns the run we just kicked off, parsed against TransformRun", async () => { + await createSeedTransform(); + + const runResult = await runCli({ + args: ["transform", "run", String(FIRST_TRANSFORM_ID), "--wait", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(runResult.exitCode, runResult.stderr).toBe(0); + const kickoff = parseJson(runResult.stdout, TransformRunResult); + const runId = kickoff.run_id; + if (runId === null) { + throw new Error("expected kickoff to return a run_id"); + } + + const result = await runCli({ + args: ["transform", "get-run", String(runId), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + const run = parseJson(result.stdout, TransformRunCompact); + expect(run).toEqual({ + id: runId, + transform_id: FIRST_TRANSFORM_ID, + status: "succeeded", + run_method: "manual", + start_time: expect.any(String), + end_time: expect.any(String), + message: null, + }); + }); + + it("get-run with non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["transform", "get-run", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid run id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("get-run against a missing run id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["transform", "get-run", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("runs lists the recently-completed run for the seeded transform", async () => { + await createSeedTransform(); + + const runResult = await runCli({ + args: ["transform", "run", String(FIRST_TRANSFORM_ID), "--wait", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(runResult.exitCode, runResult.stderr).toBe(0); + const kickoff = parseJson(runResult.stdout, TransformRunResult); + const runId = kickoff.run_id; + if (runId === null) { + throw new Error("expected kickoff to return a run_id"); + } + + const result = await runCli({ + args: ["transform", "runs", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, TransformRunListEnvelope)).toEqual({ + data: [ + { + id: runId, + transform_id: FIRST_TRANSFORM_ID, + status: "succeeded", + run_method: "manual", + start_time: expect.any(String), + end_time: expect.any(String), + message: null, + }, + ], + returned: 1, + total: 1, + }); + }); + + it("runs --transform-id filters to that transform's runs only", async () => { + await createSeedTransform(); + + const otherCreate = await runCli({ + args: ["transform", "create", "--json"], + stdin: JSON.stringify({ + ...TRANSFORM_BODY, + name: `${TRANSFORM_NAME}_other`, + target: { ...TRANSFORM_BODY.target, name: `${TRANSFORM_TARGET_TABLE}_other` }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(otherCreate.exitCode, otherCreate.stderr).toBe(0); + const otherTransform = parseJson(otherCreate.stdout, TransformCompact); + + const runResults = await Promise.all( + [FIRST_TRANSFORM_ID, otherTransform.id].map(async (transformId) => + runCli({ + args: ["transform", "run", String(transformId), "--wait", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }), + ), + ); + for (const runResult of runResults) { + expect(runResult.exitCode, runResult.stderr).toBe(0); + } + + const result = await runCli({ + args: ["transform", "runs", "--transform-id", String(FIRST_TRANSFORM_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, TransformRunListEnvelope)).toEqual({ + data: [ + { + id: expect.any(Number), + transform_id: FIRST_TRANSFORM_ID, + status: "succeeded", + run_method: "manual", + start_time: expect.any(String), + end_time: expect.any(String), + message: null, + }, + ], + returned: 1, + total: 1, + }); + }); + + it("runs --limit caps the result count to the requested page", async () => { + await createSeedTransform(); + + const runResult = await runCli({ + args: ["transform", "run", String(FIRST_TRANSFORM_ID), "--wait", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(runResult.exitCode, runResult.stderr).toBe(0); + + const result = await runCli({ + args: ["transform", "runs", "--limit", "1", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, TransformRunListEnvelope)).toEqual({ + data: [ + { + id: expect.any(Number), + transform_id: FIRST_TRANSFORM_ID, + status: "succeeded", + run_method: "manual", + start_time: expect.any(String), + end_time: expect.any(String), + message: null, + }, + ], + returned: 1, + limit: 1, + }); + }); + + it("runs with non-integer --transform-id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["transform", "runs", "--transform-id", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid --transform-id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("cancel marks an in-progress run as canceling", async () => { + const sleepBody: TransformBody = { + name: "e2e_transform_cancel", + source: { + type: "query", + query: { + type: "native", + database: E2E_DATABASES.WAREHOUSE, + native: { query: "WITH s AS (SELECT pg_sleep(20)) SELECT 1 AS one FROM s" }, + }, + }, + target: { + type: "table", + database: E2E_DATABASES.WAREHOUSE, + schema: "public", + name: "e2e_transform_cancel", + }, + }; + const createResult = await runCli({ + args: ["transform", "create", "--json"], + stdin: JSON.stringify(sleepBody), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(createResult.exitCode, createResult.stderr).toBe(0); + const created = parseJson(createResult.stdout, TransformCompact); + + const kickoffResult = await runCli({ + args: ["transform", "run", String(created.id), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(kickoffResult.exitCode, kickoffResult.stderr).toBe(0); + const kickoff = parseJson(kickoffResult.stdout, TransformRunResult); + const runId = kickoff.run_id; + if (runId === null) { + throw new Error("expected kickoff to return a run_id"); + } + await waitForRunStarted(adminClient, runId); + + const cancelResult = await runCli({ + args: ["transform", "cancel", String(created.id), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(cancelResult.exitCode, cancelResult.stderr).toBe(0); + expect(parseJson(cancelResult.stdout, TransformCancelResult)).toEqual({ + canceled: true, + id: created.id, + }); + + await waitForRunComplete(adminClient, runId); + const finalRun = await adminClient.requestParsed(TransformRun, `/api/transform/run/${runId}`); + expect(finalRun.status).toBe("canceled"); + }); + + it("cancel with no running run surfaces a 404 HttpError", async () => { + await createSeedTransform(); + + const result = await runCli({ + args: ["transform", "cancel", String(FIRST_TRANSFORM_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("cancel with non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["transform", "cancel", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + it("delete without --yes proceeds in non-TTY (auto-confirm matches kubectl/gh/docker convention)", async () => { await createSeedTransform(); From 397221dccb8000e9932a3539781dc3a58c2325d8 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 8 May 2026 18:11:13 -0400 Subject: [PATCH 22/47] more commands --- README.md | 76 +++++++ src/commands/db/get.ts | 20 +- src/commands/db/index.ts | 5 + src/commands/db/list.ts | 32 ++- src/commands/db/metadata.ts | 26 +++ src/commands/db/rescan-values.ts | 34 ++++ src/commands/db/schema-tables.ts | 37 ++++ src/commands/db/schemas.ts | 39 ++++ src/commands/db/sync-schema.ts | 34 ++++ src/domain/database.ts | 26 ++- tests/e2e/db.e2e.test.ts | 330 ++++++++++++++++++++++++++++++- tests/e2e/manifest.e2e.test.ts | 5 + 12 files changed, 646 insertions(+), 18 deletions(-) create mode 100644 src/commands/db/metadata.ts create mode 100644 src/commands/db/rescan-values.ts create mode 100644 src/commands/db/schema-tables.ts create mode 100644 src/commands/db/schemas.ts create mode 100644 src/commands/db/sync-schema.ts diff --git a/README.md b/README.md index 6955ab7..5be638e 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,82 @@ metabase transform-job delete 1 --yes | ------- | --------------------------------------------------------------------------------------------------------------------------------- | | `--yes` | Skip the interactive confirmation prompt. In non-TTY contexts the prompt is skipped automatically (kubectl/gh/docker convention). | +## Databases + +Read warehouse metadata from `/api/database`. The `db` group exposes the full database list, the per-database record, hydrated metadata (tables + fields rolled up in one response), schema and table inspection, and the two manual-sync triggers. + +`db` is aliased to `database`. + +### `metabase db list` + +```sh +metabase db list +metabase db list --json +metabase db list --include tables --full --json +metabase db list --saved --json +``` + +| Flag | Description | +| ------------------- | ------------------------------------------------------------------------------------------------------------- | +| `--include ` | Hydrate related entities. Currently only `tables` is supported (each database is returned with its `tables`). | +| `--saved` | Include the Saved Questions virtual database in the list. The virtual db has id `-1337` and no `engine`. | + +### `metabase db get ` + +```sh +metabase db get 1 +metabase db get 1 --json +metabase db get 1 --include tables.fields --full --json +``` + +| Flag | Description | +| ------------------- | ------------------------------------------------------------- | +| `--include ` | Hydrate related entities. One of `tables` or `tables.fields`. | + +### `metabase db metadata ` + +Equivalent to `GET /api/database/:id/metadata`: a single database with all its tables and fields rolled up in one response. Use this when an agent needs a one-shot warehouse introspection rather than the per-table `metabase table get --full`. + +```sh +metabase db metadata 1 --json --full --max-bytes 0 +``` + +### `metabase db schemas ` + +List the schemas in a database. Schemas with no tables are excluded. + +```sh +metabase db schemas 1 +metabase db schemas 1 --json +``` + +### `metabase db schema-tables ` + +List the tables in a given schema, sorted by display name. + +```sh +metabase db schema-tables 1 public +metabase db schema-tables 1 analytics --json +``` + +### `metabase db sync-schema ` + +Trigger a manual schema sync (`POST /api/database/:id/sync_schema`). Returns `{ id, status: "ok" }` once the sync has been queued; the actual work happens asynchronously on the server. + +```sh +metabase db sync-schema 1 +metabase db sync-schema 1 --json +``` + +### `metabase db rescan-values ` + +Trigger a rescan of cached field values (`POST /api/database/:id/rescan_values`). Returns `{ id, status: "ok" }` once the rescan has been queued. + +```sh +metabase db rescan-values 1 +metabase db rescan-values 1 --json +``` + ## Cards CRUD plus query execution on `/api/card`. A "card" is a Metabase question, model, or metric. The `query` subcommand runs the card and either returns Metabase's JSON envelope or streams a raw CSV / XLSX export. diff --git a/src/commands/db/get.ts b/src/commands/db/get.ts index 3d2f9a5..0410e48 100644 --- a/src/commands/db/get.ts +++ b/src/commands/db/get.ts @@ -1,23 +1,39 @@ +import { z } from "zod"; + import { Database, databaseView } from "../../domain/database"; import { renderItem } from "../../output/render"; +import { parseEnum } from "../../runtime/csv"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +const DatabaseGetInclude = z.enum(["tables", "tables.fields"]); + export default defineMetabaseCommand({ meta: { name: "get", description: "Get a database by id" }, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + include: { + type: "string", + description: `Hydrate related entities: ${DatabaseGetInclude.options.join("|")}`, + }, id: { type: "positional", description: "Database id", required: true }, }, outputSchema: Database, - examples: ["metabase db get 1", "metabase db get 1 --json"], + examples: [ + "metabase db get 1", + "metabase db get 1 --json", + "metabase db get 1 --include tables.fields --json", + ], async run({ args, ctx, getClient }) { const id = parseId(args.id); + const include = parseEnum(args.include, DatabaseGetInclude, "--include"); const client = await getClient(); - const database = await client.requestParsed(Database, `/api/database/${id}`); + const database = await client.requestParsed(Database, `/api/database/${id}`, { + query: { include }, + }); renderItem(database, databaseView, ctx); }, }); diff --git a/src/commands/db/index.ts b/src/commands/db/index.ts index 23b0d3d..f50f0f6 100644 --- a/src/commands/db/index.ts +++ b/src/commands/db/index.ts @@ -5,5 +5,10 @@ export default defineCommand({ subCommands: { list: () => import("./list").then((m) => m.default), get: () => import("./get").then((m) => m.default), + metadata: () => import("./metadata").then((m) => m.default), + schemas: () => import("./schemas").then((m) => m.default), + "schema-tables": () => import("./schema-tables").then((m) => m.default), + "sync-schema": () => import("./sync-schema").then((m) => m.default), + "rescan-values": () => import("./rescan-values").then((m) => m.default), }, }); diff --git a/src/commands/db/list.ts b/src/commands/db/list.ts index 89780b3..ca9bd92 100644 --- a/src/commands/db/list.ts +++ b/src/commands/db/list.ts @@ -3,9 +3,12 @@ import { z } from "zod"; import { Database, DatabaseCompact, databaseView } from "../../domain/database"; import { renderList } from "../../output/render"; import { listEnvelopeSchema, type ListEnvelope } from "../../output/types"; +import { parseEnum } from "../../runtime/csv"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +const DatabaseListInclude = z.enum(["tables"]); + const DatabaseApiList = z .object({ data: z.array(Database), @@ -17,12 +20,33 @@ export const DatabaseListEnvelope = listEnvelopeSchema(DatabaseCompact); export default defineMetabaseCommand({ meta: { name: "list", description: "List databases" }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + include: { + type: "string", + description: `Hydrate related entities: ${DatabaseListInclude.options.join("|")}`, + }, + saved: { + type: "boolean", + description: "Include the Saved Questions virtual database", + }, + }, outputSchema: DatabaseListEnvelope, - examples: ["metabase db list", "metabase db list --json"], - async run({ ctx, getClient }) { + examples: [ + "metabase db list", + "metabase db list --json", + "metabase db list --include tables --json", + "metabase db list --saved --json", + ], + async run({ args, ctx, getClient }) { + const include = parseEnum(args.include, DatabaseListInclude, "--include"); + const saved = args.saved ? true : undefined; const client = await getClient(); - const response = await client.requestParsed(DatabaseApiList, "/api/database"); + const response = await client.requestParsed(DatabaseApiList, "/api/database", { + query: { include, saved }, + }); const envelope: ListEnvelope = { data: response.data, diff --git a/src/commands/db/metadata.ts b/src/commands/db/metadata.ts new file mode 100644 index 0000000..7bda1d8 --- /dev/null +++ b/src/commands/db/metadata.ts @@ -0,0 +1,26 @@ +import { Database, databaseView } from "../../domain/database"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "metadata", + description: "Get a database with its tables and fields hydrated", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Database id", required: true }, + }, + outputSchema: Database, + examples: ["metabase db metadata 1", "metabase db metadata 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const database = await client.requestParsed(Database, `/api/database/${id}/metadata`); + renderItem(database, databaseView, ctx); + }, +}); diff --git a/src/commands/db/rescan-values.ts b/src/commands/db/rescan-values.ts new file mode 100644 index 0000000..fd0a95a --- /dev/null +++ b/src/commands/db/rescan-values.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +import { databaseSyncResultView, DatabaseSyncResult } from "../../domain/database"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +const RescanValuesApiResponse = z.object({ status: z.literal("ok") }); + +export default defineMetabaseCommand({ + meta: { + name: "rescan-values", + description: "Trigger a rescan of cached field values for a database", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Database id", required: true }, + }, + outputSchema: DatabaseSyncResult, + examples: ["metabase db rescan-values 1", "metabase db rescan-values 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const response = await client.requestParsed( + RescanValuesApiResponse, + `/api/database/${id}/rescan_values`, + { method: "POST" }, + ); + renderItem({ id, status: response.status }, databaseSyncResultView, ctx); + }, +}); diff --git a/src/commands/db/schema-tables.ts b/src/commands/db/schema-tables.ts new file mode 100644 index 0000000..73d31f3 --- /dev/null +++ b/src/commands/db/schema-tables.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +import { Table, TableCompact, tableView } from "../../domain/table"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +const SchemaTablesApiResponse = z.array(Table); + +export const DatabaseSchemaTablesEnvelope = listEnvelopeSchema(TableCompact); + +export default defineMetabaseCommand({ + meta: { + name: "schema-tables", + description: "List tables in a database schema", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Database id", required: true }, + schema: { type: "positional", description: "Schema name", required: true }, + }, + outputSchema: DatabaseSchemaTablesEnvelope, + examples: ["metabase db schema-tables 1 public", "metabase db schema-tables 1 public --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const tables = await client.requestParsed( + SchemaTablesApiResponse, + `/api/database/${id}/schema/${encodeURIComponent(args.schema)}`, + ); + renderList(wrapList(tables), tableView, ctx); + }, +}); diff --git a/src/commands/db/schemas.ts b/src/commands/db/schemas.ts new file mode 100644 index 0000000..a63a31d --- /dev/null +++ b/src/commands/db/schemas.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +import type { ResourceView } from "../../domain/view"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +const SchemaName = z.object({ name: z.string() }); +type SchemaName = z.infer; + +const schemaNameView: ResourceView = { + compactPick: SchemaName, + tableColumns: [{ key: "name", label: "Schema" }], +}; + +const SchemasApiResponse = z.array(z.string()); + +export const DatabaseSchemaListEnvelope = listEnvelopeSchema(SchemaName); + +export default defineMetabaseCommand({ + meta: { name: "schemas", description: "List schemas in a database" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Database id", required: true }, + }, + outputSchema: DatabaseSchemaListEnvelope, + examples: ["metabase db schemas 1", "metabase db schemas 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const names = await client.requestParsed(SchemasApiResponse, `/api/database/${id}/schemas`); + const rows: SchemaName[] = names.map((name) => ({ name })); + renderList(wrapList(rows), schemaNameView, ctx); + }, +}); diff --git a/src/commands/db/sync-schema.ts b/src/commands/db/sync-schema.ts new file mode 100644 index 0000000..20b4546 --- /dev/null +++ b/src/commands/db/sync-schema.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +import { databaseSyncResultView, DatabaseSyncResult } from "../../domain/database"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +const SyncSchemaApiResponse = z.object({ status: z.literal("ok") }); + +export default defineMetabaseCommand({ + meta: { + name: "sync-schema", + description: "Trigger a manual schema sync for a database", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Database id", required: true }, + }, + outputSchema: DatabaseSyncResult, + examples: ["metabase db sync-schema 1", "metabase db sync-schema 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const response = await client.requestParsed( + SyncSchemaApiResponse, + `/api/database/${id}/sync_schema`, + { method: "POST" }, + ); + renderItem({ id, status: response.status }, databaseSyncResultView, ctx); + }, +}); diff --git a/src/domain/database.ts b/src/domain/database.ts index 5fc1f57..af641af 100644 --- a/src/domain/database.ts +++ b/src/domain/database.ts @@ -1,17 +1,25 @@ import { z } from "zod"; +import { Table } from "./table"; import type { ResourceView } from "./view"; export const Database = z .object({ id: z.number().int(), name: z.string(), - engine: z.string(), + engine: z.string().optional(), + is_saved_questions: z.boolean().optional(), + tables: z.array(Table).optional(), }) .loose(); export type Database = z.infer; -export const DatabaseCompact = Database.pick({ id: true, name: true, engine: true }).strip(); +export const DatabaseCompact = Database.pick({ + id: true, + name: true, + engine: true, + is_saved_questions: true, +}).strip(); export type DatabaseCompact = z.infer; export const databaseView: ResourceView = { @@ -22,3 +30,17 @@ export const databaseView: ResourceView = { { key: "engine", label: "Engine" }, ], }; + +export const DatabaseSyncResult = z.object({ + id: z.number().int(), + status: z.literal("ok"), +}); +export type DatabaseSyncResult = z.infer; + +export const databaseSyncResultView: ResourceView = { + compactPick: DatabaseSyncResult, + tableColumns: [ + { key: "id", label: "Database" }, + { key: "status", label: "Status" }, + ], +}; diff --git a/tests/e2e/db.e2e.test.ts b/tests/e2e/db.e2e.test.ts index c3d1c0d..13ed70f 100644 --- a/tests/e2e/db.e2e.test.ts +++ b/tests/e2e/db.e2e.test.ts @@ -1,12 +1,87 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { DatabaseListEnvelope } from "../../src/commands/db/list"; -import { DatabaseCompact } from "../../src/domain/database"; +import { DatabaseSchemaListEnvelope } from "../../src/commands/db/schemas"; +import { DatabaseSchemaTablesEnvelope } from "../../src/commands/db/schema-tables"; +import { Database, DatabaseCompact, DatabaseSyncResult } from "../../src/domain/database"; +import { TableCompact } from "../../src/domain/table"; +import { listEnvelopeSchema } from "../../src/output/types"; import { parseJson } from "../../src/runtime/json"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { E2E_DATABASES } from "./seed/ids"; +import { E2E_DATABASES, E2E_TABLES } from "./seed/ids"; + +const SAVED_QUESTIONS_VIRTUAL_DB_ID = -1337; + +const PUBLIC_TABLES_SORTED_BY_DISPLAY_NAME: TableCompact[] = [ + { + id: E2E_TABLES.CUSTOMERS, + name: "customers", + display_name: "Customers", + description: "Customer dimension; mixed types for sync coverage.", + db_id: E2E_DATABASES.WAREHOUSE, + schema: "public", + entity_type: "entity/GenericTable", + }, + { + id: E2E_TABLES.ORDER_ITEMS, + name: "order_items", + display_name: "Order Items", + description: null, + db_id: E2E_DATABASES.WAREHOUSE, + schema: "public", + entity_type: "entity/TransactionTable", + }, + { + id: E2E_TABLES.ORDER_SUMMARY, + name: "order_summary", + display_name: "Order Summary", + description: null, + db_id: E2E_DATABASES.WAREHOUSE, + schema: "public", + entity_type: "entity/TransactionTable", + }, + { + id: E2E_TABLES.ORDERS, + name: "orders", + display_name: "Orders", + description: null, + db_id: E2E_DATABASES.WAREHOUSE, + schema: "public", + entity_type: "entity/TransactionTable", + }, + { + id: E2E_TABLES.PRODUCTS, + name: "products", + display_name: "Products", + description: null, + db_id: E2E_DATABASES.WAREHOUSE, + schema: "public", + entity_type: "entity/ProductTable", + }, + { + id: E2E_TABLES.REVIEWS, + name: "reviews", + display_name: "Reviews", + description: null, + db_id: E2E_DATABASES.WAREHOUSE, + schema: "public", + entity_type: "entity/GenericTable", + }, +]; + +const ANALYTICS_TABLES_SORTED_BY_DISPLAY_NAME: TableCompact[] = [ + { + id: E2E_TABLES.DAILY_SALES, + name: "daily_sales", + display_name: "Daily Sales", + description: null, + db_id: E2E_DATABASES.WAREHOUSE, + schema: "analytics", + entity_type: "entity/TransactionTable", + }, +]; describe("db e2e", () => { let bootstrap: E2EBootstrap; @@ -34,10 +109,9 @@ describe("db e2e", () => { } it("list returns the seeded warehouse database in compact form", async () => { - const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["db", "list", "--json"], - configHome, + configHome: await makeIsolatedConfigHome(), env: authEnv(), }); @@ -49,11 +123,67 @@ describe("db e2e", () => { }); }); + it("list --include tables hydrates each database with its tables", async () => { + const result = await runCli({ + args: ["db", "list", "--include", "tables", "--full", "--json", "--max-bytes", "0"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const parsed = parseJson(result.stdout, listEnvelopeSchema(Database)); + expect(parsed.data.length).toBe(1); + const warehouse = parsed.data[0]; + expect(warehouse?.id).toBe(E2E_DATABASES.WAREHOUSE); + const tableIds = (warehouse?.tables ?? []).map((table) => table.id).toSorted(); + const expectedIds = [ + ...PUBLIC_TABLES_SORTED_BY_DISPLAY_NAME, + ...ANALYTICS_TABLES_SORTED_BY_DISPLAY_NAME, + ] + .map((table) => table.id) + .toSorted(); + expect(tableIds).toEqual(expectedIds); + }); + + it("list --saved includes the Saved Questions virtual database", async () => { + const result = await runCli({ + args: ["db", "list", "--saved", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, DatabaseListEnvelope)).toEqual({ + data: [ + { id: E2E_DATABASES.WAREHOUSE, name: "Warehouse", engine: "postgres" }, + { + id: SAVED_QUESTIONS_VIRTUAL_DB_ID, + name: "Saved Questions", + is_saved_questions: true, + }, + ], + returned: 2, + total: 2, + }); + }); + + it("list rejects an unknown --include value with ConfigError", async () => { + const result = await runCli({ + args: ["db", "list", "--include", "everything", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + 'invalid --include value: "everything" (expected one of: tables)', + ); + }); + it("get returns the warehouse by id", async () => { - const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["db", "get", String(E2E_DATABASES.WAREHOUSE), "--json"], - configHome, + configHome: await makeIsolatedConfigHome(), env: authEnv(), }); @@ -65,11 +195,36 @@ describe("db e2e", () => { }); }); + it("get --include tables.fields hydrates tables and their fields", async () => { + const result = await runCli({ + args: [ + "db", + "get", + String(E2E_DATABASES.WAREHOUSE), + "--include", + "tables.fields", + "--full", + "--json", + "--max-bytes", + "0", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const parsed = parseJson(result.stdout, Database); + expect(parsed.id).toBe(E2E_DATABASES.WAREHOUSE); + const customers = (parsed.tables ?? []).find((table) => table.id === E2E_TABLES.CUSTOMERS); + expect(customers).toBeDefined(); + expect(Array.isArray(customers?.fields)).toBe(true); + expect((customers?.fields ?? []).length).toBeGreaterThan(0); + }); + it("get with a non-integer id fails fast with ConfigError", async () => { - const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["db", "get", "abc", "--json"], - configHome, + configHome: await makeIsolatedConfigHome(), env: authEnv(), }); @@ -79,14 +234,169 @@ describe("db e2e", () => { }); it("get against a missing database id surfaces a 404 HttpError", async () => { - const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["db", "get", "9999999", "--json"], - configHome, + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("metadata returns the warehouse with its tables hydrated", async () => { + const result = await runCli({ + args: [ + "db", + "metadata", + String(E2E_DATABASES.WAREHOUSE), + "--full", + "--json", + "--max-bytes", + "0", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const parsed = parseJson(result.stdout, Database); + expect(parsed.id).toBe(E2E_DATABASES.WAREHOUSE); + const tableIds = (parsed.tables ?? []).map((table) => table.id).toSorted(); + const expectedIds = [ + ...PUBLIC_TABLES_SORTED_BY_DISPLAY_NAME, + ...ANALYTICS_TABLES_SORTED_BY_DISPLAY_NAME, + ] + .map((table) => table.id) + .toSorted(); + expect(tableIds).toEqual(expectedIds); + }); + + it("metadata against a missing database id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["db", "metadata", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("schemas lists the seeded warehouse schemas alphabetically", async () => { + const result = await runCli({ + args: ["db", "schemas", String(E2E_DATABASES.WAREHOUSE), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, DatabaseSchemaListEnvelope)).toEqual({ + data: [{ name: "analytics" }, { name: "public" }], + returned: 2, + total: 2, + }); + }); + + it("schemas with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["db", "schemas", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + }); + + it("schema-tables lists tables in the public schema sorted by display name", async () => { + const result = await runCli({ + args: ["db", "schema-tables", String(E2E_DATABASES.WAREHOUSE), "public", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, DatabaseSchemaTablesEnvelope)).toEqual({ + data: PUBLIC_TABLES_SORTED_BY_DISPLAY_NAME, + returned: PUBLIC_TABLES_SORTED_BY_DISPLAY_NAME.length, + total: PUBLIC_TABLES_SORTED_BY_DISPLAY_NAME.length, + }); + }); + + it("schema-tables lists tables in the analytics schema", async () => { + const result = await runCli({ + args: ["db", "schema-tables", String(E2E_DATABASES.WAREHOUSE), "analytics", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, DatabaseSchemaTablesEnvelope)).toEqual({ + data: ANALYTICS_TABLES_SORTED_BY_DISPLAY_NAME, + returned: ANALYTICS_TABLES_SORTED_BY_DISPLAY_NAME.length, + total: ANALYTICS_TABLES_SORTED_BY_DISPLAY_NAME.length, + }); + }); + + it("schema-tables against an unknown schema surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["db", "schema-tables", String(E2E_DATABASES.WAREHOUSE), "does_not_exist", "--json"], + configHome: await makeIsolatedConfigHome(), env: authEnv(), }); expect(result.exitCode).toBe(1); expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); }); + + it("sync-schema triggers a manual schema sync and returns ok", async () => { + const result = await runCli({ + args: ["db", "sync-schema", String(E2E_DATABASES.WAREHOUSE), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, DatabaseSyncResult)).toEqual({ + id: E2E_DATABASES.WAREHOUSE, + status: "ok", + }); + }); + + it("sync-schema against a missing database id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["db", "sync-schema", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("rescan-values triggers a field-values rescan and returns ok", async () => { + const result = await runCli({ + args: ["db", "rescan-values", String(E2E_DATABASES.WAREHOUSE), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, DatabaseSyncResult)).toEqual({ + id: E2E_DATABASES.WAREHOUSE, + status: "ok", + }); + }); + + it("rescan-values with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["db", "rescan-values", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + }); }); diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index aaae160..6d9f1ac 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -40,6 +40,11 @@ describe("__manifest e2e", () => { "license remove", "db list", "db get", + "db metadata", + "db schemas", + "db schema-tables", + "db sync-schema", + "db rescan-values", "table list", "table get", "field get", From b946573b0771a7590389ccb8f5f5a3c6f28b4b6c Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 8 May 2026 18:46:04 -0400 Subject: [PATCH 23/47] commands --- README.md | 88 +++++++++++++++ src/commands/field/index.ts | 3 + src/commands/field/summary.ts | 30 +++++ src/commands/field/update.ts | 37 ++++++ src/commands/field/values.ts | 26 +++++ src/commands/table/fields.ts | 30 +++++ src/commands/table/get.ts | 7 +- src/commands/table/index.ts | 3 + src/commands/table/metadata.ts | 26 +++++ src/commands/table/update.ts | 37 ++++++ src/domain/field.ts | 78 ++++++++++++- src/domain/table.ts | 25 ++++- tests/e2e/field.e2e.test.ts | 131 +++++++++++++++++++++- tests/e2e/manifest.e2e.test.ts | 6 + tests/e2e/table.e2e.test.ts | 198 ++++++++++++++++++++++++++++++--- 15 files changed, 699 insertions(+), 26 deletions(-) create mode 100644 src/commands/field/summary.ts create mode 100644 src/commands/field/update.ts create mode 100644 src/commands/field/values.ts create mode 100644 src/commands/table/fields.ts create mode 100644 src/commands/table/metadata.ts create mode 100644 src/commands/table/update.ts diff --git a/README.md b/README.md index 5be638e..fdbe34d 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,94 @@ metabase db rescan-values 1 metabase db rescan-values 1 --json ``` +## Tables + +Inspect and edit warehouse tables via `/api/table`. + +### `metabase table list` + +```sh +metabase table list +metabase table list --db-id 1 --json +``` + +| Flag | Description | +| -------------- | ----------------------------------- | +| `--db-id ` | Filter tables by their database id. | + +### `metabase table get ` + +Returns the basic table record (no fields). Use `metabase table metadata ` when you want the rollup with fields/FKs/dimensions hydrated. + +```sh +metabase table get 42 +metabase table get 42 --json +``` + +### `metabase table metadata ` + +`GET /api/table/:id/query_metadata`: the table with its fields, FKs, and dimensions hydrated. The agent-facing one-shot introspection for a single table. + +```sh +metabase table metadata 42 --json --full --max-bytes 0 +``` + +### `metabase table fields ` + +List the fields on a table (a thin projection over `query_metadata.fields`). + +```sh +metabase table fields 42 +metabase table fields 42 --json +``` + +### `metabase table update ` + +Patch a table (`PUT /api/table/:id`). Body fields: `display_name`, `description`, `caveats`, `points_of_interest`, `entity_type`, `visibility_type`, `field_order`, `show_in_getting_started`. Pass the body via `--body`, `--file`, or stdin (exactly one). + +```sh +metabase table update 42 --body '{"display_name":"Customers"}' +metabase table update 42 --file patch.json +echo '{"description":"Customer dimension"}' | metabase table update 42 +``` + +## Fields + +Inspect and edit individual columns via `/api/field`. + +### `metabase field get ` + +```sh +metabase field get 100 +metabase field get 100 --json +``` + +### `metabase field values ` + +Fetch the cached distinct values list (`GET /api/field/:id/values`). Returns the FieldValues envelope (`{ values, field_id, has_more_values }`); empty `values` on fields whose `has_field_values` is `none` or `search`. + +```sh +metabase field values 100 --json +``` + +### `metabase field summary ` + +Row count and distinct count for the field (`GET /api/field/:id/summary`). Metabase returns this as an array-of-pairs; the CLI normalizes it to `{ field_id, count, distincts }`. + +```sh +metabase field summary 100 +metabase field summary 100 --json +``` + +### `metabase field update ` + +Patch a field (`PUT /api/field/:id`). Body fields: `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`. Pass the body via `--body`, `--file`, or stdin. + +```sh +metabase field update 100 --body '{"description":"customer email","semantic_type":"type/Email"}' +metabase field update 100 --file patch.json +``` + ## Cards CRUD plus query execution on `/api/card`. A "card" is a Metabase question, model, or metric. The `query` subcommand runs the card and either returns Metabase's JSON envelope or streams a raw CSV / XLSX export. diff --git a/src/commands/field/index.ts b/src/commands/field/index.ts index 5049483..e2ac196 100644 --- a/src/commands/field/index.ts +++ b/src/commands/field/index.ts @@ -4,5 +4,8 @@ export default defineCommand({ meta: { name: "field", description: "Inspect Metabase fields" }, subCommands: { get: () => import("./get").then((m) => m.default), + values: () => import("./values").then((m) => m.default), + summary: () => import("./summary").then((m) => m.default), + update: () => import("./update").then((m) => m.default), }, }); diff --git a/src/commands/field/summary.ts b/src/commands/field/summary.ts new file mode 100644 index 0000000..ea6afe9 --- /dev/null +++ b/src/commands/field/summary.ts @@ -0,0 +1,30 @@ +import { FieldSummary, FieldSummaryRaw, fieldSummaryView } from "../../domain/field"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "summary", + description: "Get the row count and distinct count for a field", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Field id", required: true }, + }, + outputSchema: FieldSummary, + examples: ["metabase field summary 100", "metabase field summary 100 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const [[, count], [, distincts]] = await client.requestParsed( + FieldSummaryRaw, + `/api/field/${id}/summary`, + ); + const summary: FieldSummary = { field_id: id, count, distincts }; + renderItem(summary, fieldSummaryView, ctx); + }, +}); diff --git a/src/commands/field/update.ts b/src/commands/field/update.ts new file mode 100644 index 0000000..2382afa --- /dev/null +++ b/src/commands/field/update.ts @@ -0,0 +1,37 @@ +import { Field, FieldUpdateInput, fieldView } from "../../domain/field"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "update", + description: "Update a field (description, semantic_type, FK target, visibility, etc.)", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + id: { type: "positional", description: "Field id", required: true }, + }, + outputSchema: Field, + examples: [ + 'metabase field update 100 --body \'{"description":"customer email"}\'', + "metabase field update 100 --file patch.json", + "cat patch.json | metabase field update 100", + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const body = await readBody({ flag: args.body, file: args.file }, FieldUpdateInput); + const client = await getClient(); + const updated = await client.requestParsed(Field, `/api/field/${id}`, { + method: "PUT", + body, + }); + renderItem(updated, fieldView, ctx); + }, +}); diff --git a/src/commands/field/values.ts b/src/commands/field/values.ts new file mode 100644 index 0000000..138a2dc --- /dev/null +++ b/src/commands/field/values.ts @@ -0,0 +1,26 @@ +import { FieldValues, fieldValuesView } from "../../domain/field"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "values", + description: "Fetch the cached distinct values for a field (FieldValues list)", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Field id", required: true }, + }, + outputSchema: FieldValues, + examples: ["metabase field values 100", "metabase field values 100 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const values = await client.requestParsed(FieldValues, `/api/field/${id}/values`); + renderItem(values, fieldValuesView, ctx); + }, +}); diff --git a/src/commands/table/fields.ts b/src/commands/table/fields.ts new file mode 100644 index 0000000..9557119 --- /dev/null +++ b/src/commands/table/fields.ts @@ -0,0 +1,30 @@ +import { FieldCompact, fieldView } from "../../domain/field"; +import { TableQueryMetadata } from "../../domain/table"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export const FieldListEnvelope = listEnvelopeSchema(FieldCompact); + +export default defineMetabaseCommand({ + meta: { + name: "fields", + description: "List fields on a table (projection over query_metadata.fields)", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Table id", required: true }, + }, + outputSchema: FieldListEnvelope, + examples: ["metabase table fields 42", "metabase table fields 42 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const table = await client.requestParsed(TableQueryMetadata, `/api/table/${id}/query_metadata`); + renderList(wrapList(table.fields), fieldView, ctx); + }, +}); diff --git a/src/commands/table/get.ts b/src/commands/table/get.ts index 6de3fcc..678611b 100644 --- a/src/commands/table/get.ts +++ b/src/commands/table/get.ts @@ -5,7 +5,10 @@ import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; export default defineMetabaseCommand({ - meta: { name: "get", description: "Get a table by id, with its fields" }, + meta: { + name: "get", + description: "Get a table by id (basic; use `table metadata` for hydrated fields)", + }, args: { ...outputFlags, ...profileFlag, @@ -17,7 +20,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const client = await getClient(); - const table = await client.requestParsed(Table, `/api/table/${id}/query_metadata`); + const table = await client.requestParsed(Table, `/api/table/${id}`); renderItem(table, tableView, ctx); }, }); diff --git a/src/commands/table/index.ts b/src/commands/table/index.ts index 1649538..3308f41 100644 --- a/src/commands/table/index.ts +++ b/src/commands/table/index.ts @@ -5,5 +5,8 @@ export default defineCommand({ subCommands: { list: () => import("./list").then((m) => m.default), get: () => import("./get").then((m) => m.default), + metadata: () => import("./metadata").then((m) => m.default), + fields: () => import("./fields").then((m) => m.default), + update: () => import("./update").then((m) => m.default), }, }); diff --git a/src/commands/table/metadata.ts b/src/commands/table/metadata.ts new file mode 100644 index 0000000..4a9fea8 --- /dev/null +++ b/src/commands/table/metadata.ts @@ -0,0 +1,26 @@ +import { TableQueryMetadata, tableView } from "../../domain/table"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "metadata", + description: "Get a table with its fields, FKs, and dimensions hydrated", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Table id", required: true }, + }, + outputSchema: TableQueryMetadata, + examples: ["metabase table metadata 42", "metabase table metadata 42 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const table = await client.requestParsed(TableQueryMetadata, `/api/table/${id}/query_metadata`); + renderItem(table, tableView, ctx); + }, +}); diff --git a/src/commands/table/update.ts b/src/commands/table/update.ts new file mode 100644 index 0000000..63f7ef8 --- /dev/null +++ b/src/commands/table/update.ts @@ -0,0 +1,37 @@ +import { Table, TableUpdateInput, tableView } from "../../domain/table"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "update", + description: "Update a table (display name, description, visibility, etc.)", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + id: { type: "positional", description: "Table id", required: true }, + }, + outputSchema: Table, + examples: [ + 'metabase table update 42 --body \'{"display_name":"Customers"}\'', + "metabase table update 42 --file patch.json", + "cat patch.json | metabase table update 42", + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const body = await readBody({ flag: args.body, file: args.file }, TableUpdateInput); + const client = await getClient(); + const updated = await client.requestParsed(Table, `/api/table/${id}`, { + method: "PUT", + body, + }); + renderItem(updated, tableView, ctx); + }, +}); diff --git a/src/domain/field.ts b/src/domain/field.ts index 570e30e..87bf375 100644 --- a/src/domain/field.ts +++ b/src/domain/field.ts @@ -4,7 +4,7 @@ import type { ResourceView } from "./view"; const FieldVisibilityType = z.enum(["details-only", "hidden", "normal", "retired", "sensitive"]); -const FieldValuesType = z.enum(["list", "search", "none"]); +const FieldValuesType = z.enum(["list", "search", "none", "auto-list"]); export const Field = z .object({ @@ -13,14 +13,14 @@ export const Field = z name: z.string(), display_name: z.string(), description: z.string().nullable(), - database_type: z.string(), + database_type: z.string().nullable().optional(), base_type: z.string(), semantic_type: z.string().nullable(), fk_target_field_id: z.number().int().nullable(), - has_field_values: FieldValuesType, - visibility_type: FieldVisibilityType, - active: z.boolean(), - position: z.number().int(), + has_field_values: FieldValuesType.nullable().optional(), + visibility_type: FieldVisibilityType.nullable().optional(), + active: z.boolean().optional(), + position: z.number().int().optional(), }) .loose(); export type Field = z.infer; @@ -49,3 +49,69 @@ export const fieldView: ResourceView = { { key: "description", label: "Description" }, ], }; + +export const FieldUpdateInput = z + .object({ + display_name: z.string().min(1).optional(), + description: z.string().nullable().optional(), + caveats: z.string().nullable().optional(), + points_of_interest: z.string().nullable().optional(), + semantic_type: z.string().nullable().optional(), + coercion_strategy: z.string().nullable().optional(), + fk_target_field_id: z.number().int().positive().nullable().optional(), + visibility_type: FieldVisibilityType.optional(), + has_field_values: FieldValuesType.optional(), + settings: z.record(z.string(), z.unknown()).nullable().optional(), + nfc_path: z.array(z.string()).nullable().optional(), + json_unfolding: z.boolean().nullable().optional(), + }) + .loose(); +export type FieldUpdateInput = z.infer; + +export const FieldValues = z + .object({ + values: z.array(z.array(z.unknown())), + field_id: z.number().int().optional(), + has_more_values: z.boolean().optional(), + has_field_values: FieldValuesType.optional(), + }) + .loose(); +export type FieldValues = z.infer; + +export const FieldValuesCompact = FieldValues.pick({ + values: true, + field_id: true, + has_more_values: true, +}).strip(); +export type FieldValuesCompact = z.infer; + +export const fieldValuesView: ResourceView = { + compactPick: FieldValuesCompact, + tableColumns: [ + { key: "field_id", label: "Field" }, + { key: "has_more_values", label: "Has More" }, + { key: "values", label: "Values" }, + ], +}; + +export const FieldSummaryRaw = z.tuple([ + z.tuple([z.literal("count"), z.number().int()]), + z.tuple([z.literal("distincts"), z.number().int()]), +]); +export type FieldSummaryRaw = z.infer; + +export const FieldSummary = z.object({ + field_id: z.number().int(), + count: z.number().int(), + distincts: z.number().int(), +}); +export type FieldSummary = z.infer; + +export const fieldSummaryView: ResourceView = { + compactPick: FieldSummary, + tableColumns: [ + { key: "field_id", label: "Field" }, + { key: "count", label: "Count" }, + { key: "distincts", label: "Distinct" }, + ], +}; diff --git a/src/domain/table.ts b/src/domain/table.ts index e0be699..f7b8952 100644 --- a/src/domain/table.ts +++ b/src/domain/table.ts @@ -23,6 +23,8 @@ const TableVisibilityType = z.enum([ "cruft", ]); +const TableFieldOrder = z.enum(["alphabetical", "custom", "database", "smart"]); + export const Table = z .object({ id: z.number().int(), @@ -32,13 +34,18 @@ export const Table = z db_id: z.number().int(), schema: z.string().nullable(), entity_type: TableEntityType.nullable(), - visibility_type: TableVisibilityType.nullable(), - active: z.boolean(), + visibility_type: TableVisibilityType.nullable().optional(), + active: z.boolean().optional(), fields: z.array(Field).optional(), }) .loose(); export type Table = z.infer; +export const TableQueryMetadata = Table.extend({ + fields: z.array(Field), +}); +export type TableQueryMetadata = z.infer; + export const TableCompact = Table.pick({ id: true, name: true, @@ -61,3 +68,17 @@ export const tableView: ResourceView = { { key: "description", label: "Description" }, ], }; + +export const TableUpdateInput = z + .object({ + display_name: z.string().min(1).optional(), + entity_type: TableEntityType.nullable().optional(), + visibility_type: TableVisibilityType.nullable().optional(), + description: z.string().nullable().optional(), + caveats: z.string().nullable().optional(), + points_of_interest: z.string().nullable().optional(), + show_in_getting_started: z.boolean().optional(), + field_order: TableFieldOrder.optional(), + }) + .loose(); +export type TableUpdateInput = z.infer; diff --git a/tests/e2e/field.e2e.test.ts b/tests/e2e/field.e2e.test.ts index c342691..1666396 100644 --- a/tests/e2e/field.e2e.test.ts +++ b/tests/e2e/field.e2e.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { FieldCompact } from "../../src/domain/field"; +import { Field, FieldCompact, FieldSummary, FieldValues } from "../../src/domain/field"; import { parseJson } from "../../src/runtime/json"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; @@ -76,4 +76,133 @@ describe("field e2e", () => { expect(result.exitCode).toBe(1); expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); }); + + it("values returns the FieldValues envelope for the email field", async () => { + const result = await runCli({ + args: ["field", "values", String(E2E_FIELDS.CUSTOMERS_EMAIL), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const parsed = parseJson(result.stdout, FieldValues); + expect(parsed.field_id).toBe(E2E_FIELDS.CUSTOMERS_EMAIL); + }); + + it("values with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["field", "values", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + }); + + it("summary returns the count and distinct count for the email field", async () => { + const result = await runCli({ + args: ["field", "summary", String(E2E_FIELDS.CUSTOMERS_EMAIL), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const parsed = parseJson(result.stdout, FieldSummary); + expect(parsed.field_id).toBe(E2E_FIELDS.CUSTOMERS_EMAIL); + }); + + it("summary against a missing field id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["field", "summary", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("update edits the email field description and restores it", async () => { + const newDescription = `e2e field update marker ${Date.now()}`; + const update = await runCli({ + args: [ + "field", + "update", + String(E2E_FIELDS.CUSTOMERS_EMAIL), + "--body", + JSON.stringify({ description: newDescription }), + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(update.exitCode, update.stderr).toBe(0); + expect(parseJson(update.stdout, Field).description).toBe(newDescription); + + const restore = await runCli({ + args: [ + "field", + "update", + String(E2E_FIELDS.CUSTOMERS_EMAIL), + "--body", + JSON.stringify({ description: null }), + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(restore.exitCode, restore.stderr).toBe(0); + expect(parseJson(restore.stdout, Field).description).toBeNull(); + }); + + it("update rejects multiple body sources", async () => { + const result = await runCli({ + args: [ + "field", + "update", + String(E2E_FIELDS.CUSTOMERS_EMAIL), + "--body", + '{"description":"x"}', + "--file", + "patch.json", + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("multiple body sources given"); + }); + + it("update with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["field", "update", "abc", "--body", '{"description":"x"}', "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + }); + + it("update enforces the input schema for an unknown enum value", async () => { + const result = await runCli({ + args: [ + "field", + "update", + String(E2E_FIELDS.CUSTOMERS_EMAIL), + "--body", + JSON.stringify({ visibility_type: "not-a-real-value" }), + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("value did not match expected schema"); + }); }); diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index 6d9f1ac..55d70de 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -47,7 +47,13 @@ describe("__manifest e2e", () => { "db rescan-values", "table list", "table get", + "table metadata", + "table fields", + "table update", "field get", + "field values", + "field summary", + "field update", "card list", "card get", "card query", diff --git a/tests/e2e/table.e2e.test.ts b/tests/e2e/table.e2e.test.ts index 2908d9a..13683da 100644 --- a/tests/e2e/table.e2e.test.ts +++ b/tests/e2e/table.e2e.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { FieldListEnvelope } from "../../src/commands/table/fields"; import { TableListEnvelope } from "../../src/commands/table/list"; import { Table, TableCompact } from "../../src/domain/table"; import { parseJson } from "../../src/runtime/json"; @@ -119,10 +120,9 @@ describe("table e2e", () => { } it("list filtered by --db-id returns the seeded warehouse tables", async () => { - const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["table", "list", "--db-id", String(E2E_DATABASES.WAREHOUSE), "--json"], - configHome, + configHome: await makeIsolatedConfigHome(), env: authEnv(), }); @@ -134,16 +134,67 @@ describe("table e2e", () => { }); }); - it("get returns a table with embedded fields when --full", async () => { - const configHome = await makeIsolatedConfigHome(); - const get = await runCli({ - args: ["table", "get", String(E2E_TABLES.CUSTOMERS), "--json", "--full", "--max-bytes", "0"], - configHome, + it("get returns the basic table without hydrating fields", async () => { + const result = await runCli({ + args: ["table", "get", String(E2E_TABLES.CUSTOMERS), "--json"], + configHome: await makeIsolatedConfigHome(), env: authEnv(), }); - expect(get.exitCode, get.stderr).toBe(0); - const parsed = parseJson(get.stdout, Table); + expect(result.exitCode, result.stderr).toBe(0); + const parsed = parseJson(result.stdout, Table); + expect(parsed.fields).toBeUndefined(); + expect(TableCompact.parse(parsed)).toEqual({ + id: E2E_TABLES.CUSTOMERS, + name: "customers", + display_name: "Customers", + description: "Customer dimension; mixed types for sync coverage.", + db_id: E2E_DATABASES.WAREHOUSE, + schema: "public", + entity_type: "entity/GenericTable", + }); + }); + + it("get with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["table", "get", "not-a-number", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "not-a-number" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("get against a missing table id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["table", "get", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("metadata returns the table with hydrated fields", async () => { + const result = await runCli({ + args: [ + "table", + "metadata", + String(E2E_TABLES.CUSTOMERS), + "--json", + "--full", + "--max-bytes", + "0", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const parsed = parseJson(result.stdout, Table); const fieldNames = (parsed.fields ?? []).map((field) => field.name).toSorted(); expect({ compact: TableCompact.parse(parsed), fieldNames }).toEqual({ @@ -160,16 +211,133 @@ describe("table e2e", () => { }); }); - it("get with a non-integer id fails fast with ConfigError", async () => { - const configHome = await makeIsolatedConfigHome(); + it("metadata against a missing table id surfaces a 404 HttpError", async () => { const result = await runCli({ - args: ["table", "get", "not-a-number", "--json"], - configHome, + args: ["table", "metadata", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("fields lists every field on the table in compact form", async () => { + const result = await runCli({ + args: ["table", "fields", String(E2E_TABLES.CUSTOMERS), "--json", "--max-bytes", "0"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, FieldListEnvelope); + const fieldNames = envelope.data.map((field) => field.name).toSorted(); + expect({ + returned: envelope.returned, + total: envelope.total, + fieldNames, + everyFieldHasCustomersTableId: envelope.data.every( + (field) => field.table_id === E2E_TABLES.CUSTOMERS, + ), + }).toEqual({ + returned: CUSTOMERS_FIELD_NAMES.length, + total: CUSTOMERS_FIELD_NAMES.length, + fieldNames: CUSTOMERS_FIELD_NAMES, + everyFieldHasCustomersTableId: true, + }); + }); + + it("fields with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["table", "fields", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), env: authEnv(), }); expect(result.exitCode).toBe(2); - expect(result.stderr).toContain('invalid id: "not-a-number" (expected integer)'); - expect(result.stdout).toBe(""); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + }); + + it("update edits the table description and returns the updated row", async () => { + const newDescription = `e2e update marker ${Date.now()}`; + const update = await runCli({ + args: [ + "table", + "update", + String(E2E_TABLES.REVIEWS), + "--body", + JSON.stringify({ description: newDescription }), + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(update.exitCode, update.stderr).toBe(0); + expect(parseJson(update.stdout, Table).description).toBe(newDescription); + + const restore = await runCli({ + args: [ + "table", + "update", + String(E2E_TABLES.REVIEWS), + "--body", + JSON.stringify({ description: null }), + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(restore.exitCode, restore.stderr).toBe(0); + expect(parseJson(restore.stdout, Table).description).toBeNull(); + }); + + it("update rejects multiple body sources", async () => { + const result = await runCli({ + args: [ + "table", + "update", + String(E2E_TABLES.REVIEWS), + "--body", + '{"description":"x"}', + "--file", + "patch.json", + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("multiple body sources given"); + }); + + it("update with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["table", "update", "abc", "--body", '{"description":"x"}', "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + }); + + it("update enforces the input schema when an unknown enum value is sent", async () => { + const result = await runCli({ + args: [ + "table", + "update", + String(E2E_TABLES.REVIEWS), + "--body", + JSON.stringify({ visibility_type: "not-a-real-value" }), + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("value did not match expected schema"); }); }); From d3616dd22c7ad43f695e1d4dd1a0fe7b95c1dafd Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 8 May 2026 18:52:52 -0400 Subject: [PATCH 24/47] fix --- tests/e2e/card.e2e.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/e2e/card.e2e.test.ts b/tests/e2e/card.e2e.test.ts index 7465729..10f3261 100644 --- a/tests/e2e/card.e2e.test.ts +++ b/tests/e2e/card.e2e.test.ts @@ -510,8 +510,10 @@ describe("card e2e", () => { env: authEnv(), }); - // Pre-flight is bypassed; the server then rejects the malformed body with an HttpError (exit 1). - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); + // PUT /api/card/:id accepts dataset_query as an opaque map and does not validate its inner + // shape, so the bad `database` does not trigger a 400. Bypass is proven by exit 0 — without + // --skip-validate the prior test shows pre-flight rejects with exit 2. + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, CardCompact).id).toBe(E2E_CARDS.ORDERS_BY_STATUS); }); }); From 68d980f571ba615194ac02e768a745a7c1c7e909 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 8 May 2026 19:51:57 -0400 Subject: [PATCH 25/47] more commands --- README.md | 183 ++++++++++++++++++ src/commands/measure/archive.ts | 31 +++ src/commands/measure/create.ts | 30 +++ src/commands/measure/get.ts | 23 +++ src/commands/measure/index.ts | 12 ++ src/commands/measure/list.ts | 23 +++ src/commands/measure/update.ts | 38 ++++ src/commands/revision-message-flag.ts | 10 + src/commands/segment/archive.ts | 31 +++ src/commands/segment/create.ts | 30 +++ src/commands/segment/get.ts | 23 +++ src/commands/segment/index.ts | 12 ++ src/commands/segment/list.ts | 23 +++ src/commands/segment/update.ts | 38 ++++ src/commands/snippet/archive.ts | 26 +++ src/commands/snippet/create.ts | 31 +++ src/commands/snippet/get.ts | 23 +++ src/commands/snippet/index.ts | 12 ++ src/commands/snippet/list.ts | 34 ++++ src/commands/snippet/update.ts | 35 ++++ src/domain/measure.ts | 64 +++++++ src/domain/segment.ts | 67 +++++++ src/domain/snippet.ts | 61 ++++++ src/main.ts | 3 + tests/e2e/manifest.e2e.test.ts | 15 ++ tests/e2e/measure.e2e.test.ts | 244 ++++++++++++++++++++++++ tests/e2e/seed/ids.ts | 1 + tests/e2e/segment.e2e.test.ts | 244 ++++++++++++++++++++++++ tests/e2e/snippet.e2e.test.ts | 261 ++++++++++++++++++++++++++ 29 files changed, 1628 insertions(+) create mode 100644 src/commands/measure/archive.ts create mode 100644 src/commands/measure/create.ts create mode 100644 src/commands/measure/get.ts create mode 100644 src/commands/measure/index.ts create mode 100644 src/commands/measure/list.ts create mode 100644 src/commands/measure/update.ts create mode 100644 src/commands/revision-message-flag.ts create mode 100644 src/commands/segment/archive.ts create mode 100644 src/commands/segment/create.ts create mode 100644 src/commands/segment/get.ts create mode 100644 src/commands/segment/index.ts create mode 100644 src/commands/segment/list.ts create mode 100644 src/commands/segment/update.ts create mode 100644 src/commands/snippet/archive.ts create mode 100644 src/commands/snippet/create.ts create mode 100644 src/commands/snippet/get.ts create mode 100644 src/commands/snippet/index.ts create mode 100644 src/commands/snippet/list.ts create mode 100644 src/commands/snippet/update.ts create mode 100644 src/domain/measure.ts create mode 100644 src/domain/segment.ts create mode 100644 src/domain/snippet.ts create mode 100644 tests/e2e/measure.e2e.test.ts create mode 100644 tests/e2e/segment.e2e.test.ts create mode 100644 tests/e2e/snippet.e2e.test.ts diff --git a/README.md b/README.md index fdbe34d..54b0d83 100644 --- a/README.md +++ b/README.md @@ -599,6 +599,189 @@ cat patch.json | metabase dashboard update-dashcard 1 5 The patch must contain at least one field; an empty object is rejected before the network round-trip. +## Snippets + +CRUD on `/api/native-query-snippet`. A snippet is a named, reusable piece of native (SQL) query text — referenced from cards via `{{snippet: Name}}`. The list endpoint returns either active or archived rows (mutually exclusive — pass `--archived` to swap). + +### `metabase snippet list` + +```sh +metabase snippet list +metabase snippet list --json +metabase snippet list --archived --json +``` + +| Flag | Description | +| ------------ | ---------------------------------------------- | +| `--archived` | Show archived snippets instead of active ones. | + +### `metabase snippet get ` + +```sh +metabase snippet get 1 +metabase snippet get 1 --json --full +``` + +### `metabase snippet create` + +```sh +cat snippet.json | metabase snippet create +metabase snippet create --file snippet.json +metabase snippet create --body '{"name":"active","content":"WHERE active = true"}' +``` + +| Flag | Description | +| --------------- | ----------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | + +Body fields: `name` (required), `content` (required), `description` (optional), `collection_id` (optional positive integer). + +### `metabase snippet update ` + +Patch a snippet. Body is a partial subset of the create shape plus `archived`. Only the keys you send are touched. + +```sh +cat patch.json | metabase snippet update 1 +metabase snippet update 1 --file patch.json +metabase snippet update 1 --body '{"name":"renamed"}' +metabase snippet update 1 --body '{"archived":true}' +``` + +| Flag | Description | +| --------------- | ----------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | + +### `metabase snippet archive ` + +Soft-delete a snippet by setting `archived: true`. To unarchive use `metabase snippet update --body '{"archived":false}'`. + +```sh +metabase snippet archive 1 +metabase snippet archive 1 --json +``` + +## Segments + +CRUD on `/api/segment`. A segment is a saved MBQL filter macro tied to a table — used in card filters to share a reusable predicate. Mutating endpoints require a `revision_message` for the audit log. + +### `metabase segment list` + +```sh +metabase segment list +metabase segment list --json +``` + +### `metabase segment get ` + +```sh +metabase segment get 1 +metabase segment get 1 --json --full +``` + +### `metabase segment create` + +```sh +cat segment.json | metabase segment create +metabase segment create --file segment.json +``` + +| Flag | Description | +| --------------- | ----------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | + +Body fields: `name` (required), `table_id` (required positive integer), `definition` (required MBQL filter object), `description` (optional). + +### `metabase segment update ` + +Patch a segment. The body MUST include `revision_message`. Other keys are partial: `name`, `definition`, `archived`, `description`, `caveats`, `points_of_interest`, `show_in_getting_started`. + +```sh +cat patch.json | metabase segment update 1 +metabase segment update 1 --file patch.json +metabase segment update 1 --body '{"name":"renamed","revision_message":"rename"}' +``` + +| Flag | Description | +| --------------- | ----------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | + +### `metabase segment archive ` + +Soft-delete a segment by setting `archived: true`. The default revision message is `"Archived via metabase CLI"`; override with `--revision-message`. + +```sh +metabase segment archive 1 +metabase segment archive 1 --revision-message "deprecated" +``` + +| Flag | Description | +| --------------------------- | ------------------------------------------- | +| `--revision-message ` | Audit-log message recorded with the change. | + +## Measures + +CRUD on `/api/measure`. A measure is a saved MBQL aggregation (a single `:aggregation` clause) tied to a table — referenced from cards and metrics to share a reusable computation. Mutating endpoints require a `revision_message` for the audit log. + +### `metabase measure list` + +```sh +metabase measure list +metabase measure list --json +``` + +### `metabase measure get ` + +```sh +metabase measure get 1 +metabase measure get 1 --json --full +``` + +### `metabase measure create` + +```sh +cat measure.json | metabase measure create +metabase measure create --file measure.json +``` + +| Flag | Description | +| --------------- | ----------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | + +Body fields: `name` (required), `table_id` (required positive integer), `definition` (required MBQL aggregation object), `description` (optional). + +### `metabase measure update ` + +Patch a measure. The body MUST include `revision_message`. Other keys are partial: `name`, `definition`, `archived`, `description`. + +```sh +cat patch.json | metabase measure update 1 +metabase measure update 1 --file patch.json +metabase measure update 1 --body '{"name":"renamed","revision_message":"rename"}' +``` + +| Flag | Description | +| --------------- | ----------------------- | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | + +### `metabase measure archive ` + +Soft-delete a measure by setting `archived: true`. The default revision message is `"Archived via metabase CLI"`; override with `--revision-message`. + +```sh +metabase measure archive 1 +metabase measure archive 1 --revision-message "deprecated" +``` + +| Flag | Description | +| --------------------------- | ------------------------------------------- | +| `--revision-message ` | Audit-log message recorded with the change. | + ## Collections Read collections on `/api/collection`. Collections are the folders that contain cards, dashboards, and other collections. The list endpoint surfaces a virtual root collection (id `"root"`) alongside regular numeric ids; the get endpoint accepts only the numeric id. diff --git a/src/commands/measure/archive.ts b/src/commands/measure/archive.ts new file mode 100644 index 0000000..9899fed --- /dev/null +++ b/src/commands/measure/archive.ts @@ -0,0 +1,31 @@ +import { Measure, measureView } from "../../domain/measure"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { revisionMessageFlag } from "../revision-message-flag"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "archive", description: "Archive (soft-delete) a measure by id" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...revisionMessageFlag, + id: { type: "positional", description: "Measure id", required: true }, + }, + outputSchema: Measure, + examples: [ + "metabase measure archive 1", + 'metabase measure archive 1 --revision-message "deprecated"', + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const updated = await client.requestParsed(Measure, `/api/measure/${id}`, { + method: "PUT", + body: { archived: true, revision_message: args.revisionMessage }, + }); + renderItem(updated, measureView, ctx); + }, +}); diff --git a/src/commands/measure/create.ts b/src/commands/measure/create.ts new file mode 100644 index 0000000..116b366 --- /dev/null +++ b/src/commands/measure/create.ts @@ -0,0 +1,30 @@ +import { Measure, MeasureCreateInput, measureView } from "../../domain/measure"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "create", description: "Create a measure from a JSON spec" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + }, + outputSchema: Measure, + examples: [ + "cat measure.json | metabase measure create", + "metabase measure create --file measure.json", + ], + async run({ args, ctx, getClient }) { + const body = await readBody({ flag: args.body, file: args.file }, MeasureCreateInput); + const client = await getClient(); + const created = await client.requestParsed(Measure, "/api/measure", { + method: "POST", + body, + }); + renderItem(created, measureView, ctx); + }, +}); diff --git a/src/commands/measure/get.ts b/src/commands/measure/get.ts new file mode 100644 index 0000000..af293b2 --- /dev/null +++ b/src/commands/measure/get.ts @@ -0,0 +1,23 @@ +import { Measure, measureView } from "../../domain/measure"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "get", description: "Get a measure by id" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Measure id", required: true }, + }, + outputSchema: Measure, + examples: ["metabase measure get 1", "metabase measure get 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const measure = await client.requestParsed(Measure, `/api/measure/${id}`); + renderItem(measure, measureView, ctx); + }, +}); diff --git a/src/commands/measure/index.ts b/src/commands/measure/index.ts new file mode 100644 index 0000000..b31ae3f --- /dev/null +++ b/src/commands/measure/index.ts @@ -0,0 +1,12 @@ +import { defineCommand } from "citty"; + +export default defineCommand({ + meta: { name: "measure", description: "Inspect Metabase measures" }, + subCommands: { + list: () => import("./list").then((mod) => mod.default), + get: () => import("./get").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + update: () => import("./update").then((mod) => mod.default), + archive: () => import("./archive").then((mod) => mod.default), + }, +}); diff --git a/src/commands/measure/list.ts b/src/commands/measure/list.ts new file mode 100644 index 0000000..203c181 --- /dev/null +++ b/src/commands/measure/list.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +import { Measure, MeasureCompact, measureView } from "../../domain/measure"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +const MeasureApiList = z.array(Measure); + +export const MeasureListEnvelope = listEnvelopeSchema(MeasureCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List measures" }, + args: { ...outputFlags, ...profileFlag, ...connectionFlags }, + outputSchema: MeasureListEnvelope, + examples: ["metabase measure list", "metabase measure list --json"], + async run({ ctx, getClient }) { + const client = await getClient(); + const items = await client.requestParsed(MeasureApiList, "/api/measure"); + renderList(wrapList(items), measureView, ctx); + }, +}); diff --git a/src/commands/measure/update.ts b/src/commands/measure/update.ts new file mode 100644 index 0000000..6d2d860 --- /dev/null +++ b/src/commands/measure/update.ts @@ -0,0 +1,38 @@ +import { Measure, MeasureUpdateInput, measureView } from "../../domain/measure"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "update", + description: + "Update a measure by id; body must include revision_message (audit-logged with the change)", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + id: { type: "positional", description: "Measure id", required: true }, + }, + outputSchema: Measure, + examples: [ + "cat patch.json | metabase measure update 1", + "metabase measure update 1 --file patch.json", + 'metabase measure update 1 --body \'{"name":"renamed","revision_message":"rename"}\'', + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const body = await readBody({ flag: args.body, file: args.file }, MeasureUpdateInput); + const client = await getClient(); + const updated = await client.requestParsed(Measure, `/api/measure/${id}`, { + method: "PUT", + body, + }); + renderItem(updated, measureView, ctx); + }, +}); diff --git a/src/commands/revision-message-flag.ts b/src/commands/revision-message-flag.ts new file mode 100644 index 0000000..8de6ca0 --- /dev/null +++ b/src/commands/revision-message-flag.ts @@ -0,0 +1,10 @@ +export const DEFAULT_ARCHIVE_REVISION_MESSAGE = "Archived via metabase CLI"; + +export const revisionMessageFlag = { + revisionMessage: { + type: "string", + description: "Audit-log message recorded with the change", + alias: "revision-message", + default: DEFAULT_ARCHIVE_REVISION_MESSAGE, + }, +} as const; diff --git a/src/commands/segment/archive.ts b/src/commands/segment/archive.ts new file mode 100644 index 0000000..16a0123 --- /dev/null +++ b/src/commands/segment/archive.ts @@ -0,0 +1,31 @@ +import { Segment, segmentView } from "../../domain/segment"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { revisionMessageFlag } from "../revision-message-flag"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "archive", description: "Archive (soft-delete) a segment by id" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...revisionMessageFlag, + id: { type: "positional", description: "Segment id", required: true }, + }, + outputSchema: Segment, + examples: [ + "metabase segment archive 1", + 'metabase segment archive 1 --revision-message "deprecated"', + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const updated = await client.requestParsed(Segment, `/api/segment/${id}`, { + method: "PUT", + body: { archived: true, revision_message: args.revisionMessage }, + }); + renderItem(updated, segmentView, ctx); + }, +}); diff --git a/src/commands/segment/create.ts b/src/commands/segment/create.ts new file mode 100644 index 0000000..6914435 --- /dev/null +++ b/src/commands/segment/create.ts @@ -0,0 +1,30 @@ +import { Segment, SegmentCreateInput, segmentView } from "../../domain/segment"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "create", description: "Create a segment from a JSON spec" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + }, + outputSchema: Segment, + examples: [ + "cat segment.json | metabase segment create", + "metabase segment create --file segment.json", + ], + async run({ args, ctx, getClient }) { + const body = await readBody({ flag: args.body, file: args.file }, SegmentCreateInput); + const client = await getClient(); + const created = await client.requestParsed(Segment, "/api/segment", { + method: "POST", + body, + }); + renderItem(created, segmentView, ctx); + }, +}); diff --git a/src/commands/segment/get.ts b/src/commands/segment/get.ts new file mode 100644 index 0000000..c8e00a5 --- /dev/null +++ b/src/commands/segment/get.ts @@ -0,0 +1,23 @@ +import { Segment, segmentView } from "../../domain/segment"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "get", description: "Get a segment by id" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Segment id", required: true }, + }, + outputSchema: Segment, + examples: ["metabase segment get 1", "metabase segment get 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const segment = await client.requestParsed(Segment, `/api/segment/${id}`); + renderItem(segment, segmentView, ctx); + }, +}); diff --git a/src/commands/segment/index.ts b/src/commands/segment/index.ts new file mode 100644 index 0000000..79774a0 --- /dev/null +++ b/src/commands/segment/index.ts @@ -0,0 +1,12 @@ +import { defineCommand } from "citty"; + +export default defineCommand({ + meta: { name: "segment", description: "Inspect Metabase segments" }, + subCommands: { + list: () => import("./list").then((mod) => mod.default), + get: () => import("./get").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + update: () => import("./update").then((mod) => mod.default), + archive: () => import("./archive").then((mod) => mod.default), + }, +}); diff --git a/src/commands/segment/list.ts b/src/commands/segment/list.ts new file mode 100644 index 0000000..c824caa --- /dev/null +++ b/src/commands/segment/list.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +import { Segment, SegmentCompact, segmentView } from "../../domain/segment"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +const SegmentApiList = z.array(Segment); + +export const SegmentListEnvelope = listEnvelopeSchema(SegmentCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List segments" }, + args: { ...outputFlags, ...profileFlag, ...connectionFlags }, + outputSchema: SegmentListEnvelope, + examples: ["metabase segment list", "metabase segment list --json"], + async run({ ctx, getClient }) { + const client = await getClient(); + const items = await client.requestParsed(SegmentApiList, "/api/segment"); + renderList(wrapList(items), segmentView, ctx); + }, +}); diff --git a/src/commands/segment/update.ts b/src/commands/segment/update.ts new file mode 100644 index 0000000..f26099c --- /dev/null +++ b/src/commands/segment/update.ts @@ -0,0 +1,38 @@ +import { Segment, SegmentUpdateInput, segmentView } from "../../domain/segment"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { + name: "update", + description: + "Update a segment by id; body must include revision_message (audit-logged with the change)", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + id: { type: "positional", description: "Segment id", required: true }, + }, + outputSchema: Segment, + examples: [ + "cat patch.json | metabase segment update 1", + "metabase segment update 1 --file patch.json", + 'metabase segment update 1 --body \'{"name":"renamed","revision_message":"rename"}\'', + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const body = await readBody({ flag: args.body, file: args.file }, SegmentUpdateInput); + const client = await getClient(); + const updated = await client.requestParsed(Segment, `/api/segment/${id}`, { + method: "PUT", + body, + }); + renderItem(updated, segmentView, ctx); + }, +}); diff --git a/src/commands/snippet/archive.ts b/src/commands/snippet/archive.ts new file mode 100644 index 0000000..95e26b7 --- /dev/null +++ b/src/commands/snippet/archive.ts @@ -0,0 +1,26 @@ +import { Snippet, snippetView } from "../../domain/snippet"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "archive", description: "Archive (soft-delete) a native query snippet by id" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Snippet id", required: true }, + }, + outputSchema: Snippet, + examples: ["metabase snippet archive 1", "metabase snippet archive 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const updated = await client.requestParsed(Snippet, `/api/native-query-snippet/${id}`, { + method: "PUT", + body: { archived: true }, + }); + renderItem(updated, snippetView, ctx); + }, +}); diff --git a/src/commands/snippet/create.ts b/src/commands/snippet/create.ts new file mode 100644 index 0000000..91b79ef --- /dev/null +++ b/src/commands/snippet/create.ts @@ -0,0 +1,31 @@ +import { Snippet, SnippetCreateInput, snippetView } from "../../domain/snippet"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "create", description: "Create a native query snippet from a JSON spec" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + }, + outputSchema: Snippet, + examples: [ + "cat snippet.json | metabase snippet create", + "metabase snippet create --file snippet.json", + 'metabase snippet create --body \'{"name":"active","content":"WHERE active = true"}\'', + ], + async run({ args, ctx, getClient }) { + const body = await readBody({ flag: args.body, file: args.file }, SnippetCreateInput); + const client = await getClient(); + const created = await client.requestParsed(Snippet, "/api/native-query-snippet", { + method: "POST", + body, + }); + renderItem(created, snippetView, ctx); + }, +}); diff --git a/src/commands/snippet/get.ts b/src/commands/snippet/get.ts new file mode 100644 index 0000000..d68f7c9 --- /dev/null +++ b/src/commands/snippet/get.ts @@ -0,0 +1,23 @@ +import { Snippet, snippetView } from "../../domain/snippet"; +import { renderItem } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "get", description: "Get a native query snippet by id" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Snippet id", required: true }, + }, + outputSchema: Snippet, + examples: ["metabase snippet get 1", "metabase snippet get 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const snippet = await client.requestParsed(Snippet, `/api/native-query-snippet/${id}`); + renderItem(snippet, snippetView, ctx); + }, +}); diff --git a/src/commands/snippet/index.ts b/src/commands/snippet/index.ts new file mode 100644 index 0000000..82891ba --- /dev/null +++ b/src/commands/snippet/index.ts @@ -0,0 +1,12 @@ +import { defineCommand } from "citty"; + +export default defineCommand({ + meta: { name: "snippet", description: "Inspect Metabase native query snippets" }, + subCommands: { + list: () => import("./list").then((mod) => mod.default), + get: () => import("./get").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + update: () => import("./update").then((mod) => mod.default), + archive: () => import("./archive").then((mod) => mod.default), + }, +}); diff --git a/src/commands/snippet/list.ts b/src/commands/snippet/list.ts new file mode 100644 index 0000000..93b3d49 --- /dev/null +++ b/src/commands/snippet/list.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +import { Snippet, SnippetCompact, snippetView } from "../../domain/snippet"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +const SnippetApiList = z.array(Snippet); + +export const SnippetListEnvelope = listEnvelopeSchema(SnippetCompact); + +export default defineMetabaseCommand({ + meta: { name: "list", description: "List native query snippets" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + archived: { type: "boolean", description: "Show archived snippets instead of active ones" }, + }, + outputSchema: SnippetListEnvelope, + examples: [ + "metabase snippet list", + "metabase snippet list --json", + "metabase snippet list --archived --json", + ], + async run({ args, ctx, getClient }) { + const client = await getClient(); + const items = await client.requestParsed(SnippetApiList, "/api/native-query-snippet", { + query: { archived: args.archived || undefined }, + }); + renderList(wrapList(items), snippetView, ctx); + }, +}); diff --git a/src/commands/snippet/update.ts b/src/commands/snippet/update.ts new file mode 100644 index 0000000..654e122 --- /dev/null +++ b/src/commands/snippet/update.ts @@ -0,0 +1,35 @@ +import { Snippet, SnippetUpdateInput, snippetView } from "../../domain/snippet"; +import { renderItem } from "../../output/render"; +import { readBody } from "../../runtime/body"; +import { bodyInputFlags } from "../body-flags"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "update", description: "Update a native query snippet by id" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + id: { type: "positional", description: "Snippet id", required: true }, + }, + outputSchema: Snippet, + examples: [ + "cat patch.json | metabase snippet update 1", + "metabase snippet update 1 --file patch.json", + 'metabase snippet update 1 --body \'{"name":"renamed"}\'', + "metabase snippet update 1 --body '{\"archived\":true}'", + ], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const body = await readBody({ flag: args.body, file: args.file }, SnippetUpdateInput); + const client = await getClient(); + const updated = await client.requestParsed(Snippet, `/api/native-query-snippet/${id}`, { + method: "PUT", + body, + }); + renderItem(updated, snippetView, ctx); + }, +}); diff --git a/src/domain/measure.ts b/src/domain/measure.ts new file mode 100644 index 0000000..aacd7f1 --- /dev/null +++ b/src/domain/measure.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; + +import type { ResourceView } from "./view"; + +export const Measure = z + .object({ + id: z.number().int(), + name: z.string(), + description: z.string().nullable(), + archived: z.boolean(), + table_id: z.number().int(), + definition: z.unknown(), + creator_id: z.number().int(), + entity_id: z.string().nullable(), + dimensions: z.array(z.unknown()).nullable(), + dimension_mappings: z.array(z.unknown()).nullable(), + definition_description: z.string().nullable().optional(), + result_column_name: z.string().nullable().optional(), + created_at: z.string(), + updated_at: z.string(), + }) + .loose(); +export type Measure = z.infer; + +export const MeasureCompact = Measure.pick({ + id: true, + name: true, + description: true, + archived: true, + table_id: true, +}).strip(); +export type MeasureCompact = z.infer; + +export const measureView: ResourceView = { + compactPick: MeasureCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + { key: "table_id", label: "Table" }, + { key: "archived", label: "Archived" }, + ], +}; + +export const MeasureCreateInput = z + .object({ + name: z.string().min(1), + table_id: z.number().int().positive(), + definition: z.record(z.string(), z.unknown()), + description: z.string().nullable().optional(), + }) + .loose(); +export type MeasureCreateInput = z.infer; + +export const MeasureUpdateInput = z + .object({ + name: z.string().min(1).optional(), + definition: z.record(z.string(), z.unknown()).optional(), + revision_message: z.string().min(1), + archived: z.boolean().optional(), + description: z.string().nullable().optional(), + }) + .loose(); +export type MeasureUpdateInput = z.infer; diff --git a/src/domain/segment.ts b/src/domain/segment.ts new file mode 100644 index 0000000..2754272 --- /dev/null +++ b/src/domain/segment.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import type { ResourceView } from "./view"; + +export const Segment = z + .object({ + id: z.number().int(), + name: z.string(), + description: z.string().nullable(), + archived: z.boolean(), + table_id: z.number().int(), + definition: z.unknown(), + creator_id: z.number().int(), + entity_id: z.string().nullable(), + show_in_getting_started: z.boolean().nullable(), + caveats: z.string().nullable(), + points_of_interest: z.string().nullable(), + definition_description: z.string().nullable().optional(), + created_at: z.string(), + updated_at: z.string(), + }) + .loose(); +export type Segment = z.infer; + +export const SegmentCompact = Segment.pick({ + id: true, + name: true, + description: true, + archived: true, + table_id: true, +}).strip(); +export type SegmentCompact = z.infer; + +export const segmentView: ResourceView = { + compactPick: SegmentCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + { key: "table_id", label: "Table" }, + { key: "archived", label: "Archived" }, + ], +}; + +export const SegmentCreateInput = z + .object({ + name: z.string().min(1), + table_id: z.number().int().positive(), + definition: z.record(z.string(), z.unknown()), + description: z.string().nullable().optional(), + }) + .loose(); +export type SegmentCreateInput = z.infer; + +export const SegmentUpdateInput = z + .object({ + name: z.string().min(1).optional(), + definition: z.record(z.string(), z.unknown()).optional(), + revision_message: z.string().min(1), + archived: z.boolean().optional(), + description: z.string().nullable().optional(), + caveats: z.string().nullable().optional(), + points_of_interest: z.string().nullable().optional(), + show_in_getting_started: z.boolean().optional(), + }) + .loose(); +export type SegmentUpdateInput = z.infer; diff --git a/src/domain/snippet.ts b/src/domain/snippet.ts new file mode 100644 index 0000000..d9ff715 --- /dev/null +++ b/src/domain/snippet.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +import type { ResourceView } from "./view"; + +export const Snippet = z + .object({ + id: z.number().int(), + name: z.string(), + description: z.string().nullable(), + content: z.string(), + archived: z.boolean(), + collection_id: z.number().int().nullable(), + creator_id: z.number().int(), + entity_id: z.string().nullable(), + template_tags: z.record(z.string(), z.unknown()).nullable(), + created_at: z.string(), + updated_at: z.string(), + }) + .loose(); +export type Snippet = z.infer; + +export const SnippetCompact = Snippet.pick({ + id: true, + name: true, + description: true, + archived: true, + collection_id: true, +}).strip(); +export type SnippetCompact = z.infer; + +export const snippetView: ResourceView = { + compactPick: SnippetCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + { key: "collection_id", label: "Collection" }, + { key: "archived", label: "Archived" }, + ], +}; + +export const SnippetCreateInput = z + .object({ + name: z.string().min(1), + content: z.string(), + description: z.string().nullable().optional(), + collection_id: z.number().int().positive().nullable().optional(), + }) + .loose(); +export type SnippetCreateInput = z.infer; + +export const SnippetUpdateInput = z + .object({ + name: z.string().min(1).optional(), + content: z.string().optional(), + description: z.string().nullable().optional(), + archived: z.boolean().optional(), + collection_id: z.number().int().positive().nullable().optional(), + }) + .loose(); +export type SnippetUpdateInput = z.infer; diff --git a/src/main.ts b/src/main.ts index 4fb41cd..5208985 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,6 +26,9 @@ const main: CommandDef = defineCommand({ workspace: () => import("./commands/workspace").then((mod) => mod.default), setup: () => import("./commands/setup").then((mod) => mod.default), "api-key": () => import("./commands/api-key").then((mod) => mod.default), + snippet: () => import("./commands/snippet").then((mod) => mod.default), + segment: () => import("./commands/segment").then((mod) => mod.default), + measure: () => import("./commands/measure").then((mod) => mod.default), eid: () => import("./commands/eid").then((mod) => mod.default), query: () => import("./commands/query").then((mod) => mod.default), __manifest: (): Promise => diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index 55d70de..c0213c1 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -118,6 +118,21 @@ describe("__manifest e2e", () => { "workspace ps", "setup", "api-key create", + "snippet list", + "snippet get", + "snippet create", + "snippet update", + "snippet archive", + "segment list", + "segment get", + "segment create", + "segment update", + "segment archive", + "measure list", + "measure get", + "measure create", + "measure update", + "measure archive", "eid translate", "query", ]); diff --git a/tests/e2e/measure.e2e.test.ts b/tests/e2e/measure.e2e.test.ts new file mode 100644 index 0000000..baff19f --- /dev/null +++ b/tests/e2e/measure.e2e.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { MeasureListEnvelope } from "../../src/commands/measure/list"; +import { MeasureCompact, type MeasureCreateInput } from "../../src/domain/measure"; +import { parseJson } from "../../src/runtime/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { E2E_TABLES } from "./seed/ids"; + +const FIRST_NEW_MEASURE_ID = 1; +const MEASURE_NAME = "OrderCount"; +const MEASURE_DESCRIPTION = "Count of orders rows."; + +const NEW_MEASURE_COMPACT = { + id: FIRST_NEW_MEASURE_ID, + name: MEASURE_NAME, + description: MEASURE_DESCRIPTION, + archived: false, + table_id: E2E_TABLES.ORDERS, +} as const; + +const NEW_MEASURE_BODY: MeasureCreateInput = { + name: MEASURE_NAME, + table_id: E2E_TABLES.ORDERS, + description: MEASURE_DESCRIPTION, + definition: { + "source-table": E2E_TABLES.ORDERS, + aggregation: [["count"]], + }, +}; + +describe("measure e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + METABASE_URL: bootstrap.baseUrl, + METABASE_API_KEY: bootstrap.adminApiKey, + }; + } + + async function createMeasure(): Promise { + const result = await runCli({ + args: ["measure", "create", "--json"], + stdin: JSON.stringify(NEW_MEASURE_BODY), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + } + + it("list returns an empty envelope on a fresh restore", async () => { + const result = await runCli({ + args: ["measure", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, MeasureListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("create returns the hydrated measure in compact form by default", async () => { + const result = await runCli({ + args: ["measure", "create", "--json"], + stdin: JSON.stringify(NEW_MEASURE_BODY), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, MeasureCompact)).toEqual(NEW_MEASURE_COMPACT); + }); + + it("create + list shows the new measure via the compact projection", async () => { + await createMeasure(); + + const listResult = await runCli({ + args: ["measure", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(listResult.exitCode, listResult.stderr).toBe(0); + expect(parseJson(listResult.stdout, MeasureListEnvelope)).toEqual({ + data: [NEW_MEASURE_COMPACT], + returned: 1, + total: 1, + }); + }); + + it("create with a body missing required fields fails on Zod validation", async () => { + const result = await runCli({ + args: ["measure", "create", "--json"], + stdin: JSON.stringify({ name: "missing-table-and-definition" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stdout).toBe(""); + }); + + it("get returns the measure by id in compact form", async () => { + await createMeasure(); + + const result = await runCli({ + args: ["measure", "get", String(FIRST_NEW_MEASURE_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, MeasureCompact)).toEqual(NEW_MEASURE_COMPACT); + }); + + it("get with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["measure", "get", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("get against a missing measure id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["measure", "get", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("update renames the measure and the compact view reflects the new name", async () => { + await createMeasure(); + + const result = await runCli({ + args: ["measure", "update", String(FIRST_NEW_MEASURE_ID), "--json"], + stdin: JSON.stringify({ name: "OrderCountRenamed", revision_message: "rename" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, MeasureCompact)).toEqual({ + ...NEW_MEASURE_COMPACT, + name: "OrderCountRenamed", + }); + }); + + it("update without the required revision_message fails on Zod validation", async () => { + await createMeasure(); + + const result = await runCli({ + args: ["measure", "update", String(FIRST_NEW_MEASURE_ID), "--json"], + stdin: JSON.stringify({ name: "no-revision" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stdout).toBe(""); + }); + + it("update with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["measure", "update", "abc", "--json"], + stdin: JSON.stringify({ revision_message: "x" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("archive flips archived from false to true and list excludes it", async () => { + await createMeasure(); + + const archiveResult = await runCli({ + args: ["measure", "archive", String(FIRST_NEW_MEASURE_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(archiveResult.exitCode, archiveResult.stderr).toBe(0); + expect(parseJson(archiveResult.stdout, MeasureCompact)).toEqual({ + ...NEW_MEASURE_COMPACT, + archived: true, + }); + + const listResult = await runCli({ + args: ["measure", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(listResult.exitCode, listResult.stderr).toBe(0); + expect(parseJson(listResult.stdout, MeasureListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("archive with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["measure", "archive", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); +}); diff --git a/tests/e2e/seed/ids.ts b/tests/e2e/seed/ids.ts index db99247..5ba9960 100644 --- a/tests/e2e/seed/ids.ts +++ b/tests/e2e/seed/ids.ts @@ -37,4 +37,5 @@ export const E2E_TABLES = { export const E2E_FIELDS = { CUSTOMERS_EMAIL: 1624, + ORDERS_ID: 1649, } as const; diff --git a/tests/e2e/segment.e2e.test.ts b/tests/e2e/segment.e2e.test.ts new file mode 100644 index 0000000..3368c08 --- /dev/null +++ b/tests/e2e/segment.e2e.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { SegmentListEnvelope } from "../../src/commands/segment/list"; +import { SegmentCompact, type SegmentCreateInput } from "../../src/domain/segment"; +import { parseJson } from "../../src/runtime/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { E2E_FIELDS, E2E_TABLES } from "./seed/ids"; + +const FIRST_NEW_SEGMENT_ID = 1; +const SEGMENT_NAME = "PositiveIdOrders"; +const SEGMENT_DESCRIPTION = "Orders with a positive id."; + +const NEW_SEGMENT_COMPACT = { + id: FIRST_NEW_SEGMENT_ID, + name: SEGMENT_NAME, + description: SEGMENT_DESCRIPTION, + archived: false, + table_id: E2E_TABLES.ORDERS, +} as const; + +const NEW_SEGMENT_BODY: SegmentCreateInput = { + name: SEGMENT_NAME, + table_id: E2E_TABLES.ORDERS, + description: SEGMENT_DESCRIPTION, + definition: { + "source-table": E2E_TABLES.ORDERS, + filter: [">", ["field", E2E_FIELDS.ORDERS_ID, null], 0], + }, +}; + +describe("segment e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + METABASE_URL: bootstrap.baseUrl, + METABASE_API_KEY: bootstrap.adminApiKey, + }; + } + + async function createSegment(): Promise { + const result = await runCli({ + args: ["segment", "create", "--json"], + stdin: JSON.stringify(NEW_SEGMENT_BODY), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + } + + it("list returns an empty envelope on a fresh restore", async () => { + const result = await runCli({ + args: ["segment", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SegmentListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("create returns the hydrated segment in compact form by default", async () => { + const result = await runCli({ + args: ["segment", "create", "--json"], + stdin: JSON.stringify(NEW_SEGMENT_BODY), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SegmentCompact)).toEqual(NEW_SEGMENT_COMPACT); + }); + + it("create + list shows the new segment via the compact projection", async () => { + await createSegment(); + + const listResult = await runCli({ + args: ["segment", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(listResult.exitCode, listResult.stderr).toBe(0); + expect(parseJson(listResult.stdout, SegmentListEnvelope)).toEqual({ + data: [NEW_SEGMENT_COMPACT], + returned: 1, + total: 1, + }); + }); + + it("create with a body missing required fields fails on Zod validation", async () => { + const result = await runCli({ + args: ["segment", "create", "--json"], + stdin: JSON.stringify({ name: "missing-table-and-definition" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stdout).toBe(""); + }); + + it("get returns the segment by id in compact form", async () => { + await createSegment(); + + const result = await runCli({ + args: ["segment", "get", String(FIRST_NEW_SEGMENT_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SegmentCompact)).toEqual(NEW_SEGMENT_COMPACT); + }); + + it("get with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["segment", "get", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("get against a missing segment id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["segment", "get", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("update renames the segment and the compact view reflects the new name", async () => { + await createSegment(); + + const result = await runCli({ + args: ["segment", "update", String(FIRST_NEW_SEGMENT_ID), "--json"], + stdin: JSON.stringify({ name: "OrdersWithStatusRenamed", revision_message: "rename" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SegmentCompact)).toEqual({ + ...NEW_SEGMENT_COMPACT, + name: "OrdersWithStatusRenamed", + }); + }); + + it("update without the required revision_message fails on Zod validation", async () => { + await createSegment(); + + const result = await runCli({ + args: ["segment", "update", String(FIRST_NEW_SEGMENT_ID), "--json"], + stdin: JSON.stringify({ name: "no-revision" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stdout).toBe(""); + }); + + it("update with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["segment", "update", "abc", "--json"], + stdin: JSON.stringify({ revision_message: "x" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("archive flips archived from false to true and list excludes it", async () => { + await createSegment(); + + const archiveResult = await runCli({ + args: ["segment", "archive", String(FIRST_NEW_SEGMENT_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(archiveResult.exitCode, archiveResult.stderr).toBe(0); + expect(parseJson(archiveResult.stdout, SegmentCompact)).toEqual({ + ...NEW_SEGMENT_COMPACT, + archived: true, + }); + + const listResult = await runCli({ + args: ["segment", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(listResult.exitCode, listResult.stderr).toBe(0); + expect(parseJson(listResult.stdout, SegmentListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("archive with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["segment", "archive", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); +}); diff --git a/tests/e2e/snippet.e2e.test.ts b/tests/e2e/snippet.e2e.test.ts new file mode 100644 index 0000000..104f318 --- /dev/null +++ b/tests/e2e/snippet.e2e.test.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { SnippetListEnvelope } from "../../src/commands/snippet/list"; +import { Snippet, SnippetCompact, type SnippetCreateInput } from "../../src/domain/snippet"; +import { parseJson } from "../../src/runtime/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; + +const FIRST_NEW_SNIPPET_ID = 1; +const SNIPPET_NAME = "active_filter"; +const SNIPPET_CONTENT = "WHERE active = true"; +const SNIPPET_DESCRIPTION = "Restrict to currently active rows."; + +const NEW_SNIPPET_COMPACT = { + id: FIRST_NEW_SNIPPET_ID, + name: SNIPPET_NAME, + description: SNIPPET_DESCRIPTION, + archived: false, + collection_id: null, +} as const; + +const NEW_SNIPPET_BODY: SnippetCreateInput = { + name: SNIPPET_NAME, + content: SNIPPET_CONTENT, + description: SNIPPET_DESCRIPTION, +}; + +describe("snippet e2e", () => { + let bootstrap: E2EBootstrap; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + function authEnv(): Record { + return { + METABASE_URL: bootstrap.baseUrl, + METABASE_API_KEY: bootstrap.adminApiKey, + }; + } + + async function createSnippet(): Promise { + const result = await runCli({ + args: ["snippet", "create", "--json"], + stdin: JSON.stringify(NEW_SNIPPET_BODY), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + } + + it("list returns an empty envelope on a fresh restore", async () => { + const result = await runCli({ + args: ["snippet", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SnippetListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("create returns the hydrated snippet in compact form by default", async () => { + const result = await runCli({ + args: ["snippet", "create", "--json"], + stdin: JSON.stringify(NEW_SNIPPET_BODY), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SnippetCompact)).toEqual(NEW_SNIPPET_COMPACT); + }); + + it("create + list shows the new snippet via the compact projection", async () => { + await createSnippet(); + + const listResult = await runCli({ + args: ["snippet", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(listResult.exitCode, listResult.stderr).toBe(0); + expect(parseJson(listResult.stdout, SnippetListEnvelope)).toEqual({ + data: [NEW_SNIPPET_COMPACT], + returned: 1, + total: 1, + }); + }); + + it("create with a body missing required fields fails on Zod validation", async () => { + const result = await runCli({ + args: ["snippet", "create", "--json"], + stdin: JSON.stringify({ name: "missing-content" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stdout).toBe(""); + }); + + it("get returns the snippet by id in compact form", async () => { + await createSnippet(); + + const result = await runCli({ + args: ["snippet", "get", String(FIRST_NEW_SNIPPET_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SnippetCompact)).toEqual(NEW_SNIPPET_COMPACT); + }); + + it("get --full surfaces the content field stripped from the compact view", async () => { + await createSnippet(); + + const result = await runCli({ + args: ["snippet", "get", String(FIRST_NEW_SNIPPET_ID), "--full", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, Snippet).content).toBe(SNIPPET_CONTENT); + }); + + it("get with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["snippet", "get", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("get against a missing snippet id surfaces a 404 HttpError", async () => { + const result = await runCli({ + args: ["snippet", "get", "9999999", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Endpoint not found — is this a Metabase instance?"); + }); + + it("update renames the snippet and the compact view reflects the new name", async () => { + await createSnippet(); + + const result = await runCli({ + args: ["snippet", "update", String(FIRST_NEW_SNIPPET_ID), "--json"], + stdin: JSON.stringify({ name: "active_filter_renamed" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SnippetCompact)).toEqual({ + ...NEW_SNIPPET_COMPACT, + name: "active_filter_renamed", + }); + }); + + it("update with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["snippet", "update", "abc", "--json"], + stdin: JSON.stringify({ name: "x" }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("archive flips archived from false to true and list excludes it by default", async () => { + await createSnippet(); + + const archiveResult = await runCli({ + args: ["snippet", "archive", String(FIRST_NEW_SNIPPET_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(archiveResult.exitCode, archiveResult.stderr).toBe(0); + expect(parseJson(archiveResult.stdout, SnippetCompact)).toEqual({ + ...NEW_SNIPPET_COMPACT, + archived: true, + }); + + const listResult = await runCli({ + args: ["snippet", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(listResult.exitCode, listResult.stderr).toBe(0); + expect(parseJson(listResult.stdout, SnippetListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + + it("list --archived returns the archived snippet and excludes the active one", async () => { + await createSnippet(); + const archiveResult = await runCli({ + args: ["snippet", "archive", String(FIRST_NEW_SNIPPET_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(archiveResult.exitCode, archiveResult.stderr).toBe(0); + + const listResult = await runCli({ + args: ["snippet", "list", "--archived", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(listResult.exitCode, listResult.stderr).toBe(0); + expect(parseJson(listResult.stdout, SnippetListEnvelope)).toEqual({ + data: [{ ...NEW_SNIPPET_COMPACT, archived: true }], + returned: 1, + total: 1, + }); + }); + + it("archive with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["snippet", "archive", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); +}); From 4eae6a43028ce0b1e05497a0706f10ed2a6be32d Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 8 May 2026 20:51:57 -0400 Subject: [PATCH 26/47] fixes --- README.md | 2 +- src/commands/workspace/start.ts | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 54b0d83..307b7e4 100644 --- a/README.md +++ b/README.md @@ -1186,7 +1186,7 @@ When `--repo ` is passed, the CLI bind-mounts the host directory at ` | `--port ` | Host port (default: 3000; auto-shifts up to 100 ports if taken). | | `--image ` | Docker image (default: `metabase/metabase-dev:feature-workspaces-v2`). | | `--wait` | Block until `/api/health` is ready. Default: return as soon as consumed. | -| `--timeout ` | Health check deadline (default: 180000). Used with `--wait`. | +| `--timeout ` | Per-phase readiness deadline (default: 240000). Covers post-create config consumption and (with `--wait`) the `/api/health` probe. | | `--no-pull` | Skip `docker pull` (useful if the image is already present). | | `--no-metadata` | Skip the warehouse metadata export. | | `--force` | If a container for this workspace already exists, remove it before starting. | diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts index bde0960..44d42ab 100644 --- a/src/commands/workspace/start.ts +++ b/src/commands/workspace/start.ts @@ -43,8 +43,9 @@ import { defineMetabaseCommand } from "../runtime"; const DEFAULT_IMAGE = "metabase/metabase-dev:feature-workspaces-v2"; const DEFAULT_HOST_PORT = 3000; -const DEFAULT_HEALTH_TIMEOUT_MS = 180_000; -const DEFAULT_CONFIG_CONSUMED_TIMEOUT_MS = 60_000; +// 240s: a cold boot (image pull + JVM classloading + initial app-db migrations) +// can exceed three minutes on the first start. +const DEFAULT_READY_TIMEOUT_MS = 240_000; const HEALTH_INTERVAL_MS = 2_000; const HEALTH_MAX_INTERVAL_MS = 10_000; const HEALTH_PROBE_TIMEOUT_MS = 4_000; @@ -104,8 +105,8 @@ export default defineMetabaseCommand({ }, timeout: { type: "string", - description: `Health check deadline in ms (used with --wait; default: ${DEFAULT_HEALTH_TIMEOUT_MS})`, - default: String(DEFAULT_HEALTH_TIMEOUT_MS), + description: `Per-phase readiness deadline in ms — covers post-create config consumption and (with --wait) the /api/health probe. Default: ${DEFAULT_READY_TIMEOUT_MS}.`, + default: String(DEFAULT_READY_TIMEOUT_MS), }, pull: { type: "boolean", @@ -151,7 +152,7 @@ export default defineMetabaseCommand({ const workspaceId = parseId(args.id); const containerName = containerNameFor(workspaceId); const requestedPort = parseOptionalInteger(args.port, { name: "--port", min: 1 }); - const healthTimeoutMs = parseInteger(args.timeout ?? String(DEFAULT_HEALTH_TIMEOUT_MS), { + const readyTimeoutMs = parseInteger(args.timeout ?? String(DEFAULT_READY_TIMEOUT_MS), { name: "--timeout", min: 1000, }); @@ -215,7 +216,7 @@ export default defineMetabaseCommand({ // file itself is no longer needed. Scrubbing it here keeps the warehouse password // out of the container's overlay FS for the rest of the instance's lifetime. // credentials.json stays — `workspace credentials` reads it on demand. - await waitForConfigConsumed(workspaceId, DEFAULT_CONFIG_CONSUMED_TIMEOUT_MS); + await waitForConfigConsumed(workspaceId, readyTimeoutMs); try { await scrubContainerConfig(workspaceId); } catch (error) { @@ -223,7 +224,7 @@ export default defineMetabaseCommand({ } if (args.wait) { - await waitForHealth(hostPort, healthTimeoutMs); + await waitForHealth(hostPort, readyTimeoutMs); } const result: StartResult = { From 4e732ec0c338e519943aa8b174eb2c21738ed59e Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 8 May 2026 21:10:06 -0400 Subject: [PATCH 27/47] fix --- README.md | 22 +++---- src/commands/auth/login.ts | 7 +- src/commands/auth/logout.test.ts | 13 ++++ src/commands/auth/logout.ts | 3 +- src/core/auth/rejection.test.ts | 109 +++++++++++++++++++++++++++++++ src/core/auth/rejection.ts | 84 ++++++++++++++++++++++++ src/core/config.test.ts | 36 ++++++++++ src/core/config.ts | 9 ++- tests/e2e/auth.e2e.test.ts | 77 +++++++++++++++++++++- 9 files changed, 344 insertions(+), 16 deletions(-) create mode 100644 src/core/auth/rejection.test.ts create mode 100644 src/core/auth/rejection.ts diff --git a/README.md b/README.md index 307b7e4..3bb5654 100644 --- a/README.md +++ b/README.md @@ -1181,18 +1181,18 @@ By default `start` returns once the bundle has been consumed by the child (`stat When `--repo ` is passed, the CLI bind-mounts the host directory at `/mnt/repo` inside the container and injects three settings into the workspace's `config.yml` so the child boots already wired to the repo: `remote-sync-url=file:///mnt/repo`, `remote-sync-branch=` (defaults to the current branch of the host repo, read via `git -C symbolic-ref --short HEAD`; override with `--repo-branch`), and `remote-sync-type=` (defaults to `read-write`; override with `--repo-mode read-only`, which also makes the bind mount read-only). The bind mount is set at container-create time only — to add or change it after the fact, run `start --force` again with the new flags. The host path must be an existing directory; the CLI does not create or `git init` it for you. -| Flag | Description | -| ---------------------- | --------------------------------------------------------------------------------------------------------- | -| `--port ` | Host port (default: 3000; auto-shifts up to 100 ports if taken). | -| `--image ` | Docker image (default: `metabase/metabase-dev:feature-workspaces-v2`). | -| `--wait` | Block until `/api/health` is ready. Default: return as soon as consumed. | +| Flag | Description | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `--port ` | Host port (default: 3000; auto-shifts up to 100 ports if taken). | +| `--image ` | Docker image (default: `metabase/metabase-dev:feature-workspaces-v2`). | +| `--wait` | Block until `/api/health` is ready. Default: return as soon as consumed. | | `--timeout ` | Per-phase readiness deadline (default: 240000). Covers post-create config consumption and (with `--wait`) the `/api/health` probe. | -| `--no-pull` | Skip `docker pull` (useful if the image is already present). | -| `--no-metadata` | Skip the warehouse metadata export. | -| `--force` | If a container for this workspace already exists, remove it before starting. | -| `--repo ` | Bind-mount a host directory at `/mnt/repo` and set `remote-sync-url=file:///mnt/repo` in `config.yml`. | -| `--repo-branch ` | `remote-sync-branch` value (default: current branch of the host repo). | -| `--repo-mode ` | `remote-sync-type`: `read-write` (default) or `read-only`. Read-only also makes the bind mount read-only. | +| `--no-pull` | Skip `docker pull` (useful if the image is already present). | +| `--no-metadata` | Skip the warehouse metadata export. | +| `--force` | If a container for this workspace already exists, remove it before starting. | +| `--repo ` | Bind-mount a host directory at `/mnt/repo` and set `remote-sync-url=file:///mnt/repo` in `config.yml`. | +| `--repo-branch ` | `remote-sync-branch` value (default: current branch of the host repo). | +| `--repo-mode ` | `remote-sync-type`: `read-write` (default) or `read-only`. Read-only also makes the bind mount read-only. | ### `metabase workspace stop ` diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 509d839..23d2eae 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { clearRejection, recordRejection } from "../../core/auth/rejection"; import { writeProfile } from "../../core/auth/storage"; import { verifyCredentials } from "../../core/auth/verify"; import { readEnvCredentials, resolveProfileName } from "../../core/config"; @@ -67,13 +68,17 @@ export default defineMetabaseCommand({ if (!args["skip-verify"]) { const result = await verifyCredentials(url, apiKey); if (!result.ok) { - throw new ConfigError(`verification failed: ${result.message}`); + await recordRejection(profileName, { reason: result.message, url }); + throw new ConfigError( + `verification failed: ${result.message} — credentials were not saved for profile "${profileName}"`, + ); } email = result.user.email; authenticated = true; } const location = await writeProfile({ url, apiKey }, profileName); + await clearRejection(profileName); if (location.backend === "file") { warn(`warning: OS keychain unavailable; credentials stored as plaintext at ${location.path}`); } diff --git a/src/commands/auth/logout.test.ts b/src/commands/auth/logout.test.ts index 4b95c60..888082f 100644 --- a/src/commands/auth/logout.test.ts +++ b/src/commands/auth/logout.test.ts @@ -12,6 +12,7 @@ vi.mock("@napi-rs/keyring", async () => { }); import logoutCommand from "./logout"; +import { readRejection, recordRejection } from "../../core/auth/rejection"; import { readProfile, writeProfile } from "../../core/auth/storage"; import { setupTempConfigHome, type TempConfigHome } from "../../core/auth/temp-config-home"; @@ -36,4 +37,16 @@ describe("auth logout command", () => { await runCommand(logoutCommand, { rawArgs: ["--profile", "default", "--yes"] }); expect(await readProfile()).toBeNull(); }); + + it("--yes clears any recorded rejection for the profile", async () => { + await recordRejection("staging", { + reason: "Invalid or unauthorized API key", + url: "https://staging.example.com", + }); + expect(await readRejection("staging")).not.toBeNull(); + + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + await runCommand(logoutCommand, { rawArgs: ["--profile", "staging", "--yes"] }); + expect(await readRejection("staging")).toBeNull(); + }); }); diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 86f15ac..1af006e 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { clearRejection } from "../../core/auth/rejection"; import { clearProfile } from "../../core/auth/storage"; import { resolveProfileName } from "../../core/config"; import type { ResourceView } from "../../domain/view"; @@ -47,7 +48,7 @@ export default defineMetabaseCommand({ } } - const cleared = await clearProfile(profileName); + const [cleared] = await Promise.all([clearProfile(profileName), clearRejection(profileName)]); renderItem({ profile: profileName, cleared, aborted: false }, logoutView, ctx); }, }); diff --git a/src/core/auth/rejection.test.ts b/src/core/auth/rejection.test.ts new file mode 100644 index 0000000..9a1fc84 --- /dev/null +++ b/src/core/auth/rejection.test.ts @@ -0,0 +1,109 @@ +import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ConfigError, ValidationError } from "../errors"; +import { clearRejection, readRejection, recordRejection, rejectionsFilePath } from "./rejection"; +import { setupTempConfigHome, type TempConfigHome } from "./temp-config-home"; + +describe("rejection records", () => { + let home: TempConfigHome; + + beforeEach(() => { + home = setupTempConfigHome(); + }); + + afterEach(() => { + home.cleanup(); + }); + + it("returns null when no rejection has been recorded", async () => { + expect(await readRejection("default")).toBeNull(); + }); + + it("round-trips a rejection", async () => { + await recordRejection("staging", { + reason: "Invalid or unauthorized API key", + url: "https://staging.example.com", + }); + const rejection = await readRejection("staging"); + expect(rejection?.reason).toBe("Invalid or unauthorized API key"); + expect(rejection?.url).toBe("https://staging.example.com"); + expect(rejection?.rejectedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it("isolates rejections by profile", async () => { + await recordRejection("a", { reason: "bad-a", url: "https://a.example.com" }); + await recordRejection("b", { reason: "bad-b", url: "https://b.example.com" }); + expect((await readRejection("a"))?.reason).toBe("bad-a"); + expect((await readRejection("b"))?.reason).toBe("bad-b"); + }); + + it("overwrites a prior rejection for the same profile", async () => { + await recordRejection("staging", { reason: "first", url: "https://m.example.com" }); + await recordRejection("staging", { reason: "second", url: "https://m.example.com" }); + expect((await readRejection("staging"))?.reason).toBe("second"); + }); + + it("clearRejection removes the entry and reports whether one existed", async () => { + await recordRejection("staging", { + reason: "bad", + url: "https://staging.example.com", + }); + expect(await clearRejection("staging")).toBe(true); + expect(await readRejection("staging")).toBeNull(); + expect(await clearRejection("staging")).toBe(false); + }); + + it("removes the file when the last rejection is cleared", async () => { + if (process.platform === "win32") { + return; + } + await recordRejection("only", { reason: "bad", url: "https://m.example.com" }); + await clearRejection("only"); + expect(() => statSync(rejectionsFilePath())).toThrow(/ENOENT/); + }); + + it("writes the file with 0600 perms", async () => { + if (process.platform === "win32") { + return; + } + await recordRejection("default", { reason: "bad", url: "https://m.example.com" }); + const mode = statSync(rejectionsFilePath()).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it("stores the rejection map as JSON keyed by profile", async () => { + await recordRejection("default", { reason: "bad", url: "https://m.example.com" }); + const stored: unknown = JSON.parse(readFileSync(rejectionsFilePath(), "utf8")); + expect(stored).toMatchObject({ + default: { + reason: "bad", + url: "https://m.example.com", + rejectedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + }, + }); + }); + + it("throws ConfigError when the file contains malformed JSON", async () => { + const path = rejectionsFilePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "{ not json }"); + const error = await readRejection("default").catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ConfigError); + if (!(error instanceof ConfigError)) { + throw new Error("expected ConfigError"); + } + expect(error.message).toContain(path); + expect(error.message).toContain("invalid JSON: "); + }); + + it("throws ValidationError when the file contains a missing-field record", async () => { + const path = rejectionsFilePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ default: { reason: "x" } })); + const error = await readRejection("default").catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(ValidationError); + }); +}); diff --git a/src/core/auth/rejection.ts b/src/core/auth/rejection.ts new file mode 100644 index 0000000..14798f6 --- /dev/null +++ b/src/core/auth/rejection.ts @@ -0,0 +1,84 @@ +import { promises as fs } from "node:fs"; +import { dirname, join } from "node:path"; + +import { z } from "zod"; + +import { parseJson } from "../../runtime/json"; +import { isNotFoundError } from "../errors"; +import { configDir } from "../paths"; + +const REJECTIONS_FILE = "rejections.json"; +const REJECTIONS_FILE_MODE = 0o600; +const REJECTIONS_DIR_MODE = 0o700; + +export const RejectionRecord = z.object({ + reason: z.string(), + url: z.string(), + rejectedAt: z.string(), +}); +export type RejectionRecordValue = z.infer; + +const RejectionsFileSchema = z.record(z.string(), RejectionRecord); + +export function rejectionsFilePath(): string { + return join(configDir(), REJECTIONS_FILE); +} + +async function readRejectionsFile(): Promise> { + const path = rejectionsFilePath(); + let raw: string; + try { + raw = await fs.readFile(path, "utf8"); + } catch (error) { + if (isNotFoundError(error)) { + return {}; + } + throw error; + } + return parseJson(raw, RejectionsFileSchema, { source: path }); +} + +async function writeRejectionsFile(store: Record): Promise { + const path = rejectionsFilePath(); + if (Object.keys(store).length === 0) { + await fs.unlink(path).catch(() => undefined); + return; + } + await fs.mkdir(dirname(path), { recursive: true, mode: REJECTIONS_DIR_MODE }); + await fs.writeFile(path, JSON.stringify(store, null, 2) + "\n", { + mode: REJECTIONS_FILE_MODE, + }); + if (process.platform !== "win32") { + await fs.chmod(path, REJECTIONS_FILE_MODE); + } +} + +export interface RecordRejectionInput { + reason: string; + url: string; +} + +export async function recordRejection(profile: string, input: RecordRejectionInput): Promise { + const store = await readRejectionsFile(); + store[profile] = { + reason: input.reason, + url: input.url, + rejectedAt: new Date().toISOString(), + }; + await writeRejectionsFile(store); +} + +export async function clearRejection(profile: string): Promise { + const store = await readRejectionsFile(); + if (!(profile in store)) { + return false; + } + delete store[profile]; + await writeRejectionsFile(store); + return true; +} + +export async function readRejection(profile: string): Promise { + const store = await readRejectionsFile(); + return store[profile] ?? null; +} diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 1936b89..ddf395f 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -10,6 +10,7 @@ vi.mock("@napi-rs/keyring", async () => { return createKeyringMockModule(hoisted); }); +import { recordRejection } from "./auth/rejection"; import { writeProfile } from "./auth/storage"; import { setupTempConfigHome, type TempConfigHome } from "./auth/temp-config-home"; import { resolveConfig, resolveProfileName } from "./config"; @@ -144,6 +145,41 @@ describe("resolveConfig", () => { expect(error).toBeInstanceOf(ConfigError); expect(error).toMatchObject({ message: expect.stringContaining("Not authenticated") }); }); + + it("surfaces a prior login rejection when nothing is configured", async () => { + await recordRejection("cohort_retention", { + reason: "Invalid or unauthorized API key", + url: "https://metabase.example.com/admin", + }); + const error = await resolveConfig({ profile: "cohort_retention" }).catch( + (thrown: unknown) => thrown, + ); + expect(error).toBeInstanceOf(ConfigError); + if (!(error instanceof ConfigError)) { + throw new Error("expected ConfigError"); + } + expect(error.message).toBe( + 'Last login for profile "cohort_retention" was rejected by https://metabase.example.com: Invalid or unauthorized API key. Re-run `metabase auth login --profile cohort_retention` with valid credentials.', + ); + }); + + it("ignores the rejection record when stored credentials are still present", async () => { + await writeProfile( + { url: "https://saved.example.com", apiKey: "saved-key" }, + "cohort_retention", + ); + await recordRejection("cohort_retention", { + reason: "Invalid or unauthorized API key", + url: "https://saved.example.com", + }); + const config = await resolveConfig({ profile: "cohort_retention" }); + expect(config).toEqual({ + url: "https://saved.example.com", + apiKey: "saved-key", + profile: "cohort_retention", + source: "stored", + }); + }); }); describe("resolveProfileName", () => { diff --git a/src/core/config.ts b/src/core/config.ts index 8927d04..2aece3a 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,6 +1,7 @@ +import { readRejection } from "./auth/rejection"; import { DEFAULT_PROFILE, readLicense, readProfile } from "./auth/storage"; import { ConfigError } from "./errors"; -import { normalizeUrl } from "./url"; +import { normalizeUrl, originOnly } from "./url"; const ENV_URL = "METABASE_URL"; const ENV_API_KEY = "METABASE_API_KEY"; @@ -64,6 +65,12 @@ export async function resolveConfig(flags: ConfigFlags): Promise const keyField = pickField(flagKey, env.apiKey, stored?.apiKey); if (urlField === null || keyField === null) { + const rejection = await readRejection(profile); + if (rejection !== null) { + throw new ConfigError( + `Last login for profile "${profile}" was rejected by ${originOnly(rejection.url)}: ${rejection.reason}. Re-run \`metabase auth login --profile ${profile}\` with valid credentials.`, + ); + } throw new ConfigError( `Not authenticated for profile "${profile}". Run \`metabase auth login\`, set ${ENV_URL}/${ENV_API_KEY}, or pass --url/--api-key.`, ); diff --git a/tests/e2e/auth.e2e.test.ts b/tests/e2e/auth.e2e.test.ts index 6eec1b2..f29f28d 100644 --- a/tests/e2e/auth.e2e.test.ts +++ b/tests/e2e/auth.e2e.test.ts @@ -65,7 +65,7 @@ describe("auth e2e", () => { }); }); - it("login with an invalid api key fails verification", async () => { + it("login with an invalid api key fails verification, persists a rejection record, and surfaces it on later commands", async () => { const configHome = await makeIsolatedConfigHome(); const login = await runCli({ @@ -76,13 +76,86 @@ describe("auth e2e", () => { bootstrap.baseUrl, "--api-key", "mb_definitely_not_valid_key_aaaaaaaaaa", + "--profile", + "rejected_profile", "--json", ], configHome, }); expect(login.exitCode).toBe(2); - expect(login.stderr).toContain("verification failed: Invalid or unauthorized API key"); + expect(login.stderr).toContain( + 'verification failed: Invalid or unauthorized API key — credentials were not saved for profile "rejected_profile"', + ); + + const status = await runCli({ + args: ["auth", "status", "--profile", "rejected_profile", "--json"], + configHome, + }); + expect(status.exitCode, status.stderr).toBe(0); + expect(parseJson(status.stdout, AuthStatus)).toEqual({ + profile: "rejected_profile", + present: false, + url: null, + }); + + const followup = await runCli({ + args: ["database", "list", "--profile", "rejected_profile", "--json"], + configHome, + }); + expect(followup.exitCode).toBe(2); + expect(followup.stderr).toContain('Last login for profile "rejected_profile" was rejected by'); + expect(followup.stderr).toContain("Invalid or unauthorized API key"); + expect(followup.stderr).toContain( + "Re-run `metabase auth login --profile rejected_profile` with valid credentials.", + ); + }); + + it("a successful login clears a prior rejection record for the same profile", async () => { + const configHome = await makeIsolatedConfigHome(); + + const failed = await runCli({ + args: [ + "auth", + "login", + "--url", + bootstrap.baseUrl, + "--api-key", + "mb_definitely_not_valid_key_aaaaaaaaaa", + "--profile", + "recovers", + "--json", + ], + configHome, + }); + expect(failed.exitCode).toBe(2); + + const succeeded = await runCli({ + args: [ + "auth", + "login", + "--url", + bootstrap.baseUrl, + "--api-key", + bootstrap.adminApiKey, + "--profile", + "recovers", + "--json", + ], + configHome, + }); + expect(succeeded.exitCode, succeeded.stderr).toBe(0); + + const followup = await runCli({ + args: ["auth", "status", "--profile", "recovers", "--json"], + configHome, + }); + expect(followup.exitCode, followup.stderr).toBe(0); + expect(parseJson(followup.stdout, AuthStatus)).toEqual({ + profile: "recovers", + present: true, + url: bootstrap.baseUrl, + }); }); it("logout clears stored credentials and status reflects the cleared profile", async () => { From 2371a380e6d72153efdd0ffd0fae7ae23831328c Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Mon, 11 May 2026 12:09:44 -0400 Subject: [PATCH 28/47] improvement --- src/commands/workspace/start.ts | 36 +++++++++++----- src/domain/dashboard.ts | 15 ++++++- src/domain/view.ts | 9 +++- tests/e2e/dashboard.e2e.test.ts | 73 ++++++++++++++++++++++++++++++++- 4 files changed, 118 insertions(+), 15 deletions(-) diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts index 44d42ab..33e88b2 100644 --- a/src/commands/workspace/start.ts +++ b/src/commands/workspace/start.ts @@ -120,7 +120,8 @@ export default defineMetabaseCommand({ }, force: { type: "boolean", - description: "If a container for this workspace already exists, remove it first", + description: + "Remove and recreate the container even if it is running. Stopped containers (exited/created/dead) are recreated automatically without this flag.", default: false, }, repo: { @@ -161,7 +162,7 @@ export default defineMetabaseCommand({ const licenseToken = await resolveLicenseToken({}); await checkDockerReady(); - await ensureNoExistingContainer(containerName, args.force); + await ensureNoExistingContainer(workspaceId, containerName, args.force); const pullPromise = args.pull ? pullImage(args.image) : Promise.resolve(); @@ -258,17 +259,30 @@ function assertAllDatabasesProvisioned(workspace: Workspace): void { } } -async function ensureNoExistingContainer(containerName: string, force: boolean): Promise { - if (!force) { - const status = await containerLifecycleStatus(containerName); - if (status !== "missing") { - throw new ConfigError( - `container ${containerName} already exists (state=${status}). Use --force to recreate, or stop/remove it first.`, - ); - } +async function ensureNoExistingContainer( + workspaceId: number, + containerName: string, + force: boolean, +): Promise { + if (force) { + await removeContainer(containerName); return; } - await removeContainer(containerName); + const status = await containerLifecycleStatus(containerName); + if (status === "missing") { + return; + } + // The container exists but isn't running — the workspace is unused, so recreate + // transparently. The named app-db volume persists across rm/create, so workspace + // state is preserved; recreating also picks up any new flags (--port, --image, + // --repo) and refreshes the boot bundle. + if (status === "exited" || status === "created" || status === "dead") { + await removeContainer(containerName); + return; + } + throw new ConfigError( + `container ${containerName} is currently ${status}. Run \`metabase workspace stop ${workspaceId}\` first, or use --force to recreate it.`, + ); } async function resolveHostPort(requested: number | null): Promise { diff --git a/src/domain/dashboard.ts b/src/domain/dashboard.ts index 25f6b3b..3145efa 100644 --- a/src/domain/dashboard.ts +++ b/src/domain/dashboard.ts @@ -58,6 +58,14 @@ export const DashboardTab = z .loose(); export type DashboardTab = z.infer; +export const DashboardTabCompact = DashboardTab.pick({ + id: true, + dashboard_id: true, + name: true, + position: true, +}).strip(); +export type DashboardTabCompact = z.infer; + export const Dashboard = z .object({ id: z.number().int(), @@ -91,7 +99,12 @@ export const DashboardCompact = Dashboard.pick({ description: true, archived: true, collection_id: true, -}).strip(); +}) + .strip() + .extend({ + dashcards: z.array(DashcardCompact).optional(), + tabs: z.array(DashboardTabCompact).optional(), + }); export type DashboardCompact = z.infer; export const dashboardView: ResourceView = { diff --git a/src/domain/view.ts b/src/domain/view.ts index 26904f5..e87c947 100644 --- a/src/domain/view.ts +++ b/src/domain/view.ts @@ -7,7 +7,14 @@ export interface ColumnDef { format?: (value: unknown) => string; } +export type DeepPartial = + T extends ReadonlyArray + ? ReadonlyArray> + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T; + export interface ResourceView { - compactPick: ZodType>; + compactPick: ZodType>; tableColumns: ColumnDef[]; } diff --git a/tests/e2e/dashboard.e2e.test.ts b/tests/e2e/dashboard.e2e.test.ts index 68a78a6..f0e1b72 100644 --- a/tests/e2e/dashboard.e2e.test.ts +++ b/tests/e2e/dashboard.e2e.test.ts @@ -26,6 +26,23 @@ const ORDERS_OVERVIEW_COMPACT = { collection_id: E2E_COLLECTIONS.DEFAULT, } as const; +const ORDERS_OVERVIEW_FIRST_DASHCARD_COMPACT = { + id: E2E_DASHCARDS.ORDERS_OVERVIEW_FIRST, + dashboard_id: E2E_DASHBOARDS.ORDERS_OVERVIEW, + card_id: E2E_CARDS.ORDERS_BY_STATUS, + dashboard_tab_id: null, + row: 0, + col: 0, + size_x: 12, + size_y: 6, +} as const; + +const ORDERS_OVERVIEW_DETAIL_COMPACT = { + ...ORDERS_OVERVIEW_COMPACT, + dashcards: [ORDERS_OVERVIEW_FIRST_DASHCARD_COMPACT], + tabs: [], +} as const; + describe("dashboard e2e", () => { let bootstrap: E2EBootstrap; const tempDirs: string[] = []; @@ -89,7 +106,7 @@ describe("dashboard e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - expect(parseJson(result.stdout, DashboardCompact)).toEqual(ORDERS_OVERVIEW_COMPACT); + expect(parseJson(result.stdout, DashboardCompact)).toEqual(ORDERS_OVERVIEW_DETAIL_COMPACT); }); it("get --full hydrates dashcards, tabs, and width on the seeded dashboard", async () => { @@ -237,6 +254,8 @@ describe("dashboard e2e", () => { description: "created in test", archived: false, collection_id: E2E_COLLECTIONS.DEFAULT, + dashcards: [], + tabs: [], }); const addCardResult = await runCli({ @@ -282,6 +301,56 @@ describe("dashboard e2e", () => { }); }); + it("create with dashcards in the body chains a PUT and surfaces them in compact output", async () => { + const result = await runCli({ + args: ["dashboard", "create", "--json"], + stdin: JSON.stringify({ + name: "e2e_dashboard_with_dashcards", + collection_id: E2E_COLLECTIONS.DEFAULT, + dashcards: [ + { + id: -1, + card_id: E2E_CARDS.ORDERS_BY_STATUS, + row: 0, + col: 0, + size_x: 12, + size_y: 6, + parameter_mappings: [], + visualization_settings: {}, + }, + ], + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + const compact = parseJson(result.stdout, DashboardCompact); + const firstDashcard = compact.dashcards?.[0]; + if (firstDashcard === undefined || compact.dashcards?.length !== 1) { + throw new Error(`expected exactly 1 dashcard, got ${JSON.stringify(compact.dashcards)}`); + } + expect(compact).toEqual({ + id: compact.id, + name: "e2e_dashboard_with_dashcards", + description: null, + archived: false, + collection_id: E2E_COLLECTIONS.DEFAULT, + tabs: [], + dashcards: [ + { + id: firstDashcard.id, + dashboard_id: compact.id, + card_id: E2E_CARDS.ORDERS_BY_STATUS, + dashboard_tab_id: null, + row: 0, + col: 0, + size_x: 12, + size_y: 6, + }, + ], + }); + }); + it("create with a body missing the required name field fails on Zod validation", async () => { const result = await runCli({ args: ["dashboard", "create", "--json"], @@ -534,7 +603,7 @@ describe("dashboard e2e", () => { }); expect(archiveResult.exitCode, archiveResult.stderr).toBe(0); expect(parseJson(archiveResult.stdout, DashboardCompact)).toEqual({ - ...ORDERS_OVERVIEW_COMPACT, + ...ORDERS_OVERVIEW_DETAIL_COMPACT, archived: true, }); From 80b2b17bf5ea7168377b9bd84bb433042df8bd8f Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Mon, 11 May 2026 12:30:48 -0400 Subject: [PATCH 29/47] metadata --- src/commands/workspace/start.ts | 56 +++++++++++++++++++++++++++------ src/core/docker.ts | 16 +--------- src/core/http/client.ts | 4 +++ 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts index 33e88b2..24aa929 100644 --- a/src/commands/workspace/start.ts +++ b/src/commands/workspace/start.ts @@ -17,13 +17,14 @@ import { waitForConfigConsumed, } from "../../core/docker"; import { ConfigError, errorMessage } from "../../core/errors"; -import type { Client } from "../../core/http/client"; +import { type Client, createClient } from "../../core/http/client"; import { probeHealth } from "../../core/http/probe"; import { localUrl } from "../../core/url"; import { REPO_SYNC_MODES, type RepoSettings, RepoSyncMode, + type WorkspaceCredentials, buildCredentialsJson, generateWorkspaceCredentials, injectCredentialsIntoConfig, @@ -51,6 +52,11 @@ const HEALTH_MAX_INTERVAL_MS = 10_000; const HEALTH_PROBE_TIMEOUT_MS = 4_000; const DEFAULT_REPO_MODE: RepoSyncMode = "read-write"; const REPO_FILE_URL = `file://${CONTAINER_REPO_DIR}`; +// Metadata can be multi-MB and the backend runs a 4-pass loader, so the per- +// request HTTP timeout (30s default) is too tight. Reuse the readiness budget. +const METADATA_IMPORT_TIMEOUT_MS = DEFAULT_READY_TIMEOUT_MS; + +const MetadataImportResult = z.object({ success: z.boolean() }); export const StartResult = z.object({ workspace_id: z.number().int().positive(), @@ -100,7 +106,7 @@ export default defineMetabaseCommand({ wait: { type: "boolean", description: - "Block until /api/health is ready before returning. Default: return as soon as the container has consumed config.yml.", + "Block until /api/health is ready before returning. Default: return as soon as the container has consumed config.yml. (Implied when --metadata is on, since the import requires a live API.)", default: false, }, timeout: { @@ -115,7 +121,8 @@ export default defineMetabaseCommand({ }, metadata: { type: "boolean", - description: "Fetch the workspace's warehouse metadata and stage it inside the container", + description: + "Fetch the workspace's warehouse metadata from the parent and POST it to the child instance once it is healthy", default: true, }, force: { @@ -174,10 +181,10 @@ export default defineMetabaseCommand({ const hostPort = await resolveHostPort(requestedPort); - // Boot bundle stays in process memory: no host-disk artifact for config.yml, - // credentials.json, or metadata.json. The bytes are tar-streamed into the - // container by the docker daemon and land on the overlay FS (root-only on - // the daemon host). Repo resolution overlaps with the parent fetches. + // Boot bundle stays in process memory: no host-disk artifact for config.yml + // or credentials.json. The bytes are tar-streamed into the container by the + // docker daemon and land on the overlay FS (root-only on the daemon host). + // Repo resolution overlaps with the parent fetches. const [parentConfigYaml, metadataJson, repoOptions] = await Promise.all([ fetchConfigYaml(client, workspaceId), args.metadata ? fetchMetadataJson(client, workspaceId) : Promise.resolve(null), @@ -207,7 +214,6 @@ export default defineMetabaseCommand({ hostPort, configYaml, credentialsJson, - metadataJson, licenseToken, bindMounts: repoOptions === null ? [] : [repoOptions.bindMount], }); @@ -224,15 +230,22 @@ export default defineMetabaseCommand({ warn(`could not scrub in-container config.yml: ${errorMessage(error)}`); } - if (args.wait) { + // The metadata POST lands at the child's REST API, so the child must be + // health-ready before we can ship it. That implicitly upgrades --wait when + // --metadata is on. + const needsHealth = args.wait || metadataJson !== null; + if (needsHealth) { await waitForHealth(hostPort, readyTimeoutMs); } + if (metadataJson !== null) { + await importMetadataIntoChild(hostPort, credentials, metadataJson); + } const result: StartResult = { workspace_id: workspaceId, workspace_name: workspace.name, container_name: containerName, - state: args.wait ? "running" : "starting", + state: needsHealth ? "running" : "starting", host_port: hostPort, url: localUrl(hostPort), image: args.image, @@ -330,6 +343,29 @@ async function waitForHealth(hostPort: number, timeoutMs: number): Promise ); } +async function importMetadataIntoChild( + hostPort: number, + credentials: WorkspaceCredentials, + metadataJson: Uint8Array, +): Promise { + const childClient = createClient({ + url: localUrl(hostPort), + apiKey: credentials.api_key.key, + }); + const result = await childClient.requestParsed( + MetadataImportResult, + "/api/ee/serialization/metadata/import", + { + method: "POST", + body: metadataJson, + timeoutMs: METADATA_IMPORT_TIMEOUT_MS, + }, + ); + if (!result.success) { + throw new ConfigError("workspace child rejected the metadata import (returned success=false)"); + } +} + interface ResolvedRepoOptions { bindMount: BindMount; repo: RepoSettings; diff --git a/src/core/docker.ts b/src/core/docker.ts index ea39a00..25f825d 100644 --- a/src/core/docker.ts +++ b/src/core/docker.ts @@ -30,7 +30,6 @@ const CONTAINER_CONFIG_DIR_BASENAME = CONTAINER_CONFIG_DIR.replace(/^\//, ""); const CONTAINER_APP_DB_DIR = "/metabase-app-db"; export const CONTAINER_REPO_DIR = "/mnt/repo"; const CONFIG_FILENAME = "config.yml"; -const METADATA_FILENAME = "metadata.json"; const CREDENTIALS_FILENAME = "credentials.json"; // Log line emitted by the child once it finishes applying the workspace config block. @@ -154,7 +153,6 @@ export interface WorkspaceContainerSpec { hostPort: number; configYaml: string; credentialsJson: Uint8Array; - metadataJson: Uint8Array | null; licenseToken: string; bindMounts: readonly BindMount[]; } @@ -389,14 +387,6 @@ function buildBootBundleTar(spec: WorkspaceContainerSpec): Uint8Array { mode: BUNDLE_FILE_MODE, }, ]; - if (spec.metadataJson !== null) { - entries.push({ - type: "file", - name: `${CONTAINER_CONFIG_DIR_BASENAME}/${METADATA_FILENAME}`, - content: spec.metadataJson, - mode: BUNDLE_FILE_MODE, - }); - } return buildTar(entries); } @@ -412,16 +402,12 @@ function workspaceContainerLabels(spec: WorkspaceContainerSpec): Record { - const env: Record = { + return { MB_CONFIG_FILE_PATH: `${CONTAINER_CONFIG_DIR}/${CONFIG_FILENAME}`, MB_PREMIUM_EMBEDDING_TOKEN: spec.licenseToken, MB_DB_FILE: `${CONTAINER_APP_DB_DIR}/metabase.db`, JAVA_OPTS: "-Xmx2g", }; - if (spec.metadataJson !== null) { - env["MB_TABLE_METADATA_PATH"] = `${CONTAINER_CONFIG_DIR}/${METADATA_FILENAME}`; - } - return env; } async function createContainer(options: CreateContainerOptions): Promise { diff --git a/src/core/http/client.ts b/src/core/http/client.ts index 8fbd17d..687d5f5 100644 --- a/src/core/http/client.ts +++ b/src/core/http/client.ts @@ -14,6 +14,7 @@ export type ExpectedContentType = "json" | "text" | "binary"; const DEFAULT_TIMEOUT_MS = 30_000; const JSON_CONTENT_TYPE = "application/json"; +const OCTET_STREAM_CONTENT_TYPE = "application/octet-stream"; const TEXT_CONTENT_TYPE_PREFIX = "text/"; const ERROR_BODY_BYTE_CAP = 64 * 1024; const USER_AGENT = `metabase-cli/${packageJson.version}`; @@ -158,6 +159,9 @@ export function createClient(config: ClientCredentials, overrides: ClientOverrid body = opts.body; } else if (opts.body instanceof FormData || opts.body instanceof ReadableStream) { body = opts.body; + } else if (opts.body instanceof Uint8Array) { + body = opts.body; + headers.set("content-type", OCTET_STREAM_CONTENT_TYPE); } else { body = JSON.stringify(opts.body); headers.set("content-type", JSON_CONTENT_TYPE); From 2c53ab644a19dd23825bf7cb22385a69d8fd2eef Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Mon, 11 May 2026 13:05:34 -0400 Subject: [PATCH 30/47] try disabling the scheduler --- src/core/docker.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/docker.ts b/src/core/docker.ts index 25f825d..29a48ec 100644 --- a/src/core/docker.ts +++ b/src/core/docker.ts @@ -406,6 +406,9 @@ function workspaceContainerEnv(spec: WorkspaceContainerSpec): Record Date: Mon, 11 May 2026 15:49:16 -0400 Subject: [PATCH 31/47] docker no pull default --- src/commands/workspace/start.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts index 24aa929..0572a44 100644 --- a/src/commands/workspace/start.ts +++ b/src/commands/workspace/start.ts @@ -116,8 +116,9 @@ export default defineMetabaseCommand({ }, pull: { type: "boolean", - description: "Pull the image before starting", - default: true, + description: + "Force a fresh pull of the image before starting. Default: use the locally cached image (docker auto-pulls only if it's missing).", + default: false, }, metadata: { type: "boolean", @@ -151,7 +152,7 @@ export default defineMetabaseCommand({ "metabase workspace start 1", "metabase workspace start 1 --wait", "metabase workspace start 1 --port 3100", - "metabase workspace start 1 --image metabase/metabase-dev:feature-workspaces-v2 --no-pull", + "metabase workspace start 1 --image metabase/metabase-dev:feature-workspaces-v2 --pull", "metabase workspace start 1 --force", "metabase workspace start 1 --repo /path/to/sync-repo --wait", "metabase workspace start 1 --repo /path/to/sync-repo --repo-branch dev --repo-mode read-only", From 496e69bb56e6737d0a2b25437eddf3181ae606cf Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Mon, 11 May 2026 16:20:32 -0400 Subject: [PATCH 32/47] ids --- README.md | 19 ++++++ src/commands/query.ts | 2 +- src/commands/uuid.test.ts | 64 ++++++++++++++++++++ src/commands/uuid.ts | 43 +++++++++++++ src/core/schema/validate.test.ts | 88 +++++++++++++++++++++++++++ src/core/schema/validate.ts | 101 ++++++++++++++++++++++++------- src/main.ts | 1 + tests/e2e/manifest.e2e.test.ts | 1 + tests/e2e/uuid.e2e.test.ts | 62 +++++++++++++++++++ 9 files changed, 359 insertions(+), 22 deletions(-) create mode 100644 src/commands/uuid.test.ts create mode 100644 src/commands/uuid.ts create mode 100644 tests/e2e/uuid.e2e.test.ts diff --git a/README.md b/README.md index 3bb5654..2e83b69 100644 --- a/README.md +++ b/README.md @@ -1365,6 +1365,25 @@ Agent discovery path: `metabase __manifest` lists every command's args and descr The bundled query schema is synced from a pinned `@metabase/representations` release via `bun run sync:representations`; CI guards against drift. +## UUIDs + +### `metabase uuid` + +Mint UUID v4 strings (Node `crypto.randomUUID`) for MBQL clause `lib/uuid` slots, native template-tag ids, and any other Metabase-side identifier whose schema enforces RFC 4122 format. Agents must call this command to obtain UUIDs rather than authoring them by hand: the bundled MBQL 5 schema rejects placeholder strings (`a1`, `uuid-1`, etc.) at `format: "uuid"` validation. + +```sh +metabase uuid # one UUID +metabase uuid --count 5 # five UUIDs, one per line (text mode in a TTY, JSON when piped) +metabase uuid --count 5 --json # explicit JSON: ["…", "…", "…", "…", "…"] +metabase uuid --count 5 --format text # explicit text: one UUID per line +``` + +Output: text mode prints one UUID per line; JSON mode prints a `string[]`. Default behavior follows the standard `--format auto` rule — JSON when stdout is a pipe, text when it's a TTY. + +`--count` accepts integers `1` through `10000`; outside that range exits 2 with a `ConfigError`. + +Exit codes: `0` success, `2` invalid `--count`. + ## Environment variables | Variable | Effect | diff --git a/src/commands/query.ts b/src/commands/query.ts index 71359a2..539bee6 100644 --- a/src/commands/query.ts +++ b/src/commands/query.ts @@ -32,7 +32,7 @@ export default defineMetabaseCommand({ meta: { name: "query", description: - "Run an MBQL 5 query (validates against the bundled schema first); --print-schema emits the schema for agent discovery, --dry-run validates without sending. Default is internal MBQL (numeric IDs); pass --external for the representations / string-FK form.", + "Run an MBQL 5 query (validates against the bundled schema first); --print-schema emits the schema for agent discovery, --dry-run validates without sending. Default is internal MBQL (numeric IDs); pass --external for the representations / string-FK form. Every clause options object carries a `lib/uuid` (UUID v4); mint these via `metabase uuid` — never author them by hand.", }, args: { ...outputFlags, diff --git a/src/commands/uuid.test.ts b/src/commands/uuid.test.ts new file mode 100644 index 0000000..f52a73b --- /dev/null +++ b/src/commands/uuid.test.ts @@ -0,0 +1,64 @@ +import { runCommand } from "citty"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { parseJson } from "../runtime/json"; + +import uuidCommand, { MAX_COUNT, UuidList } from "./uuid"; + +type CaptureStream = "stdout" | "stderr"; + +async function captureFromRun(rawArgs: readonly string[], stream: CaptureStream): Promise { + const captured: string[] = []; + const target = stream === "stdout" ? process.stdout : process.stderr; + const spy = vi.spyOn(target, "write").mockImplementation((chunk) => { + captured.push(String(chunk)); + return true; + }); + try { + await runCommand(uuidCommand, { rawArgs: [...rawArgs] }); + } finally { + spy.mockRestore(); + } + return captured.join(""); +} + +describe("uuid command", () => { + const previousExitCode = process.exitCode; + + afterEach(() => { + process.exitCode = previousExitCode; + }); + + it("--json --count 3 emits exactly 3 valid v4 UUIDs (all distinct)", async () => { + const stdout = await captureFromRun(["--json", "--count", "3"], "stdout"); + const uuids = parseJson(stdout, UuidList); + expect(uuids).toHaveLength(3); + expect(new Set(uuids).size).toBe(3); + }); + + it("--json with no --count flag mints a single UUID (default count = 1)", async () => { + const stdout = await captureFromRun(["--json"], "stdout"); + const uuids = parseJson(stdout, UuidList); + expect(uuids).toHaveLength(1); + }); + + it("text mode emits one valid UUID per line and nothing else (suitable for xargs piping)", async () => { + const stdout = await captureFromRun(["--format", "text", "--count", "2"], "stdout"); + const lines = stdout.trim().split("\n"); + expect(lines).toHaveLength(2); + UuidList.parse(lines); + }); + + it("rejects --count 0 with ConfigError (exit code 2)", async () => { + const stderr = await captureFromRun(["--count", "0", "--json"], "stderr"); + expect(process.exitCode).toBe(2); + expect(stderr).toContain("invalid --count: 0 (must be ≥ 1)"); + }); + + it(`rejects --count above the ${MAX_COUNT} cap with ConfigError (exit code 2)`, async () => { + const overCap = MAX_COUNT + 1; + const stderr = await captureFromRun(["--count", String(overCap), "--json"], "stderr"); + expect(process.exitCode).toBe(2); + expect(stderr).toContain(`invalid --count: ${overCap} (must be ≤ ${MAX_COUNT})`); + }); +}); diff --git a/src/commands/uuid.ts b/src/commands/uuid.ts new file mode 100644 index 0000000..a82b706 --- /dev/null +++ b/src/commands/uuid.ts @@ -0,0 +1,43 @@ +import { randomUUID } from "node:crypto"; +import { z } from "zod"; + +import { ConfigError } from "../core/errors"; +import { writeJson, writeText } from "../output/render"; + +import { outputFlags } from "./flags"; +import { parseInteger } from "./parse-integer"; +import { defineMetabaseCommand } from "./runtime"; + +export const MAX_COUNT = 10_000; + +export const UuidList = z.array(z.string().uuid()); + +export default defineMetabaseCommand({ + meta: { + name: "uuid", + description: + 'Mint UUID v4 strings (Node crypto.randomUUID) for MBQL `lib/uuid` slots, native template-tag ids, etc. Agents must call this to obtain UUIDs rather than authoring them by hand — hand-written placeholders fail the bundled MBQL 5 schema\'s `format: "uuid"` check.', + }, + args: { + ...outputFlags, + count: { + type: "string", + description: `How many UUIDs to mint (default 1, max ${MAX_COUNT})`, + default: "1", + }, + }, + outputSchema: UuidList, + examples: ["metabase uuid", "metabase uuid --count 5", "metabase uuid --count 5 --json"], + run({ args, ctx }) { + const count = parseInteger(args.count, { name: "--count", min: 1 }); + if (count > MAX_COUNT) { + throw new ConfigError(`invalid --count: ${count} (must be ≤ ${MAX_COUNT})`); + } + const uuids = Array.from({ length: count }, () => randomUUID()); + if (ctx.format === "json") { + writeJson(uuids); + return; + } + writeText(uuids.join("\n")); + }, +}); diff --git a/src/core/schema/validate.test.ts b/src/core/schema/validate.test.ts index cc1aa61..a40ae12 100644 --- a/src/core/schema/validate.test.ts +++ b/src/core/schema/validate.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import { + FIELD_SLOT1_HINT_MESSAGE, + UUID_HINT_MESSAGE, + clauseSlot1HintMessage, getQuerySchemaBundle, isLegacyEnvelopeWrappingMbql5, isMbql5Query, @@ -226,6 +229,91 @@ describe("ref-clause error messages", () => { }); }); +describe("clause-shape error messages", () => { + it("rewrites 'must be object' at /1 of a `field` clause to call out the MBQL5 vs MBQL4 ordering trap", () => { + const outcome = validateInternalQuery({ + "lib/type": "mbql/query", + database: 1, + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": 7, + breakout: [ + [ + "field", + 86, + { "lib/uuid": "55555555-5555-5555-5555-555555555555", "base-type": "type/Text" }, + ], + ], + }, + ], + }); + expect(outcome.ok).toBe(false); + expect(outcome.errors).toContainEqual({ + path: "/stages/0/breakout/0/1", + message: FIELD_SLOT1_HINT_MESSAGE, + }); + }); + + it("rewrites 'must be object' at /1 of an arbitrary clause with a generic options-position message that names the operator and the offending value", () => { + const outcome = validateInternalQuery({ + "lib/type": "mbql/query", + database: 1, + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": 7, + aggregation: [["sum", "not-an-object", ["field", {}, 86]]], + }, + ], + }); + expect(outcome.ok).toBe(false); + expect(outcome.errors).toContainEqual({ + path: "/stages/0/aggregation/0/1", + message: clauseSlot1HintMessage("sum", "not-an-object"), + }); + }); + + it("does not override slot 1 when the operator is not a string (the array isn't a clause)", () => { + const outcome = validateInternalQuery({ + "lib/type": "mbql/query", + database: 1, + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": [1, 2, 3] }], + }); + expect(outcome.ok).toBe(false); + for (const issue of outcome.errors) { + expect(issue.message).not.toContain("clause options object"); + expect(issue.message).not.toContain("field options object"); + } + }); +}); + +describe("uuid-format error messages", () => { + it("replaces Ajv's bare 'must match format \"uuid\"' with a hint pointing at `metabase uuid`", () => { + const outcome = validateInternalQuery({ + "lib/type": "mbql/query", + database: 1, + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": 7, + aggregation: [["count", { "lib/uuid": "a1" }]], + }, + ], + }); + expect(outcome.ok).toBe(false); + expect(outcome.errors).toContainEqual({ + path: "/stages/0/aggregation/0/1/lib~1uuid", + message: UUID_HINT_MESSAGE, + }); + }); + + it("uuid hint string mentions `metabase uuid` and notes that placeholders are rejected", () => { + expect(UUID_HINT_MESSAGE).toContain("metabase uuid"); + expect(UUID_HINT_MESSAGE).toContain("placeholder"); + }); +}); + describe("getQuerySchemaBundle", () => { it("external mode bundles the query schema with the string-FK id schema and the other 3 common defs", () => { const bundle = getQuerySchemaBundle("external"); diff --git a/src/core/schema/validate.ts b/src/core/schema/validate.ts index bee3238..dd4f57e 100644 --- a/src/core/schema/validate.ts +++ b/src/core/schema/validate.ts @@ -1,6 +1,6 @@ import Ajv2020 from "ajv/dist/2020.js"; import addFormats from "ajv-formats"; -import type { ValidateFunction } from "ajv"; +import type { ErrorObject, ValidateFunction } from "ajv"; import { z } from "zod"; import { isPlainObject } from "../../runtime/predicates"; @@ -75,38 +75,67 @@ function getInternalValidator(): ValidateFunction { return internalValidator; } +export const UUID_HINT_MESSAGE = + "must be a UUID v4 (RFC 4122) — run `metabase uuid` (or `metabase uuid --count N`) to mint one. The MBQL 5 schema rejects placeholder strings (`a1`, `uuid-1`, etc.); agents must call the CLI for UUIDs rather than authoring them."; + +export const FIELD_SLOT1_HINT_MESSAGE = + 'must be the field options object — MBQL 5 field refs are ["field", {options}, fieldId]; the legacy MBQL 4 shape ["field", id, opts] is not accepted here. (Tip: `metabase uuid` mints `lib/uuid` strings if you need them.)'; + +export function clauseSlot1HintMessage(operator: string, slot1: unknown): string { + return `must be the clause options object — every MBQL 5 clause is ["${operator}", {options}, ...args]; got ${describeJsonValue(slot1)} at index 1`; +} + +const FormatErrorParams = z.object({ format: z.string() }); + +function isUuidFormatIssue(issue: ErrorObject): boolean { + if (issue.keyword !== "format") { + return false; + } + const parsed = FormatErrorParams.safeParse(issue.params); + return parsed.success && parsed.data.format === "uuid"; +} + function runValidator(validator: ValidateFunction, value: unknown): ValidationOutcome { if (validator(value)) { return { ok: true, errors: [] }; } - const refHints = collectRefShapeHints(value); + const overrides = collectMessageOverrides(value); const issues = validator.errors ?? []; const errors = issues.map((issue) => { if (issue.message === undefined) { throw new Error(`Ajv issue at ${issue.instancePath} has no message`); } const path = issue.instancePath === "" ? "/" : issue.instancePath; - const enrichedMessage = refHints.get(path); - return { path, message: enrichedMessage ?? issue.message }; + if (isUuidFormatIssue(issue)) { + return { path, message: UUID_HINT_MESSAGE }; + } + const overridden = overrides.get(path); + return { path, message: overridden ?? issue.message }; }); return { ok: false, errors }; } -// Walks the candidate query and identifies ref-clause arrays whose third -// element violates its kind-specific contract. Ajv reports these as bare -// "must be string", which doesn't tell the caller *which* string is meant -// (target aggregation's lib/uuid? expression's name?). We carry the kind in -// from the parent so the swapped message names the contract directly. -function collectRefShapeHints(root: unknown): Map { - const hints = new Map(); +// Walks the candidate query and assembles per-path overrides for two common +// hand-authoring traps. Index 1 of every clause must be an options object +// (MBQL 5 puts opts second; the legacy MBQL 4 shape `[op, id, opts]` lands the +// id in this slot — Ajv just says "must be object", which doesn't tell the +// caller *why*). Index 2 of aggregation/expression refs must be a string +// (the target's lib/uuid or name); a numeric position there is the legacy +// position-index footgun. +function collectMessageOverrides(root: unknown): Map { + const overrides = new Map(); visit(root, ""); - return hints; + return overrides; function visit(node: unknown, path: string): void { if (Array.isArray(node)) { - const refMessage = refShapeMessage(node); - if (refMessage !== null) { - hints.set(`${path}/2`, refMessage); + const slot1 = clauseSlot1Message(node); + if (slot1 !== null) { + overrides.set(`${path}/1`, slot1); + } + const slot2 = refSlot2Message(node); + if (slot2 !== null) { + overrides.set(`${path}/2`, slot2); } for (let index = 0; index < node.length; index += 1) { visit(node[index], `${path}/${index}`); @@ -123,7 +152,25 @@ function collectRefShapeHints(root: unknown): Map { } } -function refShapeMessage(clause: readonly unknown[]): string | null { +function clauseSlot1Message(clause: readonly unknown[]): string | null { + if (clause.length < 2) { + return null; + } + const operator = clause[0]; + if (typeof operator !== "string") { + return null; + } + const slot1 = clause[1]; + if (isPlainObject(slot1)) { + return null; + } + if (operator === "field") { + return FIELD_SLOT1_HINT_MESSAGE; + } + return clauseSlot1HintMessage(operator, slot1); +} + +function refSlot2Message(clause: readonly unknown[]): string | null { if (clause.length !== 3) { return null; } @@ -131,14 +178,10 @@ function refShapeMessage(clause: readonly unknown[]): string | null { if (typeof kind !== "string") { return null; } - const hint = refHintForKind(kind); - if (hint === null) { - return null; - } if (typeof clause[2] === "string") { return null; } - return hint; + return refHintForKind(kind); } // Only `aggregation` and `expression` refs have unambiguously string-typed @@ -159,6 +202,22 @@ function refHintForKind(kind: string): string | null { } } +function describeJsonValue(value: unknown): string { + if (value === null) { + return "null"; + } + if (Array.isArray(value)) { + return "array"; + } + if (typeof value === "string") { + return `string ${JSON.stringify(value)}`; + } + if (typeof value === "number" || typeof value === "boolean") { + return `${typeof value} ${String(value)}`; + } + return typeof value; +} + export function validateExternalQuery(value: unknown): ValidationOutcome { return runValidator(getExternalValidator(), value); } diff --git a/src/main.ts b/src/main.ts index 5208985..1302bd7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,6 +31,7 @@ const main: CommandDef = defineCommand({ measure: () => import("./commands/measure").then((mod) => mod.default), eid: () => import("./commands/eid").then((mod) => mod.default), query: () => import("./commands/query").then((mod) => mod.default), + uuid: () => import("./commands/uuid").then((mod) => mod.default), __manifest: (): Promise => import("./commands/manifest").then((mod) => mod.createManifestCommand(main)), }, diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index c0213c1..d00d3a3 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -135,6 +135,7 @@ describe("__manifest e2e", () => { "measure archive", "eid translate", "query", + "uuid", ]); // Streaming commands legitimately have no outputSchema — they pipe raw bytes diff --git a/tests/e2e/uuid.e2e.test.ts b/tests/e2e/uuid.e2e.test.ts new file mode 100644 index 0000000..aaf95d1 --- /dev/null +++ b/tests/e2e/uuid.e2e.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { parseJson } from "../../src/runtime/json"; +import { UuidList } from "../../src/commands/uuid"; + +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; + +describe("uuid e2e", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + it("default invocation mints a single v4 UUID via JSON (subprocess stdout is non-TTY)", async () => { + const result = await runCli({ + args: ["uuid"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode, result.stderr).toBe(0); + const uuids = parseJson(result.stdout, UuidList); + expect(uuids).toHaveLength(1); + }); + + it("--count 4 --json emits exactly 4 distinct v4 UUIDs", async () => { + const result = await runCli({ + args: ["uuid", "--count", "4", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode, result.stderr).toBe(0); + const uuids = parseJson(result.stdout, UuidList); + expect(uuids).toHaveLength(4); + expect(new Set(uuids).size).toBe(4); + }); + + it("--format text --count 3 prints one UUID per line and nothing else", async () => { + const result = await runCli({ + args: ["uuid", "--format", "text", "--count", "3"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode, result.stderr).toBe(0); + const lines = result.stdout.trim().split("\n"); + expect(lines).toHaveLength(3); + UuidList.parse(lines); + }); + + it("--count 0 fails with ConfigError (exit 2) and the parse-integer message naming the flag", async () => { + const result = await runCli({ + args: ["uuid", "--count", "0", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("invalid --count: 0 (must be ≥ 1)"); + expect(result.stdout).toBe(""); + }); +}); From 63ed35ad1b73a7a7f59686a8f4caf5123cf7bb32 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Mon, 11 May 2026 16:32:36 -0400 Subject: [PATCH 33/47] Revert "docker no pull default" This reverts commit 28a297ae89f98b5684e4c4559517fc9643b205c2. --- src/commands/workspace/start.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts index 0572a44..24aa929 100644 --- a/src/commands/workspace/start.ts +++ b/src/commands/workspace/start.ts @@ -116,9 +116,8 @@ export default defineMetabaseCommand({ }, pull: { type: "boolean", - description: - "Force a fresh pull of the image before starting. Default: use the locally cached image (docker auto-pulls only if it's missing).", - default: false, + description: "Pull the image before starting", + default: true, }, metadata: { type: "boolean", @@ -152,7 +151,7 @@ export default defineMetabaseCommand({ "metabase workspace start 1", "metabase workspace start 1 --wait", "metabase workspace start 1 --port 3100", - "metabase workspace start 1 --image metabase/metabase-dev:feature-workspaces-v2 --pull", + "metabase workspace start 1 --image metabase/metabase-dev:feature-workspaces-v2 --no-pull", "metabase workspace start 1 --force", "metabase workspace start 1 --repo /path/to/sync-repo --wait", "metabase workspace start 1 --repo /path/to/sync-repo --repo-branch dev --repo-mode read-only", From c1d5eea60e500f952744eb86f8bc13d7b15d60a4 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Mon, 11 May 2026 16:34:03 -0400 Subject: [PATCH 34/47] fix collections --- src/core/errors.test.ts | 103 ++++++++++++++++++++++++++++++++++++++- src/core/errors.ts | 33 +++++++++---- src/domain/collection.ts | 7 ++- src/output/error.ts | 4 +- 4 files changed, 133 insertions(+), 14 deletions(-) diff --git a/src/core/errors.test.ts b/src/core/errors.test.ts index 1c66088..9bc977b 100644 --- a/src/core/errors.test.ts +++ b/src/core/errors.test.ts @@ -1,7 +1,15 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { AbortError, ConfigError, MetabaseError, toMetabaseError, UnknownError } from "./errors"; +import { + AbortError, + ConfigError, + formatZodIssue, + MetabaseError, + toMetabaseError, + UnknownError, + ValidationError, +} from "./errors"; import { HttpError } from "./http/errors"; describe("toMetabaseError", () => { @@ -95,3 +103,96 @@ describe("MetabaseError contract", () => { expect(error.userMessage).toBe("missing TTY"); }); }); + +describe("formatZodIssue", () => { + it("formats nested object/array paths with dot and bracket syntax", () => { + const schema = z.object({ + data: z.array(z.object({ archived: z.boolean() })), + }); + const result = schema.safeParse({ data: [{ archived: true }, { archived: null }] }); + if (result.success) { + throw new Error("expected zod failure"); + } + expect(result.error.issues.map(formatZodIssue)).toEqual([ + "data[1].archived: Invalid input: expected boolean, received null", + ]); + }); + + it("returns just the message when the issue path is empty (top-level mismatch)", () => { + const schema = z.string(); + const result = schema.safeParse(42); + if (result.success) { + throw new Error("expected zod failure"); + } + const firstIssue = result.error.issues[0]; + if (firstIssue === undefined) { + throw new Error("expected at least one issue"); + } + expect(formatZodIssue(firstIssue)).toBe("Invalid input: expected string, received number"); + }); +}); + +describe("ValidationError.userMessage", () => { + it("appends one bullet per zod issue with the offending path and the verbose hint", () => { + const error = new ValidationError( + "https://mb.example/api/collection/8/items: value did not match expected schema", + { + source: "https://mb.example/api/collection/8/items", + zodIssues: [ + { + code: "invalid_type", + expected: "boolean", + path: ["data", 3, "archived"], + message: "Expected boolean, received null", + input: null, + }, + { + code: "invalid_type", + expected: "string", + path: ["data", 7, "display"], + message: "Expected string, received null", + input: null, + }, + ], + }, + ); + expect(error.userMessage).toBe( + "https://mb.example/api/collection/8/items: value did not match expected schema (2 issues)\n" + + " - data[3].archived: Expected boolean, received null\n" + + " - data[7].display: Expected string, received null\n" + + " Set METABASE_VERBOSE=1 for the full developer detail.", + ); + }); + + it("caps the inline issue preview at 5 and reports the overflow count", () => { + const zodIssues = Array.from({ length: 7 }, (_unused, index) => ({ + code: "invalid_type" as const, + expected: "boolean" as const, + path: ["data", index, "archived"], + message: "Expected boolean, received null", + input: null, + })); + const error = new ValidationError("source: value did not match expected schema", { + source: "source", + zodIssues, + }); + expect(error.userMessage).toBe( + "source: value did not match expected schema (7 issues)\n" + + " - data[0].archived: Expected boolean, received null\n" + + " - data[1].archived: Expected boolean, received null\n" + + " - data[2].archived: Expected boolean, received null\n" + + " - data[3].archived: Expected boolean, received null\n" + + " - data[4].archived: Expected boolean, received null\n" + + " …and 2 more\n" + + " Set METABASE_VERBOSE=1 for the full developer detail.", + ); + }); + + it("falls back to the plain message when developerDetail carries no issues", () => { + const error = new ValidationError("file: malformed", { + source: "file", + zodIssues: [], + }); + expect(error.userMessage).toBe("file: malformed"); + }); +}); diff --git a/src/core/errors.ts b/src/core/errors.ts index 11d3568..076db21 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,5 +1,7 @@ import { isCancel } from "@clack/prompts"; -import { ZodError } from "zod"; +import { core as zodCore, ZodError } from "zod"; + +export const VERBOSE_ENV = "METABASE_VERBOSE"; export type ErrorCategory = | "network" @@ -68,6 +70,8 @@ export class TimeoutError extends MetabaseError { } } +const VALIDATION_ISSUE_PREVIEW_LIMIT = 5; + export class ValidationError extends MetabaseError { readonly category = "validation"; readonly isRetryable = false; @@ -79,6 +83,21 @@ export class ValidationError extends MetabaseError { this.name = "ValidationError"; this.developerDetail = developerDetail; } + + override get userMessage(): string { + const issues = this.developerDetail.zodIssues; + if (issues.length === 0) { + return this.message; + } + const shown = issues.slice(0, VALIDATION_ISSUE_PREVIEW_LIMIT); + const lines = shown.map((issue) => ` - ${formatZodIssue(issue)}`); + const trailer = + issues.length > shown.length + ? `\n …and ${issues.length - shown.length} more` + : ""; + const hint = `\n Set ${VERBOSE_ENV}=1 for the full developer detail.`; + return `${this.message} (${issues.length} issue${issues.length === 1 ? "" : "s"})\n${lines.join("\n")}${trailer}${hint}`; + } } export class ConfigError extends MetabaseError { @@ -126,7 +145,7 @@ export function toMetabaseError(error: unknown): MetabaseError { return new AbortError(); } if (error instanceof ZodError) { - return new ConfigError(formatZodError(error)); + return new ConfigError(error.issues.map(formatZodIssue).join("; ")); } if (error instanceof Error) { return new UnknownError({ originalMessage: error.message, stack: error.stack ?? null }); @@ -134,13 +153,9 @@ export function toMetabaseError(error: unknown): MetabaseError { return new UnknownError({ originalMessage: String(error), stack: null }); } -function formatZodError(error: ZodError): string { - return error.issues - .map((issue) => { - const path = issue.path.join("."); - return path ? `${path}: ${issue.message}` : issue.message; - }) - .join("; "); +export function formatZodIssue(issue: ZodError["issues"][number]): string { + const path = zodCore.toDotPath(issue.path); + return path === "" ? issue.message : `${path}: ${issue.message}`; } export function isNotFoundError(value: unknown): value is NodeJS.ErrnoException { diff --git a/src/domain/collection.ts b/src/domain/collection.ts index e1b2463..3a0f055 100644 --- a/src/domain/collection.ts +++ b/src/domain/collection.ts @@ -94,16 +94,19 @@ export const collectionView: ResourceView = { ], }; +// `archived` and `display` arrive as null on the wire for some model types (snippet, +// pulse, timeline, transform, document, table) — those queries don't select the column, +// so the union-all pads it with null. Stay permissive. export const CollectionItem = z .object({ id: z.number().int(), model: CollectionItemModel, name: z.string(), description: z.string().nullable().optional(), - archived: z.boolean(), + archived: z.boolean().nullable(), collection_id: CollectionId.nullable().optional(), collection_position: z.number().int().nullable().optional(), - display: z.string().optional(), + display: z.string().nullable().optional(), location: z.string().nullable().optional(), entity_id: z.string().nullable().optional(), database_id: z.number().int().nullable().optional(), diff --git a/src/output/error.ts b/src/output/error.ts index 7ef4539..a467d4d 100644 --- a/src/output/error.ts +++ b/src/output/error.ts @@ -1,9 +1,9 @@ -import { toMetabaseError } from "../core/errors"; +import { toMetabaseError, VERBOSE_ENV } from "../core/errors"; export function reportError(error: unknown): void { const handled = toMetabaseError(error); process.stderr.write(handled.userMessage + "\n"); - if (process.env["METABASE_VERBOSE"] === "1" && handled.developerDetail !== null) { + if (process.env[VERBOSE_ENV] === "1" && handled.developerDetail !== null) { process.stderr.write(JSON.stringify(handled.developerDetail, null, 2) + "\n"); } process.exitCode = handled.exitCode; From e883119de2b7f80366619c4101b3deb1fcc02000 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Mon, 11 May 2026 19:29:51 -0400 Subject: [PATCH 35/47] fix settings --- src/runtime/json.test.ts | 29 +++++++++-- src/runtime/json.ts | 29 +++++++++-- tests/e2e/setting.e2e.test.ts | 94 +++++++++++++++++++++++++++++++++-- 3 files changed, 139 insertions(+), 13 deletions(-) diff --git a/src/runtime/json.test.ts b/src/runtime/json.test.ts index ebb83ba..cb400dd 100644 --- a/src/runtime/json.test.ts +++ b/src/runtime/json.test.ts @@ -261,12 +261,33 @@ describe("parseJsonOrPlain", () => { }); }); - it("rejects malformed JSON content with ConfigError", () => { + it("falls back to a bare string when JSON parsing fails on application/json content", () => { + expect(parseJsonOrPlain("read-write", "application/json", z.string())).toBe("read-write"); + }); + + it("falls back to a bare string when application/json carries a charset and the body is bare text", () => { + expect(parseJsonOrPlain("read-write", "application/json; charset=utf-8", z.string())).toBe( + "read-write", + ); + }); + + it("surfaces the bare-string fallback through the schema when the shape disagrees", () => { const error = captureThrown(() => - parseJsonOrPlain("{ not json }", "application/json", Person, { source: "fixture" }), + parseJsonOrPlain("read-write", "application/json", Person, { source: "fixture" }), ); - assert(error instanceof ConfigError, "expected ConfigError"); - expect(error.message).toContain("fixture: invalid JSON: "); + assert(error instanceof ValidationError, "expected ValidationError"); + expect(error.message).toBe("fixture: value did not match expected schema"); + expect(error.developerDetail).toEqual({ + source: "fixture", + zodIssues: [ + { + code: "invalid_type", + expected: "object", + path: [], + message: "Invalid input: expected object, received string", + }, + ], + }); }); it("rejects schema mismatches on plain-text content with ValidationError", () => { diff --git a/src/runtime/json.ts b/src/runtime/json.ts index 5d6941c..62cfdca 100644 --- a/src/runtime/json.ts +++ b/src/runtime/json.ts @@ -2,6 +2,8 @@ import type { ZodType } from "zod"; import { ConfigError, errorMessage, ValidationError } from "../core/errors"; +const JSON_CONTENT_TYPE = "application/json"; + export interface ParseJsonOptions { source?: string; } @@ -46,15 +48,32 @@ export function parseJsonResult( return { ok: true, value: parsed.data }; } -const JSON_CONTENT_TYPE = "application/json"; - +// 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`. export function parseJsonOrPlain( text: string, contentType: string | null, schema: ZodType, opts: ParseJsonOptions = {}, ): T { - const isJson = contentType !== null && contentType.includes(JSON_CONTENT_TYPE); - const payload = isJson ? text : JSON.stringify(text); - return parseJson(payload, schema, opts); + if (!isJsonContentType(contentType)) { + return parseJson(JSON.stringify(text), schema, opts); + } + const attempt = parseJsonResult(text, schema, opts); + if (attempt.ok) { + return attempt.value; + } + if (attempt.error instanceof ValidationError) { + throw attempt.error; + } + return parseJson(JSON.stringify(text), schema, opts); +} + +function isJsonContentType(contentType: string | null): boolean { + return contentType !== null && contentType.includes(JSON_CONTENT_TYPE); } diff --git a/tests/e2e/setting.e2e.test.ts b/tests/e2e/setting.e2e.test.ts index a890392..d764775 100644 --- a/tests/e2e/setting.e2e.test.ts +++ b/tests/e2e/setting.e2e.test.ts @@ -1,10 +1,15 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { z } from "zod"; import { SettingListEnvelope } from "../../src/commands/setting/list"; import { createClient, type Client } from "../../src/core/http/client"; import { SettingValue } from "../../src/domain/setting"; import { parseJson } from "../../src/runtime/json"; +const IntegerSettingValue = SettingValue.extend({ value: z.number().int() }); +const NumberSettingValue = SettingValue.extend({ value: z.number() }); +const StringArraySettingValue = SettingValue.extend({ value: z.array(z.string()) }); + import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; @@ -219,9 +224,8 @@ describe("setting e2e", () => { }); it("get --json on a string-valued setting wraps the bare server response", async () => { - const STRING_KEY = "site-name"; - const ORIGINAL = "Metabase"; - const TARGET = "metabase-cli e2e site name"; + const STRING_KEY = "remote-sync-type"; + const TARGET = "read-write"; try { await adminClient.requestRaw(`/api/setting/${STRING_KEY}`, { method: "PUT", @@ -243,12 +247,94 @@ describe("setting e2e", () => { } finally { await adminClient.requestRaw(`/api/setting/${STRING_KEY}`, { method: "PUT", - body: { value: ORIGINAL }, + body: { value: null }, expectContentType: "binary", }); } }); + 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"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SettingValue)).toEqual({ + key: "admin-email", + value: bootstrap.admin.email, + }); + }); + + it("get --json on an integer setting (active-users-count) returns a JSON number", async () => { + const result = await runCli({ + args: ["setting", "get", "active-users-count", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const parsed = parseJson(result.stdout, IntegerSettingValue); + expect(parsed.key).toBe("active-users-count"); + expect(parsed.value).toBeGreaterThanOrEqual(1); + }); + + it("get --json on a float setting (startup-time-millis) returns a JSON number", async () => { + const result = await runCli({ + args: ["setting", "get", "startup-time-millis", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const parsed = parseJson(result.stdout, NumberSettingValue); + expect(parsed.key).toBe("startup-time-millis"); + expect(parsed.value).toBeGreaterThan(0); + }); + + it("get --json on a JSON-object setting (custom-geojson) returns the parsed object", async () => { + const result = await runCli({ + args: ["setting", "get", "custom-geojson", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, SettingValue)).toEqual({ + key: "custom-geojson", + value: { + us_states: { + name: "United States", + url: "app/assets/geojson/us-states.json", + region_key: "STATE", + region_name: "NAME", + builtin: true, + }, + world_countries: { + name: "World", + url: "app/assets/geojson/world.json", + region_key: "ISO_A2", + region_name: "NAME", + builtin: true, + }, + }, + }); + }); + + it("get --json on a JSON-array setting (available-fonts) returns the parsed array", async () => { + const result = await runCli({ + args: ["setting", "get", "available-fonts", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const parsed = parseJson(result.stdout, StringArraySettingValue); + expect(parsed.key).toBe("available-fonts"); + expect(parsed.value).toEqual(expect.arrayContaining(["Lato", "Roboto"])); + }); + it("get with an invalid setting key (regex fail) fails with ConfigError", async () => { const result = await runCli({ args: ["setting", "get", "..bad..", "--json"], From 5296e09c7df21b9e62fe809ba11e647ec804cf84 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Mon, 11 May 2026 20:15:31 -0400 Subject: [PATCH 36/47] fix settings --- src/core/errors.test.ts | 113 ++++++++++++++++++++----------- src/core/errors.ts | 35 +++++++--- src/core/json-pointer.test.ts | 21 ++++++ src/core/json-pointer.ts | 7 ++ src/core/schema/validate.ts | 4 +- src/output/error.test.ts | 25 ++++++- src/output/types.ts | 4 +- src/runtime/paginate.test.ts | 27 ++++++++ src/runtime/paginate.ts | 10 ++- tests/e2e/collection.e2e.test.ts | 28 ++++++++ 10 files changed, 215 insertions(+), 59 deletions(-) create mode 100644 src/core/json-pointer.test.ts create mode 100644 src/core/json-pointer.ts diff --git a/src/core/errors.test.ts b/src/core/errors.test.ts index 9bc977b..3c11f0b 100644 --- a/src/core/errors.test.ts +++ b/src/core/errors.test.ts @@ -132,62 +132,93 @@ describe("formatZodIssue", () => { }); }); -describe("ValidationError.userMessage", () => { - it("appends one bullet per zod issue with the offending path and the verbose hint", () => { +function issueLine(index: number): string { + return ` /${index}: Invalid input: expected number, received string`; +} + +describe("ValidationError userMessage formatting", () => { + it("appends a JSON-pointer path and the zod issue text for a single issue", () => { + const schema = z.object({ total: z.number() }); + const result = schema.safeParse({ total: null }); + if (result.success) { + throw new Error("expected zod failure"); + } const error = new ValidationError( - "https://mb.example/api/collection/8/items: value did not match expected schema", + "api/collection/8/items: value did not match expected schema", { - source: "https://mb.example/api/collection/8/items", - zodIssues: [ - { - code: "invalid_type", - expected: "boolean", - path: ["data", 3, "archived"], - message: "Expected boolean, received null", - input: null, - }, - { - code: "invalid_type", - expected: "string", - path: ["data", 7, "display"], - message: "Expected string, received null", - input: null, - }, - ], + source: "api/collection/8/items", + zodIssues: result.error.issues, }, ); + + expect(error.message).toBe("api/collection/8/items: value did not match expected schema"); + expect(error.userMessage).toBe( + "api/collection/8/items: value did not match expected schema\n" + + " /total: Invalid input: expected number, received null", + ); + }); + + it("renders one line per issue with array indices in the pointer", () => { + const schema = z.object({ items: z.array(z.object({ id: z.number() })) }); + const result = schema.safeParse({ items: [{ id: 1 }, { id: "bad" }] }); + if (result.success) { + throw new Error("expected zod failure"); + } + const error = new ValidationError("source: value did not match expected schema", { + source: "source", + zodIssues: result.error.issues, + }); + expect(error.userMessage).toBe( - "https://mb.example/api/collection/8/items: value did not match expected schema (2 issues)\n" + - " - data[3].archived: Expected boolean, received null\n" + - " - data[7].display: Expected string, received null\n" + - " Set METABASE_VERBOSE=1 for the full developer detail.", + "source: value did not match expected schema\n" + + " /items/1/id: Invalid input: expected number, received string", ); }); - it("caps the inline issue preview at 5 and reports the overflow count", () => { - const zodIssues = Array.from({ length: 7 }, (_unused, index) => ({ - code: "invalid_type" as const, - expected: "boolean" as const, - path: ["data", index, "archived"], - message: "Expected boolean, received null", - input: null, - })); + it("escapes JSON Pointer reserved characters in property names", () => { + const schema = z.object({ "weird/key~with-special": z.string() }); + const result = schema.safeParse({ "weird/key~with-special": 42 }); + if (result.success) { + throw new Error("expected zod failure"); + } const error = new ValidationError("source: value did not match expected schema", { source: "source", - zodIssues, + zodIssues: result.error.issues, }); + expect(error.userMessage).toBe( - "source: value did not match expected schema (7 issues)\n" + - " - data[0].archived: Expected boolean, received null\n" + - " - data[1].archived: Expected boolean, received null\n" + - " - data[2].archived: Expected boolean, received null\n" + - " - data[3].archived: Expected boolean, received null\n" + - " - data[4].archived: Expected boolean, received null\n" + - " …and 2 more\n" + - " Set METABASE_VERBOSE=1 for the full developer detail.", + "source: value did not match expected schema\n" + + " /weird~1key~0with-special: Invalid input: expected string, received number", ); }); + it("caps the printed issue list at ten and reports the overflow count", () => { + const schema = z.array(z.number()); + const result = schema.safeParse(Array.from({ length: 13 }, (_unused, index) => `bad-${index}`)); + if (result.success) { + throw new Error("expected zod failure"); + } + const error = new ValidationError("source: value did not match expected schema", { + source: "source", + zodIssues: result.error.issues, + }); + + expect(error.userMessage.split("\n")).toEqual([ + "source: value did not match expected schema", + issueLine(0), + issueLine(1), + issueLine(2), + issueLine(3), + issueLine(4), + issueLine(5), + issueLine(6), + issueLine(7), + issueLine(8), + issueLine(9), + " ... and 3 more", + ]); + }); + it("falls back to the plain message when developerDetail carries no issues", () => { const error = new ValidationError("file: malformed", { source: "file", diff --git a/src/core/errors.ts b/src/core/errors.ts index 076db21..553acd8 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,6 +1,8 @@ import { isCancel } from "@clack/prompts"; import { core as zodCore, ZodError } from "zod"; +import { escapeJsonPointerSegment } from "./json-pointer"; + export const VERBOSE_ENV = "METABASE_VERBOSE"; export type ErrorCategory = @@ -70,8 +72,6 @@ export class TimeoutError extends MetabaseError { } } -const VALIDATION_ISSUE_PREVIEW_LIMIT = 5; - export class ValidationError extends MetabaseError { readonly category = "validation"; readonly isRetryable = false; @@ -89,15 +89,30 @@ export class ValidationError extends MetabaseError { if (issues.length === 0) { return this.message; } - const shown = issues.slice(0, VALIDATION_ISSUE_PREVIEW_LIMIT); - const lines = shown.map((issue) => ` - ${formatZodIssue(issue)}`); - const trailer = - issues.length > shown.length - ? `\n …and ${issues.length - shown.length} more` - : ""; - const hint = `\n Set ${VERBOSE_ENV}=1 for the full developer detail.`; - return `${this.message} (${issues.length} issue${issues.length === 1 ? "" : "s"})\n${lines.join("\n")}${trailer}${hint}`; + return `${this.message}\n${formatZodIssueList(issues)}`; + } +} + +const MAX_ISSUES_PRINTED = 10; + +function formatZodIssueList(issues: ZodError["issues"]): string { + const head = issues.slice(0, MAX_ISSUES_PRINTED).map(formatZodIssueLine); + const overflow = issues.length - MAX_ISSUES_PRINTED; + if (overflow > 0) { + head.push(` ... and ${overflow} more`); + } + return head.join("\n"); +} + +function formatZodIssueLine(issue: ZodError["issues"][number]): string { + return ` ${formatZodIssuePointer(issue.path)}: ${issue.message}`; +} + +function formatZodIssuePointer(path: ReadonlyArray): string { + if (path.length === 0) { + return "/"; } + return path.map((key) => `/${escapeJsonPointerSegment(key)}`).join(""); } export class ConfigError extends MetabaseError { diff --git a/src/core/json-pointer.test.ts b/src/core/json-pointer.test.ts new file mode 100644 index 0000000..d37b1ea --- /dev/null +++ b/src/core/json-pointer.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { escapeJsonPointerSegment } from "./json-pointer"; + +describe("escapeJsonPointerSegment", () => { + it("returns plain string keys unchanged when they contain no RFC 6901 reserved chars", () => { + expect(escapeJsonPointerSegment("data")).toBe("data"); + }); + + it("escapes tilde as ~0 before slash as ~1 so order-sensitivity matches RFC 6901", () => { + expect(escapeJsonPointerSegment("a~/b")).toBe("a~0~1b"); + }); + + it("renders numeric array indices as bare decimals without escaping", () => { + expect(escapeJsonPointerSegment(3)).toBe("3"); + }); + + it("stringifies symbol keys for safe rendering", () => { + expect(escapeJsonPointerSegment(Symbol("anonymous"))).toBe("Symbol(anonymous)"); + }); +}); diff --git a/src/core/json-pointer.ts b/src/core/json-pointer.ts new file mode 100644 index 0000000..e9d8cb3 --- /dev/null +++ b/src/core/json-pointer.ts @@ -0,0 +1,7 @@ +export function escapeJsonPointerSegment(key: PropertyKey): string { + if (typeof key === "number") { + return String(key); + } + const segment = typeof key === "symbol" ? key.toString() : key; + return segment.replaceAll("~", "~0").replaceAll("/", "~1"); +} diff --git a/src/core/schema/validate.ts b/src/core/schema/validate.ts index dd4f57e..7dc5af2 100644 --- a/src/core/schema/validate.ts +++ b/src/core/schema/validate.ts @@ -4,6 +4,7 @@ import type { ErrorObject, ValidateFunction } from "ajv"; import { z } from "zod"; import { isPlainObject } from "../../runtime/predicates"; +import { escapeJsonPointerSegment } from "../json-pointer"; import idSchema from "./data/schemas/common/id.json" with { type: "json" }; import parameterSchema from "./data/schemas/common/parameter.json" with { type: "json" }; @@ -146,8 +147,7 @@ function collectMessageOverrides(root: unknown): Map { return; } for (const key of Object.keys(node)) { - const segment = key.replace(/~/g, "~0").replace(/\//g, "~1"); - visit(node[key], `${path}/${segment}`); + visit(node[key], `${path}/${escapeJsonPointerSegment(key)}`); } } } diff --git a/src/output/error.test.ts b/src/output/error.test.ts index d3424f5..bb9d237 100644 --- a/src/output/error.test.ts +++ b/src/output/error.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; -import { AbortError, ConfigError, UnknownError } from "../core/errors"; +import { AbortError, ConfigError, UnknownError, ValidationError } from "../core/errors"; import { reportError } from "./error"; interface CapturedStreams { @@ -75,4 +76,26 @@ describe("reportError", () => { expect(streams.stderr).toBe("plain string\n"); expect(process.exitCode).toBe(1); }); + + it("prints the JSON-pointer issue path on the stderr line beneath the ValidationError header", () => { + const schema = z.object({ total: z.number() }); + const result = schema.safeParse({ total: null }); + if (result.success) { + throw new Error("expected zod failure"); + } + reportError( + new ValidationError( + "https://m.example.com/api/collection/8/items: value did not match expected schema", + { + source: "https://m.example.com/api/collection/8/items", + zodIssues: result.error.issues, + }, + ), + ); + expect(streams.stderr).toBe( + "https://m.example.com/api/collection/8/items: value did not match expected schema\n" + + " /total: Invalid input: expected number, received null\n", + ); + expect(process.exitCode).toBe(1); + }); }); diff --git a/src/output/types.ts b/src/output/types.ts index 0c16478..3ddc3f2 100644 --- a/src/output/types.ts +++ b/src/output/types.ts @@ -12,7 +12,7 @@ export interface TruncationInfo { export interface ListEnvelope { data: T[]; returned: number; - total?: number | undefined; + total?: number | null | undefined; limit?: number | undefined; truncated?: TruncationInfo | undefined; } @@ -21,7 +21,7 @@ export function listEnvelopeSchema(item: ZodType): ZodType return z.object({ data: z.array(item), returned: z.number().int().nonnegative(), - total: z.number().int().nonnegative().optional(), + total: z.number().int().nonnegative().nullable().optional(), limit: z.number().int().nonnegative().optional(), truncated: z .object({ diff --git a/src/runtime/paginate.test.ts b/src/runtime/paginate.test.ts index 1cc0c96..c389afc 100644 --- a/src/runtime/paginate.test.ts +++ b/src/runtime/paginate.test.ts @@ -214,6 +214,33 @@ describe("paginate", () => { const items = await collectPaginated(client, "/api/search", Card); expect(items).toEqual([{ id: 1, name: "a" }]); }); + + it("accepts total: null on an empty page without falling over (collection-items shape)", async () => { + const handle = makeFakeFetch([{ body: { data: [], total: null, models: ["card"] } }]); + const client = createClient(CONFIG, { fetchImpl: handle.fetch }); + + const items = await collectPaginated(client, "/api/collection/8/items", Card, { pageSize: 50 }); + + expect(items).toEqual([]); + expect(handle.calls).toHaveLength(1); + }); + + it("treats total: null as unknown total and continues paginating until a short page", async () => { + const handle = makeFakeFetch([ + { body: { data: [{ id: 1, name: "a" }], total: null } }, + { body: { data: [{ id: 2, name: "b" }], total: null } }, + { body: { data: [], total: null } }, + ]); + const client = createClient(CONFIG, { fetchImpl: handle.fetch }); + + const items = await collectPaginated(client, "/api/card", Card, { pageSize: 1 }); + + expect(items).toEqual([ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + ]); + expect(handle.calls).toHaveLength(3); + }); }); describe("paginate edge-case grid", () => { diff --git a/src/runtime/paginate.ts b/src/runtime/paginate.ts index 251dfb7..2dcb9b3 100644 --- a/src/runtime/paginate.ts +++ b/src/runtime/paginate.ts @@ -13,7 +13,7 @@ export interface PaginateOptions { export interface PaginatedEnvelope { data: T[]; - total?: number | undefined; + total?: number | null | undefined; limit?: number | undefined; offset?: number | undefined; } @@ -54,7 +54,11 @@ export async function* paginate( if (envelope.data.length < requested) { return; } - if (envelope.total !== undefined && offset + envelope.data.length >= envelope.total) { + if ( + envelope.total !== undefined && + envelope.total !== null && + offset + envelope.data.length >= envelope.total + ) { return; } @@ -79,7 +83,7 @@ function paginatedEnvelopeSchema(itemSchema: ZodType): ZodType { expect(result.stdout).toBe(""); }); + it("items on a freshly-created empty collection returns an empty envelope (server total: null)", async () => { + const configHome = await makeIsolatedConfigHome(); + const createResult = await runCli({ + args: ["collection", "create", "--json"], + stdin: JSON.stringify({ + name: `e2e_empty_collection_${Date.now()}`, + parent_id: E2E_COLLECTIONS.DEFAULT, + }), + configHome, + env: authEnv(), + }); + expect(createResult.exitCode, createResult.stderr).toBe(0); + const created = parseJson(createResult.stdout, Collection); + + const itemsResult = await runCli({ + args: ["collection", "items", String(created.id), "--json"], + configHome, + env: authEnv(), + }); + + expect(itemsResult.exitCode, itemsResult.stderr).toBe(0); + expect(parseJson(itemsResult.stdout, CollectionItemListEnvelope)).toEqual({ + data: [], + returned: 0, + total: 0, + }); + }); + it("items root surfaces the seeded collection at the root level with collection_id null", async () => { const result = await runCli({ args: ["collection", "items", "root", "--json"], From b88ecb1f5f304caed9ea7e3e330f235fdbbfd4a9 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Mon, 11 May 2026 20:45:37 -0400 Subject: [PATCH 37/47] fix --- README.md | 54 +++++++++++++++++++-------------- src/commands/table/get.ts | 28 +++++++++++++++-- src/domain/table.ts | 8 +++-- tests/e2e/table.e2e.test.ts | 60 ++++++++++++++++++++++++++++++++++--- 4 files changed, 119 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 2e83b69..5e50cff 100644 --- a/README.md +++ b/README.md @@ -271,39 +271,42 @@ metabase transform-job delete 1 --yes ## Databases -Read warehouse metadata from `/api/database`. The `db` group exposes the full database list, the per-database record, hydrated metadata (tables + fields rolled up in one response), schema and table inspection, and the two manual-sync triggers. +Read warehouse metadata from `/api/database`. The `db` group exposes the full database list, the per-database record, schema and table inspection, the two manual-sync triggers, and (rarely useful) full-warehouse rollup endpoints. `db` is aliased to `database`. +> **Agent traversal:** prefer the granular path — `db list` → `db schemas ` → `db schema-tables ` → `table get --include fields`. On a real warehouse (dozens of schemas, hundreds of tables, dozens of fields per table) the rollup commands (`db metadata`, `db get --include tables.fields`, `db list --include tables`) return megabytes of JSON and exhaust the agent context. Reach for them only on small/dev warehouses where you know the size up front. + ### `metabase db list` ```sh metabase db list metabase db list --json -metabase db list --include tables --full --json metabase db list --saved --json +metabase db list --include tables --full --json # rollup: every db with its full table list ``` -| Flag | Description | -| ------------------- | ------------------------------------------------------------------------------------------------------------- | -| `--include ` | Hydrate related entities. Currently only `tables` is supported (each database is returned with its `tables`). | -| `--saved` | Include the Saved Questions virtual database in the list. The virtual db has id `-1337` and no `engine`. | +| Flag | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--include ` | Hydrate related entities. Currently only `tables` is supported (each database is returned with its `tables`). On real warehouses this returns hundreds of table records per db — use the granular traversal instead. | +| `--saved` | Include the Saved Questions virtual database in the list. The virtual db has id `-1337` and no `engine`. | ### `metabase db get ` ```sh metabase db get 1 metabase db get 1 --json -metabase db get 1 --include tables.fields --full --json +metabase db get 1 --include tables --full --json # rollup: db + every table (compact) +metabase db get 1 --include tables.fields --full --json # rollup: db + every table + every field ``` -| Flag | Description | -| ------------------- | ------------------------------------------------------------- | -| `--include ` | Hydrate related entities. One of `tables` or `tables.fields`. | +| Flag | Description | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--include ` | Hydrate related entities. One of `tables` or `tables.fields`. `tables.fields` returns every column of every table in the database in one response — only safe on small/dev warehouses. For a real warehouse use `db schemas` → `db schema-tables` → `table get --include fields`. | ### `metabase db metadata ` -Equivalent to `GET /api/database/:id/metadata`: a single database with all its tables and fields rolled up in one response. Use this when an agent needs a one-shot warehouse introspection rather than the per-table `metabase table get --full`. +Equivalent to `GET /api/database/:id/metadata`: a single database with all its tables and fields rolled up in one response. This is the largest read in the `db` group — on a real warehouse the response will exceed the agent context. Use only when you know the database is small (a seeded dev instance, a sample db, a freshly-bootstrapped test fixture). For agent-driven introspection on a real warehouse, walk `db schemas` → `db schema-tables` → `table get --include fields` instead. ```sh metabase db metadata 1 --json --full --max-bytes 0 @@ -311,7 +314,7 @@ metabase db metadata 1 --json --full --max-bytes 0 ### `metabase db schemas ` -List the schemas in a database. Schemas with no tables are excluded. +List the schemas in a database. Schemas with no tables are excluded. Cheap and bounded — this is the right entry point for an agent walking a warehouse. ```sh metabase db schemas 1 @@ -320,7 +323,7 @@ metabase db schemas 1 --json ### `metabase db schema-tables ` -List the tables in a given schema, sorted by display name. +List the tables in one schema, sorted by display name. Returns compact projections without fields — pair with `table get --include fields` (or `table fields `) per table you actually need to introspect. ```sh metabase db schema-tables 1 public @@ -347,10 +350,12 @@ metabase db rescan-values 1 --json ## Tables -Inspect and edit warehouse tables via `/api/table`. +Inspect and edit warehouse tables via `/api/table`. For agent-driven field introspection, `table get --include fields` is the default — it returns the table plus its columns in a single bounded response. ### `metabase table list` +Returns every table in the chosen database (or across all databases) as a flat compact list — no fields, no per-table hydration. On a real warehouse with hundreds of tables this is still bounded (kilobytes), but `db schema-tables ` is the better starting point when you know the schema. + ```sh metabase table list metabase table list --db-id 1 --json @@ -362,28 +367,33 @@ metabase table list --db-id 1 --json ### `metabase table get ` -Returns the basic table record (no fields). Use `metabase table metadata ` when you want the rollup with fields/FKs/dimensions hydrated. +Returns the basic table record (no fields). Pass `--include fields` to route through `/api/table/:id/query_metadata` so the response carries the table's columns compact-projected as `fields` — this is the default agent path for field introspection. Use `metabase table fields ` if you only want the fields as a list envelope, or `metabase table metadata ` when you also need FKs and dimensions hydrated. ```sh metabase table get 42 metabase table get 42 --json +metabase table get 42 --include fields --json ``` -### `metabase table metadata ` +| Flag | Description | +| ------------------- | --------------------------------------------------------------------------------------------------- | +| `--include ` | Hydrate related entities. Currently only `fields` is supported (bundles compact-projected columns). | -`GET /api/table/:id/query_metadata`: the table with its fields, FKs, and dimensions hydrated. The agent-facing one-shot introspection for a single table. +### `metabase table fields ` + +List the fields on a table (a thin projection over `query_metadata.fields`). Use this when you want just the field array without the surrounding table metadata. ```sh -metabase table metadata 42 --json --full --max-bytes 0 +metabase table fields 42 +metabase table fields 42 --json ``` -### `metabase table fields ` +### `metabase table metadata ` -List the fields on a table (a thin projection over `query_metadata.fields`). +`GET /api/table/:id/query_metadata`: the table with its fields, FKs, dimensions, segments, and measures all hydrated. Heavier than `table get --include fields` — reach for it only when you actually need the FK / dimension / segment / measure data. ```sh -metabase table fields 42 -metabase table fields 42 --json +metabase table metadata 42 --json --full --max-bytes 0 ``` ### `metabase table update ` diff --git a/src/commands/table/get.ts b/src/commands/table/get.ts index 678611b..e62efea 100644 --- a/src/commands/table/get.ts +++ b/src/commands/table/get.ts @@ -1,25 +1,47 @@ -import { Table, tableView } from "../../domain/table"; +import { z } from "zod"; + +import { Table, TableQueryMetadata, tableView } from "../../domain/table"; import { renderItem } from "../../output/render"; +import { parseEnum } from "../../runtime/csv"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +const TableGetInclude = z.enum(["fields"]); + export default defineMetabaseCommand({ meta: { name: "get", - description: "Get a table by id (basic; use `table metadata` for hydrated fields)", + description: "Get a table by id; pass --include fields to bundle hydrated fields", }, args: { ...outputFlags, ...profileFlag, ...connectionFlags, + include: { + type: "string", + description: `Hydrate related entities: ${TableGetInclude.options.join("|")}`, + }, id: { type: "positional", description: "Table id", required: true }, }, outputSchema: Table, - examples: ["metabase table get 42", "metabase table get 42 --json"], + examples: [ + "metabase table get 42", + "metabase table get 42 --json", + "metabase table get 42 --include fields --json", + ], async run({ args, ctx, getClient }) { const id = parseId(args.id); + const include = parseEnum(args.include, TableGetInclude, "--include"); const client = await getClient(); + if (include === "fields") { + const table = await client.requestParsed( + TableQueryMetadata, + `/api/table/${id}/query_metadata`, + ); + renderItem(table, tableView, ctx); + return; + } const table = await client.requestParsed(Table, `/api/table/${id}`); renderItem(table, tableView, ctx); }, diff --git a/src/domain/table.ts b/src/domain/table.ts index f7b8952..d70aca1 100644 --- a/src/domain/table.ts +++ b/src/domain/table.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { Field } from "./field"; +import { Field, FieldCompact } from "./field"; import type { ResourceView } from "./view"; const TableEntityType = z.enum([ @@ -54,7 +54,11 @@ export const TableCompact = Table.pick({ db_id: true, schema: true, entity_type: true, -}).strip(); +}) + .strip() + .extend({ + fields: z.array(FieldCompact).optional(), + }); export type TableCompact = z.infer; export const tableView: ResourceView
= { diff --git a/tests/e2e/table.e2e.test.ts b/tests/e2e/table.e2e.test.ts index 13683da..a6b1851 100644 --- a/tests/e2e/table.e2e.test.ts +++ b/tests/e2e/table.e2e.test.ts @@ -155,6 +155,57 @@ describe("table e2e", () => { }); }); + it("get --include fields hydrates and projects them in compact form", async () => { + const result = await runCli({ + args: [ + "table", + "get", + String(E2E_TABLES.CUSTOMERS), + "--include", + "fields", + "--json", + "--max-bytes", + "0", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const parsed = parseJson(result.stdout, TableCompact); + const { fields, ...tableBody } = parsed; + const fieldNames = (fields ?? []).map((field) => field.name).toSorted(); + const allFieldsBelongToCustomersTable = (fields ?? []).every( + (field) => field.table_id === E2E_TABLES.CUSTOMERS, + ); + expect({ tableBody, fieldNames, allFieldsBelongToCustomersTable }).toEqual({ + tableBody: { + id: E2E_TABLES.CUSTOMERS, + name: "customers", + display_name: "Customers", + description: "Customer dimension; mixed types for sync coverage.", + db_id: E2E_DATABASES.WAREHOUSE, + schema: "public", + entity_type: "entity/GenericTable", + }, + fieldNames: CUSTOMERS_FIELD_NAMES, + allFieldsBelongToCustomersTable: true, + }); + }); + + it("get rejects an unknown --include value with ConfigError", async () => { + const result = await runCli({ + args: ["table", "get", String(E2E_TABLES.CUSTOMERS), "--include", "everything", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + 'invalid --include value: "everything" (expected one of: fields)', + ); + }); + it("get with a non-integer id fails fast with ConfigError", async () => { const result = await runCli({ args: ["table", "get", "not-a-number", "--json"], @@ -194,11 +245,12 @@ describe("table e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - const parsed = parseJson(result.stdout, Table); - const fieldNames = (parsed.fields ?? []).map((field) => field.name).toSorted(); + const parsed = parseJson(result.stdout, TableCompact); + const { fields, ...tableBody } = parsed; + const fieldNames = (fields ?? []).map((field) => field.name).toSorted(); - expect({ compact: TableCompact.parse(parsed), fieldNames }).toEqual({ - compact: { + expect({ tableBody, fieldNames }).toEqual({ + tableBody: { id: E2E_TABLES.CUSTOMERS, name: "customers", display_name: "Customers", From aa383a14eee99528ea59d1813f43ef47fd6a46ba Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Mon, 11 May 2026 21:13:41 -0400 Subject: [PATCH 38/47] commands fixes --- README.md | 8 + src/commands/dashboard/create.ts | 30 +- src/commands/dashboard/preflight.test.ts | 354 +++++++++++++++++++++++ src/commands/dashboard/preflight.ts | 123 ++++++++ src/commands/dashboard/update.ts | 6 +- src/core/errors.ts | 26 ++ tests/e2e/dashboard.e2e.test.ts | 152 +++++++++- 7 files changed, 690 insertions(+), 9 deletions(-) create mode 100644 src/commands/dashboard/preflight.test.ts create mode 100644 src/commands/dashboard/preflight.ts diff --git a/README.md b/README.md index 5e50cff..fa41b12 100644 --- a/README.md +++ b/README.md @@ -1375,6 +1375,14 @@ Agent discovery path: `metabase __manifest` lists every command's args and descr The bundled query schema is synced from a pinned `@metabase/representations` release via `bun run sync:representations`; CI guards against drift. +### Card-reference pre-flight in `dashboard create` / `dashboard update` + +Before either command sends anything, every positive `card_id` referenced from the body's `dashcards` array is checked against `GET /api/card/:id` in parallel (de-duplicated per id). Cards that don't exist, are archived, or aren't readable fail pre-flight: the CLI writes a `{ ok: false, errors: [{ path, message }] }` envelope to stdout (one entry per offending dashcard, `path` is a JSON pointer like `/dashcards/3/card_id`) and exits **2** with `dashboard card-reference pre-flight failed: N error(s) — fix the dashcard card_id values listed above` on stderr. No dashboard is created or modified on a pre-flight miss — this is the contract that prevents orphan dashboards when a stale spec references an archived or missing card. + +There is no `--skip-validate` escape hatch here. The pre-flight queries live server state (no bundled schema to drift from), so the only legitimate path on a pre-flight miss is to fix the input. + +If the chained `PUT /api/dashboard/:id` fails _after_ the create has already inserted the row (rare with pre-flight in place, but possible on a permission / 5xx / network failure mid-flight), the user-facing error is rewritten to `dashboard created but follow-up PUT /api/dashboard/ failed: ; dashcards not applied`, so the caller knows the orphan exists. Recovery: `dashboard update --body '{"dashcards":[...]}'` to retry the dashcards, or `dashboard update --body '{"archived":true}'` to archive the orphan. + ## UUIDs ### `metabase uuid` diff --git a/src/commands/dashboard/create.ts b/src/commands/dashboard/create.ts index 42094f1..8d017b2 100644 --- a/src/commands/dashboard/create.ts +++ b/src/commands/dashboard/create.ts @@ -10,9 +10,20 @@ import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { preflightDashcardCardReferences, wrapChainedDashboardWriteError } from "./preflight"; + export default defineMetabaseCommand({ - meta: { name: "create", description: "Create a dashboard from a JSON spec" }, - args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, + meta: { + name: "create", + description: + "Create a dashboard from a JSON spec; any positive card_id referenced from dashcards is pre-flight-validated against /api/card/:id (exists, not archived) before the dashboard is created", + }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + ...bodyInputFlags, + }, outputSchema: Dashboard, examples: [ "cat dashboard.json | metabase dashboard create", @@ -24,6 +35,7 @@ export default defineMetabaseCommand({ const body = await readBody({ flag: args.body, file: args.file }, DashboardCreateInput); const { dashcards, tabs, ...createOnly } = body; const client = await getClient(); + await preflightDashcardCardReferences(client, dashcards); const created = await client.requestParsed(Dashboard, "/api/dashboard", { method: "POST", body: createOnly, @@ -32,10 +44,14 @@ export default defineMetabaseCommand({ renderItem(created, dashboardView, ctx); return; } - const updated = await client.requestParsed(DashboardDetail, `/api/dashboard/${created.id}`, { - method: "PUT", - body: { dashcards, tabs }, - }); - renderItem(updated, dashboardView, ctx); + try { + const updated = await client.requestParsed(DashboardDetail, `/api/dashboard/${created.id}`, { + method: "PUT", + body: { dashcards, tabs }, + }); + renderItem(updated, dashboardView, ctx); + } catch (error) { + throw wrapChainedDashboardWriteError(error, created.id); + } }, }); diff --git a/src/commands/dashboard/preflight.test.ts b/src/commands/dashboard/preflight.test.ts new file mode 100644 index 0000000..871e955 --- /dev/null +++ b/src/commands/dashboard/preflight.test.ts @@ -0,0 +1,354 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ZodType } from "zod"; + +import { ChainedRequestError, ConfigError, NetworkError } from "../../core/errors"; +import type { Client, RequestOptions } from "../../core/http/client"; +import { HttpError } from "../../core/http/errors"; +import { Card } from "../../domain/card"; + +import { + collectDashcardCardReferences, + preflightDashcardCardReferences, + wrapChainedDashboardWriteError, +} from "./preflight"; + +function emptyPlan(): FakeClientPlan { + return { responses: new Map(), errors: new Map() }; +} + +function cardFixture(id: number, archived = false): Card { + return Card.parse({ + id, + name: `card-${id}`, + type: "question", + display: "table", + description: null, + archived, + query_type: "query", + database_id: 1, + table_id: null, + collection_id: null, + entity_id: null, + creator_id: 1, + dataset_query: {}, + visualization_settings: {}, + }); +} + +interface FakeClientPlan { + readonly responses: ReadonlyMap; + readonly errors: ReadonlyMap; +} + +type MetabaseLikeError = HttpError | NetworkError; + +function fakeClient(plan: FakeClientPlan): { client: Client; calls: string[] } { + const calls: string[] = []; + const client: Client = { + async requestParsed(schema: ZodType, path: string, _opts?: RequestOptions): Promise { + calls.push(path); + const failure = plan.errors.get(path); + if (failure !== undefined) { + throw failure; + } + const response = plan.responses.get(path); + if (response === undefined) { + throw new Error(`unexpected path: ${path}`); + } + return schema.parse(response); + }, + async requestRaw() { + throw new Error("not implemented in fake"); + }, + async requestStream() { + throw new Error("not implemented in fake"); + }, + }; + return { client, calls }; +} + +describe("collectDashcardCardReferences", () => { + it("returns an empty array when dashcards is undefined", () => { + expect(collectDashcardCardReferences(undefined)).toEqual([]); + }); + + it("returns an empty array when dashcards is empty", () => { + expect(collectDashcardCardReferences([])).toEqual([]); + }); + + it("collects positive card_ids with JSON-pointer paths preserving index order", () => { + const dashcards = [ + { id: -1, card_id: 42, row: 0, col: 0 }, + { id: -2, card_id: 17, row: 0, col: 1 }, + { id: -3, card_id: 42, row: 1, col: 0 }, + ]; + expect(collectDashcardCardReferences(dashcards)).toEqual([ + { cardId: 42, path: "/dashcards/0/card_id" }, + { cardId: 17, path: "/dashcards/1/card_id" }, + { cardId: 42, path: "/dashcards/2/card_id" }, + ]); + }); + + it("skips null, negative, zero, and missing card_id entries", () => { + const dashcards = [ + { id: -1, card_id: null, row: 0, col: 0 }, + { id: -2, card_id: -5, row: 0, col: 1 }, + { id: -3, card_id: 0, row: 1, col: 0 }, + { id: -4, row: 1, col: 1 }, + ]; + expect(collectDashcardCardReferences(dashcards)).toEqual([]); + }); + + it("skips malformed entries silently so the server stays the authority on shape", () => { + const dashcards = [ + { id: -1, card_id: 99 }, + "not an object", + 42, + null, + { id: -2, card_id: "not a number" }, + { id: -3, card_id: 7 }, + ]; + expect(collectDashcardCardReferences(dashcards)).toEqual([ + { cardId: 99, path: "/dashcards/0/card_id" }, + { cardId: 7, path: "/dashcards/5/card_id" }, + ]); + }); +}); + +describe("preflightDashcardCardReferences", () => { + let captured: string[]; + + beforeEach(() => { + captured = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + captured.push(String(chunk)); + return true; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns without making any HTTP calls when dashcards is undefined", async () => { + const { client, calls } = fakeClient(emptyPlan()); + await preflightDashcardCardReferences(client, undefined); + expect(calls).toEqual([]); + }); + + it("returns without making any HTTP calls when dashcards has no positive card_id", async () => { + const { client, calls } = fakeClient(emptyPlan()); + await preflightDashcardCardReferences(client, [ + { id: -1, card_id: null }, + { id: -2, card_id: -3 }, + ]); + expect(calls).toEqual([]); + }); + + it("returns without throwing when all referenced cards exist and are not archived", async () => { + const { client, calls } = fakeClient({ + responses: new Map([ + ["/api/card/42", cardFixture(42)], + ["/api/card/17", cardFixture(17)], + ]), + errors: new Map(), + }); + await preflightDashcardCardReferences(client, [ + { id: -1, card_id: 42 }, + { id: -2, card_id: 17 }, + ]); + expect(calls.toSorted()).toEqual(["/api/card/17", "/api/card/42"]); + expect(captured).toEqual([]); + }); + + it("deduplicates HTTP calls when the same card_id appears in multiple dashcards", async () => { + const { client, calls } = fakeClient({ + responses: new Map([["/api/card/42", cardFixture(42)]]), + errors: new Map(), + }); + await preflightDashcardCardReferences(client, [ + { id: -1, card_id: 42 }, + { id: -2, card_id: 42 }, + { id: -3, card_id: 42 }, + ]); + expect(calls).toEqual(["/api/card/42"]); + }); + + it("throws ConfigError with the archived card listed under its dashcard path", async () => { + const { client } = fakeClient({ + responses: new Map([["/api/card/134", cardFixture(134, true)]]), + errors: new Map(), + }); + const failure = preflightDashcardCardReferences(client, [{ id: -1, card_id: 134 }]); + await expect(failure).rejects.toBeInstanceOf(ConfigError); + await expect(failure).rejects.toThrow( + "dashboard card-reference pre-flight failed: 1 error(s) — fix the dashcard card_id values listed above", + ); + expect(captured.join("")).toBe( + `${JSON.stringify( + { ok: false, errors: [{ path: "/dashcards/0/card_id", message: "card 134 is archived" }] }, + null, + 2, + )}\n`, + ); + }); + + it("emits one envelope entry per dashcard reference even when they share an archived card", async () => { + const { client } = fakeClient({ + responses: new Map([["/api/card/134", cardFixture(134, true)]]), + errors: new Map(), + }); + const failure = preflightDashcardCardReferences(client, [ + { id: -1, card_id: 134 }, + { id: -2, card_id: 134 }, + ]); + await expect(failure).rejects.toBeInstanceOf(ConfigError); + await expect(failure).rejects.toThrow( + "dashboard card-reference pre-flight failed: 2 error(s) — fix the dashcard card_id values listed above", + ); + expect(captured.join("")).toBe( + `${JSON.stringify( + { + ok: false, + errors: [ + { path: "/dashcards/0/card_id", message: "card 134 is archived" }, + { path: "/dashcards/1/card_id", message: "card 134 is archived" }, + ], + }, + null, + 2, + )}\n`, + ); + }); + + it("reports a missing card_id as 'card N not found' when /api/card/:id returns 404", async () => { + const notFound = new HttpError({ + status: 404, + statusText: "Not Found", + method: "GET", + url: "https://example.com/api/card/9999", + responseHeaders: { "content-type": "application/json" }, + rawBody: '{"message":"Not found"}', + }); + const { client } = fakeClient({ + responses: new Map(), + errors: new Map([["/api/card/9999", notFound]]), + }); + const failure = preflightDashcardCardReferences(client, [{ id: -1, card_id: 9999 }]); + await expect(failure).rejects.toBeInstanceOf(ConfigError); + await expect(failure).rejects.toThrow( + "dashboard card-reference pre-flight failed: 1 error(s) — fix the dashcard card_id values listed above", + ); + expect(captured.join("")).toBe( + `${JSON.stringify( + { ok: false, errors: [{ path: "/dashcards/0/card_id", message: "card 9999 not found" }] }, + null, + 2, + )}\n`, + ); + }); + + it("reports a permission-denied card as not readable with the original message", async () => { + const forbidden = new HttpError({ + status: 403, + statusText: "Forbidden", + method: "GET", + url: "https://example.com/api/card/55", + responseHeaders: { "content-type": "application/json" }, + rawBody: '{"message":"You do not have permissions to do that."}', + }); + const { client } = fakeClient({ + responses: new Map(), + errors: new Map([["/api/card/55", forbidden]]), + }); + const failure = preflightDashcardCardReferences(client, [{ id: -1, card_id: 55 }]); + await expect(failure).rejects.toBeInstanceOf(ConfigError); + await expect(failure).rejects.toThrow( + "dashboard card-reference pre-flight failed: 1 error(s) — fix the dashcard card_id values listed above", + ); + expect(captured.join("")).toBe( + `${JSON.stringify( + { + ok: false, + errors: [ + { + path: "/dashcards/0/card_id", + message: "card 55 is not readable: You do not have permissions to do that.", + }, + ], + }, + null, + 2, + )}\n`, + ); + }); + + it("propagates non-HTTP errors so the user sees a 5xx / network failure verbatim", async () => { + const network = new NetworkError("Could not reach Metabase: connect ECONNREFUSED", { + method: "GET", + url: "https://example.com/api/card/1", + cause: "connect ECONNREFUSED", + }); + const { client } = fakeClient({ + responses: new Map(), + errors: new Map([["/api/card/1", network]]), + }); + await expect(preflightDashcardCardReferences(client, [{ id: -1, card_id: 1 }])).rejects.toBe( + network, + ); + expect(captured).toEqual([]); + }); +}); + +describe("wrapChainedDashboardWriteError", () => { + it("returns the original value unchanged for non-MetabaseError inputs", () => { + const raw = new TypeError("unexpected"); + expect(wrapChainedDashboardWriteError(raw, 7)).toBe(raw); + }); + + it("wraps an HttpError into a new HttpError preserving status + sanitized body but rewriting userMessage", () => { + const original = new HttpError({ + status: 400, + statusText: "Bad Request", + method: "PUT", + url: "https://example.com/api/dashboard/7", + responseHeaders: { "content-type": "application/json" }, + rawBody: '{"message":"The object has been archived."}', + }); + const wrapped = wrapChainedDashboardWriteError(original, 7); + expect(wrapped).toBeInstanceOf(HttpError); + if (!(wrapped instanceof HttpError)) { + throw new Error("expected HttpError"); + } + expect(wrapped.status).toBe(400); + expect(wrapped.developerDetail.body).toBe('{"message":"The object has been archived."}'); + expect(wrapped.userMessage).toBe( + "dashboard 7 created but follow-up PUT /api/dashboard/7 failed: The object has been archived.; dashcards not applied", + ); + expect(wrapped.exitCode).toBe(1); + }); + + it("wraps a NetworkError into a ChainedRequestError carrying category, exitCode, and developerDetail", () => { + const original = new NetworkError("Could not reach Metabase: socket hang up", { + method: "PUT", + url: "https://example.com/api/dashboard/9", + cause: "socket hang up", + }); + const wrapped = wrapChainedDashboardWriteError(original, 9); + expect(wrapped).toBeInstanceOf(ChainedRequestError); + if (!(wrapped instanceof ChainedRequestError)) { + throw new Error("expected ChainedRequestError"); + } + expect(wrapped.userMessage).toBe( + "dashboard 9 created but follow-up PUT /api/dashboard/9 failed: Could not reach Metabase: socket hang up; dashcards not applied", + ); + expect(wrapped.category).toBe("network"); + expect(wrapped.exitCode).toBe(1); + expect(wrapped.isRetryable).toBe(true); + expect(wrapped.developerDetail).toEqual({ + method: "PUT", + url: "https://example.com/api/dashboard/9", + cause: "socket hang up", + }); + }); +}); diff --git a/src/commands/dashboard/preflight.ts b/src/commands/dashboard/preflight.ts new file mode 100644 index 0000000..6e2efa0 --- /dev/null +++ b/src/commands/dashboard/preflight.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; + +import { ChainedRequestError, ConfigError, MetabaseError } from "../../core/errors"; +import type { Client } from "../../core/http/client"; +import { HttpError } from "../../core/http/errors"; +import { ValidationIssue, ValidationOutcome } from "../../core/schema/validate"; +import { Card } from "../../domain/card"; +import { writeJson } from "../../output/render"; + +const PreflightDashcard = z.looseObject({ + card_id: z.number().int().nullable().optional(), +}); + +interface CardReference { + cardId: number; + path: string; +} + +type CardCheck = { status: "ok" } | { status: "error"; message: string }; + +export function collectDashcardCardReferences( + dashcards: ReadonlyArray | undefined, +): CardReference[] { + if (dashcards === undefined) { + return []; + } + const refs: CardReference[] = []; + dashcards.forEach((dashcard, index) => { + const parsed = PreflightDashcard.safeParse(dashcard); + if (!parsed.success) { + return; + } + const cardId = parsed.data.card_id; + if (typeof cardId === "number" && cardId > 0) { + refs.push({ cardId, path: `/dashcards/${index}/card_id` }); + } + }); + return refs; +} + +export async function preflightDashcardCardReferences( + client: Client, + dashcards: ReadonlyArray | undefined, +): Promise { + const references = collectDashcardCardReferences(dashcards); + if (references.length === 0) { + return; + } + const grouped = new Map(); + for (const ref of references) { + const list = grouped.get(ref.cardId); + if (list === undefined) { + grouped.set(ref.cardId, [ref]); + } else { + list.push(ref); + } + } + const checks = await Promise.all( + Array.from(grouped.entries()).map(async ([cardId, refs]) => ({ + refs, + result: await classifyCardReference(client, cardId), + })), + ); + const errors: ValidationIssue[] = []; + for (const check of checks) { + if (check.result.status === "ok") { + continue; + } + for (const ref of check.refs) { + errors.push({ path: ref.path, message: check.result.message }); + } + } + if (errors.length === 0) { + return; + } + const outcome: ValidationOutcome = { ok: false, errors }; + writeJson(outcome); + throw new ConfigError( + `dashboard card-reference pre-flight failed: ${errors.length} error(s) — fix the dashcard card_id values listed above`, + ); +} + +export function wrapChainedDashboardWriteError(error: unknown, dashboardId: number): unknown { + if (!(error instanceof MetabaseError)) { + return error; + } + const prefix = `dashboard ${dashboardId} created but follow-up PUT /api/dashboard/${dashboardId} failed`; + const suffix = "dashcards not applied"; + const message = `${prefix}: ${error.userMessage}; ${suffix}`; + if (error instanceof HttpError) { + return new HttpError({ + status: error.status, + statusText: error.developerDetail.statusText, + method: error.developerDetail.method, + url: error.developerDetail.url, + responseHeaders: error.developerDetail.responseHeaders, + rawBody: error.developerDetail.body, + overrideUserMessage: message, + }); + } + return new ChainedRequestError(message, error); +} + +async function classifyCardReference(client: Client, cardId: number): Promise { + try { + const card = await client.requestParsed(Card, `/api/card/${cardId}`); + if (card.archived) { + return { status: "error", message: `card ${cardId} is archived` }; + } + return { status: "ok" }; + } catch (error) { + if (!(error instanceof HttpError)) { + throw error; + } + if (error.status === 404) { + return { status: "error", message: `card ${cardId} not found` }; + } + if (error.status === 401 || error.status === 403) { + return { status: "error", message: `card ${cardId} is not readable: ${error.userMessage}` }; + } + throw error; + } +} diff --git a/src/commands/dashboard/update.ts b/src/commands/dashboard/update.ts index 4641fa2..b2995ca 100644 --- a/src/commands/dashboard/update.ts +++ b/src/commands/dashboard/update.ts @@ -6,10 +6,13 @@ import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { preflightDashcardCardReferences } from "./preflight"; + export default defineMetabaseCommand({ meta: { name: "update", - description: "Update a dashboard (and optionally its dashcards/tabs) by id", + description: + "Update a dashboard (and optionally its dashcards/tabs) by id; any positive card_id referenced from dashcards is pre-flight-validated against /api/card/:id (exists, not archived) before the PUT", }, args: { ...outputFlags, @@ -29,6 +32,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, DashboardUpdateInput); const client = await getClient(); + await preflightDashcardCardReferences(client, body.dashcards); const updated = await client.requestParsed(DashboardDetail, `/api/dashboard/${id}`, { method: "PUT", body, diff --git a/src/core/errors.ts b/src/core/errors.ts index 553acd8..3783c29 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -139,6 +139,32 @@ export class AbortError extends MetabaseError { } } +export class ChainedRequestError extends MetabaseError { + override readonly cause: MetabaseError; + + constructor(message: string, cause: MetabaseError) { + super(message); + this.name = "ChainedRequestError"; + this.cause = cause; + } + + override get category(): ErrorCategory { + return this.cause.category; + } + + override get isRetryable(): boolean { + return this.cause.isRetryable; + } + + override get exitCode(): number { + return this.cause.exitCode; + } + + override get developerDetail(): unknown { + return this.cause.developerDetail; + } +} + export class UnknownError extends MetabaseError { readonly category = "unknown"; readonly isRetryable = false; diff --git a/tests/e2e/dashboard.e2e.test.ts b/tests/e2e/dashboard.e2e.test.ts index f0e1b72..47b95a3 100644 --- a/tests/e2e/dashboard.e2e.test.ts +++ b/tests/e2e/dashboard.e2e.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { DashcardListEnvelope } from "../../src/commands/dashboard/cards"; import { DashboardListEnvelope } from "../../src/commands/dashboard/list"; +import { ValidationOutcome } from "../../src/core/schema/validate"; +import { CardCompact } from "../../src/domain/card"; import { Dashboard, DashboardCompact, @@ -13,7 +15,13 @@ import { parseJson } from "../../src/runtime/json"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { E2E_CARDS, E2E_COLLECTIONS, E2E_DASHBOARDS, E2E_DASHCARDS } from "./seed/ids"; +import { + E2E_COLLECTIONS, + E2E_CARDS, + E2E_DASHBOARDS, + E2E_DASHCARDS, + E2E_DATABASES, +} from "./seed/ids"; const ORDERS_OVERVIEW_NAME = "Orders Overview"; const ORDERS_OVERVIEW_DESCRIPTION = "E2E seeded dashboard with one orders dashcard."; @@ -68,6 +76,46 @@ describe("dashboard e2e", () => { }; } + async function createScratchCard(name: string): Promise { + const result = await runCli({ + args: ["card", "create", "--json"], + stdin: JSON.stringify({ + name, + display: "table", + visualization_settings: {}, + collection_id: E2E_COLLECTIONS.DEFAULT, + dataset_query: { + type: "native", + database: E2E_DATABASES.WAREHOUSE, + native: { query: "SELECT 1 AS x" }, + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(result.exitCode, result.stderr).toBe(0); + return parseJson(result.stdout, CardCompact).id; + } + + function singleDashcardBody(name: string, cardId: number) { + return { + name, + collection_id: E2E_COLLECTIONS.DEFAULT, + dashcards: [ + { + id: -1, + card_id: cardId, + row: 0, + col: 0, + size_x: 12, + size_y: 6, + parameter_mappings: [], + visualization_settings: {}, + }, + ], + }; + } + it("list includes the seeded Orders Overview dashboard with no archived rows", async () => { const result = await runCli({ args: ["dashboard", "list", "--json"], @@ -364,6 +412,108 @@ describe("dashboard e2e", () => { expect(result.stdout).toBe(""); }); + it("create with a non-existent card_id fails preflight and does not create a dashboard", async () => { + const missingCardId = 999_999_999; + const dashboardName = "e2e_dashboard_preflight_missing"; + + const result = await runCli({ + args: ["dashboard", "create", "--json"], + stdin: JSON.stringify(singleDashcardBody(dashboardName, missingCardId)), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "dashboard card-reference pre-flight failed: 1 error(s) — fix the dashcard card_id values listed above", + ); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/dashcards/0/card_id", message: `card ${missingCardId} not found` }], + }); + + const search = await runCli({ + args: ["search", dashboardName, "--models", "dashboard", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(search.exitCode, search.stderr).toBe(0); + expect(search.stdout).not.toContain(dashboardName); + }); + + it("create with an archived card_id fails preflight with the archived diagnostic", async () => { + const newCardId = await createScratchCard("e2e_preflight_card_to_archive"); + const archive = await runCli({ + args: ["card", "archive", String(newCardId)], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(archive.exitCode, archive.stderr).toBe(0); + + const result = await runCli({ + args: ["dashboard", "create", "--json"], + stdin: JSON.stringify(singleDashcardBody("e2e_dashboard_preflight_archived", newCardId)), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "dashboard card-reference pre-flight failed: 1 error(s) — fix the dashcard card_id values listed above", + ); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/dashcards/0/card_id", message: `card ${newCardId} is archived` }], + }); + }); + + it("update with an archived card_id fails preflight and does not touch the dashboard", async () => { + const newCardId = await createScratchCard("e2e_preflight_update_card"); + const archive = await runCli({ + args: ["card", "archive", String(newCardId)], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(archive.exitCode, archive.stderr).toBe(0); + + const beforeGet = await runCli({ + args: ["dashboard", "get", String(E2E_DASHBOARDS.ORDERS_OVERVIEW), "--json", "--full"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(beforeGet.exitCode, beforeGet.stderr).toBe(0); + const beforeDetail = parseJson(beforeGet.stdout, DashboardDetail); + + const result = await runCli({ + args: ["dashboard", "update", String(E2E_DASHBOARDS.ORDERS_OVERVIEW), "--json"], + stdin: JSON.stringify({ + dashcards: singleDashcardBody("ignored", newCardId).dashcards, + tabs: [], + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "dashboard card-reference pre-flight failed: 1 error(s) — fix the dashcard card_id values listed above", + ); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/dashcards/0/card_id", message: `card ${newCardId} is archived` }], + }); + + const afterGet = await runCli({ + args: ["dashboard", "get", String(E2E_DASHBOARDS.ORDERS_OVERVIEW), "--json", "--full"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(afterGet.exitCode, afterGet.stderr).toBe(0); + const afterDetail = parseJson(afterGet.stdout, DashboardDetail); + expect(afterDetail.dashcards).toEqual(beforeDetail.dashcards); + expect(afterDetail.tabs).toEqual(beforeDetail.tabs); + }); + it("update with a non-integer id fails fast with ConfigError", async () => { const result = await runCli({ args: ["dashboard", "update", "abc", "--json"], From 89f3505ca858738eec33b34f0e8f4689792112ad Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 12 May 2026 00:47:58 -0400 Subject: [PATCH 39/47] fix --- README.md | 4 ++- src/commands/query.ts | 17 +++++++-- src/commands/validate-query.ts | 10 ++---- src/core/schema/validate.test.ts | 46 ++++++++++++++++++++++++ src/core/schema/validate.ts | 33 +++++++++++++++++ tests/e2e/query.e2e.test.ts | 61 ++++++++++++++++++++++++++++++++ 6 files changed, 159 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index fa41b12..330d613 100644 --- a/README.md +++ b/README.md @@ -1350,7 +1350,9 @@ metabase query --file q.json --skip-validate # bypass pre-flight; let serve Body sources: `--file`, `--body`, or stdin (exactly one). Body is JSON. -`--skip-validate` is an escape hatch when the bundled schema disagrees with what the server actually accepts (drift, false negative, edge case). Validation is skipped entirely and the body is sent as-is. Mutually exclusive with `--dry-run` (which is itself the validation mode). +Legacy native bodies — `{ "type": "native", "database": N, "native": { "query": "..." } }` or any non-MBQL-5 body that carries a top-level `native:` key — skip MBQL 5 pre-flight automatically. The bundled schema only models MBQL 5, and `/api/dataset` accepts the legacy native shape as-is; the CLI detects it and routes straight to the server. `--dry-run` on a legacy native body emits `{ ok: true, errors: [] }` (no schema applies). The double-wrap footgun — an MBQL 5 query nested inside a `{type:"query", query:…}` envelope — is still rejected with a `ConfigError` before send. + +`--skip-validate` is an escape hatch when the bundled schema disagrees with what the server actually accepts (drift, false negative, edge case) for MBQL 5 bodies. Validation is skipped entirely and the body is sent as-is. Mutually exclusive with `--dry-run` (which is itself the validation mode). Exit codes: diff --git a/src/commands/query.ts b/src/commands/query.ts index 539bee6..15984af 100644 --- a/src/commands/query.ts +++ b/src/commands/query.ts @@ -2,7 +2,9 @@ import { z } from "zod"; import { ConfigError } from "../core/errors"; import { + assertNotLegacyEnvelopeWrappingMbql5, getQuerySchemaBundle, + isLegacyNativeQuery, validateExternalQuery, validateInternalQuery, } from "../core/schema/validate"; @@ -32,7 +34,7 @@ export default defineMetabaseCommand({ meta: { name: "query", description: - "Run an MBQL 5 query (validates against the bundled schema first); --print-schema emits the schema for agent discovery, --dry-run validates without sending. Default is internal MBQL (numeric IDs); pass --external for the representations / string-FK form. Every clause options object carries a `lib/uuid` (UUID v4); mint these via `metabase uuid` — never author them by hand.", + 'Run an MBQL 5 query (validates against the bundled schema first); --print-schema emits the schema for agent discovery, --dry-run validates without sending. Default is internal MBQL (numeric IDs); pass --external for the representations / string-FK form. Legacy native bodies ({type:"native", …} or any top-level `native:`) skip pre-flight automatically — the bundled schema only models MBQL 5. Every clause options object carries a `lib/uuid` (UUID v4); mint these via `metabase uuid` — never author them by hand.', }, args: { ...outputFlags, @@ -73,13 +75,19 @@ export default defineMetabaseCommand({ } const dryRun = args["dry-run"] === true; - const skipValidation = args["skip-validate"] === true; - if (dryRun && skipValidation) { + const explicitSkip = args["skip-validate"] === true; + if (dryRun && explicitSkip) { throw new ConfigError("--skip-validate cannot be combined with --dry-run"); } const body = await readBody({ flag: args.body, file: args.file }, QueryBody); + if (!explicitSkip) { + assertNotLegacyEnvelopeWrappingMbql5(body, { contextLabel: "query", bodyNoun: "the body" }); + } + + const skipValidation = explicitSkip || isLegacyNativeQuery(body); + if (!skipValidation) { const outcome = mode.validate(body); if (!outcome.ok) { @@ -91,6 +99,9 @@ export default defineMetabaseCommand({ writeJson(outcome); return; } + } else if (dryRun) { + writeJson({ ok: true, errors: [] }); + return; } const client = await getClient(); diff --git a/src/commands/validate-query.ts b/src/commands/validate-query.ts index 6576af5..b4a234a 100644 --- a/src/commands/validate-query.ts +++ b/src/commands/validate-query.ts @@ -1,6 +1,6 @@ import { ConfigError } from "../core/errors"; import { - isLegacyEnvelopeWrappingMbql5, + assertNotLegacyEnvelopeWrappingMbql5, isMbql5Query, validateInternalQuery, } from "../core/schema/validate"; @@ -28,13 +28,7 @@ export function preflightInternalMbql5Query( if (options.skip) { return; } - if (isLegacyEnvelopeWrappingMbql5(query)) { - throw new ConfigError( - `${contextLabel}: MBQL 5 query nested inside a legacy {type:"query", query:…} envelope. ` + - `For MBQL 5, dataset_query is the mbql/query value itself: ` + - `{"lib/type":"mbql/query", database:N, stages:[…]}.`, - ); - } + assertNotLegacyEnvelopeWrappingMbql5(query, { contextLabel, bodyNoun: "dataset_query" }); if (!isMbql5Query(query)) { return; } diff --git a/src/core/schema/validate.test.ts b/src/core/schema/validate.test.ts index a40ae12..751fc3f 100644 --- a/src/core/schema/validate.test.ts +++ b/src/core/schema/validate.test.ts @@ -6,6 +6,7 @@ import { clauseSlot1HintMessage, getQuerySchemaBundle, isLegacyEnvelopeWrappingMbql5, + isLegacyNativeQuery, isMbql5Query, validateExternalQuery, validateInternalQuery, @@ -155,6 +156,51 @@ describe("isLegacyEnvelopeWrappingMbql5", () => { }); }); +describe("isLegacyNativeQuery", () => { + it("returns true for a legacy MBQL 4 native body", () => { + expect( + isLegacyNativeQuery({ + type: "native", + database: 2, + native: { query: "SELECT 1" }, + }), + ).toBe(true); + }); + + it("returns true when a non-MBQL5 body carries a top-level native key without an explicit type", () => { + expect(isLegacyNativeQuery({ database: 2, native: { query: "SELECT 1" } })).toBe(true); + }); + + it("returns false for a well-formed MBQL 5 query even if a stray top-level native key is present", () => { + expect( + isLegacyNativeQuery({ + "lib/type": "mbql/query", + database: 1, + stages: [{ "lib/type": "mbql.stage/native", native: "SELECT 1" }], + native: "stray", + }), + ).toBe(false); + }); + + it("returns false for a legacy MBQL 4 structured envelope", () => { + expect(isLegacyNativeQuery({ type: "query", database: 2, query: { "source-table": 7 } })).toBe( + false, + ); + }); + + it("returns false for a plain MBQL 5 query with no native fields", () => { + expect(isLegacyNativeQuery(VALID_INTERNAL)).toBe(false); + }); + + it("returns false for non-objects, arrays, and null", () => { + expect(isLegacyNativeQuery(null)).toBe(false); + expect(isLegacyNativeQuery(undefined)).toBe(false); + expect(isLegacyNativeQuery("SELECT 1")).toBe(false); + expect(isLegacyNativeQuery(42)).toBe(false); + expect(isLegacyNativeQuery([{ type: "native" }])).toBe(false); + }); +}); + describe("ref-clause error messages", () => { it("rewrites 'must be string' on aggregation_ref's UUID slot and reports the cascading 'then' shape errors verbatim", () => { const outcome = validateInternalQuery({ diff --git a/src/core/schema/validate.ts b/src/core/schema/validate.ts index 7dc5af2..5c9203c 100644 --- a/src/core/schema/validate.ts +++ b/src/core/schema/validate.ts @@ -4,6 +4,7 @@ import type { ErrorObject, ValidateFunction } from "ajv"; import { z } from "zod"; import { isPlainObject } from "../../runtime/predicates"; +import { ConfigError } from "../errors"; import { escapeJsonPointerSegment } from "../json-pointer"; import idSchema from "./data/schemas/common/id.json" with { type: "json" }; @@ -255,6 +256,38 @@ export function isLegacyEnvelopeWrappingMbql5(value: unknown): boolean { return "lib/type" in inner && inner["lib/type"] === "mbql/query"; } +// MBQL 5 native lives inside a stage (`stages[*].native`), never at the top +// level — the `isMbql5Query` guard keeps a well-formed MBQL 5 body out of this +// branch even if it carries a stray top-level `native` field. +export function isLegacyNativeQuery(value: unknown): boolean { + if (!isPlainObject(value)) { + return false; + } + if (isMbql5Query(value)) { + return false; + } + return value["type"] === "native" || "native" in value; +} + +export interface LegacyEnvelopeAssertOptions { + readonly contextLabel: string; + readonly bodyNoun: string; +} + +export function assertNotLegacyEnvelopeWrappingMbql5( + value: unknown, + options: LegacyEnvelopeAssertOptions, +): void { + if (!isLegacyEnvelopeWrappingMbql5(value)) { + return; + } + throw new ConfigError( + `${options.contextLabel}: MBQL 5 query nested inside a legacy {type:"query", query:…} envelope. ` + + `For MBQL 5, ${options.bodyNoun} is the mbql/query value itself: ` + + `{"lib/type":"mbql/query", database:N, stages:[…]}.`, + ); +} + export const SchemaMode = z.enum(["external", "internal"]); export type SchemaMode = z.infer; diff --git a/tests/e2e/query.e2e.test.ts b/tests/e2e/query.e2e.test.ts index 15d060f..dead632 100644 --- a/tests/e2e/query.e2e.test.ts +++ b/tests/e2e/query.e2e.test.ts @@ -254,4 +254,65 @@ describe("query e2e", () => { expect(queryResult.data.rows).toHaveLength(3); } }); + + it("run with a legacy native body skips MBQL 5 pre-flight and executes against /api/dataset", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--json"], + stdin: JSON.stringify({ + type: "native", + database: E2E_DATABASES.WAREHOUSE, + native: { query: "SELECT 1 AS one, 2 AS two" }, + }), + configHome, + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const queryResult = parseJson(result.stdout, CardQueryResult); + expect(queryResult.status).toBe("completed"); + if (queryResult.status === "completed") { + expect(queryResult.row_count).toBe(1); + expect(queryResult.data.rows).toEqual([[1, 2]]); + } + }); + + it("--dry-run with a legacy native body returns ok and exits 0 (no schema applies)", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--dry-run"], + stdin: JSON.stringify({ + type: "native", + database: E2E_DATABASES.WAREHOUSE, + native: { query: "SELECT 1" }, + }), + configHome, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ ok: true, errors: [] }); + }); + + it('rejects the double-wrap footgun (MBQL 5 inside a legacy {type:"query"} envelope) with a ConfigError', async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--dry-run"], + stdin: JSON.stringify({ + type: "query", + database: E2E_DATABASES.WAREHOUSE, + query: { + "lib/type": "mbql/query", + database: E2E_DATABASES.WAREHOUSE, + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": E2E_TABLES.ORDERS }], + }, + }), + configHome, + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + 'query: MBQL 5 query nested inside a legacy {type:"query", query:…} envelope.', + ); + expect(result.stdout).toBe(""); + }); }); From a2b3e1e826f17f9328da320096335c074c731024 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 12 May 2026 01:54:26 -0400 Subject: [PATCH 40/47] fix --- tests/e2e/collection.e2e.test.ts | 43 ++++++++++++++++++++++++++++---- tests/e2e/seed/ids.ts | 18 ++++++------- tests/e2e/sync.e2e.test.ts | 6 ++--- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/tests/e2e/collection.e2e.test.ts b/tests/e2e/collection.e2e.test.ts index 5b9debc..23896ef 100644 --- a/tests/e2e/collection.e2e.test.ts +++ b/tests/e2e/collection.e2e.test.ts @@ -44,6 +44,31 @@ const TRASH_COMPACT = { is_personal: false, } as const; +const USAGE_ANALYTICS_COMPACT = { + id: 2, + name: "Usage analytics", + description: + "Your instance data. To customize these questions and dashboards, you can duplicate them and save them in the custom reports collection.", + archived: false, + location: "/", + parent_id: null, + type: "instance-analytics", + authority_level: null, + is_personal: false, +} as const; + +const CUSTOM_REPORTS_COMPACT = { + id: 3, + name: "Custom reports", + description: "Save your Metabase analytics custom questions and dashboards here", + archived: false, + location: "/2/", + parent_id: 2, + type: null, + authority_level: null, + is_personal: false, +} as const; + describe("collection e2e", () => { let bootstrap: E2EBootstrap; const tempDirs: string[] = []; @@ -78,9 +103,9 @@ describe("collection e2e", () => { expect(result.exitCode, result.stderr).toBe(0); expect(parseJson(result.stdout, CollectionListEnvelope)).toEqual({ - data: [ROOT_COMPACT, DEFAULT_COMPACT], - returned: 2, - total: 2, + data: [ROOT_COMPACT, CUSTOM_REPORTS_COMPACT, DEFAULT_COMPACT, USAGE_ANALYTICS_COMPACT], + returned: 4, + total: 4, }); }); @@ -405,9 +430,17 @@ describe("collection e2e", () => { archived: false, collection_id: null, }, + { + id: USAGE_ANALYTICS_COMPACT.id, + model: "collection", + name: USAGE_ANALYTICS_COMPACT.name, + description: USAGE_ANALYTICS_COMPACT.description, + archived: false, + collection_id: null, + }, ], - returned: 1, - total: 1, + returned: 2, + total: 2, }); }); diff --git a/tests/e2e/seed/ids.ts b/tests/e2e/seed/ids.ts index 5ba9960..037b94f 100644 --- a/tests/e2e/seed/ids.ts +++ b/tests/e2e/seed/ids.ts @@ -26,16 +26,16 @@ export const E2E_DASHCARDS = { } as const; export const E2E_TABLES = { - CUSTOMERS: 166, - DAILY_SALES: 165, - ORDER_ITEMS: 170, - ORDER_SUMMARY: 167, - ORDERS: 171, - PRODUCTS: 169, - REVIEWS: 168, + CUSTOMERS: 169, + DAILY_SALES: 168, + ORDER_ITEMS: 173, + ORDER_SUMMARY: 170, + ORDERS: 174, + PRODUCTS: 172, + REVIEWS: 171, } as const; export const E2E_FIELDS = { - CUSTOMERS_EMAIL: 1624, - ORDERS_ID: 1649, + CUSTOMERS_EMAIL: 1664, + ORDERS_ID: 1689, } as const; diff --git a/tests/e2e/sync.e2e.test.ts b/tests/e2e/sync.e2e.test.ts index 0c94bd5..2a38851 100644 --- a/tests/e2e/sync.e2e.test.ts +++ b/tests/e2e/sync.e2e.test.ts @@ -277,14 +277,14 @@ describe("sync e2e against EE remote-sync endpoints", () => { expect(result.stderr).toContain("Metabase returned 400"); }); - it("remove-collection surfaces a 400 HttpError in the default config (read-only or paywall)", async () => { + it("remove-collection is idempotent when the collection is not in the sync config", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["sync", "remove-collection", "1", "--json"], configHome, env: authEnv(), }); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Metabase returned 400"); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ success: true }); }); }); From a2ab47d16991b44107f57143fee912355d8e6ba2 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 12 May 2026 14:08:57 -0400 Subject: [PATCH 41/47] fixes --- src/commands/card/create.ts | 8 +- src/commands/card/update.ts | 8 +- src/commands/transform/create.ts | 8 +- src/commands/transform/update.ts | 8 +- src/commands/validate-query.test.ts | 49 ++++++--- src/commands/validate-query.ts | 21 +++- src/core/http/errors.test.ts | 159 ++++++++++++++++++++++++++++ src/core/http/errors.ts | 83 +++++++++++++-- 8 files changed, 315 insertions(+), 29 deletions(-) create mode 100644 src/core/http/errors.test.ts diff --git a/src/commands/card/create.ts b/src/commands/card/create.ts index 36ff4a9..9a7a7b0 100644 --- a/src/commands/card/create.ts +++ b/src/commands/card/create.ts @@ -4,7 +4,11 @@ import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { preflightInternalMbql5Query, skipValidateFlag } from "../validate-query"; +import { + CARD_DATASET_QUERY_LABELS, + preflightInternalMbql5Query, + skipValidateFlag, +} from "../validate-query"; export default defineMetabaseCommand({ meta: { @@ -28,7 +32,7 @@ export default defineMetabaseCommand({ ], async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, CardCreateInput); - preflightInternalMbql5Query(body.dataset_query, "card.dataset_query validation failed", { + preflightInternalMbql5Query(body.dataset_query, CARD_DATASET_QUERY_LABELS, { skip: args["skip-validate"] === true, }); const client = await getClient(); diff --git a/src/commands/card/update.ts b/src/commands/card/update.ts index a77330d..41edcd9 100644 --- a/src/commands/card/update.ts +++ b/src/commands/card/update.ts @@ -5,7 +5,11 @@ import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { preflightInternalMbql5Query, skipValidateFlag } from "../validate-query"; +import { + CARD_DATASET_QUERY_LABELS, + preflightInternalMbql5Query, + skipValidateFlag, +} from "../validate-query"; export default defineMetabaseCommand({ meta: { @@ -33,7 +37,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, CardUpdateInput); - preflightInternalMbql5Query(body.dataset_query, "card.dataset_query validation failed", { + preflightInternalMbql5Query(body.dataset_query, CARD_DATASET_QUERY_LABELS, { skip: args["skip-validate"] === true, }); const client = await getClient(); diff --git a/src/commands/transform/create.ts b/src/commands/transform/create.ts index 50fc1f0..cc95355 100644 --- a/src/commands/transform/create.ts +++ b/src/commands/transform/create.ts @@ -4,7 +4,11 @@ import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; -import { preflightInternalMbql5Query, skipValidateFlag } from "../validate-query"; +import { + TRANSFORM_SOURCE_QUERY_LABELS, + preflightInternalMbql5Query, + skipValidateFlag, +} from "../validate-query"; export default defineMetabaseCommand({ meta: { @@ -28,7 +32,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, TransformCreateInput); if (body.source.type === "query") { - preflightInternalMbql5Query(body.source.query, "transform.source.query validation failed", { + preflightInternalMbql5Query(body.source.query, TRANSFORM_SOURCE_QUERY_LABELS, { skip: args["skip-validate"] === true, }); } diff --git a/src/commands/transform/update.ts b/src/commands/transform/update.ts index 2d3f2eb..f5fbc72 100644 --- a/src/commands/transform/update.ts +++ b/src/commands/transform/update.ts @@ -5,7 +5,11 @@ import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; -import { preflightInternalMbql5Query, skipValidateFlag } from "../validate-query"; +import { + TRANSFORM_SOURCE_QUERY_LABELS, + preflightInternalMbql5Query, + skipValidateFlag, +} from "../validate-query"; export default defineMetabaseCommand({ meta: { @@ -32,7 +36,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, TransformUpdateInput); if (body.source !== undefined && body.source.type === "query") { - preflightInternalMbql5Query(body.source.query, "transform.source.query validation failed", { + preflightInternalMbql5Query(body.source.query, TRANSFORM_SOURCE_QUERY_LABELS, { skip: args["skip-validate"] === true, }); } diff --git a/src/commands/validate-query.test.ts b/src/commands/validate-query.test.ts index 4427ed7..84f8f7e 100644 --- a/src/commands/validate-query.test.ts +++ b/src/commands/validate-query.test.ts @@ -4,7 +4,11 @@ import { ConfigError } from "../core/errors"; import { ValidationOutcome } from "../core/schema/validate"; import { parseJson } from "../runtime/json"; -import { preflightInternalMbql5Query } from "./validate-query"; +import { + CARD_DATASET_QUERY_LABELS, + TRANSFORM_SOURCE_QUERY_LABELS, + preflightInternalMbql5Query, +} from "./validate-query"; interface Streams { stdout: string; @@ -33,7 +37,7 @@ describe("preflightInternalMbql5Query", () => { it("returns silently when the body is not MBQL 5 (legacy MBQL 4)", () => { preflightInternalMbql5Query( { type: "query", database: 1, query: { "source-table": 5 } }, - "card.dataset_query validation failed", + CARD_DATASET_QUERY_LABELS, { skip: false }, ); expect(streams.stdout).toBe(""); @@ -41,9 +45,9 @@ describe("preflightInternalMbql5Query", () => { }); it("returns silently when the body is undefined / null / non-object", () => { - preflightInternalMbql5Query(undefined, "x", { skip: false }); - preflightInternalMbql5Query(null, "x", { skip: false }); - preflightInternalMbql5Query("native sql", "x", { skip: false }); + preflightInternalMbql5Query(undefined, CARD_DATASET_QUERY_LABELS, { skip: false }); + preflightInternalMbql5Query(null, CARD_DATASET_QUERY_LABELS, { skip: false }); + preflightInternalMbql5Query("native sql", CARD_DATASET_QUERY_LABELS, { skip: false }); expect(streams.stdout).toBe(""); }); @@ -54,7 +58,7 @@ describe("preflightInternalMbql5Query", () => { database: 1, stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], }, - "card.dataset_query validation failed", + CARD_DATASET_QUERY_LABELS, { skip: false }, ); expect(streams.stdout).toBe(""); @@ -69,7 +73,7 @@ describe("preflightInternalMbql5Query", () => { database: "oops", stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], }, - "card.dataset_query validation failed", + CARD_DATASET_QUERY_LABELS, { skip: false }, ), ).toThrow( @@ -90,7 +94,7 @@ describe("preflightInternalMbql5Query", () => { database: "oops", stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], }, - "card.dataset_query validation failed", + CARD_DATASET_QUERY_LABELS, { skip: true }, ); expect(streams.stdout).toBe(""); @@ -108,9 +112,7 @@ describe("preflightInternalMbql5Query", () => { }, }; expect(() => - preflightInternalMbql5Query(doubleWrapped, "card.dataset_query validation failed", { - skip: false, - }), + preflightInternalMbql5Query(doubleWrapped, CARD_DATASET_QUERY_LABELS, { skip: false }), ).toThrow( new ConfigError( 'card.dataset_query validation failed: MBQL 5 query nested inside a legacy {type:"query", query:…} envelope. ' + @@ -122,6 +124,29 @@ describe("preflightInternalMbql5Query", () => { expect(streams.stderr).toBe(""); }); + it("names source.query when the transform preset is threaded", () => { + const doubleWrapped = { + type: "query", + database: 2, + query: { + "lib/type": "mbql/query", + database: 2, + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], + }, + }; + expect(() => + preflightInternalMbql5Query(doubleWrapped, TRANSFORM_SOURCE_QUERY_LABELS, { skip: false }), + ).toThrow( + new ConfigError( + 'transform.source.query validation failed: MBQL 5 query nested inside a legacy {type:"query", query:…} envelope. ' + + "For MBQL 5, source.query is the mbql/query value itself: " + + '{"lib/type":"mbql/query", database:N, stages:[…]}.', + ), + ); + expect(streams.stdout).toBe(""); + expect(streams.stderr).toBe(""); + }); + it("legacy-envelope detection is bypassed by skip", () => { preflightInternalMbql5Query( { @@ -129,7 +154,7 @@ describe("preflightInternalMbql5Query", () => { database: 2, query: { "lib/type": "mbql/query", database: 2, stages: [] }, }, - "card.dataset_query validation failed", + CARD_DATASET_QUERY_LABELS, { skip: true }, ); expect(streams.stdout).toBe(""); diff --git a/src/commands/validate-query.ts b/src/commands/validate-query.ts index b4a234a..9bc9ed6 100644 --- a/src/commands/validate-query.ts +++ b/src/commands/validate-query.ts @@ -14,6 +14,21 @@ export const skipValidateFlag = { }, } as const; +export interface PreflightLabels { + readonly contextLabel: string; + readonly bodyNoun: string; +} + +export const CARD_DATASET_QUERY_LABELS: PreflightLabels = { + contextLabel: "card.dataset_query validation failed", + bodyNoun: "dataset_query", +}; + +export const TRANSFORM_SOURCE_QUERY_LABELS: PreflightLabels = { + contextLabel: "transform.source.query validation failed", + bodyNoun: "source.query", +}; + export interface PreflightOptions { readonly skip: boolean; } @@ -22,13 +37,13 @@ export interface PreflightOptions { // legacy formats are still accepted by the server. export function preflightInternalMbql5Query( query: unknown, - contextLabel: string, + labels: PreflightLabels, options: PreflightOptions, ): void { if (options.skip) { return; } - assertNotLegacyEnvelopeWrappingMbql5(query, { contextLabel, bodyNoun: "dataset_query" }); + assertNotLegacyEnvelopeWrappingMbql5(query, labels); if (!isMbql5Query(query)) { return; } @@ -38,6 +53,6 @@ export function preflightInternalMbql5Query( } writeJson(outcome); throw new ConfigError( - `${contextLabel}: ${outcome.errors.length} error(s) — pass valid MBQL 5 or use the legacy format`, + `${labels.contextLabel}: ${outcome.errors.length} error(s) — pass valid MBQL 5 or use the legacy format`, ); } diff --git a/src/core/http/errors.test.ts b/src/core/http/errors.test.ts new file mode 100644 index 0000000..a0a81fc --- /dev/null +++ b/src/core/http/errors.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; + +import { HttpError } from "./errors"; + +interface HttpErrorFixtureOverrides { + status?: number; + rawBody?: string | null; + overrideUserMessage?: string; +} + +function buildHttpError(overrides: HttpErrorFixtureOverrides = {}): HttpError { + const base = { + status: overrides.status ?? 400, + statusText: "Bad Request", + method: "POST", + url: "https://example.invalid/api/test", + responseHeaders: new Headers(), + rawBody: overrides.rawBody ?? null, + }; + if (overrides.overrideUserMessage !== undefined) { + return new HttpError({ ...base, overrideUserMessage: overrides.overrideUserMessage }); + } + return new HttpError(base); +} + +describe("HttpError message extraction", () => { + it("prefers top-level message over other fields", () => { + const body = JSON.stringify({ + message: "top-level wins", + error: "ignored", + "error-message": "also ignored", + }); + expect(buildHttpError({ rawBody: body }).message).toBe("top-level wins"); + }); + + it("falls back to error when message is absent", () => { + const body = JSON.stringify({ error: "raw error string" }); + expect(buildHttpError({ rawBody: body }).message).toBe("raw error string"); + }); + + it("falls back to error-message when message and error are absent", () => { + const body = JSON.stringify({ "error-message": "kebab key" }); + expect(buildHttpError({ rawBody: body }).message).toBe("kebab key"); + }); + + it("extracts via[0].message for 5xx server-thrown ex-info bodies", () => { + const body = JSON.stringify({ + via: [{ type: "java.lang.AssertionError", message: "Assert failed: (keyword? driver)" }], + trace: [["clojure.core$apply", "invokeStatic", "core.clj", 667]], + }); + expect(buildHttpError({ status: 500, rawBody: body }).message).toBe( + "Assert failed: (keyword? driver)", + ); + }); + + it("skips via entries without a message and picks the next one", () => { + const body = JSON.stringify({ + via: [{ type: "java.lang.RuntimeException" }, { message: "second entry has the cause" }], + }); + expect(buildHttpError({ status: 500, rawBody: body }).message).toBe( + "second entry has the cause", + ); + }); + + it("formats specific-errors with field-level array messages", () => { + const body = JSON.stringify({ + "specific-errors": { database: ['should be an integer, received: "My DB"'] }, + errors: { database: "nullable integer" }, + }); + expect(buildHttpError({ rawBody: body }).message).toBe( + 'database: should be an integer, received: "My DB"', + ); + }); + + it("joins multiple array entries on the same field with semicolons", () => { + const body = JSON.stringify({ + "specific-errors": { + name: ["should be a string, received: nil", "non-blank string, received: nil"], + }, + }); + expect(buildHttpError({ rawBody: body }).message).toBe( + "name: should be a string, received: nil; non-blank string, received: nil", + ); + }); + + it("walks nested specific-errors maps and joins leaves with paths", () => { + const body = JSON.stringify({ + "specific-errors": { source: { "source-tables": ["missing required key, received: nil"] } }, + }); + expect(buildHttpError({ rawBody: body }).message).toBe( + "source.source-tables: missing required key, received: nil", + ); + }); + + it("falls back to errors map when specific-errors is absent", () => { + const body = JSON.stringify({ + errors: { dataset_query: "Value must be a map." }, + }); + expect(buildHttpError({ rawBody: body }).message).toBe("dataset_query: Value must be a map."); + }); + + it("falls back to the status default when the body has no extractable fields", () => { + const body = JSON.stringify({ unrelated: "data", trace: [] }); + expect(buildHttpError({ status: 500, rawBody: body }).message).toBe("Metabase returned 500"); + }); + + it("falls back to the status default for malformed JSON bodies", () => { + expect(buildHttpError({ status: 500, rawBody: "not json at all" }).message).toBe( + "Metabase returned 500", + ); + }); + + it("preserves the override message for known status codes", () => { + expect(buildHttpError({ status: 401, rawBody: null }).message).toBe( + "Invalid or unauthorized API key", + ); + expect(buildHttpError({ status: 404, rawBody: null }).message).toBe( + "Endpoint not found — is this a Metabase instance?", + ); + expect(buildHttpError({ status: 408, rawBody: null }).message).toBe( + "Metabase timed out responding", + ); + expect(buildHttpError({ status: 429, rawBody: null }).message).toBe( + "Metabase rate-limited the request", + ); + }); + + it("body-derived messages override status-default messages", () => { + const body = JSON.stringify({ message: "actual problem from server" }); + expect(buildHttpError({ status: 401, rawBody: body }).message).toBe( + "actual problem from server", + ); + }); + + it("caps long extracted messages with an ellipsis at 500 characters", () => { + const longMessage = "x".repeat(800); + const body = JSON.stringify({ message: longMessage }); + expect(buildHttpError({ rawBody: body }).message).toBe("x".repeat(499) + "…"); + }); + + it("returns short extracted messages unchanged", () => { + const body = JSON.stringify({ message: "short" }); + expect(buildHttpError({ rawBody: body }).message).toBe("short"); + }); + + it("ignores whitespace-only string leaves when walking specific-errors", () => { + const body = JSON.stringify({ + "specific-errors": { ignored: " ", real: ["actual problem"] }, + }); + expect(buildHttpError({ rawBody: body }).message).toBe("real: actual problem"); + }); + + it("respects overrideUserMessage and skips body extraction", () => { + const body = JSON.stringify({ message: "would be extracted otherwise" }); + expect( + buildHttpError({ rawBody: body, overrideUserMessage: "explicit override" }).message, + ).toBe("explicit override"); + }); +}); diff --git a/src/core/http/errors.ts b/src/core/http/errors.ts index 7dc50a2..8c35c74 100644 --- a/src/core/http/errors.ts +++ b/src/core/http/errors.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { MetabaseError } from "../errors"; import { parseJsonResult } from "../../runtime/json"; +import { isPlainObject } from "../../runtime/predicates"; import { redactBody, redactHeaders, type RedactionContext } from "./sanitize"; @@ -23,11 +24,19 @@ const STATUS_CLASSIFICATIONS: Record = { 504: { retryable: true, message: "Metabase timed out responding" }, }; -const ErrorEnvelope = z.object({ - message: z.string().optional(), - error: z.string().optional(), - "error-message": z.string().optional(), -}); +const ErrorEnvelope = z + .object({ + message: z.string().optional(), + error: z.string().optional(), + "error-message": z.string().optional(), + via: z.array(z.object({ message: z.string().optional() }).loose()).optional(), + "specific-errors": z.unknown().optional(), + errors: z.unknown().optional(), + }) + .loose(); + +const MAX_EXTRACTED_MESSAGE_LEN = 500; +const ELLIPSIS = "…"; export interface HttpErrorDetail { status: number; @@ -106,7 +115,69 @@ function parseEnvelopeMessage(sanitizedBody: string | null): string | null { return null; } const envelope = result.value; - return envelope.message ?? envelope.error ?? envelope["error-message"] ?? null; + const topLevel = envelope.message ?? envelope.error ?? envelope["error-message"]; + if (topLevel) { + return capLength(topLevel); + } + const viaMessage = envelope.via?.find((entry) => entry.message)?.message; + if (viaMessage) { + return capLength(viaMessage); + } + const specific = formatErrorTree(envelope["specific-errors"]); + if (specific) { + return capLength(specific); + } + const generic = formatErrorTree(envelope.errors); + if (generic) { + return capLength(generic); + } + return null; +} + +interface LeafEntry { + path: string; + message: string; +} + +function formatErrorTree(value: unknown): string | null { + const entries = collectLeafEntries(value, []); + if (entries.length === 0) { + return null; + } + return entries.map(formatLeafEntry).join("; "); +} + +function formatLeafEntry(entry: LeafEntry): string { + return entry.path === "" ? entry.message : `${entry.path}: ${entry.message}`; +} + +function collectLeafEntries(value: unknown, path: ReadonlyArray): LeafEntry[] { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed === "" ? [] : [{ path: path.join("."), message: trimmed }]; + } + if (Array.isArray(value)) { + const messages = value.filter( + (entry): entry is string => typeof entry === "string" && entry.trim() !== "", + ); + if (messages.length === 0) { + return []; + } + return [{ path: path.join("."), message: messages.join("; ") }]; + } + if (isPlainObject(value)) { + return Object.entries(value).flatMap(([key, child]) => + collectLeafEntries(child, [...path, key]), + ); + } + return []; +} + +function capLength(message: string): string { + if (message.length <= MAX_EXTRACTED_MESSAGE_LEN) { + return message; + } + return message.slice(0, MAX_EXTRACTED_MESSAGE_LEN - ELLIPSIS.length) + ELLIPSIS; } function defaultMessageForStatus(status: number): string { From 650653c45cef85104e6e2d34bbc1ed5f39b08ff8 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 12 May 2026 14:18:18 -0400 Subject: [PATCH 42/47] fixes --- README.md | 56 ++++++++++++++----------- src/commands/measure/create.ts | 16 ++++++- src/commands/measure/update.ts | 12 +++++- src/commands/segment/create.ts | 16 ++++++- src/commands/segment/update.ts | 12 +++++- src/commands/validate-query.ts | 10 +++++ tests/e2e/measure.e2e.test.ts | 77 +++++++++++++++++++++++++++++++++- tests/e2e/segment.e2e.test.ts | 77 +++++++++++++++++++++++++++++++++- 8 files changed, 246 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 330d613..d39702b 100644 --- a/README.md +++ b/README.md @@ -695,29 +695,33 @@ metabase segment get 1 --json --full ```sh cat segment.json | metabase segment create metabase segment create --file segment.json +metabase segment create --file segment.json --skip-validate ``` -| Flag | Description | -| --------------- | ----------------------- | -| `--body ` | Inline JSON body. | -| `--file ` | Path to JSON body file. | +| Flag | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | +| `--skip-validate` | Skip the local MBQL 5 pre-flight validation; let the server be the authority. Use only when the bundled schema disagrees with what the server accepts. | -Body fields: `name` (required), `table_id` (required positive integer), `definition` (required MBQL filter object), `description` (optional). +Body fields: `name` (required), `table_id` (required positive integer), `definition` (required MBQL filter object), `description` (optional). If `definition` is MBQL 5 (`lib/type: "mbql/query"`) it goes through the same pre-flight validation as `card create` and `metabase query`; pass `--skip-validate` to bypass. ### `metabase segment update ` -Patch a segment. The body MUST include `revision_message`. Other keys are partial: `name`, `definition`, `archived`, `description`, `caveats`, `points_of_interest`, `show_in_getting_started`. +Patch a segment. The body MUST include `revision_message`. Other keys are partial: `name`, `definition`, `archived`, `description`, `caveats`, `points_of_interest`, `show_in_getting_started`. If `definition` is MBQL 5 (`lib/type: "mbql/query"`) it goes through the same pre-flight validation as `segment create`; pass `--skip-validate` to bypass. ```sh cat patch.json | metabase segment update 1 metabase segment update 1 --file patch.json metabase segment update 1 --body '{"name":"renamed","revision_message":"rename"}' +metabase segment update 1 --file patch.json --skip-validate ``` -| Flag | Description | -| --------------- | ----------------------- | -| `--body ` | Inline JSON body. | -| `--file ` | Path to JSON body file. | +| Flag | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | +| `--skip-validate` | Skip the local MBQL 5 pre-flight validation; let the server be the authority. Use only when the bundled schema disagrees with what the server accepts. | ### `metabase segment archive ` @@ -755,29 +759,33 @@ metabase measure get 1 --json --full ```sh cat measure.json | metabase measure create metabase measure create --file measure.json +metabase measure create --file measure.json --skip-validate ``` -| Flag | Description | -| --------------- | ----------------------- | -| `--body ` | Inline JSON body. | -| `--file ` | Path to JSON body file. | +| Flag | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | +| `--skip-validate` | Skip the local MBQL 5 pre-flight validation; let the server be the authority. Use only when the bundled schema disagrees with what the server accepts. | -Body fields: `name` (required), `table_id` (required positive integer), `definition` (required MBQL aggregation object), `description` (optional). +Body fields: `name` (required), `table_id` (required positive integer), `definition` (required MBQL aggregation object), `description` (optional). If `definition` is MBQL 5 (`lib/type: "mbql/query"`) it goes through the same pre-flight validation as `card create` and `metabase query`; pass `--skip-validate` to bypass. ### `metabase measure update ` -Patch a measure. The body MUST include `revision_message`. Other keys are partial: `name`, `definition`, `archived`, `description`. +Patch a measure. The body MUST include `revision_message`. Other keys are partial: `name`, `definition`, `archived`, `description`. If `definition` is MBQL 5 (`lib/type: "mbql/query"`) it goes through the same pre-flight validation as `measure create`; pass `--skip-validate` to bypass. ```sh cat patch.json | metabase measure update 1 metabase measure update 1 --file patch.json metabase measure update 1 --body '{"name":"renamed","revision_message":"rename"}' +metabase measure update 1 --file patch.json --skip-validate ``` -| Flag | Description | -| --------------- | ----------------------- | -| `--body ` | Inline JSON body. | -| `--file ` | Path to JSON body file. | +| Flag | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--body ` | Inline JSON body. | +| `--file ` | Path to JSON body file. | +| `--skip-validate` | Skip the local MBQL 5 pre-flight validation; let the server be the authority. Use only when the bundled schema disagrees with what the server accepts. | ### `metabase measure archive ` @@ -1367,13 +1375,13 @@ Output by mode: - Run failure (no `--dry-run`) — same `{ ok, errors }` envelope on stdout, exit 2, no request made. - Run success — the streamed `CardQueryResult`. -### MBQL 5 pre-flight in `card create`/`update` and `transform create`/`update` +### MBQL 5 pre-flight in `card create`/`update`, `transform create`/`update`, `measure create`/`update`, and `segment create`/`update` -When the embedded query (`card.dataset_query`, or `transform.source.query` for `source.type: "query"`) is MBQL 5 (`lib/type: "mbql/query"`), it is pre-flight-validated against the same schema as `metabase query`. Validation failure: `{ ok, errors }` envelope on stdout, exit 2, request not made. MBQL 4 (legacy) bodies and Python transform sources skip validation — they're still accepted by the server and we don't ship a schema for them. +When the embedded query (`card.dataset_query`, `transform.source.query` for `source.type: "query"`, or `measure.definition` / `segment.definition`) is MBQL 5 (`lib/type: "mbql/query"`), it is pre-flight-validated against the same schema as `metabase query`. Validation failure: `{ ok, errors }` envelope on stdout, exit 2, request not made. MBQL 4 (legacy) bodies and Python transform sources skip validation — they're still accepted by the server and we don't ship a schema for them. -Pass `--skip-validate` to bypass the pre-flight on `card create`, `card update`, `transform create`, or `transform update` — the body is sent as-is and the server is the authority. Same escape hatch as on `metabase query`; use only when the bundled schema disagrees with what the server actually accepts. +Pass `--skip-validate` to bypass the pre-flight on any of `card create`, `card update`, `transform create`, `transform update`, `measure create`, `measure update`, `segment create`, or `segment update` — the body is sent as-is and the server is the authority. Same escape hatch as on `metabase query`; use only when the bundled schema disagrees with what the server actually accepts. -Agent discovery path: `metabase __manifest` lists every command's args and description; the description for `card create`/`update` and `transform create`/`update` references `metabase query --print-schema` so an agent can fetch the validating schema directly. +Agent discovery path: `metabase __manifest` lists every command's args and description; the description for `card create`/`update`, `transform create`/`update`, `measure create`/`update`, and `segment create`/`update` references `metabase query --print-schema` so an agent can fetch the validating schema directly. The bundled query schema is synced from a pinned `@metabase/representations` release via `bun run sync:representations`; CI guards against drift. diff --git a/src/commands/measure/create.ts b/src/commands/measure/create.ts index 116b366..3ddf64f 100644 --- a/src/commands/measure/create.ts +++ b/src/commands/measure/create.ts @@ -4,22 +4,36 @@ import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { + MEASURE_DEFINITION_LABELS, + preflightInternalMbql5Query, + skipValidateFlag, +} from "../validate-query"; export default defineMetabaseCommand({ - meta: { name: "create", description: "Create a measure from a JSON spec" }, + meta: { + name: "create", + description: + "Create a measure from a JSON spec; if definition is MBQL 5 (lib/type: mbql/query) it is pre-flight-validated against the same schema as `metabase query` (see `metabase query --print-schema`)", + }, args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags, + ...skipValidateFlag, }, outputSchema: Measure, examples: [ "cat measure.json | metabase measure create", "metabase measure create --file measure.json", + "metabase measure create --file measure.json --skip-validate", ], async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, MeasureCreateInput); + preflightInternalMbql5Query(body.definition, MEASURE_DEFINITION_LABELS, { + skip: args["skip-validate"] === true, + }); const client = await getClient(); const created = await client.requestParsed(Measure, "/api/measure", { method: "POST", diff --git a/src/commands/measure/update.ts b/src/commands/measure/update.ts index 6d2d860..b8e7f6d 100644 --- a/src/commands/measure/update.ts +++ b/src/commands/measure/update.ts @@ -5,18 +5,24 @@ import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { + MEASURE_DEFINITION_LABELS, + preflightInternalMbql5Query, + skipValidateFlag, +} from "../validate-query"; export default defineMetabaseCommand({ meta: { name: "update", description: - "Update a measure by id; body must include revision_message (audit-logged with the change)", + "Update a measure by id; body must include revision_message (audit-logged with the change). If definition is MBQL 5 (lib/type: mbql/query) it is pre-flight-validated against the same schema as `metabase query` (see `metabase query --print-schema`)", }, args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags, + ...skipValidateFlag, id: { type: "positional", description: "Measure id", required: true }, }, outputSchema: Measure, @@ -24,10 +30,14 @@ export default defineMetabaseCommand({ "cat patch.json | metabase measure update 1", "metabase measure update 1 --file patch.json", 'metabase measure update 1 --body \'{"name":"renamed","revision_message":"rename"}\'', + "metabase measure update 1 --file patch.json --skip-validate", ], async run({ args, ctx, getClient }) { const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, MeasureUpdateInput); + preflightInternalMbql5Query(body.definition, MEASURE_DEFINITION_LABELS, { + skip: args["skip-validate"] === true, + }); const client = await getClient(); const updated = await client.requestParsed(Measure, `/api/measure/${id}`, { method: "PUT", diff --git a/src/commands/segment/create.ts b/src/commands/segment/create.ts index 6914435..9d445d7 100644 --- a/src/commands/segment/create.ts +++ b/src/commands/segment/create.ts @@ -4,22 +4,36 @@ import { readBody } from "../../runtime/body"; import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; +import { + SEGMENT_DEFINITION_LABELS, + preflightInternalMbql5Query, + skipValidateFlag, +} from "../validate-query"; export default defineMetabaseCommand({ - meta: { name: "create", description: "Create a segment from a JSON spec" }, + meta: { + name: "create", + description: + "Create a segment from a JSON spec; if definition is MBQL 5 (lib/type: mbql/query) it is pre-flight-validated against the same schema as `metabase query` (see `metabase query --print-schema`)", + }, args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags, + ...skipValidateFlag, }, outputSchema: Segment, examples: [ "cat segment.json | metabase segment create", "metabase segment create --file segment.json", + "metabase segment create --file segment.json --skip-validate", ], async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, SegmentCreateInput); + preflightInternalMbql5Query(body.definition, SEGMENT_DEFINITION_LABELS, { + skip: args["skip-validate"] === true, + }); const client = await getClient(); const created = await client.requestParsed(Segment, "/api/segment", { method: "POST", diff --git a/src/commands/segment/update.ts b/src/commands/segment/update.ts index f26099c..e886327 100644 --- a/src/commands/segment/update.ts +++ b/src/commands/segment/update.ts @@ -5,18 +5,24 @@ import { bodyInputFlags } from "../body-flags"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; +import { + SEGMENT_DEFINITION_LABELS, + preflightInternalMbql5Query, + skipValidateFlag, +} from "../validate-query"; export default defineMetabaseCommand({ meta: { name: "update", description: - "Update a segment by id; body must include revision_message (audit-logged with the change)", + "Update a segment by id; body must include revision_message (audit-logged with the change). If definition is MBQL 5 (lib/type: mbql/query) it is pre-flight-validated against the same schema as `metabase query` (see `metabase query --print-schema`)", }, args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags, + ...skipValidateFlag, id: { type: "positional", description: "Segment id", required: true }, }, outputSchema: Segment, @@ -24,10 +30,14 @@ export default defineMetabaseCommand({ "cat patch.json | metabase segment update 1", "metabase segment update 1 --file patch.json", 'metabase segment update 1 --body \'{"name":"renamed","revision_message":"rename"}\'', + "metabase segment update 1 --file patch.json --skip-validate", ], async run({ args, ctx, getClient }) { const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, SegmentUpdateInput); + preflightInternalMbql5Query(body.definition, SEGMENT_DEFINITION_LABELS, { + skip: args["skip-validate"] === true, + }); const client = await getClient(); const updated = await client.requestParsed(Segment, `/api/segment/${id}`, { method: "PUT", diff --git a/src/commands/validate-query.ts b/src/commands/validate-query.ts index 9bc9ed6..76f9476 100644 --- a/src/commands/validate-query.ts +++ b/src/commands/validate-query.ts @@ -29,6 +29,16 @@ export const TRANSFORM_SOURCE_QUERY_LABELS: PreflightLabels = { bodyNoun: "source.query", }; +export const MEASURE_DEFINITION_LABELS: PreflightLabels = { + contextLabel: "measure.definition validation failed", + bodyNoun: "definition", +}; + +export const SEGMENT_DEFINITION_LABELS: PreflightLabels = { + contextLabel: "segment.definition validation failed", + bodyNoun: "definition", +}; + export interface PreflightOptions { readonly skip: boolean; } diff --git a/tests/e2e/measure.e2e.test.ts b/tests/e2e/measure.e2e.test.ts index baff19f..586a4af 100644 --- a/tests/e2e/measure.e2e.test.ts +++ b/tests/e2e/measure.e2e.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { MeasureListEnvelope } from "../../src/commands/measure/list"; +import { ValidationOutcome } from "../../src/core/schema/validate"; import { MeasureCompact, type MeasureCreateInput } from "../../src/domain/measure"; import { parseJson } from "../../src/runtime/json"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { E2E_TABLES } from "./seed/ids"; +import { E2E_DATABASES, E2E_TABLES } from "./seed/ids"; const FIRST_NEW_MEASURE_ID = 1; const MEASURE_NAME = "OrderCount"; @@ -109,6 +110,53 @@ describe("measure e2e", () => { }); }); + it("create with invalid MBQL 5 definition fails pre-flight before sending", async () => { + const result = await runCli({ + args: ["measure", "create", "--json"], + stdin: JSON.stringify({ + name: "preflight-fail", + table_id: E2E_TABLES.ORDERS, + definition: { + "lib/type": "mbql/query", + database: E2E_DATABASES.WAREHOUSE, + stages: [], + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/stages", message: "must NOT have fewer than 1 items" }], + }); + expect(result.stderr).toContain( + "measure.definition validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", + ); + }); + + it("create --skip-validate bypasses the MBQL 5 pre-flight (server is the authority)", async () => { + const result = await runCli({ + args: ["measure", "create", "--skip-validate", "--json"], + stdin: JSON.stringify({ + name: "skip-validate-bypass", + table_id: E2E_TABLES.ORDERS, + definition: { + "lib/type": "mbql/query", + database: E2E_DATABASES.WAREHOUSE, + stages: [], + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Metabase returned 500"); + expect(result.stdout).toBe(""); + }); + it("create with a body missing required fields fails on Zod validation", async () => { const result = await runCli({ args: ["measure", "create", "--json"], @@ -175,6 +223,33 @@ describe("measure e2e", () => { }); }); + it("update with invalid MBQL 5 definition fails pre-flight before sending", async () => { + await createMeasure(); + + const result = await runCli({ + args: ["measure", "update", String(FIRST_NEW_MEASURE_ID), "--json"], + stdin: JSON.stringify({ + revision_message: "bad definition", + definition: { + "lib/type": "mbql/query", + database: E2E_DATABASES.WAREHOUSE, + stages: [], + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/stages", message: "must NOT have fewer than 1 items" }], + }); + expect(result.stderr).toContain( + "measure.definition validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", + ); + }); + it("update without the required revision_message fails on Zod validation", async () => { await createMeasure(); diff --git a/tests/e2e/segment.e2e.test.ts b/tests/e2e/segment.e2e.test.ts index 3368c08..74f1db5 100644 --- a/tests/e2e/segment.e2e.test.ts +++ b/tests/e2e/segment.e2e.test.ts @@ -1,12 +1,13 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { SegmentListEnvelope } from "../../src/commands/segment/list"; +import { ValidationOutcome } from "../../src/core/schema/validate"; import { SegmentCompact, type SegmentCreateInput } from "../../src/domain/segment"; import { parseJson } from "../../src/runtime/json"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -import { E2E_FIELDS, E2E_TABLES } from "./seed/ids"; +import { E2E_DATABASES, E2E_FIELDS, E2E_TABLES } from "./seed/ids"; const FIRST_NEW_SEGMENT_ID = 1; const SEGMENT_NAME = "PositiveIdOrders"; @@ -109,6 +110,53 @@ describe("segment e2e", () => { }); }); + it("create with invalid MBQL 5 definition fails pre-flight before sending", async () => { + const result = await runCli({ + args: ["segment", "create", "--json"], + stdin: JSON.stringify({ + name: "preflight-fail", + table_id: E2E_TABLES.ORDERS, + definition: { + "lib/type": "mbql/query", + database: E2E_DATABASES.WAREHOUSE, + stages: [], + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/stages", message: "must NOT have fewer than 1 items" }], + }); + expect(result.stderr).toContain( + "segment.definition validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", + ); + }); + + it("create --skip-validate bypasses the MBQL 5 pre-flight (server is the authority)", async () => { + const result = await runCli({ + args: ["segment", "create", "--skip-validate", "--json"], + stdin: JSON.stringify({ + name: "skip-validate-bypass", + table_id: E2E_TABLES.ORDERS, + definition: { + "lib/type": "mbql/query", + database: E2E_DATABASES.WAREHOUSE, + stages: [], + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Metabase returned 500"); + expect(result.stdout).toBe(""); + }); + it("create with a body missing required fields fails on Zod validation", async () => { const result = await runCli({ args: ["segment", "create", "--json"], @@ -175,6 +223,33 @@ describe("segment e2e", () => { }); }); + it("update with invalid MBQL 5 definition fails pre-flight before sending", async () => { + await createSegment(); + + const result = await runCli({ + args: ["segment", "update", String(FIRST_NEW_SEGMENT_ID), "--json"], + stdin: JSON.stringify({ + revision_message: "bad definition", + definition: { + "lib/type": "mbql/query", + database: E2E_DATABASES.WAREHOUSE, + stages: [], + }, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(2); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ + ok: false, + errors: [{ path: "/stages", message: "must NOT have fewer than 1 items" }], + }); + expect(result.stderr).toContain( + "segment.definition validation failed: 1 error(s) — pass valid MBQL 5 or use the legacy format", + ); + }); + it("update without the required revision_message fails on Zod validation", async () => { await createSegment(); From dc82ae4d4eb3974706fd33234d7bf1288d798bab Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 12 May 2026 14:37:28 -0400 Subject: [PATCH 43/47] fix --- src/commands/card/create.ts | 4 +- src/commands/card/update.ts | 4 +- src/commands/measure/create.ts | 4 +- src/commands/measure/update.ts | 4 +- src/commands/query.ts | 34 ++------ src/commands/segment/create.ts | 4 +- src/commands/segment/update.ts | 4 +- src/commands/transform/create.ts | 4 +- src/commands/transform/update.ts | 4 +- src/commands/validate-query.test.ts | 24 +++--- src/commands/validate-query.ts | 6 +- src/core/schema/validate.test.ts | 128 +++++++++++----------------- src/core/schema/validate.ts | 63 +++++--------- src/domain/card.ts | 18 +++- tests/e2e/card.e2e.test.ts | 73 ++++++++++++++++ tests/e2e/query.e2e.test.ts | 75 ++++------------ 16 files changed, 211 insertions(+), 242 deletions(-) diff --git a/src/commands/card/create.ts b/src/commands/card/create.ts index 9a7a7b0..84d421d 100644 --- a/src/commands/card/create.ts +++ b/src/commands/card/create.ts @@ -6,7 +6,7 @@ import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; import { CARD_DATASET_QUERY_LABELS, - preflightInternalMbql5Query, + preflightMbql5Query, skipValidateFlag, } from "../validate-query"; @@ -32,7 +32,7 @@ export default defineMetabaseCommand({ ], async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, CardCreateInput); - preflightInternalMbql5Query(body.dataset_query, CARD_DATASET_QUERY_LABELS, { + preflightMbql5Query(body.dataset_query, CARD_DATASET_QUERY_LABELS, { skip: args["skip-validate"] === true, }); const client = await getClient(); diff --git a/src/commands/card/update.ts b/src/commands/card/update.ts index 41edcd9..14c8088 100644 --- a/src/commands/card/update.ts +++ b/src/commands/card/update.ts @@ -7,7 +7,7 @@ import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; import { CARD_DATASET_QUERY_LABELS, - preflightInternalMbql5Query, + preflightMbql5Query, skipValidateFlag, } from "../validate-query"; @@ -37,7 +37,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, CardUpdateInput); - preflightInternalMbql5Query(body.dataset_query, CARD_DATASET_QUERY_LABELS, { + preflightMbql5Query(body.dataset_query, CARD_DATASET_QUERY_LABELS, { skip: args["skip-validate"] === true, }); const client = await getClient(); diff --git a/src/commands/measure/create.ts b/src/commands/measure/create.ts index 3ddf64f..24cc5dd 100644 --- a/src/commands/measure/create.ts +++ b/src/commands/measure/create.ts @@ -6,7 +6,7 @@ import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; import { MEASURE_DEFINITION_LABELS, - preflightInternalMbql5Query, + preflightMbql5Query, skipValidateFlag, } from "../validate-query"; @@ -31,7 +31,7 @@ export default defineMetabaseCommand({ ], async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, MeasureCreateInput); - preflightInternalMbql5Query(body.definition, MEASURE_DEFINITION_LABELS, { + preflightMbql5Query(body.definition, MEASURE_DEFINITION_LABELS, { skip: args["skip-validate"] === true, }); const client = await getClient(); diff --git a/src/commands/measure/update.ts b/src/commands/measure/update.ts index b8e7f6d..29b3cae 100644 --- a/src/commands/measure/update.ts +++ b/src/commands/measure/update.ts @@ -7,7 +7,7 @@ import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; import { MEASURE_DEFINITION_LABELS, - preflightInternalMbql5Query, + preflightMbql5Query, skipValidateFlag, } from "../validate-query"; @@ -35,7 +35,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, MeasureUpdateInput); - preflightInternalMbql5Query(body.definition, MEASURE_DEFINITION_LABELS, { + preflightMbql5Query(body.definition, MEASURE_DEFINITION_LABELS, { skip: args["skip-validate"] === true, }); const client = await getClient(); diff --git a/src/commands/query.ts b/src/commands/query.ts index 15984af..7f1bf8d 100644 --- a/src/commands/query.ts +++ b/src/commands/query.ts @@ -5,8 +5,7 @@ import { assertNotLegacyEnvelopeWrappingMbql5, getQuerySchemaBundle, isLegacyNativeQuery, - validateExternalQuery, - validateInternalQuery, + validateQuery, } from "../core/schema/validate"; import { CardQueryResult, cardQueryView } from "../domain/card"; import { renderItem, writeJson } from "../output/render"; @@ -19,58 +18,39 @@ import { skipValidateFlag } from "./validate-query"; const QueryBody = z.unknown(); -const INTERNAL = { - mode: "internal", - validate: validateInternalQuery, - endpoint: "/api/dataset", -} as const; -const EXTERNAL = { - mode: "external", - validate: validateExternalQuery, - endpoint: "/api/dataset/external", -} as const; +const QUERY_ENDPOINT = "/api/dataset"; export default defineMetabaseCommand({ meta: { name: "query", description: - 'Run an MBQL 5 query (validates against the bundled schema first); --print-schema emits the schema for agent discovery, --dry-run validates without sending. Default is internal MBQL (numeric IDs); pass --external for the representations / string-FK form. Legacy native bodies ({type:"native", …} or any top-level `native:`) skip pre-flight automatically — the bundled schema only models MBQL 5. Every clause options object carries a `lib/uuid` (UUID v4); mint these via `metabase uuid` — never author them by hand.', + 'Run an MBQL 5 query (validates against the bundled schema first); --print-schema emits the schema for agent discovery, --dry-run validates without sending. Legacy native bodies ({type:"native", …} or any top-level `native:`) skip pre-flight automatically — the bundled schema only models MBQL 5. Every clause options object carries a `lib/uuid` (UUID v4); mint these via `metabase uuid` — never author them by hand.', }, args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags, - external: { - type: "boolean", - description: - "Validate as external MBQL (string FKs) and POST to /api/dataset/external; default is internal (numeric IDs) → /api/dataset", - }, "dry-run": { type: "boolean", description: "Validate the body and exit without sending the query", }, "print-schema": { type: "boolean", - description: - "Emit the bundled MBQL 5 query JSON Schema (with --external for the string-FK variant) and exit; no body required", + description: "Emit the bundled MBQL 5 query JSON Schema and exit; no body required", }, ...skipValidateFlag, }, outputSchema: CardQueryResult, examples: [ "metabase query --print-schema", - "metabase query --print-schema --external", "cat q.json | metabase query --dry-run", "metabase query --file q.json", - "metabase query --file q.json --external", "metabase query --file q.json --skip-validate", ], async run({ args, ctx, getClient }) { - const mode = args.external === true ? EXTERNAL : INTERNAL; - if (args["print-schema"] === true) { - writeJson(getQuerySchemaBundle(mode.mode)); + writeJson(getQuerySchemaBundle()); return; } @@ -89,7 +69,7 @@ export default defineMetabaseCommand({ const skipValidation = explicitSkip || isLegacyNativeQuery(body); if (!skipValidation) { - const outcome = mode.validate(body); + const outcome = validateQuery(body); if (!outcome.ok) { writeJson(outcome); const hint = dryRun ? "" : " — pass --dry-run to validate without sending"; @@ -105,7 +85,7 @@ export default defineMetabaseCommand({ } const client = await getClient(); - const queryResult = await client.requestParsed(CardQueryResult, mode.endpoint, { + const queryResult = await client.requestParsed(CardQueryResult, QUERY_ENDPOINT, { method: "POST", body, }); diff --git a/src/commands/segment/create.ts b/src/commands/segment/create.ts index 9d445d7..379fb99 100644 --- a/src/commands/segment/create.ts +++ b/src/commands/segment/create.ts @@ -6,7 +6,7 @@ import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; import { SEGMENT_DEFINITION_LABELS, - preflightInternalMbql5Query, + preflightMbql5Query, skipValidateFlag, } from "../validate-query"; @@ -31,7 +31,7 @@ export default defineMetabaseCommand({ ], async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, SegmentCreateInput); - preflightInternalMbql5Query(body.definition, SEGMENT_DEFINITION_LABELS, { + preflightMbql5Query(body.definition, SEGMENT_DEFINITION_LABELS, { skip: args["skip-validate"] === true, }); const client = await getClient(); diff --git a/src/commands/segment/update.ts b/src/commands/segment/update.ts index e886327..6a6e47a 100644 --- a/src/commands/segment/update.ts +++ b/src/commands/segment/update.ts @@ -7,7 +7,7 @@ import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; import { SEGMENT_DEFINITION_LABELS, - preflightInternalMbql5Query, + preflightMbql5Query, skipValidateFlag, } from "../validate-query"; @@ -35,7 +35,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, SegmentUpdateInput); - preflightInternalMbql5Query(body.definition, SEGMENT_DEFINITION_LABELS, { + preflightMbql5Query(body.definition, SEGMENT_DEFINITION_LABELS, { skip: args["skip-validate"] === true, }); const client = await getClient(); diff --git a/src/commands/transform/create.ts b/src/commands/transform/create.ts index cc95355..28a0a17 100644 --- a/src/commands/transform/create.ts +++ b/src/commands/transform/create.ts @@ -6,7 +6,7 @@ import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; import { TRANSFORM_SOURCE_QUERY_LABELS, - preflightInternalMbql5Query, + preflightMbql5Query, skipValidateFlag, } from "../validate-query"; @@ -32,7 +32,7 @@ export default defineMetabaseCommand({ async run({ args, ctx, getClient }) { const body = await readBody({ flag: args.body, file: args.file }, TransformCreateInput); if (body.source.type === "query") { - preflightInternalMbql5Query(body.source.query, TRANSFORM_SOURCE_QUERY_LABELS, { + preflightMbql5Query(body.source.query, TRANSFORM_SOURCE_QUERY_LABELS, { skip: args["skip-validate"] === true, }); } diff --git a/src/commands/transform/update.ts b/src/commands/transform/update.ts index f5fbc72..0390038 100644 --- a/src/commands/transform/update.ts +++ b/src/commands/transform/update.ts @@ -7,7 +7,7 @@ import { parseId } from "../parse-id"; import { defineMetabaseCommand } from "../runtime"; import { TRANSFORM_SOURCE_QUERY_LABELS, - preflightInternalMbql5Query, + preflightMbql5Query, skipValidateFlag, } from "../validate-query"; @@ -36,7 +36,7 @@ export default defineMetabaseCommand({ const id = parseId(args.id); const body = await readBody({ flag: args.body, file: args.file }, TransformUpdateInput); if (body.source !== undefined && body.source.type === "query") { - preflightInternalMbql5Query(body.source.query, TRANSFORM_SOURCE_QUERY_LABELS, { + preflightMbql5Query(body.source.query, TRANSFORM_SOURCE_QUERY_LABELS, { skip: args["skip-validate"] === true, }); } diff --git a/src/commands/validate-query.test.ts b/src/commands/validate-query.test.ts index 84f8f7e..e0f8587 100644 --- a/src/commands/validate-query.test.ts +++ b/src/commands/validate-query.test.ts @@ -7,7 +7,7 @@ import { parseJson } from "../runtime/json"; import { CARD_DATASET_QUERY_LABELS, TRANSFORM_SOURCE_QUERY_LABELS, - preflightInternalMbql5Query, + preflightMbql5Query, } from "./validate-query"; interface Streams { @@ -33,9 +33,9 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("preflightInternalMbql5Query", () => { +describe("preflightMbql5Query", () => { it("returns silently when the body is not MBQL 5 (legacy MBQL 4)", () => { - preflightInternalMbql5Query( + preflightMbql5Query( { type: "query", database: 1, query: { "source-table": 5 } }, CARD_DATASET_QUERY_LABELS, { skip: false }, @@ -45,14 +45,14 @@ describe("preflightInternalMbql5Query", () => { }); it("returns silently when the body is undefined / null / non-object", () => { - preflightInternalMbql5Query(undefined, CARD_DATASET_QUERY_LABELS, { skip: false }); - preflightInternalMbql5Query(null, CARD_DATASET_QUERY_LABELS, { skip: false }); - preflightInternalMbql5Query("native sql", CARD_DATASET_QUERY_LABELS, { skip: false }); + preflightMbql5Query(undefined, CARD_DATASET_QUERY_LABELS, { skip: false }); + preflightMbql5Query(null, CARD_DATASET_QUERY_LABELS, { skip: false }); + preflightMbql5Query("native sql", CARD_DATASET_QUERY_LABELS, { skip: false }); expect(streams.stdout).toBe(""); }); it("returns silently when the MBQL 5 body validates", () => { - preflightInternalMbql5Query( + preflightMbql5Query( { "lib/type": "mbql/query", database: 1, @@ -67,7 +67,7 @@ describe("preflightInternalMbql5Query", () => { it("writes the structured envelope and throws ConfigError when MBQL 5 validation fails", () => { expect(() => - preflightInternalMbql5Query( + preflightMbql5Query( { "lib/type": "mbql/query", database: "oops", @@ -88,7 +88,7 @@ describe("preflightInternalMbql5Query", () => { }); it("returns silently when skip is true, regardless of body validity", () => { - preflightInternalMbql5Query( + preflightMbql5Query( { "lib/type": "mbql/query", database: "oops", @@ -112,7 +112,7 @@ describe("preflightInternalMbql5Query", () => { }, }; expect(() => - preflightInternalMbql5Query(doubleWrapped, CARD_DATASET_QUERY_LABELS, { skip: false }), + preflightMbql5Query(doubleWrapped, CARD_DATASET_QUERY_LABELS, { skip: false }), ).toThrow( new ConfigError( 'card.dataset_query validation failed: MBQL 5 query nested inside a legacy {type:"query", query:…} envelope. ' + @@ -135,7 +135,7 @@ describe("preflightInternalMbql5Query", () => { }, }; expect(() => - preflightInternalMbql5Query(doubleWrapped, TRANSFORM_SOURCE_QUERY_LABELS, { skip: false }), + preflightMbql5Query(doubleWrapped, TRANSFORM_SOURCE_QUERY_LABELS, { skip: false }), ).toThrow( new ConfigError( 'transform.source.query validation failed: MBQL 5 query nested inside a legacy {type:"query", query:…} envelope. ' + @@ -148,7 +148,7 @@ describe("preflightInternalMbql5Query", () => { }); it("legacy-envelope detection is bypassed by skip", () => { - preflightInternalMbql5Query( + preflightMbql5Query( { type: "query", database: 2, diff --git a/src/commands/validate-query.ts b/src/commands/validate-query.ts index 76f9476..59a1604 100644 --- a/src/commands/validate-query.ts +++ b/src/commands/validate-query.ts @@ -2,7 +2,7 @@ import { ConfigError } from "../core/errors"; import { assertNotLegacyEnvelopeWrappingMbql5, isMbql5Query, - validateInternalQuery, + validateQuery, } from "../core/schema/validate"; import { writeJson } from "../output/render"; @@ -45,7 +45,7 @@ export interface PreflightOptions { // Skips MBQL 4 / native — we only have a schema for MBQL 5 today, and the // legacy formats are still accepted by the server. -export function preflightInternalMbql5Query( +export function preflightMbql5Query( query: unknown, labels: PreflightLabels, options: PreflightOptions, @@ -57,7 +57,7 @@ export function preflightInternalMbql5Query( if (!isMbql5Query(query)) { return; } - const outcome = validateInternalQuery(query); + const outcome = validateQuery(query); if (outcome.ok) { return; } diff --git a/src/core/schema/validate.test.ts b/src/core/schema/validate.test.ts index 751fc3f..21f7899 100644 --- a/src/core/schema/validate.test.ts +++ b/src/core/schema/validate.test.ts @@ -8,22 +8,10 @@ import { isLegacyEnvelopeWrappingMbql5, isLegacyNativeQuery, isMbql5Query, - validateExternalQuery, - validateInternalQuery, + validateQuery, } from "./validate"; -const VALID_EXTERNAL = { - "lib/type": "mbql/query", - database: "My DB", - stages: [ - { - "lib/type": "mbql.stage/mbql", - "source-table": ["My DB", null, "orders"], - }, - ], -}; - -const VALID_INTERNAL = { +const VALID_QUERY = { "lib/type": "mbql/query", database: 1, stages: [ @@ -34,27 +22,48 @@ const VALID_INTERNAL = { ], }; -describe("validateExternalQuery", () => { - it("accepts a structurally valid external-MBQL body", () => { - expect(validateExternalQuery(VALID_EXTERNAL)).toEqual({ ok: true, errors: [] }); +describe("validateQuery", () => { + it("accepts a structurally valid MBQL 5 body", () => { + expect(validateQuery(VALID_QUERY)).toEqual({ ok: true, errors: [] }); }); - it("rejects integer database (would belong to internal MBQL)", () => { - expect(validateExternalQuery(VALID_INTERNAL)).toEqual({ + it("rejects a string database id / FK-tuple source-table (only positive integers are accepted)", () => { + expect( + validateQuery({ + "lib/type": "mbql/query", + database: "My DB", + stages: [ + { + "lib/type": "mbql.stage/mbql", + "source-table": ["My DB", null, "orders"], + }, + ], + }), + ).toEqual({ ok: false, errors: [ - { path: "/database", message: "must be string" }, - { path: "/stages/0/source-table", message: "must be array" }, + { path: "/database", message: "must be integer" }, + { path: "/stages/0/source-table", message: "must be integer" }, { path: "/stages/0", message: 'must match "then" schema' }, ], }); }); + it("rejects zero or negative database id", () => { + const outcome = validateQuery({ + "lib/type": "mbql/query", + database: 0, + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], + }); + expect(outcome.ok).toBe(false); + expect(outcome.errors).toContainEqual({ path: "/database", message: "must be >= 1" }); + }); + it("rejects an empty stages array", () => { expect( - validateExternalQuery({ + validateQuery({ "lib/type": "mbql/query", - database: "My DB", + database: 1, stages: [], }), ).toEqual({ @@ -64,9 +73,9 @@ describe("validateExternalQuery", () => { }); it("rejects a missing top-level lib/type", () => { - const outcome = validateExternalQuery({ - database: "My DB", - stages: [{ "lib/type": "mbql.stage/mbql", "source-table": ["My DB", null, "orders"] }], + const outcome = validateQuery({ + database: 1, + stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], }); expect(outcome.ok).toBe(false); expect(outcome.errors).toContainEqual({ @@ -76,33 +85,6 @@ describe("validateExternalQuery", () => { }); }); -describe("validateInternalQuery", () => { - it("accepts a structurally valid internal-MBQL body", () => { - expect(validateInternalQuery(VALID_INTERNAL)).toEqual({ ok: true, errors: [] }); - }); - - it("rejects string database / FK-tuple source-table (would belong to external MBQL)", () => { - expect(validateInternalQuery(VALID_EXTERNAL)).toEqual({ - ok: false, - errors: [ - { path: "/database", message: "must be integer" }, - { path: "/stages/0/source-table", message: "must be integer" }, - { path: "/stages/0", message: 'must match "then" schema' }, - ], - }); - }); - - it("rejects zero or negative database id", () => { - const outcome = validateInternalQuery({ - "lib/type": "mbql/query", - database: 0, - stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], - }); - expect(outcome.ok).toBe(false); - expect(outcome.errors).toContainEqual({ path: "/database", message: "must be >= 1" }); - }); -}); - describe("isMbql5Query", () => { it("returns true for an object with lib/type: mbql/query", () => { expect(isMbql5Query({ "lib/type": "mbql/query" })).toBe(true); @@ -143,7 +125,7 @@ describe("isLegacyEnvelopeWrappingMbql5", () => { }); it("returns false for a top-level MBQL 5 query", () => { - expect(isLegacyEnvelopeWrappingMbql5(VALID_INTERNAL)).toBe(false); + expect(isLegacyEnvelopeWrappingMbql5(VALID_QUERY)).toBe(false); }); it("returns false for non-objects, arrays, and null", () => { @@ -189,7 +171,7 @@ describe("isLegacyNativeQuery", () => { }); it("returns false for a plain MBQL 5 query with no native fields", () => { - expect(isLegacyNativeQuery(VALID_INTERNAL)).toBe(false); + expect(isLegacyNativeQuery(VALID_QUERY)).toBe(false); }); it("returns false for non-objects, arrays, and null", () => { @@ -203,7 +185,7 @@ describe("isLegacyNativeQuery", () => { describe("ref-clause error messages", () => { it("rewrites 'must be string' on aggregation_ref's UUID slot and reports the cascading 'then' shape errors verbatim", () => { - const outcome = validateInternalQuery({ + const outcome = validateQuery({ "lib/type": "mbql/query", database: 1, stages: [ @@ -238,7 +220,7 @@ describe("ref-clause error messages", () => { }); it("rewrites the message for expression refs to reference the name contract", () => { - const outcome = validateInternalQuery({ + const outcome = validateQuery({ "lib/type": "mbql/query", database: 1, stages: [ @@ -263,7 +245,7 @@ describe("ref-clause error messages", () => { }); it("does not rewrite non-string-typed errors (only 'must be string' at ref-third positions is enriched)", () => { - const outcome = validateInternalQuery({ + const outcome = validateQuery({ "lib/type": "mbql/query", database: "oops", stages: [{ "lib/type": "mbql.stage/mbql", "source-table": 7 }], @@ -277,7 +259,7 @@ describe("ref-clause error messages", () => { describe("clause-shape error messages", () => { it("rewrites 'must be object' at /1 of a `field` clause to call out the MBQL5 vs MBQL4 ordering trap", () => { - const outcome = validateInternalQuery({ + const outcome = validateQuery({ "lib/type": "mbql/query", database: 1, stages: [ @@ -302,7 +284,7 @@ describe("clause-shape error messages", () => { }); it("rewrites 'must be object' at /1 of an arbitrary clause with a generic options-position message that names the operator and the offending value", () => { - const outcome = validateInternalQuery({ + const outcome = validateQuery({ "lib/type": "mbql/query", database: 1, stages: [ @@ -321,7 +303,7 @@ describe("clause-shape error messages", () => { }); it("does not override slot 1 when the operator is not a string (the array isn't a clause)", () => { - const outcome = validateInternalQuery({ + const outcome = validateQuery({ "lib/type": "mbql/query", database: 1, stages: [{ "lib/type": "mbql.stage/mbql", "source-table": [1, 2, 3] }], @@ -336,7 +318,7 @@ describe("clause-shape error messages", () => { describe("uuid-format error messages", () => { it("replaces Ajv's bare 'must match format \"uuid\"' with a hint pointing at `metabase uuid`", () => { - const outcome = validateInternalQuery({ + const outcome = validateQuery({ "lib/type": "mbql/query", database: 1, stages: [ @@ -361,31 +343,17 @@ describe("uuid-format error messages", () => { }); describe("getQuerySchemaBundle", () => { - it("external mode bundles the query schema with the string-FK id schema and the other 3 common defs", () => { - const bundle = getQuerySchemaBundle("external"); - expect(bundle.mode).toBe("external"); - expect(bundle.schema).toBe(getQuerySchemaBundle("external").schema); + it("bundles the query schema with the 4 common defs and pins every id $def to a positive integer", () => { + const bundle = getQuerySchemaBundle(); expect(Object.keys(bundle.defs)).toEqual([ "id.yaml", "parameter.yaml", "ref.yaml", "temporal_bucketing.yaml", ]); - }); - - it("external mode's id schema describes database_id as a string", () => { - const bundle = getQuerySchemaBundle("external"); - expect(bundle.defs["id.yaml"]).toMatchObject({ - $defs: { database_id: { type: "string" } }, - }); - }); - - it("internal mode's id schema describes every id $def as a positive integer", () => { - const bundle = getQuerySchemaBundle("internal"); - expect(bundle.mode).toBe("internal"); expect(bundle.defs["id.yaml"]).toEqual({ - title: "ID (internal)", - description: "Internal-MBQL identifier overrides — every ID is a positive integer.", + title: "ID", + description: "MBQL identifier $defs — every id is a positive integer.", $defs: { entity_id: { type: "integer", minimum: 1 }, user_id: { type: "integer", minimum: 1 }, diff --git a/src/core/schema/validate.ts b/src/core/schema/validate.ts index 5c9203c..9d41c7e 100644 --- a/src/core/schema/validate.ts +++ b/src/core/schema/validate.ts @@ -7,7 +7,6 @@ import { isPlainObject } from "../../runtime/predicates"; import { ConfigError } from "../errors"; import { escapeJsonPointerSegment } from "../json-pointer"; -import idSchema from "./data/schemas/common/id.json" with { type: "json" }; import parameterSchema from "./data/schemas/common/parameter.json" with { type: "json" }; import querySchema from "./data/schemas/common/query.json" with { type: "json" }; import refSchema from "./data/schemas/common/ref.json" with { type: "json" }; @@ -25,13 +24,13 @@ export const ValidationOutcome = z.object({ }); export type ValidationOutcome = z.infer; -// Internal MBQL is structurally identical to external MBQL except every ID -// field is a positive integer instead of a portable string / FK tuple. We -// override the bundled id.yaml's five $defs to express that. +// MBQL 5 IDs are always positive integers in the only endpoint the CLI talks to +// (`POST /api/dataset`). The bundled query.yaml `$ref`s id.yaml#/$defs/...; this +// override declares every id $def as a positive integer. const POSITIVE_INTEGER = { type: "integer", minimum: 1 } as const; -const internalIdSchema = { - title: "ID (internal)", - description: "Internal-MBQL identifier overrides — every ID is a positive integer.", +const idSchema = { + title: "ID", + description: "MBQL identifier $defs — every id is a positive integer.", $defs: { entity_id: POSITIVE_INTEGER, user_id: POSITIVE_INTEGER, @@ -41,17 +40,19 @@ const internalIdSchema = { }, }; -let externalValidator: ValidateFunction | null = null; -let internalValidator: ValidateFunction | null = null; +let validator: ValidateFunction | null = null; -function buildAjv(idVariant: typeof idSchema | typeof internalIdSchema): ValidateFunction { +function getValidator(): ValidateFunction { + if (validator !== null) { + return validator; + } const ajv = new Ajv2020({ allErrors: true, strictTuples: false, allowUnionTypes: true, }); addFormats(ajv); - ajv.addSchema(idVariant, "id.yaml"); + ajv.addSchema(idSchema, "id.yaml"); ajv.addSchema(parameterSchema, "parameter.yaml"); ajv.addSchema(refSchema, "ref.yaml"); ajv.addSchema(temporalSchema, "temporal_bucketing.yaml"); @@ -60,21 +61,8 @@ function buildAjv(idVariant: typeof idSchema | typeof internalIdSchema): Validat if (compiled === undefined) { throw new Error("internal: query.yaml validator not registered"); } - return compiled; -} - -function getExternalValidator(): ValidateFunction { - if (externalValidator === null) { - externalValidator = buildAjv(idSchema); - } - return externalValidator; -} - -function getInternalValidator(): ValidateFunction { - if (internalValidator === null) { - internalValidator = buildAjv(internalIdSchema); - } - return internalValidator; + validator = compiled; + return validator; } export const UUID_HINT_MESSAGE = @@ -97,12 +85,12 @@ function isUuidFormatIssue(issue: ErrorObject): boolean { return parsed.success && parsed.data.format === "uuid"; } -function runValidator(validator: ValidateFunction, value: unknown): ValidationOutcome { - if (validator(value)) { +function runValidator(validatorFn: ValidateFunction, value: unknown): ValidationOutcome { + if (validatorFn(value)) { return { ok: true, errors: [] }; } const overrides = collectMessageOverrides(value); - const issues = validator.errors ?? []; + const issues = validatorFn.errors ?? []; const errors = issues.map((issue) => { if (issue.message === undefined) { throw new Error(`Ajv issue at ${issue.instancePath} has no message`); @@ -219,12 +207,8 @@ function describeJsonValue(value: unknown): string { return typeof value; } -export function validateExternalQuery(value: unknown): ValidationOutcome { - return runValidator(getExternalValidator(), value); -} - -export function validateInternalQuery(value: unknown): ValidationOutcome { - return runValidator(getInternalValidator(), value); +export function validateQuery(value: unknown): ValidationOutcome { + return runValidator(getValidator(), value); } export function isMbql5Query(value: unknown): boolean { @@ -288,11 +272,7 @@ export function assertNotLegacyEnvelopeWrappingMbql5( ); } -export const SchemaMode = z.enum(["external", "internal"]); -export type SchemaMode = z.infer; - export const QuerySchemaBundle = z.object({ - mode: SchemaMode, schema: z.unknown(), defs: z.object({ "id.yaml": z.unknown(), @@ -303,12 +283,11 @@ export const QuerySchemaBundle = z.object({ }); export type QuerySchemaBundle = z.infer; -export function getQuerySchemaBundle(mode: SchemaMode): QuerySchemaBundle { +export function getQuerySchemaBundle(): QuerySchemaBundle { return { - mode, schema: querySchema, defs: { - "id.yaml": mode === "internal" ? internalIdSchema : idSchema, + "id.yaml": idSchema, "parameter.yaml": parameterSchema, "ref.yaml": refSchema, "temporal_bucketing.yaml": temporalSchema, diff --git a/src/domain/card.ts b/src/domain/card.ts index 17c993e..811b807 100644 --- a/src/domain/card.ts +++ b/src/domain/card.ts @@ -6,6 +6,20 @@ const CardType = z.enum(["question", "model", "metric"]); const CardQueryType = z.enum(["native", "query"]); +// `dataset_query: {}` is accepted by the server's `::query` schema for historic +// reasons but immediately trips the NOT NULL constraint on REPORT_CARD.DATABASE_ID +// during INSERT, surfacing as a raw H2 stack trace. `dataset_query: null` is +// rejected by the create endpoint with a generic 400. Both are unrecoverable — +// reject at the CLI boundary so the agent gets a readable error. +export const CardDatasetQuery = z + .object({}) + .loose() + .refine((value) => "lib/type" in value || "type" in value, { + message: + 'dataset_query must include "lib/type" (MBQL 5) or "type" (legacy MBQL/native); empty `{}` is rejected', + }); +export type CardDatasetQuery = z.infer; + export const Card = z .object({ id: z.number().int(), @@ -56,7 +70,7 @@ export const CardCreateInput = z .object({ name: z.string().min(1), type: CardType.optional(), - dataset_query: z.unknown(), + dataset_query: CardDatasetQuery, display: z.string().min(1), visualization_settings: z.unknown(), description: z.string().nullable().optional(), @@ -73,7 +87,7 @@ export const CardUpdateInput = z .object({ name: z.string().min(1).optional(), type: CardType.optional(), - dataset_query: z.unknown().optional(), + dataset_query: CardDatasetQuery.optional(), display: z.string().min(1).optional(), visualization_settings: z.unknown().optional(), description: z.string().nullable().optional(), diff --git a/tests/e2e/card.e2e.test.ts b/tests/e2e/card.e2e.test.ts index 10f3261..0aa6e07 100644 --- a/tests/e2e/card.e2e.test.ts +++ b/tests/e2e/card.e2e.test.ts @@ -516,4 +516,77 @@ describe("card e2e", () => { expect(result.exitCode, result.stderr).toBe(0); expect(parseJson(result.stdout, CardCompact).id).toBe(E2E_CARDS.ORDERS_BY_STATUS); }); + + it("create with dataset_query: {} is rejected at the CLI boundary (no H2 stack trace)", async () => { + const result = await runCli({ + args: ["card", "create", "--json"], + stdin: JSON.stringify({ + name: "empty-dataset-query", + display: "table", + visualization_settings: {}, + collection_id: E2E_COLLECTIONS.DEFAULT, + dataset_query: {}, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stderr).toContain( + 'dataset_query must include "lib/type" (MBQL 5) or "type" (legacy MBQL/native); empty `{}` is rejected', + ); + expect(result.stderr).not.toContain("DATABASE_ID"); + expect(result.stdout).toBe(""); + }); + + it("create with dataset_query: null is rejected at the CLI boundary", async () => { + const result = await runCli({ + args: ["card", "create", "--json"], + stdin: JSON.stringify({ + name: "null-dataset-query", + display: "table", + visualization_settings: {}, + collection_id: E2E_COLLECTIONS.DEFAULT, + dataset_query: null, + }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stderr).toContain("expected object, received null"); + expect(result.stdout).toBe(""); + }); + + it("update with dataset_query: {} is rejected at the CLI boundary", async () => { + const result = await runCli({ + args: ["card", "update", String(E2E_CARDS.ORDERS_BY_STATUS), "--json"], + stdin: JSON.stringify({ dataset_query: {} }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stderr).toContain( + 'dataset_query must include "lib/type" (MBQL 5) or "type" (legacy MBQL/native); empty `{}` is rejected', + ); + expect(result.stdout).toBe(""); + }); + + it("update with dataset_query: null is rejected at the CLI boundary", async () => { + const result = await runCli({ + args: ["card", "update", String(E2E_CARDS.ORDERS_BY_STATUS), "--json"], + stdin: JSON.stringify({ dataset_query: null }), + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("request body: value did not match expected schema"); + expect(result.stderr).toContain("expected object, received null"); + expect(result.stdout).toBe(""); + }); }); diff --git a/tests/e2e/query.e2e.test.ts b/tests/e2e/query.e2e.test.ts index dead632..0f196a0 100644 --- a/tests/e2e/query.e2e.test.ts +++ b/tests/e2e/query.e2e.test.ts @@ -12,24 +12,24 @@ import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; import { E2E_DATABASES, E2E_TABLES } from "./seed/ids"; -const VALID_EXTERNAL = { +const VALID_QUERY = { "lib/type": "mbql/query", - database: "My DB", + database: 1, stages: [ { "lib/type": "mbql.stage/mbql", - "source-table": ["My DB", null, "orders"], + "source-table": 7, }, ], }; -const VALID_INTERNAL = { +const STRING_FK_BODY = { "lib/type": "mbql/query", - database: 1, + database: "My DB", stages: [ { "lib/type": "mbql.stage/mbql", - "source-table": 7, + "source-table": ["My DB", null, "orders"], }, ], }; @@ -65,7 +65,7 @@ describe("query e2e", () => { }; } - it("--print-schema (default) emits the internal-mode bundle with all 4 common defs", async () => { + it("--print-schema emits the schema bundle with all 4 common defs", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["query", "--print-schema"], @@ -73,37 +73,14 @@ describe("query e2e", () => { }); expect(result.exitCode, result.stderr).toBe(0); - expect(parseJson(result.stdout, QuerySchemaBundle)).toEqual(getQuerySchemaBundle("internal")); - }); - - it("--print-schema --external emits the external-mode bundle", async () => { - const configHome = await makeIsolatedConfigHome(); - const result = await runCli({ - args: ["query", "--print-schema", "--external"], - configHome, - }); - - expect(result.exitCode, result.stderr).toBe(0); - expect(parseJson(result.stdout, QuerySchemaBundle)).toEqual(getQuerySchemaBundle("external")); + expect(parseJson(result.stdout, QuerySchemaBundle)).toEqual(getQuerySchemaBundle()); }); - it("--dry-run (default = internal) with a valid numeric-IDs body returns ok and exits 0", async () => { + it("--dry-run with a valid numeric-IDs body returns ok and exits 0", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["query", "--dry-run"], - stdin: JSON.stringify(VALID_INTERNAL), - configHome, - }); - - expect(result.exitCode, result.stderr).toBe(0); - expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ ok: true, errors: [] }); - }); - - it("--external --dry-run with a valid string-FK body returns ok and exits 0", async () => { - const configHome = await makeIsolatedConfigHome(); - const result = await runCli({ - args: ["query", "--external", "--dry-run"], - stdin: JSON.stringify(VALID_EXTERNAL), + stdin: JSON.stringify(VALID_QUERY), configHome, }); @@ -111,11 +88,11 @@ describe("query e2e", () => { expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ ok: true, errors: [] }); }); - it("--dry-run (default = internal) rejects external-shaped IDs (string database, FK-tuple source-table)", async () => { + it("--dry-run rejects string-id / FK-tuple bodies (only positive integers are accepted)", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["query", "--dry-run"], - stdin: JSON.stringify(VALID_EXTERNAL), + stdin: JSON.stringify(STRING_FK_BODY), configHome, }); @@ -131,26 +108,6 @@ describe("query e2e", () => { expect(result.stderr).toContain("validation failed: 3 error(s)"); }); - it("--external --dry-run rejects internal-shaped IDs (integer database, integer source-table)", async () => { - const configHome = await makeIsolatedConfigHome(); - const result = await runCli({ - args: ["query", "--external", "--dry-run"], - stdin: JSON.stringify(VALID_INTERNAL), - configHome, - }); - - expect(result.exitCode).toBe(2); - expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ - ok: false, - errors: [ - { path: "/database", message: "must be string" }, - { path: "/stages/0/source-table", message: "must be array" }, - { path: "/stages/0", message: 'must match "then" schema' }, - ], - }); - expect(result.stderr).toContain("validation failed: 3 error(s)"); - }); - it("--dry-run with an empty stages array reports the structural error and exits 2", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ @@ -202,7 +159,7 @@ describe("query e2e", () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["query", "--skip-validate", "--dry-run"], - stdin: JSON.stringify(VALID_INTERNAL), + stdin: JSON.stringify(VALID_QUERY), configHome, }); @@ -213,11 +170,9 @@ describe("query e2e", () => { it("--skip-validate sends an invalid body and surfaces the server-side error (HttpError, exit 1)", async () => { const configHome = await makeIsolatedConfigHome(); - // External-shaped body in default (internal) mode would fail pre-flight; with --skip-validate the - // body reaches the server, which rejects it with an HTTP error. const result = await runCli({ args: ["query", "--skip-validate", "--json"], - stdin: JSON.stringify(VALID_EXTERNAL), + stdin: JSON.stringify(STRING_FK_BODY), configHome, env: authEnv(), }); @@ -227,7 +182,7 @@ describe("query e2e", () => { expect(result.stdout).toBe(""); }); - it("run (default = internal) executes a valid MBQL 5 query against /api/dataset and returns rows", async () => { + it("run executes a valid MBQL 5 query against /api/dataset and returns rows", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ args: ["query", "--json"], From ae94d551a62c6960e76d8b006e5baed616f3c1b9 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 12 May 2026 15:09:19 -0400 Subject: [PATCH 44/47] fix --- README.md | 15 +++-------- src/commands/query.ts | 6 ++--- src/core/schema/validate.test.ts | 46 -------------------------------- src/core/schema/validate.ts | 13 --------- tests/e2e/query.e2e.test.ts | 41 ++++++++++++++++++++++++++++ 5 files changed, 48 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index d39702b..a1ea426 100644 --- a/README.md +++ b/README.md @@ -1340,25 +1340,18 @@ metabase eid translate --body '{"entity_ids":{"card":["abc123XYZ"]}}' Run an MBQL 5 query with built-in schema validation. Three modes — discover the schema (`--print-schema`), validate without sending (`--dry-run`), run. -Two MBQL flavors: - -- **Internal MBQL** (default) — numeric IDs (`database: 1`, `source-table: 7`). POSTs to `/api/dataset`. This is what every existing Metabase API endpoint accepts. -- **External MBQL** (`--external`) — string-id FKs (`database: "My DB"`, `source-table: ["My DB", null, "orders"]`). POSTs to `/api/dataset/external` (forward-looking representations endpoint). - -External and internal MBQL are structurally identical; only the ID types differ. The bundled query schema is synced from `@metabase/representations`; the internal validator overrides `id.yaml` to require positive integers for every ID `$def`. +MBQL 5 bodies use numeric IDs (`database: 1`, `source-table: 7`) and POST to `/api/dataset`. The bundled query schema is synced from `@metabase/representations`; `id.yaml` is overridden to require positive integers for every ID `$def`. ```sh -metabase query --print-schema # internal JSON Schema bundle -metabase query --print-schema --external # string-FK variant +metabase query --print-schema # JSON Schema bundle cat q.json | metabase query --dry-run # validate, no network metabase query --file q.json -metabase query --file q.json --external metabase query --file q.json --skip-validate # bypass pre-flight; let server reject ``` Body sources: `--file`, `--body`, or stdin (exactly one). Body is JSON. -Legacy native bodies — `{ "type": "native", "database": N, "native": { "query": "..." } }` or any non-MBQL-5 body that carries a top-level `native:` key — skip MBQL 5 pre-flight automatically. The bundled schema only models MBQL 5, and `/api/dataset` accepts the legacy native shape as-is; the CLI detects it and routes straight to the server. `--dry-run` on a legacy native body emits `{ ok: true, errors: [] }` (no schema applies). The double-wrap footgun — an MBQL 5 query nested inside a `{type:"query", query:…}` envelope — is still rejected with a `ConfigError` before send. +Any non-MBQL 5 body skips pre-flight automatically — legacy MBQL 4 (`{ "type": "query", "database": N, "query": { "source-table": T, ... } }`), legacy native (`{ "type": "native", "database": N, "native": { "query": "..." } }`), or any other shape that doesn't carry `"lib/type": "mbql/query"`. The bundled schema only models MBQL 5; `/api/dataset` normalizes the rest server-side via `lib-be/normalize-query` (the same normalizer that backs `card create` / `transform create`), so behavior is symmetric across endpoints. `--dry-run` on a non-MBQL 5 body emits `{ ok: true, errors: [] }` (no schema applies). The double-wrap footgun — an MBQL 5 query nested inside a `{type:"query", query:…}` envelope — is still rejected with a `ConfigError` before send. `--skip-validate` is an escape hatch when the bundled schema disagrees with what the server actually accepts (drift, false negative, edge case) for MBQL 5 bodies. Validation is skipped entirely and the body is sent as-is. Mutually exclusive with `--dry-run` (which is itself the validation mode). @@ -1370,7 +1363,7 @@ Exit codes: Output by mode: -- `--print-schema` — `{ mode, schema, defs: { "id.yaml", "parameter.yaml", "ref.yaml", "temporal_bucketing.yaml" } }`. The query schema's `$ref`s point into the `defs` namespace by file path; an agent can either feed the bundle directly into Ajv (`addSchema(defs["id.yaml"], "id.yaml")` etc., then `compile(schema)`) or read it as documentation. +- `--print-schema` — `{ schema, defs: { "id.yaml", "parameter.yaml", "ref.yaml", "temporal_bucketing.yaml" } }`. The query schema's `$ref`s point into the `defs` namespace by file path; an agent can either feed the bundle directly into Ajv (`addSchema(defs["id.yaml"], "id.yaml")` etc., then `compile(schema)`) or read it as documentation. - `--dry-run` — `{ ok: boolean, errors: { path: string, message: string }[] }`. `path` is a JSON Pointer into the body, `message` is the Ajv error string. - Run failure (no `--dry-run`) — same `{ ok, errors }` envelope on stdout, exit 2, no request made. - Run success — the streamed `CardQueryResult`. diff --git a/src/commands/query.ts b/src/commands/query.ts index 7f1bf8d..29c6fcb 100644 --- a/src/commands/query.ts +++ b/src/commands/query.ts @@ -4,7 +4,7 @@ import { ConfigError } from "../core/errors"; import { assertNotLegacyEnvelopeWrappingMbql5, getQuerySchemaBundle, - isLegacyNativeQuery, + isMbql5Query, validateQuery, } from "../core/schema/validate"; import { CardQueryResult, cardQueryView } from "../domain/card"; @@ -24,7 +24,7 @@ export default defineMetabaseCommand({ meta: { name: "query", description: - 'Run an MBQL 5 query (validates against the bundled schema first); --print-schema emits the schema for agent discovery, --dry-run validates without sending. Legacy native bodies ({type:"native", …} or any top-level `native:`) skip pre-flight automatically — the bundled schema only models MBQL 5. Every clause options object carries a `lib/uuid` (UUID v4); mint these via `metabase uuid` — never author them by hand.', + 'Run an MBQL 5 query (validates against the bundled schema first); --print-schema emits the schema for agent discovery, --dry-run validates without sending. Any non-MBQL 5 body — legacy MBQL 4 ({type:"query", …}), legacy native ({type:"native", …}), or any other non-{lib/type:"mbql/query"} shape — skips pre-flight automatically and is normalized server-side by lib-be/normalize-query. The bundled schema only models MBQL 5. Every clause options object carries a `lib/uuid` (UUID v4); mint these via `metabase uuid` — never author them by hand.', }, args: { ...outputFlags, @@ -66,7 +66,7 @@ export default defineMetabaseCommand({ assertNotLegacyEnvelopeWrappingMbql5(body, { contextLabel: "query", bodyNoun: "the body" }); } - const skipValidation = explicitSkip || isLegacyNativeQuery(body); + const skipValidation = explicitSkip || !isMbql5Query(body); if (!skipValidation) { const outcome = validateQuery(body); diff --git a/src/core/schema/validate.test.ts b/src/core/schema/validate.test.ts index 21f7899..406e878 100644 --- a/src/core/schema/validate.test.ts +++ b/src/core/schema/validate.test.ts @@ -6,7 +6,6 @@ import { clauseSlot1HintMessage, getQuerySchemaBundle, isLegacyEnvelopeWrappingMbql5, - isLegacyNativeQuery, isMbql5Query, validateQuery, } from "./validate"; @@ -138,51 +137,6 @@ describe("isLegacyEnvelopeWrappingMbql5", () => { }); }); -describe("isLegacyNativeQuery", () => { - it("returns true for a legacy MBQL 4 native body", () => { - expect( - isLegacyNativeQuery({ - type: "native", - database: 2, - native: { query: "SELECT 1" }, - }), - ).toBe(true); - }); - - it("returns true when a non-MBQL5 body carries a top-level native key without an explicit type", () => { - expect(isLegacyNativeQuery({ database: 2, native: { query: "SELECT 1" } })).toBe(true); - }); - - it("returns false for a well-formed MBQL 5 query even if a stray top-level native key is present", () => { - expect( - isLegacyNativeQuery({ - "lib/type": "mbql/query", - database: 1, - stages: [{ "lib/type": "mbql.stage/native", native: "SELECT 1" }], - native: "stray", - }), - ).toBe(false); - }); - - it("returns false for a legacy MBQL 4 structured envelope", () => { - expect(isLegacyNativeQuery({ type: "query", database: 2, query: { "source-table": 7 } })).toBe( - false, - ); - }); - - it("returns false for a plain MBQL 5 query with no native fields", () => { - expect(isLegacyNativeQuery(VALID_QUERY)).toBe(false); - }); - - it("returns false for non-objects, arrays, and null", () => { - expect(isLegacyNativeQuery(null)).toBe(false); - expect(isLegacyNativeQuery(undefined)).toBe(false); - expect(isLegacyNativeQuery("SELECT 1")).toBe(false); - expect(isLegacyNativeQuery(42)).toBe(false); - expect(isLegacyNativeQuery([{ type: "native" }])).toBe(false); - }); -}); - describe("ref-clause error messages", () => { it("rewrites 'must be string' on aggregation_ref's UUID slot and reports the cascading 'then' shape errors verbatim", () => { const outcome = validateQuery({ diff --git a/src/core/schema/validate.ts b/src/core/schema/validate.ts index 9d41c7e..d451ff2 100644 --- a/src/core/schema/validate.ts +++ b/src/core/schema/validate.ts @@ -240,19 +240,6 @@ export function isLegacyEnvelopeWrappingMbql5(value: unknown): boolean { return "lib/type" in inner && inner["lib/type"] === "mbql/query"; } -// MBQL 5 native lives inside a stage (`stages[*].native`), never at the top -// level — the `isMbql5Query` guard keeps a well-formed MBQL 5 body out of this -// branch even if it carries a stray top-level `native` field. -export function isLegacyNativeQuery(value: unknown): boolean { - if (!isPlainObject(value)) { - return false; - } - if (isMbql5Query(value)) { - return false; - } - return value["type"] === "native" || "native" in value; -} - export interface LegacyEnvelopeAssertOptions { readonly contextLabel: string; readonly bodyNoun: string; diff --git a/tests/e2e/query.e2e.test.ts b/tests/e2e/query.e2e.test.ts index 0f196a0..3b41c29 100644 --- a/tests/e2e/query.e2e.test.ts +++ b/tests/e2e/query.e2e.test.ts @@ -248,6 +248,47 @@ describe("query e2e", () => { expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ ok: true, errors: [] }); }); + it("run with a legacy MBQL 4 body skips MBQL 5 pre-flight and executes against /api/dataset (parity with card create)", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--json"], + stdin: JSON.stringify({ + type: "query", + database: E2E_DATABASES.WAREHOUSE, + query: { + "source-table": E2E_TABLES.ORDERS, + limit: 3, + }, + }), + configHome, + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const queryResult = parseJson(result.stdout, CardQueryResult); + expect(queryResult.status).toBe("completed"); + if (queryResult.status === "completed") { + expect(queryResult.row_count).toBe(3); + expect(queryResult.data.rows).toHaveLength(3); + } + }); + + it("--dry-run with a legacy MBQL 4 body returns ok and exits 0 (server normalizes; no MBQL 5 schema applies)", async () => { + const configHome = await makeIsolatedConfigHome(); + const result = await runCli({ + args: ["query", "--dry-run"], + stdin: JSON.stringify({ + type: "query", + database: E2E_DATABASES.WAREHOUSE, + query: { "source-table": E2E_TABLES.ORDERS, limit: 1 }, + }), + configHome, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, ValidationOutcome)).toEqual({ ok: true, errors: [] }); + }); + it('rejects the double-wrap footgun (MBQL 5 inside a legacy {type:"query"} envelope) with a ConfigError', async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ From d9c31ecdef843bbda18924f3eecafc470d2b6cdf Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 12 May 2026 15:26:36 -0400 Subject: [PATCH 45/47] remote sync --- README.md | 86 +++++++++---------- .../{sync => remote-sync}/add-collection.ts | 4 +- .../{sync => remote-sync}/branches.ts | 2 +- .../{sync => remote-sync}/cancel-task.ts | 4 +- .../{sync => remote-sync}/create-branch.ts | 6 +- .../{sync => remote-sync}/current-task.ts | 7 +- src/commands/{sync => remote-sync}/dirty.ts | 2 +- src/commands/{sync => remote-sync}/export.ts | 6 +- .../has-remote-changes.ts | 4 +- src/commands/{sync => remote-sync}/import.ts | 6 +- src/commands/{sync => remote-sync}/index.ts | 2 +- .../{sync => remote-sync}/is-dirty.ts | 2 +- .../{sync => remote-sync}/poll-task.test.ts | 0 .../{sync => remote-sync}/poll-task.ts | 2 +- .../remove-collection.ts | 4 +- src/commands/{sync => remote-sync}/stash.ts | 4 +- src/commands/{sync => remote-sync}/status.ts | 7 +- src/commands/{sync => remote-sync}/wait.ts | 4 +- src/main.ts | 2 +- tests/e2e/manifest.e2e.test.ts | 28 +++--- ...nc.e2e.test.ts => remote-sync.e2e.test.ts} | 65 ++++++++------ 21 files changed, 131 insertions(+), 116 deletions(-) rename src/commands/{sync => remote-sync}/add-collection.ts (94%) rename src/commands/{sync => remote-sync}/branches.ts (93%) rename src/commands/{sync => remote-sync}/cancel-task.ts (86%) rename src/commands/{sync => remote-sync}/create-branch.ts (91%) rename src/commands/{sync => remote-sync}/current-task.ts (79%) rename src/commands/{sync => remote-sync}/dirty.ts (92%) rename src/commands/{sync => remote-sync}/export.ts (95%) rename src/commands/{sync => remote-sync}/has-remote-changes.ts (93%) rename src/commands/{sync => remote-sync}/import.ts (95%) rename src/commands/{sync => remote-sync}/index.ts (91%) rename src/commands/{sync => remote-sync}/is-dirty.ts (92%) rename src/commands/{sync => remote-sync}/poll-task.test.ts (100%) rename src/commands/{sync => remote-sync}/poll-task.ts (98%) rename src/commands/{sync => remote-sync}/remove-collection.ts (89%) rename src/commands/{sync => remote-sync}/stash.ts (95%) rename src/commands/{sync => remote-sync}/status.ts (88%) rename src/commands/{sync => remote-sync}/wait.ts (88%) rename tests/e2e/{sync.e2e.test.ts => remote-sync.e2e.test.ts} (81%) diff --git a/README.md b/README.md index a1ea426..c5e8177 100644 --- a/README.md +++ b/README.md @@ -932,73 +932,73 @@ metabase search products --archived | `--table-db-id` | Restrict to items on a given database id. | | `--verified` | Only verified content. | -## Sync +## Remote Sync -Drive Metabase Enterprise Remote Sync (`/api/ee/remote-sync`) — import / export Metabase content against a configured git remote, inspect dirty state, and manage branches. All sync commands require an active EE token and superuser credentials. +Drive Metabase Enterprise Remote Sync (`/api/ee/remote-sync`) — import / export Metabase content against a configured git remote, inspect dirty state, and manage branches. All remote-sync commands require an active EE token and superuser credentials. -### `metabase sync status` +### `metabase remote-sync status` Roll up the current sync state in one call: configured branch, dirty flag, and the most recent sync task (or `null` if none has ever run). ```sh -metabase sync status -metabase sync status --json +metabase remote-sync status +metabase remote-sync status --json ``` -### `metabase sync is-dirty` +### `metabase remote-sync is-dirty` Boolean check for whether any synced collection has unsynced local changes. ```sh -metabase sync is-dirty --json +metabase remote-sync is-dirty --json ``` -### `metabase sync has-remote-changes` +### `metabase remote-sync has-remote-changes` Compare the latest version on the remote branch against the version Metabase last imported. Cached for a short TTL server-side; pass `--force-refresh` to bypass. ```sh -metabase sync has-remote-changes -metabase sync has-remote-changes --force-refresh --json +metabase remote-sync has-remote-changes +metabase remote-sync has-remote-changes --force-refresh --json ``` | Flag | Description | | ----------------- | --------------------------------------------------- | | `--force-refresh` | Bypass the in-memory cache and re-check the remote. | -### `metabase sync dirty` +### `metabase remote-sync dirty` List every object that has unsynced local changes (compact list envelope; `--full` for the per-row payload). ```sh -metabase sync dirty -metabase sync dirty --json +metabase remote-sync dirty +metabase remote-sync dirty --json ``` -### `metabase sync current-task` +### `metabase remote-sync current-task` Fetch the most recent sync task. Renders `{ status: "idle" }` when no task has ever run, otherwise the full task with its hydrated `status`. ```sh -metabase sync current-task -metabase sync current-task --json +metabase remote-sync current-task +metabase remote-sync current-task --json ``` -### `metabase sync cancel-task` +### `metabase remote-sync cancel-task` Cancel the currently running sync task. Fails with HTTP 400 if no task is running. ```sh -metabase sync cancel-task --json +metabase remote-sync cancel-task --json ``` -### `metabase sync wait` +### `metabase remote-sync wait` Poll `/current-task` until it reaches a terminal status (`successful`, `errored`, `cancelled`, `timed-out`, `conflict`). Exits 0 on `successful` or `cancelled`; exits 1 on `errored` / `timed-out` / `conflict`. Returns immediately with `{ status: "idle" }` if no task is running. ```sh -metabase sync wait -metabase sync wait --timeout 300000 --json +metabase remote-sync wait +metabase remote-sync wait --timeout 300000 --json ``` | Flag | Description | @@ -1006,14 +1006,14 @@ metabase sync wait --timeout 300000 --json | `--timeout ` | Polling timeout in ms (default 600000). | | `--interval ` | Polling interval in ms (default 2000). | -### `metabase sync import` +### `metabase remote-sync import` Import content from the configured git remote into Metabase (repo → Metabase). Auto-polls until the resulting task reaches a terminal status; pass `--no-wait` to return immediately after kickoff. ```sh -metabase sync import -metabase sync import --branch main --json -metabase sync import --force --no-wait +metabase remote-sync import +metabase remote-sync import --branch main --json +metabase remote-sync import --force --no-wait ``` | Flag | Description | @@ -1024,14 +1024,14 @@ metabase sync import --force --no-wait | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | | `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | -### `metabase sync export` +### `metabase remote-sync export` Export Metabase changes back to the configured git remote (Metabase → repo). Auto-polls by default. ```sh -metabase sync export -m "update dashboards" -metabase sync export --branch main --json -metabase sync export --no-wait +metabase remote-sync export -m "update dashboards" +metabase remote-sync export --branch main --json +metabase remote-sync export --no-wait ``` | Flag | Description | @@ -1043,13 +1043,13 @@ metabase sync export --no-wait | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | | `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | -### `metabase sync stash` +### `metabase remote-sync stash` Export the current Metabase state to a NEW branch on the remote and switch sync to it. Requires `remote-sync-type` to be `read-write`. ```sh -metabase sync stash --new-branch wip -metabase sync stash --new-branch wip -m "work in progress" --json +metabase remote-sync stash --new-branch wip +metabase remote-sync stash --new-branch wip -m "work in progress" --json ``` | Flag | Description | @@ -1060,41 +1060,41 @@ metabase sync stash --new-branch wip -m "work in progress" --json | `--timeout ` | Polling timeout in ms. Used with `--wait`. | | `--interval ` | Polling interval in ms. Used with `--wait`. | -### `metabase sync branches` +### `metabase remote-sync branches` List branches available on the configured git remote. ```sh -metabase sync branches --json +metabase remote-sync branches --json ``` -### `metabase sync create-branch ` +### `metabase remote-sync create-branch ` Create a new branch on the git remote (from the last imported version) and switch sync to it. ```sh -metabase sync create-branch feat/dashboards -metabase sync create-branch feat/x --json +metabase remote-sync create-branch feat/dashboards +metabase remote-sync create-branch feat/x --json ``` -### `metabase sync add-collection ` +### `metabase remote-sync add-collection ` Mark a collection as remote-synced. The toggle cascades to every descendant by `location` prefix, so flagging a parent flags the whole subtree. Returns `{ success, task_id? }`; `task_id` only appears when the toggle triggers a follow-up task (e.g. a finalization import after switching to read-only mode). ```sh -metabase sync add-collection 12 -metabase sync add-collection 12 --json --profile prod +metabase remote-sync add-collection 12 +metabase remote-sync add-collection 12 --json --profile prod ``` The server rejects toggles while `remote-sync-type` is `read-only` (the install default). Switch first with `metabase setting set remote-sync-type '"read-write"'`. -### `metabase sync remove-collection ` +### `metabase remote-sync remove-collection ` Unmark a collection as remote-synced. Same cascade and same `read-only` precondition as `add-collection`. ```sh -metabase sync remove-collection 12 -metabase sync remove-collection 12 --json --profile prod +metabase remote-sync remove-collection 12 +metabase remote-sync remove-collection 12 --json --profile prod ``` ## Workspaces diff --git a/src/commands/sync/add-collection.ts b/src/commands/remote-sync/add-collection.ts similarity index 94% rename from src/commands/sync/add-collection.ts rename to src/commands/remote-sync/add-collection.ts index 74f35e6..9b4ece5 100644 --- a/src/commands/sync/add-collection.ts +++ b/src/commands/remote-sync/add-collection.ts @@ -47,8 +47,8 @@ export default defineMetabaseCommand({ }, outputSchema: SyncSettingsUpdateResult, examples: [ - "metabase sync add-collection 12", - "metabase sync add-collection 12 --json --profile prod", + "metabase remote-sync add-collection 12", + "metabase remote-sync add-collection 12 --json --profile prod", ], async run({ args, ctx, getClient }) { const collectionId = parseId(args.id, "id"); diff --git a/src/commands/sync/branches.ts b/src/commands/remote-sync/branches.ts similarity index 93% rename from src/commands/sync/branches.ts rename to src/commands/remote-sync/branches.ts index e212db4..a2b078f 100644 --- a/src/commands/sync/branches.ts +++ b/src/commands/remote-sync/branches.ts @@ -26,7 +26,7 @@ export default defineMetabaseCommand({ meta: { name: "branches", description: "List branches on the configured git remote" }, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncBranchListEnvelope, - examples: ["metabase sync branches", "metabase sync branches --json"], + examples: ["metabase remote-sync branches", "metabase remote-sync branches --json"], async run({ ctx, getClient }) { const client = await getClient(); const response = await client.requestParsed( diff --git a/src/commands/sync/cancel-task.ts b/src/commands/remote-sync/cancel-task.ts similarity index 86% rename from src/commands/sync/cancel-task.ts rename to src/commands/remote-sync/cancel-task.ts index 70ac466..6424c19 100644 --- a/src/commands/sync/cancel-task.ts +++ b/src/commands/remote-sync/cancel-task.ts @@ -6,10 +6,10 @@ import { defineMetabaseCommand } from "../runtime"; import { REMOTE_SYNC_PATHS } from "./poll-task"; export default defineMetabaseCommand({ - meta: { name: "cancel-task", description: "Cancel the running sync task" }, + meta: { name: "cancel-task", description: "Cancel the running remote-sync task" }, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncTask, - examples: ["metabase sync cancel-task", "metabase sync cancel-task --json"], + examples: ["metabase remote-sync cancel-task", "metabase remote-sync cancel-task --json"], async run({ ctx, getClient }) { const client = await getClient(); const task = await client.requestParsed(SyncTask, REMOTE_SYNC_PATHS.cancelTask, { diff --git a/src/commands/sync/create-branch.ts b/src/commands/remote-sync/create-branch.ts similarity index 91% rename from src/commands/sync/create-branch.ts rename to src/commands/remote-sync/create-branch.ts index e748613..5c04267 100644 --- a/src/commands/sync/create-branch.ts +++ b/src/commands/remote-sync/create-branch.ts @@ -25,7 +25,7 @@ const createBranchView: ResourceView = { export default defineMetabaseCommand({ meta: { name: "create-branch", - description: "Create a new branch on the git remote and switch sync to it", + description: "Create a new branch on the git remote and switch remote-sync to it", }, args: { ...outputFlags, @@ -35,8 +35,8 @@ export default defineMetabaseCommand({ }, outputSchema: CreateBranchResult, examples: [ - "metabase sync create-branch feat/dashboards", - "metabase sync create-branch feat/x --json", + "metabase remote-sync create-branch feat/dashboards", + "metabase remote-sync create-branch feat/x --json", ], async run({ args, ctx, getClient }) { const name = args.name.trim(); diff --git a/src/commands/sync/current-task.ts b/src/commands/remote-sync/current-task.ts similarity index 79% rename from src/commands/sync/current-task.ts rename to src/commands/remote-sync/current-task.ts index 11b8f2d..e225691 100644 --- a/src/commands/sync/current-task.ts +++ b/src/commands/remote-sync/current-task.ts @@ -8,10 +8,13 @@ import { fetchCurrentTask, syncTaskIdleView, SyncTaskIdle, SyncTaskOrIdle } from export const CurrentTaskResult = SyncTaskOrIdle; export default defineMetabaseCommand({ - meta: { name: "current-task", description: "Get the most recent sync task (or idle if none)" }, + meta: { + name: "current-task", + description: "Get the most recent remote-sync task (or idle if none)", + }, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: CurrentTaskResult, - examples: ["metabase sync current-task", "metabase sync current-task --json"], + examples: ["metabase remote-sync current-task", "metabase remote-sync current-task --json"], async run({ ctx, getClient }) { const client = await getClient(); const task = await fetchCurrentTask(client); diff --git a/src/commands/sync/dirty.ts b/src/commands/remote-sync/dirty.ts similarity index 92% rename from src/commands/sync/dirty.ts rename to src/commands/remote-sync/dirty.ts index 1939822..d59c192 100644 --- a/src/commands/sync/dirty.ts +++ b/src/commands/remote-sync/dirty.ts @@ -18,7 +18,7 @@ export default defineMetabaseCommand({ meta: { name: "dirty", description: "List objects with unsynced local changes" }, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncDirtyListEnvelope, - examples: ["metabase sync dirty", "metabase sync dirty --json"], + examples: ["metabase remote-sync dirty", "metabase remote-sync dirty --json"], async run({ ctx, getClient }) { const client = await getClient(); const response = await client.requestParsed(SyncDirtyApiResponse, REMOTE_SYNC_PATHS.dirty); diff --git a/src/commands/sync/export.ts b/src/commands/remote-sync/export.ts similarity index 95% rename from src/commands/sync/export.ts rename to src/commands/remote-sync/export.ts index f768f77..0478440 100644 --- a/src/commands/sync/export.ts +++ b/src/commands/remote-sync/export.ts @@ -65,9 +65,9 @@ export default defineMetabaseCommand({ }, outputSchema: SyncExportResult, examples: [ - 'metabase sync export -m "update dashboards"', - "metabase sync export --branch main --json", - "metabase sync export --no-wait", + 'metabase remote-sync export -m "update dashboards"', + "metabase remote-sync export --branch main --json", + "metabase remote-sync export --no-wait", ], async run({ args, ctx, getClient }) { const timeoutMs = parseId(args.timeout, "timeout"); diff --git a/src/commands/sync/has-remote-changes.ts b/src/commands/remote-sync/has-remote-changes.ts similarity index 93% rename from src/commands/sync/has-remote-changes.ts rename to src/commands/remote-sync/has-remote-changes.ts index 6cd12d4..baa5e9a 100644 --- a/src/commands/sync/has-remote-changes.ts +++ b/src/commands/remote-sync/has-remote-changes.ts @@ -43,8 +43,8 @@ export default defineMetabaseCommand({ }, outputSchema: HasRemoteChangesResult, examples: [ - "metabase sync has-remote-changes", - "metabase sync has-remote-changes --force-refresh --json", + "metabase remote-sync has-remote-changes", + "metabase remote-sync has-remote-changes --force-refresh --json", ], async run({ args, ctx, getClient }) { const client = await getClient(); diff --git a/src/commands/sync/import.ts b/src/commands/remote-sync/import.ts similarity index 95% rename from src/commands/sync/import.ts rename to src/commands/remote-sync/import.ts index fc035ab..327d262 100644 --- a/src/commands/sync/import.ts +++ b/src/commands/remote-sync/import.ts @@ -58,9 +58,9 @@ export default defineMetabaseCommand({ }, outputSchema: SyncImportResult, examples: [ - "metabase sync import", - "metabase sync import --branch main --json", - "metabase sync import --force --no-wait", + "metabase remote-sync import", + "metabase remote-sync import --branch main --json", + "metabase remote-sync import --force --no-wait", ], async run({ args, ctx, getClient }) { const timeoutMs = parseId(args.timeout, "timeout"); diff --git a/src/commands/sync/index.ts b/src/commands/remote-sync/index.ts similarity index 91% rename from src/commands/sync/index.ts rename to src/commands/remote-sync/index.ts index ea50e17..5c5904a 100644 --- a/src/commands/sync/index.ts +++ b/src/commands/remote-sync/index.ts @@ -1,7 +1,7 @@ import { defineCommand } from "citty"; export default defineCommand({ - meta: { name: "sync", description: "Remote-sync operations (import, export, status, branches)" }, + meta: { name: "remote-sync", description: "Sync Metabase content with a git remote" }, subCommands: { status: () => import("./status").then((mod) => mod.default), "is-dirty": () => import("./is-dirty").then((mod) => mod.default), diff --git a/src/commands/sync/is-dirty.ts b/src/commands/remote-sync/is-dirty.ts similarity index 92% rename from src/commands/sync/is-dirty.ts rename to src/commands/remote-sync/is-dirty.ts index 35ef786..55b96d6 100644 --- a/src/commands/sync/is-dirty.ts +++ b/src/commands/remote-sync/is-dirty.ts @@ -24,7 +24,7 @@ export default defineMetabaseCommand({ }, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: IsDirtyResult, - examples: ["metabase sync is-dirty", "metabase sync is-dirty --json"], + examples: ["metabase remote-sync is-dirty", "metabase remote-sync is-dirty --json"], async run({ ctx, getClient }) { const client = await getClient(); const result = await client.requestParsed(IsDirtyResult, REMOTE_SYNC_PATHS.isDirty); diff --git a/src/commands/sync/poll-task.test.ts b/src/commands/remote-sync/poll-task.test.ts similarity index 100% rename from src/commands/sync/poll-task.test.ts rename to src/commands/remote-sync/poll-task.test.ts diff --git a/src/commands/sync/poll-task.ts b/src/commands/remote-sync/poll-task.ts similarity index 98% rename from src/commands/sync/poll-task.ts rename to src/commands/remote-sync/poll-task.ts index 2baa491..ca189c6 100644 --- a/src/commands/sync/poll-task.ts +++ b/src/commands/remote-sync/poll-task.ts @@ -104,5 +104,5 @@ export function throwIfFailedTask(final: SyncTask | null, verb: string): void { return; } const detail = final.error_message ? `: ${final.error_message}` : ""; - throw new Error(`sync ${verb} ${final.status}${detail}`); + throw new Error(`remote-sync ${verb} ${final.status}${detail}`); } diff --git a/src/commands/sync/remove-collection.ts b/src/commands/remote-sync/remove-collection.ts similarity index 89% rename from src/commands/sync/remove-collection.ts rename to src/commands/remote-sync/remove-collection.ts index 430e05c..1edf867 100644 --- a/src/commands/sync/remove-collection.ts +++ b/src/commands/remote-sync/remove-collection.ts @@ -22,8 +22,8 @@ export default defineMetabaseCommand({ }, outputSchema: SyncSettingsUpdateResult, examples: [ - "metabase sync remove-collection 12", - "metabase sync remove-collection 12 --json --profile prod", + "metabase remote-sync remove-collection 12", + "metabase remote-sync remove-collection 12 --json --profile prod", ], async run({ args, ctx, getClient }) { const collectionId = parseId(args.id, "id"); diff --git a/src/commands/sync/stash.ts b/src/commands/remote-sync/stash.ts similarity index 95% rename from src/commands/sync/stash.ts rename to src/commands/remote-sync/stash.ts index 96cf519..233fb60 100644 --- a/src/commands/sync/stash.ts +++ b/src/commands/remote-sync/stash.ts @@ -65,8 +65,8 @@ export default defineMetabaseCommand({ }, outputSchema: SyncStashResult, examples: [ - "metabase sync stash --new-branch wip", - 'metabase sync stash --new-branch wip -m "work in progress" --json', + "metabase remote-sync stash --new-branch wip", + 'metabase remote-sync stash --new-branch wip -m "work in progress" --json', ], async run({ args, ctx, getClient }) { const newBranch = args.newBranch.trim(); diff --git a/src/commands/sync/status.ts b/src/commands/remote-sync/status.ts similarity index 88% rename from src/commands/sync/status.ts rename to src/commands/remote-sync/status.ts index beb813d..d38b6af 100644 --- a/src/commands/sync/status.ts +++ b/src/commands/remote-sync/status.ts @@ -28,10 +28,13 @@ const syncStatusView: ResourceView = { }; export default defineMetabaseCommand({ - meta: { name: "status", description: "Show current sync state (branch, dirty, current task)" }, + meta: { + name: "status", + description: "Show current remote-sync state (branch, dirty, current task)", + }, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncStatus, - examples: ["metabase sync status", "metabase sync status --json"], + examples: ["metabase remote-sync status", "metabase remote-sync status --json"], async run({ ctx, getClient }) { const client = await getClient(); const [branch, isDirty, currentTask] = await Promise.all([ diff --git a/src/commands/sync/wait.ts b/src/commands/remote-sync/wait.ts similarity index 88% rename from src/commands/sync/wait.ts rename to src/commands/remote-sync/wait.ts index 27d29ef..1a2b261 100644 --- a/src/commands/sync/wait.ts +++ b/src/commands/remote-sync/wait.ts @@ -18,7 +18,7 @@ export const WaitResult = SyncTaskOrIdle; export default defineMetabaseCommand({ meta: { name: "wait", - description: "Poll the current sync task until it reaches a terminal status", + description: "Poll the current remote-sync task until it reaches a terminal status", }, args: { ...outputFlags, @@ -36,7 +36,7 @@ export default defineMetabaseCommand({ }, }, outputSchema: WaitResult, - examples: ["metabase sync wait", "metabase sync wait --timeout 300000 --json"], + examples: ["metabase remote-sync wait", "metabase remote-sync wait --timeout 300000 --json"], async run({ args, ctx, getClient }) { const timeoutMs = parseId(args.timeout, "timeout"); const intervalMs = parseId(args.interval, "interval"); diff --git a/src/main.ts b/src/main.ts index 1302bd7..e1627a7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,7 +22,7 @@ const main: CommandDef = defineCommand({ "transform-job": () => import("./commands/transform-job").then((mod) => mod.default), setting: () => import("./commands/setting").then((mod) => mod.default), search: () => import("./commands/search").then((mod) => mod.default), - sync: () => import("./commands/sync").then((mod) => mod.default), + "remote-sync": () => import("./commands/remote-sync").then((mod) => mod.default), workspace: () => import("./commands/workspace").then((mod) => mod.default), setup: () => import("./commands/setup").then((mod) => mod.default), "api-key": () => import("./commands/api-key").then((mod) => mod.default), diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index d00d3a3..fc36550 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -90,20 +90,20 @@ describe("__manifest e2e", () => { "setting get", "setting set", "search", - "sync status", - "sync is-dirty", - "sync has-remote-changes", - "sync dirty", - "sync current-task", - "sync cancel-task", - "sync wait", - "sync import", - "sync export", - "sync stash", - "sync branches", - "sync create-branch", - "sync add-collection", - "sync remove-collection", + "remote-sync status", + "remote-sync is-dirty", + "remote-sync has-remote-changes", + "remote-sync dirty", + "remote-sync current-task", + "remote-sync cancel-task", + "remote-sync wait", + "remote-sync import", + "remote-sync export", + "remote-sync stash", + "remote-sync branches", + "remote-sync create-branch", + "remote-sync add-collection", + "remote-sync remove-collection", "workspace list", "workspace create", "workspace database provision", diff --git a/tests/e2e/sync.e2e.test.ts b/tests/e2e/remote-sync.e2e.test.ts similarity index 81% rename from tests/e2e/sync.e2e.test.ts rename to tests/e2e/remote-sync.e2e.test.ts index 2a38851..fd2af61 100644 --- a/tests/e2e/sync.e2e.test.ts +++ b/tests/e2e/remote-sync.e2e.test.ts @@ -1,16 +1,16 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { CurrentTaskResult } from "../../src/commands/sync/current-task"; -import { SyncDirtyListEnvelope } from "../../src/commands/sync/dirty"; -import { IsDirtyResult } from "../../src/commands/sync/is-dirty"; -import { SyncStatus } from "../../src/commands/sync/status"; -import { WaitResult } from "../../src/commands/sync/wait"; +import { CurrentTaskResult } from "../../src/commands/remote-sync/current-task"; +import { SyncDirtyListEnvelope } from "../../src/commands/remote-sync/dirty"; +import { IsDirtyResult } from "../../src/commands/remote-sync/is-dirty"; +import { SyncStatus } from "../../src/commands/remote-sync/status"; +import { WaitResult } from "../../src/commands/remote-sync/wait"; import { parseJson } from "../../src/runtime/json"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -describe("sync arg validation e2e (no Metabase contact required)", () => { +describe("remote-sync arg validation e2e (no Metabase contact required)", () => { const tempDirs: string[] = []; afterEach(async () => { @@ -26,7 +26,7 @@ describe("sync arg validation e2e (no Metabase contact required)", () => { it("wait with non-integer --timeout fails fast with ConfigError before any network call", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "wait", "--timeout", "abc", "--json"], + args: ["remote-sync", "wait", "--timeout", "abc", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -37,7 +37,7 @@ describe("sync arg validation e2e (no Metabase contact required)", () => { it("wait with non-integer --interval fails fast with ConfigError before any network call", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "wait", "--interval", "xyz", "--json"], + args: ["remote-sync", "wait", "--interval", "xyz", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -48,7 +48,7 @@ describe("sync arg validation e2e (no Metabase contact required)", () => { it("stash with whitespace-only --new-branch fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "stash", "--new-branch", " ", "--json"], + args: ["remote-sync", "stash", "--new-branch", " ", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -59,7 +59,7 @@ describe("sync arg validation e2e (no Metabase contact required)", () => { it("stash with whitespace-only --message fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "stash", "--new-branch", "wip", "--message", " ", "--json"], + args: ["remote-sync", "stash", "--new-branch", "wip", "--message", " ", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -70,7 +70,7 @@ describe("sync arg validation e2e (no Metabase contact required)", () => { it("create-branch with whitespace-only positional fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "create-branch", " ", "--json"], + args: ["remote-sync", "create-branch", " ", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -81,7 +81,7 @@ describe("sync arg validation e2e (no Metabase contact required)", () => { it("add-collection with non-integer positional fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "add-collection", "abc", "--json"], + args: ["remote-sync", "add-collection", "abc", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -92,7 +92,7 @@ describe("sync arg validation e2e (no Metabase contact required)", () => { it("add-collection with zero positional fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "add-collection", "0", "--json"], + args: ["remote-sync", "add-collection", "0", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -103,7 +103,7 @@ describe("sync arg validation e2e (no Metabase contact required)", () => { it("remove-collection with negative positional fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "remove-collection", "--", "-3", "--json"], + args: ["remote-sync", "remove-collection", "--", "-3", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -112,7 +112,7 @@ describe("sync arg validation e2e (no Metabase contact required)", () => { }); }); -describe("sync e2e against EE remote-sync endpoints", () => { +describe("remote-sync e2e against EE remote-sync endpoints", () => { let bootstrap: E2EBootstrap; const tempDirs: string[] = []; @@ -140,7 +140,7 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("current-task returns the idle marker when no sync has ever run", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "current-task", "--json"], + args: ["remote-sync", "current-task", "--json"], configHome, env: authEnv(), }); @@ -151,7 +151,7 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("is-dirty reports false when no synced collections exist", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "is-dirty", "--json"], + args: ["remote-sync", "is-dirty", "--json"], configHome, env: authEnv(), }); @@ -162,7 +162,7 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("dirty returns an empty list envelope when nothing is dirty", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "dirty", "--json"], + args: ["remote-sync", "dirty", "--json"], configHome, env: authEnv(), }); @@ -177,7 +177,7 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("status rolls up branch (null), is_dirty (false), and current_task (null)", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "status", "--json"], + args: ["remote-sync", "status", "--json"], configHome, env: authEnv(), }); @@ -192,7 +192,7 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("wait exits successfully with the idle marker when no task is running", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "wait", "--json"], + args: ["remote-sync", "wait", "--json"], configHome, env: authEnv(), }); @@ -203,7 +203,7 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("import without remote-sync configured surfaces a 400 HttpError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "import", "--no-wait", "--json"], + args: ["remote-sync", "import", "--no-wait", "--json"], configHome, env: authEnv(), }); @@ -214,7 +214,7 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("export without remote-sync configured surfaces a 400 HttpError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "export", "--no-wait", "--json"], + args: ["remote-sync", "export", "--no-wait", "--json"], configHome, env: authEnv(), }); @@ -225,7 +225,7 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("has-remote-changes without remote-sync configured surfaces a 400 HttpError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "has-remote-changes", "--json"], + args: ["remote-sync", "has-remote-changes", "--json"], configHome, env: authEnv(), }); @@ -236,7 +236,7 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("cancel-task surfaces a 400 HttpError when there is no running task", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "cancel-task", "--json"], + args: ["remote-sync", "cancel-task", "--json"], configHome, env: authEnv(), }); @@ -247,7 +247,16 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("stash surfaces a 400 HttpError when remote-sync-type is not read-write", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "stash", "--new-branch", "wip", "--message", "x", "--no-wait", "--json"], + args: [ + "remote-sync", + "stash", + "--new-branch", + "wip", + "--message", + "x", + "--no-wait", + "--json", + ], configHome, env: authEnv(), }); @@ -258,7 +267,7 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("branches surfaces an HttpError when no source URL is configured", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "branches", "--json"], + args: ["remote-sync", "branches", "--json"], configHome, env: authEnv(), }); @@ -269,7 +278,7 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("add-collection surfaces a 400 HttpError in the default config (read-only or paywall)", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "add-collection", "1", "--json"], + args: ["remote-sync", "add-collection", "1", "--json"], configHome, env: authEnv(), }); @@ -280,7 +289,7 @@ describe("sync e2e against EE remote-sync endpoints", () => { it("remove-collection is idempotent when the collection is not in the sync config", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["sync", "remove-collection", "1", "--json"], + args: ["remote-sync", "remove-collection", "1", "--json"], configHome, env: authEnv(), }); From 935a64ce08b5fcbf32d073344dac30c4e36cf6c3 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 12 May 2026 16:31:51 -0400 Subject: [PATCH 46/47] fix --- .../workspace/database/parse-schemas.ts | 5 +-- src/commands/workspace/database/provision.ts | 4 +- src/commands/workspace/database/update.ts | 4 +- src/domain/workspace.ts | 38 ++++--------------- 4 files changed, 13 insertions(+), 38 deletions(-) diff --git a/src/commands/workspace/database/parse-schemas.ts b/src/commands/workspace/database/parse-schemas.ts index 7cb85f6..9ffea81 100644 --- a/src/commands/workspace/database/parse-schemas.ts +++ b/src/commands/workspace/database/parse-schemas.ts @@ -1,11 +1,10 @@ import { ConfigError } from "../../../core/errors"; -import type { WorkspaceInputNamespace } from "../../../domain/workspace"; import { parseCsv } from "../../../runtime/csv"; -export function parseSchemasCsv(raw: string): WorkspaceInputNamespace[] { +export function parseSchemasCsv(raw: string): string[] { const parts = parseCsv(raw); if (parts.length === 0) { throw new ConfigError("--schemas must contain at least one schema name"); } - return parts.map((schema) => ({ schema })); + return parts; } diff --git a/src/commands/workspace/database/provision.ts b/src/commands/workspace/database/provision.ts index 2abf384..c65896e 100644 --- a/src/commands/workspace/database/provision.ts +++ b/src/commands/workspace/database/provision.ts @@ -47,8 +47,8 @@ export default defineMetabaseCommand({ if (schemasFlag === undefined || schemasFlag === "") { throw new ConfigError("--schemas is required when using --database-id"); } - const input = parseSchemasCsv(schemasFlag); - body = WorkspaceProvisionInput.parse({ database_id: databaseId, input }); + const input_schemas = parseSchemasCsv(schemasFlag); + body = WorkspaceProvisionInput.parse({ database_id: databaseId, input_schemas }); } else { body = await readBody({ flag: args.body, file: args.file }, WorkspaceProvisionInput); } diff --git a/src/commands/workspace/database/update.ts b/src/commands/workspace/database/update.ts index 3a24341..809835c 100644 --- a/src/commands/workspace/database/update.ts +++ b/src/commands/workspace/database/update.ts @@ -43,8 +43,8 @@ export default defineMetabaseCommand({ let body: WorkspaceUpdateDatabaseInput; if (schemasFlag !== undefined && schemasFlag !== "") { - const input = parseSchemasCsv(schemasFlag); - body = WorkspaceUpdateDatabaseInput.parse({ input }); + const input_schemas = parseSchemasCsv(schemasFlag); + body = WorkspaceUpdateDatabaseInput.parse({ input_schemas }); } else { body = await readBody({ flag: args.body, file: args.file }, WorkspaceUpdateDatabaseInput); } diff --git a/src/domain/workspace.ts b/src/domain/workspace.ts index aaf16c2..de7d7b3 100644 --- a/src/domain/workspace.ts +++ b/src/domain/workspace.ts @@ -9,22 +9,11 @@ const WorkspaceDatabaseStatus = z.enum([ "deprovisioning", ]); -export const WorkspaceInputNamespace = z - .object({ - db: z.string().min(1).optional(), - schema: z.string().min(1).optional(), - }) - .loose() - .refine((value) => value.db !== undefined || value.schema !== undefined, { - message: "input namespace must specify at least one of db or schema", - }); -export type WorkspaceInputNamespace = z.infer; - export const WorkspaceDatabase = z .object({ database_id: z.number().int(), - output_schema: z.string(), - input: z.array(WorkspaceInputNamespace), + output_namespace: z.string(), + input_schemas: z.array(z.string().min(1)), status: WorkspaceDatabaseStatus, }) .loose(); @@ -84,14 +73,14 @@ export type WorkspaceCreateInput = z.infer; export const WorkspaceProvisionInput = z .object({ database_id: z.number().int().positive(), - input: z.array(WorkspaceInputNamespace).min(1), + input_schemas: z.array(z.string().min(1)).min(1), }) .loose(); export type WorkspaceProvisionInput = z.infer; export const WorkspaceUpdateDatabaseInput = z .object({ - input: z.array(WorkspaceInputNamespace).min(1), + input_schemas: z.array(z.string().min(1)).min(1), }) .loose(); export type WorkspaceUpdateDatabaseInput = z.infer; @@ -106,22 +95,9 @@ function formatDatabases(value: unknown): string { } return parsed.data .map((entry) => { - const inputList = - entry.input.length === 0 ? "" : ` [${entry.input.map(formatInputNamespace).join(", ")}]`; - return `${entry.database_id} (${entry.status})${inputList}`; + const schemas = + entry.input_schemas.length === 0 ? "" : ` [${entry.input_schemas.join(", ")}]`; + return `${entry.database_id} (${entry.status})${schemas}`; }) .join("; "); } - -function formatInputNamespace(namespace: WorkspaceInputNamespace): string { - if (namespace.db !== undefined && namespace.schema !== undefined) { - return `${namespace.db}.${namespace.schema}`; - } - if (namespace.schema !== undefined) { - return namespace.schema; - } - if (namespace.db !== undefined) { - return namespace.db; - } - throw new Error("WorkspaceInputNamespace must specify db or schema"); -} From fbed89b625069177ebb5771a9a8c6bd848f7ca04 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Tue, 12 May 2026 19:00:16 -0400 Subject: [PATCH 47/47] git sync --- README.md | 90 +++++++++---------- .../add-collection.ts | 6 +- .../{remote-sync => git-sync}/branches.ts | 2 +- .../{remote-sync => git-sync}/cancel-task.ts | 6 +- .../create-branch.ts | 6 +- .../{remote-sync => git-sync}/current-task.ts | 6 +- .../{remote-sync => git-sync}/dirty.ts | 4 +- .../{remote-sync => git-sync}/export.ts | 8 +- .../has-remote-changes.ts | 4 +- .../{remote-sync => git-sync}/import.ts | 8 +- .../{remote-sync => git-sync}/index.ts | 2 +- .../{remote-sync => git-sync}/is-dirty.ts | 2 +- .../poll-task.test.ts | 2 +- .../{remote-sync => git-sync}/poll-task.ts | 4 +- .../remove-collection.ts | 6 +- .../{remote-sync => git-sync}/stash.ts | 6 +- .../{remote-sync => git-sync}/status.ts | 6 +- .../{remote-sync => git-sync}/wait.ts | 6 +- src/domain/{remote-sync.ts => git-sync.ts} | 0 src/main.ts | 2 +- ...-sync.e2e.test.ts => git-sync.e2e.test.ts} | 71 +++++++-------- tests/e2e/manifest.e2e.test.ts | 28 +++--- 22 files changed, 133 insertions(+), 142 deletions(-) rename src/commands/{remote-sync => git-sync}/add-collection.ts (89%) rename src/commands/{remote-sync => git-sync}/branches.ts (93%) rename src/commands/{remote-sync => git-sync}/cancel-task.ts (78%) rename src/commands/{remote-sync => git-sync}/create-branch.ts (91%) rename src/commands/{remote-sync => git-sync}/current-task.ts (78%) rename src/commands/{remote-sync => git-sync}/dirty.ts (89%) rename src/commands/{remote-sync => git-sync}/export.ts (93%) rename src/commands/{remote-sync => git-sync}/has-remote-changes.ts (93%) rename src/commands/{remote-sync => git-sync}/import.ts (93%) rename src/commands/{remote-sync => git-sync}/index.ts (92%) rename src/commands/{remote-sync => git-sync}/is-dirty.ts (92%) rename src/commands/{remote-sync => git-sync}/poll-task.test.ts (92%) rename src/commands/{remote-sync => git-sync}/poll-task.ts (95%) rename src/commands/{remote-sync => git-sync}/remove-collection.ts (80%) rename src/commands/{remote-sync => git-sync}/stash.ts (94%) rename src/commands/{remote-sync => git-sync}/status.ts (87%) rename src/commands/{remote-sync => git-sync}/wait.ts (85%) rename src/domain/{remote-sync.ts => git-sync.ts} (100%) rename tests/e2e/{remote-sync.e2e.test.ts => git-sync.e2e.test.ts} (79%) diff --git a/README.md b/README.md index c5e8177..6fda8ea 100644 --- a/README.md +++ b/README.md @@ -932,73 +932,73 @@ metabase search products --archived | `--table-db-id` | Restrict to items on a given database id. | | `--verified` | Only verified content. | -## Remote Sync +## Git Sync -Drive Metabase Enterprise Remote Sync (`/api/ee/remote-sync`) — import / export Metabase content against a configured git remote, inspect dirty state, and manage branches. All remote-sync commands require an active EE token and superuser credentials. +Drive Metabase Enterprise Remote Sync (`/api/ee/remote-sync`) — import / export Metabase content against a configured git remote, inspect dirty state, and manage branches. All git-sync commands require an active EE token and superuser credentials. -### `metabase remote-sync status` +### `metabase git-sync status` Roll up the current sync state in one call: configured branch, dirty flag, and the most recent sync task (or `null` if none has ever run). ```sh -metabase remote-sync status -metabase remote-sync status --json +metabase git-sync status +metabase git-sync status --json ``` -### `metabase remote-sync is-dirty` +### `metabase git-sync is-dirty` Boolean check for whether any synced collection has unsynced local changes. ```sh -metabase remote-sync is-dirty --json +metabase git-sync is-dirty --json ``` -### `metabase remote-sync has-remote-changes` +### `metabase git-sync has-remote-changes` Compare the latest version on the remote branch against the version Metabase last imported. Cached for a short TTL server-side; pass `--force-refresh` to bypass. ```sh -metabase remote-sync has-remote-changes -metabase remote-sync has-remote-changes --force-refresh --json +metabase git-sync has-remote-changes +metabase git-sync has-remote-changes --force-refresh --json ``` | Flag | Description | | ----------------- | --------------------------------------------------- | | `--force-refresh` | Bypass the in-memory cache and re-check the remote. | -### `metabase remote-sync dirty` +### `metabase git-sync dirty` List every object that has unsynced local changes (compact list envelope; `--full` for the per-row payload). ```sh -metabase remote-sync dirty -metabase remote-sync dirty --json +metabase git-sync dirty +metabase git-sync dirty --json ``` -### `metabase remote-sync current-task` +### `metabase git-sync current-task` Fetch the most recent sync task. Renders `{ status: "idle" }` when no task has ever run, otherwise the full task with its hydrated `status`. ```sh -metabase remote-sync current-task -metabase remote-sync current-task --json +metabase git-sync current-task +metabase git-sync current-task --json ``` -### `metabase remote-sync cancel-task` +### `metabase git-sync cancel-task` Cancel the currently running sync task. Fails with HTTP 400 if no task is running. ```sh -metabase remote-sync cancel-task --json +metabase git-sync cancel-task --json ``` -### `metabase remote-sync wait` +### `metabase git-sync wait` Poll `/current-task` until it reaches a terminal status (`successful`, `errored`, `cancelled`, `timed-out`, `conflict`). Exits 0 on `successful` or `cancelled`; exits 1 on `errored` / `timed-out` / `conflict`. Returns immediately with `{ status: "idle" }` if no task is running. ```sh -metabase remote-sync wait -metabase remote-sync wait --timeout 300000 --json +metabase git-sync wait +metabase git-sync wait --timeout 300000 --json ``` | Flag | Description | @@ -1006,14 +1006,14 @@ metabase remote-sync wait --timeout 300000 --json | `--timeout ` | Polling timeout in ms (default 600000). | | `--interval ` | Polling interval in ms (default 2000). | -### `metabase remote-sync import` +### `metabase git-sync import` Import content from the configured git remote into Metabase (repo → Metabase). Auto-polls until the resulting task reaches a terminal status; pass `--no-wait` to return immediately after kickoff. ```sh -metabase remote-sync import -metabase remote-sync import --branch main --json -metabase remote-sync import --force --no-wait +metabase git-sync import +metabase git-sync import --branch main --json +metabase git-sync import --force --no-wait ``` | Flag | Description | @@ -1024,14 +1024,14 @@ metabase remote-sync import --force --no-wait | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | | `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | -### `metabase remote-sync export` +### `metabase git-sync export` Export Metabase changes back to the configured git remote (Metabase → repo). Auto-polls by default. ```sh -metabase remote-sync export -m "update dashboards" -metabase remote-sync export --branch main --json -metabase remote-sync export --no-wait +metabase git-sync export -m "update dashboards" +metabase git-sync export --branch main --json +metabase git-sync export --no-wait ``` | Flag | Description | @@ -1043,13 +1043,13 @@ metabase remote-sync export --no-wait | `--timeout ` | Polling timeout in ms (default 600000). Used with `--wait`. | | `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | -### `metabase remote-sync stash` +### `metabase git-sync stash` Export the current Metabase state to a NEW branch on the remote and switch sync to it. Requires `remote-sync-type` to be `read-write`. ```sh -metabase remote-sync stash --new-branch wip -metabase remote-sync stash --new-branch wip -m "work in progress" --json +metabase git-sync stash --new-branch wip +metabase git-sync stash --new-branch wip -m "work in progress" --json ``` | Flag | Description | @@ -1060,41 +1060,41 @@ metabase remote-sync stash --new-branch wip -m "work in progress" --json | `--timeout ` | Polling timeout in ms. Used with `--wait`. | | `--interval ` | Polling interval in ms. Used with `--wait`. | -### `metabase remote-sync branches` +### `metabase git-sync branches` List branches available on the configured git remote. ```sh -metabase remote-sync branches --json +metabase git-sync branches --json ``` -### `metabase remote-sync create-branch ` +### `metabase git-sync create-branch ` Create a new branch on the git remote (from the last imported version) and switch sync to it. ```sh -metabase remote-sync create-branch feat/dashboards -metabase remote-sync create-branch feat/x --json +metabase git-sync create-branch feat/dashboards +metabase git-sync create-branch feat/x --json ``` -### `metabase remote-sync add-collection ` +### `metabase git-sync add-collection ` -Mark a collection as remote-synced. The toggle cascades to every descendant by `location` prefix, so flagging a parent flags the whole subtree. Returns `{ success, task_id? }`; `task_id` only appears when the toggle triggers a follow-up task (e.g. a finalization import after switching to read-only mode). +Mark a collection as git-synced. The toggle cascades to every descendant by `location` prefix, so flagging a parent flags the whole subtree. Returns `{ success, task_id? }`; `task_id` only appears when the toggle triggers a follow-up task (e.g. a finalization import after switching to read-only mode). ```sh -metabase remote-sync add-collection 12 -metabase remote-sync add-collection 12 --json --profile prod +metabase git-sync add-collection 12 +metabase git-sync add-collection 12 --json --profile prod ``` The server rejects toggles while `remote-sync-type` is `read-only` (the install default). Switch first with `metabase setting set remote-sync-type '"read-write"'`. -### `metabase remote-sync remove-collection ` +### `metabase git-sync remove-collection ` -Unmark a collection as remote-synced. Same cascade and same `read-only` precondition as `add-collection`. +Unmark a collection as git-synced. Same cascade and same `read-only` precondition as `add-collection`. ```sh -metabase remote-sync remove-collection 12 -metabase remote-sync remove-collection 12 --json --profile prod +metabase git-sync remove-collection 12 +metabase git-sync remove-collection 12 --json --profile prod ``` ## Workspaces diff --git a/src/commands/remote-sync/add-collection.ts b/src/commands/git-sync/add-collection.ts similarity index 89% rename from src/commands/remote-sync/add-collection.ts rename to src/commands/git-sync/add-collection.ts index 9b4ece5..ebb1024 100644 --- a/src/commands/remote-sync/add-collection.ts +++ b/src/commands/git-sync/add-collection.ts @@ -37,7 +37,7 @@ export async function setCollectionRemoteSynced( export default defineMetabaseCommand({ meta: { name: "add-collection", - description: "Mark a collection as remote-synced; cascades to descendants by location prefix", + description: "Mark a collection as git-synced; cascades to descendants by location prefix", }, args: { ...outputFlags, @@ -47,8 +47,8 @@ export default defineMetabaseCommand({ }, outputSchema: SyncSettingsUpdateResult, examples: [ - "metabase remote-sync add-collection 12", - "metabase remote-sync add-collection 12 --json --profile prod", + "metabase git-sync add-collection 12", + "metabase git-sync add-collection 12 --json --profile prod", ], async run({ args, ctx, getClient }) { const collectionId = parseId(args.id, "id"); diff --git a/src/commands/remote-sync/branches.ts b/src/commands/git-sync/branches.ts similarity index 93% rename from src/commands/remote-sync/branches.ts rename to src/commands/git-sync/branches.ts index a2b078f..40539d9 100644 --- a/src/commands/remote-sync/branches.ts +++ b/src/commands/git-sync/branches.ts @@ -26,7 +26,7 @@ export default defineMetabaseCommand({ meta: { name: "branches", description: "List branches on the configured git remote" }, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncBranchListEnvelope, - examples: ["metabase remote-sync branches", "metabase remote-sync branches --json"], + examples: ["metabase git-sync branches", "metabase git-sync branches --json"], async run({ ctx, getClient }) { const client = await getClient(); const response = await client.requestParsed( diff --git a/src/commands/remote-sync/cancel-task.ts b/src/commands/git-sync/cancel-task.ts similarity index 78% rename from src/commands/remote-sync/cancel-task.ts rename to src/commands/git-sync/cancel-task.ts index 6424c19..cf95758 100644 --- a/src/commands/remote-sync/cancel-task.ts +++ b/src/commands/git-sync/cancel-task.ts @@ -1,4 +1,4 @@ -import { SyncTask, syncTaskView } from "../../domain/remote-sync"; +import { SyncTask, syncTaskView } from "../../domain/git-sync"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; @@ -6,10 +6,10 @@ import { defineMetabaseCommand } from "../runtime"; import { REMOTE_SYNC_PATHS } from "./poll-task"; export default defineMetabaseCommand({ - meta: { name: "cancel-task", description: "Cancel the running remote-sync task" }, + meta: { name: "cancel-task", description: "Cancel the running git-sync task" }, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncTask, - examples: ["metabase remote-sync cancel-task", "metabase remote-sync cancel-task --json"], + examples: ["metabase git-sync cancel-task", "metabase git-sync cancel-task --json"], async run({ ctx, getClient }) { const client = await getClient(); const task = await client.requestParsed(SyncTask, REMOTE_SYNC_PATHS.cancelTask, { diff --git a/src/commands/remote-sync/create-branch.ts b/src/commands/git-sync/create-branch.ts similarity index 91% rename from src/commands/remote-sync/create-branch.ts rename to src/commands/git-sync/create-branch.ts index 5c04267..7c49494 100644 --- a/src/commands/remote-sync/create-branch.ts +++ b/src/commands/git-sync/create-branch.ts @@ -25,7 +25,7 @@ const createBranchView: ResourceView = { export default defineMetabaseCommand({ meta: { name: "create-branch", - description: "Create a new branch on the git remote and switch remote-sync to it", + description: "Create a new branch on the git remote and switch git-sync to it", }, args: { ...outputFlags, @@ -35,8 +35,8 @@ export default defineMetabaseCommand({ }, outputSchema: CreateBranchResult, examples: [ - "metabase remote-sync create-branch feat/dashboards", - "metabase remote-sync create-branch feat/x --json", + "metabase git-sync create-branch feat/dashboards", + "metabase git-sync create-branch feat/x --json", ], async run({ args, ctx, getClient }) { const name = args.name.trim(); diff --git a/src/commands/remote-sync/current-task.ts b/src/commands/git-sync/current-task.ts similarity index 78% rename from src/commands/remote-sync/current-task.ts rename to src/commands/git-sync/current-task.ts index e225691..e7d9043 100644 --- a/src/commands/remote-sync/current-task.ts +++ b/src/commands/git-sync/current-task.ts @@ -1,4 +1,4 @@ -import { syncTaskView } from "../../domain/remote-sync"; +import { syncTaskView } from "../../domain/git-sync"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; import { defineMetabaseCommand } from "../runtime"; @@ -10,11 +10,11 @@ export const CurrentTaskResult = SyncTaskOrIdle; export default defineMetabaseCommand({ meta: { name: "current-task", - description: "Get the most recent remote-sync task (or idle if none)", + description: "Get the most recent git-sync task (or idle if none)", }, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: CurrentTaskResult, - examples: ["metabase remote-sync current-task", "metabase remote-sync current-task --json"], + examples: ["metabase git-sync current-task", "metabase git-sync current-task --json"], async run({ ctx, getClient }) { const client = await getClient(); const task = await fetchCurrentTask(client); diff --git a/src/commands/remote-sync/dirty.ts b/src/commands/git-sync/dirty.ts similarity index 89% rename from src/commands/remote-sync/dirty.ts rename to src/commands/git-sync/dirty.ts index d59c192..befec5c 100644 --- a/src/commands/remote-sync/dirty.ts +++ b/src/commands/git-sync/dirty.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { SyncDirtyItem, SyncDirtyItemCompact, syncDirtyItemView } from "../../domain/remote-sync"; +import { SyncDirtyItem, SyncDirtyItemCompact, syncDirtyItemView } from "../../domain/git-sync"; import { renderList } from "../../output/render"; import { listEnvelopeSchema, wrapList } from "../../output/types"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; @@ -18,7 +18,7 @@ export default defineMetabaseCommand({ meta: { name: "dirty", description: "List objects with unsynced local changes" }, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncDirtyListEnvelope, - examples: ["metabase remote-sync dirty", "metabase remote-sync dirty --json"], + examples: ["metabase git-sync dirty", "metabase git-sync dirty --json"], async run({ ctx, getClient }) { const client = await getClient(); const response = await client.requestParsed(SyncDirtyApiResponse, REMOTE_SYNC_PATHS.dirty); diff --git a/src/commands/remote-sync/export.ts b/src/commands/git-sync/export.ts similarity index 93% rename from src/commands/remote-sync/export.ts rename to src/commands/git-sync/export.ts index 0478440..c8ccf0e 100644 --- a/src/commands/remote-sync/export.ts +++ b/src/commands/git-sync/export.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { SyncTask } from "../../domain/remote-sync"; +import { SyncTask } from "../../domain/git-sync"; import type { ResourceView } from "../../domain/view"; import { warn } from "../../output/notice"; import { renderItem } from "../../output/render"; @@ -65,9 +65,9 @@ export default defineMetabaseCommand({ }, outputSchema: SyncExportResult, examples: [ - 'metabase remote-sync export -m "update dashboards"', - "metabase remote-sync export --branch main --json", - "metabase remote-sync export --no-wait", + 'metabase git-sync export -m "update dashboards"', + "metabase git-sync export --branch main --json", + "metabase git-sync export --no-wait", ], async run({ args, ctx, getClient }) { const timeoutMs = parseId(args.timeout, "timeout"); diff --git a/src/commands/remote-sync/has-remote-changes.ts b/src/commands/git-sync/has-remote-changes.ts similarity index 93% rename from src/commands/remote-sync/has-remote-changes.ts rename to src/commands/git-sync/has-remote-changes.ts index baa5e9a..1279e02 100644 --- a/src/commands/remote-sync/has-remote-changes.ts +++ b/src/commands/git-sync/has-remote-changes.ts @@ -43,8 +43,8 @@ export default defineMetabaseCommand({ }, outputSchema: HasRemoteChangesResult, examples: [ - "metabase remote-sync has-remote-changes", - "metabase remote-sync has-remote-changes --force-refresh --json", + "metabase git-sync has-remote-changes", + "metabase git-sync has-remote-changes --force-refresh --json", ], async run({ args, ctx, getClient }) { const client = await getClient(); diff --git a/src/commands/remote-sync/import.ts b/src/commands/git-sync/import.ts similarity index 93% rename from src/commands/remote-sync/import.ts rename to src/commands/git-sync/import.ts index 327d262..d866279 100644 --- a/src/commands/remote-sync/import.ts +++ b/src/commands/git-sync/import.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { SyncTask } from "../../domain/remote-sync"; +import { SyncTask } from "../../domain/git-sync"; import type { ResourceView } from "../../domain/view"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; @@ -58,9 +58,9 @@ export default defineMetabaseCommand({ }, outputSchema: SyncImportResult, examples: [ - "metabase remote-sync import", - "metabase remote-sync import --branch main --json", - "metabase remote-sync import --force --no-wait", + "metabase git-sync import", + "metabase git-sync import --branch main --json", + "metabase git-sync import --force --no-wait", ], async run({ args, ctx, getClient }) { const timeoutMs = parseId(args.timeout, "timeout"); diff --git a/src/commands/remote-sync/index.ts b/src/commands/git-sync/index.ts similarity index 92% rename from src/commands/remote-sync/index.ts rename to src/commands/git-sync/index.ts index 5c5904a..bda7908 100644 --- a/src/commands/remote-sync/index.ts +++ b/src/commands/git-sync/index.ts @@ -1,7 +1,7 @@ import { defineCommand } from "citty"; export default defineCommand({ - meta: { name: "remote-sync", description: "Sync Metabase content with a git remote" }, + meta: { name: "git-sync", description: "Sync Metabase content with a git remote" }, subCommands: { status: () => import("./status").then((mod) => mod.default), "is-dirty": () => import("./is-dirty").then((mod) => mod.default), diff --git a/src/commands/remote-sync/is-dirty.ts b/src/commands/git-sync/is-dirty.ts similarity index 92% rename from src/commands/remote-sync/is-dirty.ts rename to src/commands/git-sync/is-dirty.ts index 55b96d6..54e3b1f 100644 --- a/src/commands/remote-sync/is-dirty.ts +++ b/src/commands/git-sync/is-dirty.ts @@ -24,7 +24,7 @@ export default defineMetabaseCommand({ }, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: IsDirtyResult, - examples: ["metabase remote-sync is-dirty", "metabase remote-sync is-dirty --json"], + examples: ["metabase git-sync is-dirty", "metabase git-sync is-dirty --json"], async run({ ctx, getClient }) { const client = await getClient(); const result = await client.requestParsed(IsDirtyResult, REMOTE_SYNC_PATHS.isDirty); diff --git a/src/commands/remote-sync/poll-task.test.ts b/src/commands/git-sync/poll-task.test.ts similarity index 92% rename from src/commands/remote-sync/poll-task.test.ts rename to src/commands/git-sync/poll-task.test.ts index d5b615f..7101d48 100644 --- a/src/commands/remote-sync/poll-task.test.ts +++ b/src/commands/git-sync/poll-task.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { SyncTaskStatus } from "../../domain/remote-sync"; +import type { SyncTaskStatus } from "../../domain/git-sync"; import { isFailure, isTerminal } from "./poll-task"; diff --git a/src/commands/remote-sync/poll-task.ts b/src/commands/git-sync/poll-task.ts similarity index 95% rename from src/commands/remote-sync/poll-task.ts rename to src/commands/git-sync/poll-task.ts index ca189c6..58da860 100644 --- a/src/commands/remote-sync/poll-task.ts +++ b/src/commands/git-sync/poll-task.ts @@ -1,6 +1,6 @@ import { z, type ZodType } from "zod"; -import { SyncTask, type SyncTaskStatus } from "../../domain/remote-sync"; +import { SyncTask, type SyncTaskStatus } from "../../domain/git-sync"; import type { Client } from "../../core/http/client"; import type { ResourceView } from "../../domain/view"; import { parseJsonOrPlain } from "../../runtime/json"; @@ -104,5 +104,5 @@ export function throwIfFailedTask(final: SyncTask | null, verb: string): void { return; } const detail = final.error_message ? `: ${final.error_message}` : ""; - throw new Error(`remote-sync ${verb} ${final.status}${detail}`); + throw new Error(`git-sync ${verb} ${final.status}${detail}`); } diff --git a/src/commands/remote-sync/remove-collection.ts b/src/commands/git-sync/remove-collection.ts similarity index 80% rename from src/commands/remote-sync/remove-collection.ts rename to src/commands/git-sync/remove-collection.ts index 1edf867..8860856 100644 --- a/src/commands/remote-sync/remove-collection.ts +++ b/src/commands/git-sync/remove-collection.ts @@ -12,7 +12,7 @@ import { export default defineMetabaseCommand({ meta: { name: "remove-collection", - description: "Unmark a collection as remote-synced; cascades to descendants by location prefix", + description: "Unmark a collection as git-synced; cascades to descendants by location prefix", }, args: { ...outputFlags, @@ -22,8 +22,8 @@ export default defineMetabaseCommand({ }, outputSchema: SyncSettingsUpdateResult, examples: [ - "metabase remote-sync remove-collection 12", - "metabase remote-sync remove-collection 12 --json --profile prod", + "metabase git-sync remove-collection 12", + "metabase git-sync remove-collection 12 --json --profile prod", ], async run({ args, ctx, getClient }) { const collectionId = parseId(args.id, "id"); diff --git a/src/commands/remote-sync/stash.ts b/src/commands/git-sync/stash.ts similarity index 94% rename from src/commands/remote-sync/stash.ts rename to src/commands/git-sync/stash.ts index 233fb60..d07c7f2 100644 --- a/src/commands/remote-sync/stash.ts +++ b/src/commands/git-sync/stash.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { ConfigError } from "../../core/errors"; -import { SyncTask } from "../../domain/remote-sync"; +import { SyncTask } from "../../domain/git-sync"; import type { ResourceView } from "../../domain/view"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; @@ -65,8 +65,8 @@ export default defineMetabaseCommand({ }, outputSchema: SyncStashResult, examples: [ - "metabase remote-sync stash --new-branch wip", - 'metabase remote-sync stash --new-branch wip -m "work in progress" --json', + "metabase git-sync stash --new-branch wip", + 'metabase git-sync stash --new-branch wip -m "work in progress" --json', ], async run({ args, ctx, getClient }) { const newBranch = args.newBranch.trim(); diff --git a/src/commands/remote-sync/status.ts b/src/commands/git-sync/status.ts similarity index 87% rename from src/commands/remote-sync/status.ts rename to src/commands/git-sync/status.ts index d38b6af..febc341 100644 --- a/src/commands/remote-sync/status.ts +++ b/src/commands/git-sync/status.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { SyncTask } from "../../domain/remote-sync"; +import { SyncTask } from "../../domain/git-sync"; import type { ResourceView } from "../../domain/view"; import { renderItem } from "../../output/render"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; @@ -30,11 +30,11 @@ const syncStatusView: ResourceView = { export default defineMetabaseCommand({ meta: { name: "status", - description: "Show current remote-sync state (branch, dirty, current task)", + description: "Show current git-sync state (branch, dirty, current task)", }, args: { ...outputFlags, ...profileFlag, ...connectionFlags }, outputSchema: SyncStatus, - examples: ["metabase remote-sync status", "metabase remote-sync status --json"], + examples: ["metabase git-sync status", "metabase git-sync status --json"], async run({ ctx, getClient }) { const client = await getClient(); const [branch, isDirty, currentTask] = await Promise.all([ diff --git a/src/commands/remote-sync/wait.ts b/src/commands/git-sync/wait.ts similarity index 85% rename from src/commands/remote-sync/wait.ts rename to src/commands/git-sync/wait.ts index 1a2b261..28e32dc 100644 --- a/src/commands/remote-sync/wait.ts +++ b/src/commands/git-sync/wait.ts @@ -1,4 +1,4 @@ -import { syncTaskView } from "../../domain/remote-sync"; +import { syncTaskView } from "../../domain/git-sync"; import { renderItem } from "../../output/render"; import { DEFAULT_INTERVAL_MS, DEFAULT_TIMEOUT_MS } from "../../runtime/poll"; import { connectionFlags, outputFlags, profileFlag } from "../flags"; @@ -18,7 +18,7 @@ export const WaitResult = SyncTaskOrIdle; export default defineMetabaseCommand({ meta: { name: "wait", - description: "Poll the current remote-sync task until it reaches a terminal status", + description: "Poll the current git-sync task until it reaches a terminal status", }, args: { ...outputFlags, @@ -36,7 +36,7 @@ export default defineMetabaseCommand({ }, }, outputSchema: WaitResult, - examples: ["metabase remote-sync wait", "metabase remote-sync wait --timeout 300000 --json"], + examples: ["metabase git-sync wait", "metabase git-sync wait --timeout 300000 --json"], async run({ args, ctx, getClient }) { const timeoutMs = parseId(args.timeout, "timeout"); const intervalMs = parseId(args.interval, "interval"); diff --git a/src/domain/remote-sync.ts b/src/domain/git-sync.ts similarity index 100% rename from src/domain/remote-sync.ts rename to src/domain/git-sync.ts diff --git a/src/main.ts b/src/main.ts index e1627a7..e621618 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,7 +22,7 @@ const main: CommandDef = defineCommand({ "transform-job": () => import("./commands/transform-job").then((mod) => mod.default), setting: () => import("./commands/setting").then((mod) => mod.default), search: () => import("./commands/search").then((mod) => mod.default), - "remote-sync": () => import("./commands/remote-sync").then((mod) => mod.default), + "git-sync": () => import("./commands/git-sync").then((mod) => mod.default), workspace: () => import("./commands/workspace").then((mod) => mod.default), setup: () => import("./commands/setup").then((mod) => mod.default), "api-key": () => import("./commands/api-key").then((mod) => mod.default), diff --git a/tests/e2e/remote-sync.e2e.test.ts b/tests/e2e/git-sync.e2e.test.ts similarity index 79% rename from tests/e2e/remote-sync.e2e.test.ts rename to tests/e2e/git-sync.e2e.test.ts index fd2af61..48e9bf8 100644 --- a/tests/e2e/remote-sync.e2e.test.ts +++ b/tests/e2e/git-sync.e2e.test.ts @@ -1,16 +1,16 @@ import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { CurrentTaskResult } from "../../src/commands/remote-sync/current-task"; -import { SyncDirtyListEnvelope } from "../../src/commands/remote-sync/dirty"; -import { IsDirtyResult } from "../../src/commands/remote-sync/is-dirty"; -import { SyncStatus } from "../../src/commands/remote-sync/status"; -import { WaitResult } from "../../src/commands/remote-sync/wait"; +import { CurrentTaskResult } from "../../src/commands/git-sync/current-task"; +import { SyncDirtyListEnvelope } from "../../src/commands/git-sync/dirty"; +import { IsDirtyResult } from "../../src/commands/git-sync/is-dirty"; +import { SyncStatus } from "../../src/commands/git-sync/status"; +import { WaitResult } from "../../src/commands/git-sync/wait"; import { parseJson } from "../../src/runtime/json"; import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -describe("remote-sync arg validation e2e (no Metabase contact required)", () => { +describe("git-sync arg validation e2e (no Metabase contact required)", () => { const tempDirs: string[] = []; afterEach(async () => { @@ -26,7 +26,7 @@ describe("remote-sync arg validation e2e (no Metabase contact required)", () => it("wait with non-integer --timeout fails fast with ConfigError before any network call", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "wait", "--timeout", "abc", "--json"], + args: ["git-sync", "wait", "--timeout", "abc", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -37,7 +37,7 @@ describe("remote-sync arg validation e2e (no Metabase contact required)", () => it("wait with non-integer --interval fails fast with ConfigError before any network call", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "wait", "--interval", "xyz", "--json"], + args: ["git-sync", "wait", "--interval", "xyz", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -48,7 +48,7 @@ describe("remote-sync arg validation e2e (no Metabase contact required)", () => it("stash with whitespace-only --new-branch fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "stash", "--new-branch", " ", "--json"], + args: ["git-sync", "stash", "--new-branch", " ", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -59,7 +59,7 @@ describe("remote-sync arg validation e2e (no Metabase contact required)", () => it("stash with whitespace-only --message fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "stash", "--new-branch", "wip", "--message", " ", "--json"], + args: ["git-sync", "stash", "--new-branch", "wip", "--message", " ", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -70,7 +70,7 @@ describe("remote-sync arg validation e2e (no Metabase contact required)", () => it("create-branch with whitespace-only positional fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "create-branch", " ", "--json"], + args: ["git-sync", "create-branch", " ", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -81,7 +81,7 @@ describe("remote-sync arg validation e2e (no Metabase contact required)", () => it("add-collection with non-integer positional fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "add-collection", "abc", "--json"], + args: ["git-sync", "add-collection", "abc", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -92,7 +92,7 @@ describe("remote-sync arg validation e2e (no Metabase contact required)", () => it("add-collection with zero positional fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "add-collection", "0", "--json"], + args: ["git-sync", "add-collection", "0", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -103,7 +103,7 @@ describe("remote-sync arg validation e2e (no Metabase contact required)", () => it("remove-collection with negative positional fails with ConfigError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "remove-collection", "--", "-3", "--json"], + args: ["git-sync", "remove-collection", "--", "-3", "--json"], configHome, }); expect(result.exitCode).toBe(2); @@ -112,7 +112,7 @@ describe("remote-sync arg validation e2e (no Metabase contact required)", () => }); }); -describe("remote-sync e2e against EE remote-sync endpoints", () => { +describe("git-sync e2e against EE git-sync endpoints", () => { let bootstrap: E2EBootstrap; const tempDirs: string[] = []; @@ -140,7 +140,7 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { it("current-task returns the idle marker when no sync has ever run", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "current-task", "--json"], + args: ["git-sync", "current-task", "--json"], configHome, env: authEnv(), }); @@ -151,7 +151,7 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { it("is-dirty reports false when no synced collections exist", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "is-dirty", "--json"], + args: ["git-sync", "is-dirty", "--json"], configHome, env: authEnv(), }); @@ -162,7 +162,7 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { it("dirty returns an empty list envelope when nothing is dirty", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "dirty", "--json"], + args: ["git-sync", "dirty", "--json"], configHome, env: authEnv(), }); @@ -177,7 +177,7 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { it("status rolls up branch (null), is_dirty (false), and current_task (null)", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "status", "--json"], + args: ["git-sync", "status", "--json"], configHome, env: authEnv(), }); @@ -192,7 +192,7 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { it("wait exits successfully with the idle marker when no task is running", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "wait", "--json"], + args: ["git-sync", "wait", "--json"], configHome, env: authEnv(), }); @@ -200,10 +200,10 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { expect(parseJson(result.stdout, WaitResult)).toEqual({ status: "idle" }); }); - it("import without remote-sync configured surfaces a 400 HttpError", async () => { + it("import without git-sync configured surfaces a 400 HttpError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "import", "--no-wait", "--json"], + args: ["git-sync", "import", "--no-wait", "--json"], configHome, env: authEnv(), }); @@ -211,10 +211,10 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { expect(result.stderr).toContain("Metabase returned 400"); }); - it("export without remote-sync configured surfaces a 400 HttpError", async () => { + it("export without git-sync configured surfaces a 400 HttpError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "export", "--no-wait", "--json"], + args: ["git-sync", "export", "--no-wait", "--json"], configHome, env: authEnv(), }); @@ -222,10 +222,10 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { expect(result.stderr).toContain("Metabase returned 400"); }); - it("has-remote-changes without remote-sync configured surfaces a 400 HttpError", async () => { + it("has-remote-changes without git-sync configured surfaces a 400 HttpError", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "has-remote-changes", "--json"], + args: ["git-sync", "has-remote-changes", "--json"], configHome, env: authEnv(), }); @@ -236,7 +236,7 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { it("cancel-task surfaces a 400 HttpError when there is no running task", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "cancel-task", "--json"], + args: ["git-sync", "cancel-task", "--json"], configHome, env: authEnv(), }); @@ -247,16 +247,7 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { it("stash surfaces a 400 HttpError when remote-sync-type is not read-write", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: [ - "remote-sync", - "stash", - "--new-branch", - "wip", - "--message", - "x", - "--no-wait", - "--json", - ], + args: ["git-sync", "stash", "--new-branch", "wip", "--message", "x", "--no-wait", "--json"], configHome, env: authEnv(), }); @@ -267,7 +258,7 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { it("branches surfaces an HttpError when no source URL is configured", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "branches", "--json"], + args: ["git-sync", "branches", "--json"], configHome, env: authEnv(), }); @@ -278,7 +269,7 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { it("add-collection surfaces a 400 HttpError in the default config (read-only or paywall)", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "add-collection", "1", "--json"], + args: ["git-sync", "add-collection", "1", "--json"], configHome, env: authEnv(), }); @@ -289,7 +280,7 @@ describe("remote-sync e2e against EE remote-sync endpoints", () => { it("remove-collection is idempotent when the collection is not in the sync config", async () => { const configHome = await makeIsolatedConfigHome(); const result = await runCli({ - args: ["remote-sync", "remove-collection", "1", "--json"], + args: ["git-sync", "remove-collection", "1", "--json"], configHome, env: authEnv(), }); diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index fc36550..ab979fa 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -90,20 +90,20 @@ describe("__manifest e2e", () => { "setting get", "setting set", "search", - "remote-sync status", - "remote-sync is-dirty", - "remote-sync has-remote-changes", - "remote-sync dirty", - "remote-sync current-task", - "remote-sync cancel-task", - "remote-sync wait", - "remote-sync import", - "remote-sync export", - "remote-sync stash", - "remote-sync branches", - "remote-sync create-branch", - "remote-sync add-collection", - "remote-sync remove-collection", + "git-sync status", + "git-sync is-dirty", + "git-sync has-remote-changes", + "git-sync dirty", + "git-sync current-task", + "git-sync cancel-task", + "git-sync wait", + "git-sync import", + "git-sync export", + "git-sync stash", + "git-sync branches", + "git-sync create-branch", + "git-sync add-collection", + "git-sync remove-collection", "workspace list", "workspace create", "workspace database provision",