From 7269f218ea20f70be3ffcd0bcf3c7ab4b93332cf Mon Sep 17 00:00:00 2001 From: Bryan Maass Date: Mon, 6 Jul 2026 13:34:00 -0600 Subject: [PATCH 1/2] feat(workspace): mb workspace list/get/create/destroy Thin wrappers over /api/ee/workspace-manager (EE, v62+, workspaces premium feature). create provisions warehouse isolation blocking; destroy confirms, supports --ignore-pending, and surfaces orphaned_resources. Lifecycle e2e self-skips until the server carries the workspaces token feature. connect + profile-write land with the parent credentials endpoint. Refs GHY-4050 --- README.md | 47 +++++ src/commands/parse-id.test.ts | 39 ++++ src/commands/parse-id.ts | 11 ++ src/commands/workspace/create.ts | 42 +++++ src/commands/workspace/destroy.ts | 103 +++++++++++ src/commands/workspace/get.ts | 24 +++ src/commands/workspace/index.ts | 11 ++ src/commands/workspace/list.ts | 24 +++ src/domain/workspace.ts | 76 ++++++++ src/main.ts | 1 + tests/e2e/manifest.e2e.test.ts | 4 + tests/e2e/workspace.e2e.test.ts | 283 ++++++++++++++++++++++++++++++ 12 files changed, 665 insertions(+) create mode 100644 src/commands/parse-id.test.ts create mode 100644 src/commands/workspace/create.ts create mode 100644 src/commands/workspace/destroy.ts create mode 100644 src/commands/workspace/get.ts create mode 100644 src/commands/workspace/index.ts create mode 100644 src/commands/workspace/list.ts create mode 100644 src/domain/workspace.ts create mode 100644 tests/e2e/workspace.e2e.test.ts diff --git a/README.md b/README.md index e44a001..0f2eba5 100644 --- a/README.md +++ b/README.md @@ -1294,6 +1294,53 @@ mb git-sync remove-collection 12 mb git-sync remove-collection 12 --json --profile prod ``` +## Workspaces + +Lifecycle for EE workspaces via `/api/ee/workspace-manager` (Metabase v62+, `workspaces` premium feature). A workspace attaches databases and provisions warehouse isolation — a temporary schema + user per database — so work runs against real data without touching prod schemas. Alias: `mb ws`. + +### `mb workspace list` + +```sh +mb workspace list +mb workspace list --json +``` + +### `mb workspace get ` + +Includes per-database provisioning status (`unprovisioned` | `provisioning` | `provisioned` | `deprovisioning`), input schemas, and the isolated output namespace. + +```sh +mb workspace get 1 --json +``` + +### `mb workspace create` + +Creates the workspace and provisions isolation for each database (blocking). Each database must be eligible for workspaces (driver support + the `database-enable-workspaces` database setting). + +```sh +mb workspace create --name ws-reports --database-ids 1 +mb workspace create --name ws-etl --database-ids 1,2 --json +``` + +| Flag | Description | +| ---------------------- | ---------------------------------------- | +| `--name ` | Workspace name. | +| `--database-ids ` | Database ids to attach, comma separated. | + +### `mb workspace destroy ` + +Tears down each provisioned database's warehouse isolation, then removes the workspace. Prompts unless `--yes`. Refuses (409) while any database is still provisioning/deprovisioning unless `--ignore-pending`, which removes the records and leaves those warehouse objects behind; unreachable-warehouse leftovers are reported as `orphaned_resources`. + +```sh +mb workspace destroy 1 --yes +mb workspace destroy 1 --yes --ignore-pending +``` + +| Flag | Description | +| ------------------ | ----------------------------------------------------------------------- | +| `--yes` | Skip confirmation. | +| `--ignore-pending` | Remove workspace records even while databases are mid-(de)provisioning. | + ## Instance setup Bootstrapping a fresh, not-yet-configured Metabase instance. diff --git a/src/commands/parse-id.test.ts b/src/commands/parse-id.test.ts new file mode 100644 index 0000000..b456f43 --- /dev/null +++ b/src/commands/parse-id.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { ConfigError } from "../core/errors"; + +import { parseIdCsv } from "./parse-id"; + +describe("parseIdCsv", () => { + it("parses a comma-separated list, trimming whitespace", () => { + expect(parseIdCsv("1, 2,3", "database id")).toEqual([1, 2, 3]); + }); + + it("parses a single id", () => { + expect(parseIdCsv("7", "database id")).toEqual([7]); + }); + + it("throws ConfigError when the list is empty", () => { + expect(() => parseIdCsv("", "database id")).toThrow( + new ConfigError("expected at least one database id (comma separated)"), + ); + }); + + it("throws ConfigError when the list is only separators", () => { + expect(() => parseIdCsv(",,", "database id")).toThrow( + new ConfigError("expected at least one database id (comma separated)"), + ); + }); + + it("throws ConfigError on a non-integer entry, preserving the raw value", () => { + expect(() => parseIdCsv("1,abc", "database id")).toThrow( + new ConfigError(`invalid database id: "abc" (expected integer)`), + ); + }); + + it("throws ConfigError on a zero id (ids start at 1)", () => { + expect(() => parseIdCsv("0", "database id")).toThrow( + new ConfigError("invalid database id: 0 (must be ≥ 1)"), + ); + }); +}); diff --git a/src/commands/parse-id.ts b/src/commands/parse-id.ts index 1bcf31b..a791e1a 100644 --- a/src/commands/parse-id.ts +++ b/src/commands/parse-id.ts @@ -1,5 +1,16 @@ +import { ConfigError } from "../core/errors"; +import { parseCsv } from "../runtime/csv"; + import { parseInteger } from "./parse-integer"; export function parseId(value: string, name = "id"): number { return parseInteger(value, { name, min: 1 }); } + +export function parseIdCsv(value: string, name: string): number[] { + const ids = parseCsv(value).map((part) => parseId(part, name)); + if (ids.length === 0) { + throw new ConfigError(`expected at least one ${name} (comma separated)`); + } + return ids; +} diff --git a/src/commands/workspace/create.ts b/src/commands/workspace/create.ts new file mode 100644 index 0000000..af3bae3 --- /dev/null +++ b/src/commands/workspace/create.ts @@ -0,0 +1,42 @@ +import { Workspace, workspaceView } from "../../domain/workspace"; +import { renderSummary } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseIdCsv } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +export default defineMetabaseCommand({ + meta: { name: "create", description: "Create and provision a workspace" }, + details: + "Attaches the given databases and provisions warehouse isolation (a temporary schema + user per database) before returning. Each database must be eligible for workspaces; provisioning is blocking, so the response carries the final per-database status.", + capabilities: { minVersion: 62, tokenFeature: "workspaces" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + name: { type: "string", description: "Workspace name", required: true }, + "database-ids": { + type: "string", + description: "Database ids to attach, comma separated", + required: true, + }, + }, + outputSchema: Workspace, + examples: [ + "mb workspace create --name ws-reports --database-ids 1", + "mb workspace create --name ws-etl --database-ids 1,2 --json", + ], + async run({ args, ctx, getClient }) { + const databaseIds = parseIdCsv(args["database-ids"], "database id"); + const client = await getClient(); + const created = await client.requestParsed(Workspace, "/api/ee/workspace-manager/", { + method: "POST", + body: { name: args.name, database_ids: databaseIds }, + }); + renderSummary( + created, + workspaceView, + `Created workspace ${created.id} "${created.name}".`, + ctx, + ); + }, +}); diff --git a/src/commands/workspace/destroy.ts b/src/commands/workspace/destroy.ts new file mode 100644 index 0000000..14e2218 --- /dev/null +++ b/src/commands/workspace/destroy.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; + +import { ConfigError } from "../../core/errors"; +import type { ResourceView } from "../../domain/view"; +import { promptConfirm } from "../../output/prompt"; +import { renderSummary } from "../../output/render"; +import { connectionFlags, outputFlags, profileFlag } from "../flags"; +import { parseId } from "../parse-id"; +import { defineMetabaseCommand } from "../runtime"; + +const WorkspaceOrphanedResource = z + .object({ + workspace_database_id: z.number().int(), + database_id: z.number().int(), + driver: z.string(), + schema: z.string(), + user: z.string(), + reason: z.string().nullable().optional(), + }) + .loose(); + +const WorkspaceDeleteResponse = z + .object({ + id: z.number().int(), + deleted: z.boolean(), + message: z.string().optional(), + orphaned_resources: z.array(WorkspaceOrphanedResource).optional(), + }) + .loose(); + +export const WorkspaceDestroyResult = WorkspaceDeleteResponse.extend({ + aborted: z.boolean(), +}); +export type WorkspaceDestroyResultJson = z.infer; + +const workspaceDestroyView: ResourceView = { + compactPick: WorkspaceDestroyResult, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "deleted", label: "Destroyed" }, + { key: "aborted", label: "Aborted" }, + { key: "message", label: "Message" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { name: "destroy", description: "Destroy a workspace and its warehouse isolation" }, + details: + "Tears down each provisioned database's warehouse isolation (temporary schema + user) before removing the workspace. Refuses with a 409 while any database is still provisioning/deprovisioning unless --ignore-pending is passed, which removes the workspace records and leaves those warehouse objects behind. If the warehouse was unreachable, the result lists the leftover schema/user objects under orphaned_resources.", + capabilities: { minVersion: 62, tokenFeature: "workspaces" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + yes: { type: "boolean", description: "Skip confirmation", default: false }, + "ignore-pending": { + type: "boolean", + description: "Remove workspace records even while databases are provisioning/deprovisioning", + default: false, + }, + id: { type: "positional", description: "Workspace id", required: true }, + }, + outputSchema: WorkspaceDestroyResult, + examples: ["mb workspace destroy 1 --yes", "mb workspace destroy 1 --yes --ignore-pending"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + if (!args.yes) { + if (process.stdin.isTTY !== true) { + throw new ConfigError( + `refusing to destroy workspace ${id} without confirmation — pass --yes to proceed non-interactively`, + ); + } + const ok = await promptConfirm({ + message: `Destroy workspace ${id}? Its warehouse isolation (schema + user) is dropped.`, + initialValue: false, + }); + if (!ok) { + renderSummary( + { id, deleted: false, aborted: true }, + workspaceDestroyView, + `Aborted; workspace ${id} was not destroyed.`, + ctx, + ); + return; + } + } + const client = await getClient(); + const response = await client.requestParsed( + WorkspaceDeleteResponse, + `/api/ee/workspace-manager/${id}`, + { + method: "DELETE", + query: { "ignore-pending": args["ignore-pending"] ? true : undefined }, + }, + ); + renderSummary( + { ...response, aborted: false }, + workspaceDestroyView, + response.message ?? `Destroyed workspace ${id}.`, + ctx, + ); + }, +}); diff --git a/src/commands/workspace/get.ts b/src/commands/workspace/get.ts new file mode 100644 index 0000000..124a872 --- /dev/null +++ b/src/commands/workspace/get.ts @@ -0,0 +1,24 @@ +import { Workspace, workspaceView } from "../../domain/workspace"; +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 workspace by id" }, + capabilities: { minVersion: 62, tokenFeature: "workspaces" }, + args: { + ...outputFlags, + ...profileFlag, + ...connectionFlags, + id: { type: "positional", description: "Workspace id", required: true }, + }, + outputSchema: Workspace, + examples: ["mb workspace get 1", "mb workspace get 1 --json"], + async run({ args, ctx, getClient }) { + const id = parseId(args.id); + const client = await getClient(); + const workspace = await client.requestParsed(Workspace, `/api/ee/workspace-manager/${id}`); + renderItem(workspace, workspaceView, ctx); + }, +}); diff --git a/src/commands/workspace/index.ts b/src/commands/workspace/index.ts new file mode 100644 index 0000000..732f728 --- /dev/null +++ b/src/commands/workspace/index.ts @@ -0,0 +1,11 @@ +import { defineCommand } from "citty"; + +export default defineCommand({ + meta: { name: "workspace", description: "Manage Metabase workspaces", alias: "ws" }, + subCommands: { + list: () => import("./list").then((mod) => mod.default), + get: () => import("./get").then((mod) => mod.default), + create: () => import("./create").then((mod) => mod.default), + destroy: () => import("./destroy").then((mod) => mod.default), + }, +}); diff --git a/src/commands/workspace/list.ts b/src/commands/workspace/list.ts new file mode 100644 index 0000000..fcdee31 --- /dev/null +++ b/src/commands/workspace/list.ts @@ -0,0 +1,24 @@ +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" }, + capabilities: { minVersion: 62, tokenFeature: "workspaces" }, + args: { ...outputFlags, ...profileFlag, ...connectionFlags }, + outputSchema: WorkspaceListEnvelope, + examples: ["mb workspace list", "mb 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/domain/workspace.ts b/src/domain/workspace.ts new file mode 100644 index 0000000..dc2496a --- /dev/null +++ b/src/domain/workspace.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +import type { ResourceView } from "./view"; + +const WorkspaceDatabaseStatus = z.enum([ + "unprovisioned", + "provisioning", + "provisioned", + "deprovisioning", +]); + +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(); + +const WorkspaceDatabase = z + .object({ + database_id: z.number().int(), + input_schemas: z.array(z.string()), + output_namespace: z.string(), + status: WorkspaceDatabaseStatus, + database: z.object({ id: z.number().int(), name: z.string() }).loose().nullable().optional(), + }) + .loose(); + +const WorkspaceDatabaseCompact = WorkspaceDatabase.pick({ + database_id: true, + input_schemas: true, + output_namespace: true, + status: true, +}).strip(); + +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, + created_at: true, +}) + .strip() + .extend({ databases: z.array(WorkspaceDatabaseCompact).optional() }); +export type WorkspaceCompact = z.infer; + +export const workspaceView: ResourceView = { + compactPick: WorkspaceCompact, + tableColumns: [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "created_at", label: "Created" }, + { key: "databases", label: "Databases", format: (value) => formatDatabases(value) }, + ], +}; + +function formatDatabases(value: unknown): string { + const parsed = z.array(WorkspaceDatabaseCompact).safeParse(value); + if (!parsed.success) { + return ""; + } + return parsed.data.map((db) => `${db.database_id}:${db.status}`).join(", "); +} diff --git a/src/main.ts b/src/main.ts index ef10281..09b049c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,6 +25,7 @@ const main: CommandDef = defineCommand({ setting: () => import("./commands/setting").then((mod) => mod.default), search: () => import("./commands/search").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), snippet: () => import("./commands/snippet").then((mod) => mod.default), segment: () => import("./commands/segment").then((mod) => mod.default), diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index 10caf5f..fe19f10 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -120,6 +120,10 @@ describe("__manifest e2e", () => { "git-sync create-branch", "git-sync add-collection", "git-sync remove-collection", + "workspace list", + "workspace get", + "workspace create", + "workspace destroy", "setup", "snippet list", "snippet get", diff --git a/tests/e2e/workspace.e2e.test.ts b/tests/e2e/workspace.e2e.test.ts new file mode 100644 index 0000000..1dcd50e --- /dev/null +++ b/tests/e2e/workspace.e2e.test.ts @@ -0,0 +1,283 @@ +import { afterEach, assert, beforeAll, describe, expect, it } from "vitest"; + +import { WorkspaceDestroyResult } from "../../src/commands/workspace/destroy"; +import { WorkspaceListEnvelope } from "../../src/commands/workspace/list"; +import { createClient, type Client } from "../../src/core/http/client"; +import { WorkspaceCompact } from "../../src/domain/workspace"; +import { parseJson } from "../../src/runtime/json"; + +import { readBootstrap, type E2EBootstrap } from "./bootstrap-data"; +import { cliErrorMessage } from "./cli-error"; +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; +import { SEEDED } from "./seed/seeded"; +import { requireServer, serverVersionBelow } from "./server-gate"; + +const WORKSPACE_MIN_VERSION = 62; +const skipReason = requireServer({ + minVersion: WORKSPACE_MIN_VERSION, + tokenFeature: "workspaces", +}); + +const FIRST_WORKSPACE_ID = 1; +const WORKSPACE_NAME = "e2e_workspace"; +const MISSING_WORKSPACE_ID = 9999999; +// The seeded warehouse's active table schemas; the server derives input schemas as the distinct +// non-blank schema names, sorted. +const WAREHOUSE_INPUT_SCHEMAS = ["analytics", "public"]; +// Provisioning (schema + user DDL, blocking) makes create/destroy slower than plain CRUD calls. +const PROVISION_TIMEOUT_MS = 60_000; + +const WORKSPACE_COMPACT = { + id: FIRST_WORKSPACE_ID, + name: WORKSPACE_NAME, + created_at: expect.any(String), + databases: [ + { + database_id: SEEDED.warehouseDbId, + input_schemas: WAREHOUSE_INPUT_SCHEMAS, + output_namespace: expect.stringContaining("mb__isolation_"), + status: "provisioned", + }, + ], +}; + +describe.skipIf(skipReason !== null)("workspace e2e", () => { + let bootstrap: E2EBootstrap; + let adminClient: Client; + const tempDirs: string[] = []; + + beforeAll(async () => { + bootstrap = await readBootstrap(); + adminClient = createClient({ + url: bootstrap.baseUrl, + credential: { kind: "apiKey", 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 { + MB_URL: bootstrap.baseUrl, + MB_API_KEY: bootstrap.adminApiKey, + }; + } + + // `database-enable-workspaces` is a database-local setting wiped by the per-test snapshot + // restore, so every test that creates a workspace re-enables it first. + async function enableWorkspacesOnWarehouse(): Promise { + await adminClient.requestRaw(`/api/database/${SEEDED.warehouseDbId}`, { + method: "PUT", + body: { settings: { "database-enable-workspaces": true } }, + }); + } + + async function createSeedWorkspace(): Promise { + await enableWorkspacesOnWarehouse(); + const result = await runCli({ + args: [ + "workspace", + "create", + "--name", + WORKSPACE_NAME, + "--database-ids", + String(SEEDED.warehouseDbId), + "--json", + ], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, WorkspaceCompact)).toEqual(WORKSPACE_COMPACT); + } + + it("list returns the just-created workspace as the only entry, databases hydrated", async () => { + await createSeedWorkspace(); + + const result = await runCli({ + args: ["workspace", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, WorkspaceListEnvelope)).toEqual({ + data: [WORKSPACE_COMPACT], + returned: 1, + total: 1, + }); + }); + + it("create + get round-trip returns the provisioned workspace by id", async () => { + await createSeedWorkspace(); + + const result = await runCli({ + args: ["workspace", "get", String(FIRST_WORKSPACE_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(parseJson(result.stdout, WorkspaceCompact)).toEqual(WORKSPACE_COMPACT); + }); + + it("destroy --yes tears down the workspace; subsequent get returns 404", async () => { + await createSeedWorkspace(); + + const destroyResult = await runCli({ + args: ["workspace", "destroy", String(FIRST_WORKSPACE_ID), "--yes", "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + timeoutMs: PROVISION_TIMEOUT_MS, + }); + expect(destroyResult.exitCode, destroyResult.stderr).toBe(0); + expect(parseJson(destroyResult.stdout, WorkspaceDestroyResult)).toEqual({ + id: FIRST_WORKSPACE_ID, + deleted: true, + aborted: false, + }); + + const getResult = await runCli({ + args: ["workspace", "get", String(FIRST_WORKSPACE_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + expect(getResult.exitCode).toBe(1); + expect(cliErrorMessage(getResult.stderr)).toContain( + `Not found: GET /api/ee/workspace-manager/${FIRST_WORKSPACE_ID}.`, + ); + expect(getResult.stdout).toBe(""); + }); + + it("get on a missing workspace id surfaces the server 404", async () => { + const result = await runCli({ + args: ["workspace", "get", String(MISSING_WORKSPACE_ID), "--json"], + configHome: await makeIsolatedConfigHome(), + env: authEnv(), + }); + + expect(result.exitCode).toBe(1); + expect(cliErrorMessage(result.stderr)).toContain( + `Not found: GET /api/ee/workspace-manager/${MISSING_WORKSPACE_ID}.`, + ); + expect(result.stdout).toBe(""); + }); +}); + +describe("workspace arg validation e2e (no Metabase contact required)", () => { + 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("get with a non-integer id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["workspace", "get", "abc", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)'); + expect(result.stdout).toBe(""); + }); + + it("create with a non-integer database id fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["workspace", "create", "--name", "ws", "--database-ids", "1,abc", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain( + 'invalid database id: "abc" (expected integer)', + ); + expect(result.stdout).toBe(""); + }); + + it("create with only separators in --database-ids fails fast with ConfigError", async () => { + const result = await runCli({ + args: ["workspace", "create", "--name", "ws", "--database-ids", ",,", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain( + "expected at least one database id (comma separated)", + ); + expect(result.stdout).toBe(""); + }); + + it("destroy without --yes refuses non-interactively with ConfigError before any network call", async () => { + const result = await runCli({ + args: ["workspace", "destroy", "5", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + expect(result.exitCode).toBe(2); + expect(cliErrorMessage(result.stderr)).toContain( + "refusing to destroy workspace 5 without confirmation — pass --yes to proceed non-interactively", + ); + expect(result.stdout).toBe(""); + }); +}); + +describe.skipIf(!serverVersionBelow(WORKSPACE_MIN_VERSION))( + "workspace capability gate against a sub-v62 server", + () => { + 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; + } + + it("workspace list refuses with CapabilityError (exit 2) naming the v62 requirement", async () => { + const serverTag = bootstrap.server.version?.tag; + assert(serverTag !== undefined, "gate block requires a known cached server version"); + const configHome = await makeIsolatedConfigHome(); + const login = await runCli({ + args: [ + "auth", + "login", + "--url", + bootstrap.baseUrl, + "--api-key", + bootstrap.adminApiKey, + "--json", + ], + configHome, + }); + assert(login.exitCode === 0, login.stderr); + + const result = await runCli({ args: ["workspace", "list", "--json"], configHome }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + `This command requires Metabase v${WORKSPACE_MIN_VERSION}+ (this server is ${serverTag}). Upgrade Metabase or pin mb-cli to an older release.`, + ); + expect(result.stdout).toBe(""); + }); + }, +); From 5c4e9304547d7c1eb6d6def6519497f958d76f4c Mon Sep 17 00:00:00 2001 From: Bryan Maass Date: Mon, 6 Jul 2026 13:42:54 -0600 Subject: [PATCH 2/2] fix(e2e): pass token env vars bare so unset stays unset ${VAR:-} injected an empty METASTORE_DEV_SERVER_URL; Metabase's dev-mode token check treats empty as configured and builds a hostless URL, so token validation always failed. Bare list-syntax entries only pass through when the shell defines them, letting the staging token-check fallback work. --- tests/e2e/docker-compose.yml | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml index 0fcf031..1dae06e 100644 --- a/tests/e2e/docker-compose.yml +++ b/tests/e2e/docker-compose.yml @@ -21,21 +21,24 @@ services: ports: - "${METABASE_E2E_PORT:-13000}:3000" environment: - MB_DB_TYPE: h2 - MB_DB_FILE: /metabase/metabase.db - MB_ENABLE_TEST_ENDPOINTS: "true" - MB_SITE_NAME: metabase-cli-e2e - JAVA_TIMEZONE: UTC - MB_PASSWORD_COMPLEXITY: weak - MB_DISABLE_SESSION_THROTTLE: "true" - MB_LOAD_SAMPLE_CONTENT: "false" - MB_CHECK_FOR_UPDATES: "false" - MB_ANON_TRACKING_ENABLED: "false" + - MB_DB_TYPE=h2 + - MB_DB_FILE=/metabase/metabase.db + - MB_ENABLE_TEST_ENDPOINTS=true + - MB_SITE_NAME=metabase-cli-e2e + - JAVA_TIMEZONE=UTC + - MB_PASSWORD_COMPLEXITY=weak + - MB_DISABLE_SESSION_THROTTLE=true + - MB_LOAD_SAMPLE_CONTENT=false + - MB_CHECK_FOR_UPDATES=false + - MB_ANON_TRACKING_ENABLED=false # `dev` so Metabase honors METASTORE_DEV_SERVER_URL for token checks; # the prod default is hard-pinned to https://token-check.metabase.com. - MB_RUN_MODE: dev - MB_PREMIUM_EMBEDDING_TOKEN: ${MB_PREMIUM_EMBEDDING_TOKEN:-} - METASTORE_DEV_SERVER_URL: ${METASTORE_DEV_SERVER_URL:-} + # Bare (valueless) entries pass through only when set in the invoking shell — + # a `${VAR:-}` default would inject an empty string, and Metabase treats an + # empty METASTORE_DEV_SERVER_URL as configured, breaking the staging fallback. + - MB_RUN_MODE=dev + - MB_PREMIUM_EMBEDDING_TOKEN + - METASTORE_DEV_SERVER_URL volumes: - mb-app-db:/metabase - ./snapshots:/e2e/snapshots