diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 030ae6e..90e6f1a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "plugins": [ { "name": "metabase-cli", - "description": "Drive a Metabase instance from the terminal via the `mb` CLI: auth, list/get/create/update/delete on every resource, run queries and transforms, git-sync content to and from a remote, manage Enterprise workspaces. Bundles workspace, transform, and git-sync references as on-demand skills served by `mb skills get`.", + "description": "Drive a Metabase instance from the terminal via the `mb` CLI: auth, list/get/create/update/delete on every resource, run queries and transforms, git-sync content to and from a remote. Bundles transform and git-sync references as on-demand skills served by `mb skills get`.", "source": "./", "strict": false, "skills": ["./skills/metabase-cli"], diff --git a/CLAUDE.md b/CLAUDE.md index 3bee8cd..3ea0160 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ Metabase CLI. TypeScript ESM. citty + native `fetch` + Zod + @clack/prompts. oxl - Type guards must validate what they narrow. `function isFoo(value): value is Foo` must check the property that distinguishes `Foo`, not a weaker shared property. A guard that narrows on `instanceof Error` while claiming `is NodeJS.ErrnoException` is a hidden cast — callers will read `.code` off something that doesn't have it. - Eloquence: prefer the simplest realistic expression. Don't stack ceremony — repeated `override readonly` modifiers, generic gymnastics, intermediate abstract classes, or option-bag wrappers — when a plain field, an early return, or a non-generic shape is shorter and clearer. If two real-world engineers wouldn't both reach for the pattern, don't write it. Idiomatic > technically-pristine. - No big inline expressions. `if (a && b && (c.x?.y ?? 0) > Z || isFooBar(d))` is noise. Split into semantically-named locals (`const hasBudget = …; const isFresh = …; if (hasBudget && isFresh) …`). Same for ternary chains — flatten with early returns, guard clauses, or a small lookup. The conditional in an `if`/return should read as one phrase, not a puzzle. -- We do not duplicate auth resolution for SOURCE/TARGET. Use profiles. Multi-instance commands take `--from-profile` / `--to-profile`, each routed through the same `resolveConfig`. There is no `METABASE_SOURCE_*` env-var family, no parallel `getSourceClient`, no shadow flag set. Inline reads of `process.env.METABASE_URL` / `METABASE_API_KEY` / `METABASE_LICENSE_TOKEN` belong in `core/config.ts` only. +- We do not duplicate auth resolution for SOURCE/TARGET. Use profiles. Multi-instance commands take `--from-profile` / `--to-profile`, each routed through the same `resolveConfig`. There is no `METABASE_SOURCE_*` env-var family, no parallel `getSourceClient`, no shadow flag set. Inline reads of `process.env.METABASE_URL` / `METABASE_API_KEY` belong in `core/config.ts` only. - Tests import production Zod schemas from `src/`; they never redeclare them. `LoginResult` (`src/commands/auth/login.ts`), `AuthStatus` (`src/commands/auth/status.ts`), every `` / `Compact` in `src/domain/`, and every `ListEnvelope` in `src/commands//list.ts` is THE contract — copying the shape into a test creates silent drift the type-checker can't catch. - Compact projections MUST chain `.strip()` after `.pick()`: `.pick({...}).strip()`. Zod 4's `.pick()` on a `.loose()` parent inherits the loose catchall — without `.strip()` the projection silently passes every API field through. The bug is invisible until you look at the rendered `--json` output and see fields you never picked. This applies to every `Compact` in `src/domain/` and any other `pick()` derived from a `.loose()` schema. - Tests reuse `src/runtime/` and `src/core/errors` helpers (`parseJson`, `pollUntil`, `isNotFoundError`, `errorMessage`) instead of reimplementing `JSON.parse` + Zod, sleep+deadline loops, or ENOENT shape checks. Tests are code; the layering rules don't bite, but the duplication and drift rules do. @@ -31,7 +31,7 @@ Metabase CLI. TypeScript ESM. citty + native `fetch` + Zod + @clack/prompts. oxl - `src/commands/` — CLI shell only. No HTTP, no parsing, no formatting. - `src/core/` — pure logic, no CLI deps. - `auth/` — storage + verify. - - `config.ts` — flag → env → stored resolver. Profile-aware (`resolveProfileName`, `resolveConfig`, `resolveLicenseToken`). All `METABASE_*` env-var reads live here. + - `config.ts` — flag → env → stored resolver. Profile-aware (`resolveProfileName`, `resolveConfig`). All `METABASE_*` env-var reads live here. - `errors.ts` — `isNotFoundError`, `errorMessage` (Node error type guards used outside the HTTP boundary). - `http/` — the HTTP boundary. `client.ts` wraps native `fetch` with `requestParsed(schema, path, opts)` (the ONLY typed-JSON path), `requestRaw`, `requestStream`. Retries are idempotency-aware: GET/HEAD/OPTIONS retry on retryable status codes by default; POST/PUT/PATCH/DELETE never retry on status (only on network/timeout). Callers may override via `RequestOptions.idempotent`. `errors.ts` owns the discriminated `MetabaseError` taxonomy and `toMetabaseError(unknown)`. `sanitize.ts` runs at `HttpError` construction — secret redaction is not optional. `retry.ts` is the backoff math; it is also the only `core/http/` site allowed to drive a `setTimeout`-based wait loop (via `node:timers/promises`) outside `src/runtime/poll.ts`. Nothing outside this directory may import a third-party HTTP library or call `fetch` directly; this is enforced by `tests/structure.test.ts`. - `url.ts` — `normalizeUrl` and `originOnly`. The single permitted home for `new URL(...)` outside `src/core/http/**`; the URL helpers belong here, not at call sites. @@ -47,7 +47,7 @@ Metabase CLI. TypeScript ESM. citty + native `fetch` + Zod + @clack/prompts. oxl ## Commands runtime - `src/commands/runtime.ts` — `defineMetabaseCommand({ meta, args, run })` is the canonical command shell. It merges `commonFlags` into `args` (callers add only their extra flags), parses `args` through `resolveCommonFlags` to build `ctx`, and exposes a lazy `getClient()` that runs `resolveConfig` + `createClient` on first call (cached). Use it instead of `defineCommand` directly. Pass `args: {}` when a command adds no extra flags. -- **Capabilities + preflight.** The minimum supported server is **Metabase v0.58**. Every command declares `capabilities: { minVersion, tokenFeature? }` (`minVersion` is the bare Metabase major integer like `58`, not semver). Baseline is `{ minVersion: 58 }` and is treated as "no gating" (no probe, no enforcement). Commands that never touch a Metabase server (e.g. `uuid`, `upgrade`) declare `capabilities: null` so the manifest reports no version requirement rather than a misleading baseline — don't fake a baseline for a local command. Annotate every command explicitly (a `{...}` or `null`); uniformity keeps the manifest honest. The server version and token-features are probed once on `auth login`/`auth list` and cached in the profile record; For non-baseline commands `getClient()` runs a preflight against that cache and throws `CapabilityError` (exit `2`) on a version/feature mismatch, or warns and proceeds when the version is unknown; baseline and `null` commands never preflight. `--skip-preflight` (per-invocation) or `METABASE_CLI_SKIP_PREFLIGHT=1` (process-wide) bypasses the check. To find the right `minVersion`/feature for a new endpoint, validate against `../metabase` at `origin/release-x.58.x` (route file `src/metabase/api_routes/routes.clj`, EE routes `enterprise/backend/src/metabase_enterprise/api_routes/routes.clj`); token-feature keys are the underscored map keys in `src/metabase/premium_features/settings.clj` (e.g. `remote_sync`, `transforms`, `workspaces`). +- **Capabilities + preflight.** The minimum supported server is **Metabase v0.58**. Every command declares `capabilities: { minVersion, tokenFeature? }` (`minVersion` is the bare Metabase major integer like `58`, not semver). Baseline is `{ minVersion: 58 }` and is treated as "no gating" (no probe, no enforcement). Commands that never touch a Metabase server (e.g. `uuid`, `upgrade`) declare `capabilities: null` so the manifest reports no version requirement rather than a misleading baseline — don't fake a baseline for a local command. Annotate every command explicitly (a `{...}` or `null`); uniformity keeps the manifest honest. The server version and token-features are probed once on `auth login`/`auth list` and cached in the profile record; For non-baseline commands `getClient()` runs a preflight against that cache and throws `CapabilityError` (exit `2`) on a version/feature mismatch, or warns and proceeds when the version is unknown; baseline and `null` commands never preflight. `--skip-preflight` (per-invocation) or `METABASE_CLI_SKIP_PREFLIGHT=1` (process-wide) bypasses the check. To find the right `minVersion`/feature for a new endpoint, validate against `../metabase` at `origin/release-x.58.x` (route file `src/metabase/api_routes/routes.clj`, EE routes `enterprise/backend/src/metabase_enterprise/api_routes/routes.clj`); token-feature keys are the underscored map keys in `src/metabase/premium_features/settings.clj` (e.g. `remote_sync`, `transforms`). - `src/output/prompt.ts` — `promptText` / `promptPassword` / `promptConfirm` / `promptSelect` wrap `@clack/prompts`. They throw `AbortError` on user cancel and `ConfigError` when stdin is not a TTY. Commands import these instead of `@clack/prompts` directly so the cancel-to-`AbortError` pathway is funneled in one place. ## Domain pattern @@ -115,7 +115,7 @@ Lives under `tests/e2e/`. The whole point is to run the **built `dist/cli.mjs`** - `tests/e2e/setup/bootstrap.ts` — standalone script invoked by `bun run e2e:bootstrap` and by `tests/e2e/setup/global-setup.ts`. Idempotent: reuses `.bootstrap..json` when the stored key still authenticates, otherwise calls `/api/setup` (or logs in directly if already setup), mints a fresh admin API key, discovers seeded ids, and probes the server. The Metabase HTTP responses it parses are setup-only — their schemas live colocated here, not in `src/domain/`. - `tests/e2e/setup/global-setup.ts` — vitest globalSetup. Verifies `dist/cli.mjs` exists, then spawns `bootstrap.ts` once per `bun run test:e2e`. - `tests/e2e/defaults.ts` — sole owner of `DEFAULT_E2E_BASE_URL`/`resolveE2EBaseUrl()` (reads `METABASE_CLI_E2E_URL`), `DEFAULT_E2E_STACK`/`resolveStackId()` (reads `METABASE_CLI_E2E_STACK`, default `default`), and `resolveSnapshotName()` (`cli_`). Anything needing a base URL, stack id, or snapshot name imports from here. -- `tests/e2e/server-gate.ts` — `requireServer({ minVersion?, tokenFeature? })` returns a skip reason (or `null`) by feeding the persisted `server` block through the production `checkCapabilities`. Suites whose commands declare non-baseline capabilities self-skip via `describe.skipIf(requireServer(...) !== null)` (measure, transform, transform-job → v59; git-sync → v60 + remote_sync; workspace → v62 + workspaces). This is how a lane "passes or skips" rather than failing on a server that can't satisfy the command. +- `tests/e2e/server-gate.ts` — `requireServer({ minVersion?, tokenFeature? })` returns a skip reason (or `null`) by feeding the persisted `server` block through the production `checkCapabilities`. Suites whose commands declare non-baseline capabilities self-skip via `describe.skipIf(requireServer(...) !== null)` (measure, transform, transform-job → v59; git-sync → v60 + remote_sync). This is how a lane "passes or skips" rather than failing on a server that can't satisfy the command. - `tests/e2e/docker-compose.yml` — Postgres warehouse + Metabase (image via `METABASE_E2E_IMAGE`, host port via `METABASE_E2E_PORT`, project/volume namespaced by the runner's `-p mb-e2e-`). Token override via `MB_PREMIUM_EMBEDDING_TOKEN` env passes through; absence is fine — EE boots without a token, and token-gated suites skip themselves. - `scripts/e2e-matrix.ts` (`bun run e2e:matrix`) — runs the suite against the version/edition matrix (oss/ee × 58–61 + oss/ee head), each in an isolated stack (own project, port, app-db volume, `.bootstrap..json`, `cli_.sql`). `--stack=` runs one; no flag runs all sequentially; `--parallel[=N]` runs N at a time. CI (`.github/workflows/e2e.yml`) runs one matrix job per stack with `fail-fast: false`. @@ -143,7 +143,7 @@ Running e2e — the suite is slow (~3–5 minutes for a full run, ~hundreds of m - Add a third-party HTTP library (`axios`, `got`, `node-fetch`, `undici`, etc.). `src/core/http/` is the HTTP boundary; it wraps native `fetch` (Node ≥ 20.6) with our project-specific contract (`requestParsed(schema)`, `HttpError`-with-sanitization-at-construction, idempotency-aware retries, `expectContentType` enforcement). Extend that module instead of importing a library — every off-the-shelf client would need to be wrapped to satisfy our contract anyway, and the wrapping is more code than the current implementation. - Write a dotenv parser. Use Node's native `--env-file` (Node ≥ 20.6). - Add deps for one-off helpers — inline. -- Read or print the EE license token. The dev token is supplied to the e2e stack via `MB_PREMIUM_EMBEDDING_TOKEN` (Metabase's own env var name), and the dev token-check URL via `METASTORE_DEV_SERVER_URL` (also Metabase's name; honored only when `MB_RUN_MODE=dev`, which the compose file already sets). Both flow shell → docker compose → JVM and shell → vitest → `runCli({ stdin })`. Never `cat`/`Read` `.env`/`.envrc`/shell rcs that may contain them, never `echo $MB_PREMIUM_EMBEDDING_TOKEN`, never `console.log` or `expect(...).toContain(token)`. To check whether a license test will run, inspect `process.env["MB_PREMIUM_EMBEDDING_TOKEN"] === undefined` — never the value. Use `mb_dev_…` dummy tokens for storage-roundtrip tests; only the EE-integration suite (gated on the token + dev URL being set) ever threads the real value, and only as opaque stdin to the CLI subprocess. +- Read or print the EE license token. The dev token is supplied to the e2e stack via `MB_PREMIUM_EMBEDDING_TOKEN` (Metabase's own env var name), and the dev token-check URL via `METASTORE_DEV_SERVER_URL` (also Metabase's name; honored only when `MB_RUN_MODE=dev`, which the compose file already sets). Both flow shell → docker compose → JVM to enable EE features (transforms, measures, remote-sync) on the booted server. Never `cat`/`Read` `.env`/`.envrc`/shell rcs that may contain them, never `echo $MB_PREMIUM_EMBEDDING_TOKEN`, never `console.log` or `expect(...).toContain(token)`. To check whether a token-gated suite will run, inspect `process.env["MB_PREMIUM_EMBEDDING_TOKEN"] === undefined` — never the value. ## Commands diff --git a/README.md b/README.md index bd30c62..a6778e0 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The minimum supported server is **Metabase v0.58** (major `58`). Anything older Commands that need more than a baseline OSS server declare it — a higher minimum major version or a premium token feature. The server version and token features are detected and cached when you run `mb auth login` (or `mb auth list`). For those commands, a preflight check runs before the first request and refuses with an actionable message (exit code `2`) when: - the server is older than the command's minimum version, or -- the command needs a premium feature (e.g. `remote_sync`, `workspaces`) that isn't enabled. +- the command needs a premium feature (e.g. `remote_sync`, `transforms`) that isn't enabled. Plain OSS commands against a v0.58+ server (the majority) carry no elevated requirement and skip the preflight entirely. When a gated command runs but the server version can't be detected (no cached probe), it proceeds with a warning rather than refusing. To bypass the check for a single run, pass `--skip-preflight`; to bypass it process-wide (e.g. in CI), set `METABASE_CLI_SKIP_PREFLIGHT=1`. Both are footguns — only for servers you know are patched. @@ -1089,226 +1089,6 @@ mb git-sync remove-collection 12 mb git-sync remove-collection 12 --json --profile prod ``` -## Workspaces - -CRUD on `/api/ee/workspace-manager`. Run against the workspace-manager parent instance. - -### `mb workspace list` - -```sh -mb workspace list -mb workspace list --json -``` - -### `mb workspace create` - -```sh -mb workspace create --name analytics -echo '{"name":"analytics"}' | mb workspace create -mb workspace create --file workspace.json -``` - -| Flag | Description | -| --------------- | ------------------------------------------------------- | -| `--name ` | Workspace name. Shortcut for `--body '{"name":""}'`. | -| `--body ` | Inline JSON body. | -| `--file ` | Path to JSON body file. | - -### `mb workspace database provision [db-id]` - -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 -mb workspace database provision 1 5 --schemas analytics,github -mb workspace database provision 1 5 --schemas analytics --wait -mb workspace database provision 1 --file provision.json -``` - -| Arg / Flag | Description | -| ----------------- | -------------------------------------------------------------- | -| `` | Database id positional (used with `--schemas`). | -| `--schemas ` | Comma-separated input schemas (used with the `db-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`. | - -### `mb workspace database update ` - -Update a workspace's provisioned database (server-side this is deprovision + provision). Body accepts only `input` — the database id comes from the URL. - -```sh -mb workspace database update 1 5 --schemas analytics,github -mb workspace database update 1 5 --schemas analytics --wait -mb workspace database update 1 5 --file update.json -``` - -| Flag | Description | -| ----------------- | ----------------------------------------------------------------- | -| `--schemas ` | Comma-separated input schemas. Shortcut for body. | -| `--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`. | -| `--interval ` | Polling interval in ms (default 2000). Used with `--wait`. | - -### `mb workspace database deprovision ` - -```sh -mb workspace database deprovision 1 5 --yes -mb workspace database deprovision 1 5 --yes --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 - -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`. - -### `mb workspace start ` - -```sh -mb workspace start 1 -mb workspace start 1 --wait -mb workspace start 1 --port 3100 -mb workspace start 1 --image metabase/metabase-enterprise:latest --no-pull -mb workspace start 1 --force -mb workspace start 1 --repo /path/to/sync-repo --wait -mb 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 `mb workspace license set` writes to). Refuses to start if the workspace has any database that isn't `status: "provisioned"`. - -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 `mb 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"`. - -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-enterprise:latest` once Metabase v62 is released, otherwise `metabase/metabase-enterprise-head:latest`. | -| `--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, (with `--wait`) the `/api/health` probe, and (with `--metadata`) the metadata-import status poll. | -| `--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. | - -### `mb workspace stop ` - -```sh -mb workspace stop 1 -mb workspace stop 1 --json -``` - -Stops the running container; no-ops if it's already exited or missing. Reports the prior state. - -### `mb workspace delete ` - -```sh -mb workspace delete 1 --yes -mb workspace delete 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 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`). | - -### `mb workspace logs ` - -```sh -mb workspace logs 1 -mb workspace logs 1 --follow -mb 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). | - -### `mb workspace url ` - -```sh -mb workspace url 1 -mb 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. - -### `mb workspace credentials ` - -```sh -mb workspace credentials 1 -mb 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`. - -### `mb workspace ps` - -```sh -mb workspace ps -mb 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). - -### License - -The Metabase Enterprise license token is stored locally and forwarded to the child instance by `mb workspace start`. It is global to the CLI install (not per-profile). - -#### `mb workspace license set [token]` - -Store a license token. Resolution order: positional → piped stdin → `METABASE_LICENSE_TOKEN` → interactive prompt. Stdin is auto-detected when not a TTY. - -Common output flags (`--json`, `--format`, `--full`, `--fields`, `--max-bytes`) are accepted; the result payload is rendered through the standard output layer. - -```sh -echo "$MB_LICENSE" | mb workspace license set -mb workspace license set < token.txt -``` - -#### `mb workspace license status` - -Show whether a license is stored. Does not reveal the value. - -```sh -mb workspace license status -mb workspace license status --json -``` - -| Flag | Description | -| -------- | ----------------------------------- | -| `--json` | Emit JSON. Auto-enabled on non-TTY. | - -#### `mb workspace license remove` - -Clear the stored license. - -```sh -mb workspace license remove --yes -``` - -| Flag | Description | -| ------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `--yes` | Skip the interactive confirmation prompt. In non-TTY contexts the prompt is skipped automatically (kubectl/gh/docker convention). | - ## Instance setup Bootstrapping a fresh, not-yet-configured Metabase instance. @@ -1474,7 +1254,7 @@ The CLI ships with bundled agent skills (Claude Code / `npx skills add` compatib mb skills list # discover bundled skills (table or JSON) mb skills get core # print the top-level guide mb skills get core --full # include references and templates -mb skills get workspace,transform # comma-separated, multi-skill fetch +mb skills get git-sync,transform # comma-separated, multi-skill fetch mb skills get --all --json --max-bytes 0 # every non-hidden skill, structured (default cap truncates) mb skills path # absolute paths for direct Read mb skills path core # one path @@ -1487,7 +1267,6 @@ Bundled skills: | Name | Use | | ----------- | -------------------------------------------------------------------------------------- | | `core` | Top-level guide: auth, flag conventions, output flags, body input, every command group | -| `workspace` | Enterprise workspace lifecycle (create, provision, start, child credentials, diagnose) | | `transform` | Authoring and running transforms (native SQL + MBQL 5), iteration, run inspection | | `git-sync` | Round-tripping Metabase content to/from a git remote | @@ -1505,7 +1284,6 @@ Exit codes: `0` success, `2` `ConfigError` (missing name, unknown name, `MB_SKIL | `METABASE_URL` | Default URL for `auth login` and config resolution. | | `METABASE_API_KEY` | Default API key (overrides interactive prompt; not stored). | | `METABASE_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | -| `METABASE_LICENSE_TOKEN` | Default license token for `workspace license set`. | | `METABASE_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | | `METABASE_CLI_SKIP_PREFLIGHT` | When set to `1`, bypasses the per-command server version / token-feature preflight check. Escape hatch for patched Metabase builds; can mask real compatibility problems. | | `MB_SKILLS_DIR` | Override the directory `mb skills` scans (dev/test only; defaults to the CLI's bundled `skills` + `skill-data` trees). | diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 92a28fd..5fe3bdb 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -1,18 +1,18 @@ --- name: core -description: Drive a Metabase instance from the terminal via the `mb` CLI — auth, databases, cards, dashboards, collections, transforms, queries, search, git-sync, Enterprise workspaces. Use for any `mb ` task. +description: Drive a Metabase instance from the terminal via the `mb` CLI — auth, databases, cards, dashboards, collections, transforms, queries, search, git-sync. Use for any `mb ` task. allowed-tools: Read, Write, Edit, Bash, AskUserQuestion --- # metabase-cli (core) -The official Metabase CLI (`mb`) drives a Metabase instance over its REST API. It covers auth, list/get/create/update/delete on every resource, query and transform execution, content search, git-sync (representations ↔ instance), Enterprise workspaces, and entity-id translation. +The official Metabase CLI (`mb`) drives a Metabase instance over its REST API. It covers auth, list/get/create/update/delete on every resource, query and transform execution, content search, git-sync (representations ↔ instance), and entity-id translation. Top-level command groups (run `mb --help` to discover verbs): ``` auth | db | table | field | query | card | dashboard | snippet | segment | measure | collection -transform | transform-job | setting | search | git-sync | workspace | setup | eid | uuid | upgrade | skills +transform | transform-job | setting | search | git-sync | setup | eid | uuid | upgrade | skills ``` The patterns below — auth, flag conventions, output flags, body input — apply across **every** group. Per-command flags, examples, and output schemas live in `mb __manifest` (see below). A few flows have their own specialized skills; load them on demand (see "Specialized skills"). Authoring any query body (cards, transforms, measures, segments, ad-hoc `mb query`) is one — load `mbql` whenever you build MBQL by hand. @@ -21,8 +21,6 @@ The patterns below — auth, flag conventions, output flags, body input — appl **The agent does not log in for the user.** Authentication is the human's job — they pick the base URL, paste credentials, and store them as a named profile. The agent's role is to _check_ what profiles exist, _ask_ which to use, and pass `--profile ` through every command. -**The one exception** is a freshly bootstrapped workspace child: its API key is minted by the parent the human already authorized, so the agent reads it via `mb workspace credentials ` and saves it with `auth login` — piping the key on **stdin** (`printf '%s' "$KEY" | mb auth login …`), never on an `--api-key` flag (the CLI rejects the flag form). See the `workspace` skill, step 4. - ### Discover what's already configured ```bash @@ -35,17 +33,7 @@ mb auth status --profile --json # → status of a specific profile ### Pick the profile to use -If exactly one profile is configured and the user's intent doesn't disambiguate, use it. If multiple profiles exist and the user hasn't named one, ask via `AskUserQuestion`, presenting the names from `auth list`. Once a name is established, pass `--profile ` to **every** subsequent command. Profile names are arbitrary local labels — `prod`, `staging`, the workspace name — let the user pick. - -### Other secrets (license, warehouse passwords) - -Same rule: the human runs the storing command. To check whether a license is present: - -```bash -mb workspace license status --json # → {present: bool} -``` - -If `present: false`, ask the user to run `echo "" | mb workspace license set` from their terminal — don't paste the token in chat. +If exactly one profile is configured and the user's intent doesn't disambiguate, use it. If multiple profiles exist and the user hasn't named one, ask via `AskUserQuestion`, presenting the names from `auth list`. Once a name is established, pass `--profile ` to **every** subsequent command. Profile names are arbitrary local labels — `prod`, `staging` — let the user pick. ## Flag conventions @@ -56,21 +44,21 @@ If `present: false`, ask the user to run `echo "" | mb workspace lic ❌ mb --profile prod table list # → error: "Unknown command prod" ``` -`--profile` attaches **after** the full verb chain (`table list`, `card get`, `workspace start`). +`--profile` attaches **after** the full verb chain (`table list`, `card get`, `git-sync export`). ### `--wait` for async operations -`workspace start`, `workspace database provision`, `transform run`, and similar async verbs return immediately by default. Pass `--wait` for any interactive flow where the next step depends on completion. Without it you'll race the operation and see "not ready" / `state: starting` / transient connection refusals. +`transform run`, `git-sync import`, and similar async verbs return immediately by default. Pass `--wait` for any interactive flow where the next step depends on completion. Without it you'll race the operation and see "not ready" / transient connection refusals. ### Some outputs are JSON envelopes, not bare strings -A handful of "lookup" verbs return a JSON object even when you only want a single field. `mb workspace url ` returns `{"workspace_id": ..., "url": "http://..."}`, not `"http://..."`. Don't drop them raw into another flag — extract: +A handful of "lookup" verbs return a JSON object even when you only want a single field. `mb setting get ` returns `{"key": "...", "value": ...}`, not the bare value. Don't drop them raw into another flag — extract: ```bash -WS_URL=$(mb workspace url --json | jq -r '.url') +VALUE=$(mb setting get --json | jq -r '.value') ``` -If you find yourself writing `--url $(mb ...)` and the receiving command rejects it with "URL must start with http://", this is what happened. +If you find yourself piping a `--json` envelope straight into another flag and the receiving command rejects it, this is what happened. ## Output @@ -78,7 +66,7 @@ Every list/get verb supports the same output flags: - `--json` — emit the full JSON envelope, safe for `jq`. Default is human-readable text. - `--full` — include every field (compact projection is the default for list/get). -- `--fields a,b.c.d` — project specific dot-paths. Mutually exclusive with `--full`. +- `--fields a,b.c.d` — project specific dot-paths. Mutually exclusive with `--full`. **Paths are relative to each `data[]` item on list verbs, and to the root on single-item verbs.** So it's `--fields id,name` on `… list` / `database schema-tables` (the projection runs per row) — `data.id` and `data[].id` both fail with `unknown field path: "data.id"`. On single-object verbs the path is root-relative: `--fields id,name,display` on `card get`, and `--fields data.rows` on `mb query` (whose `data` is an object, not an array). - `--max-bytes ` — cap **list** output size (drops trailing items, sets `truncated`). Default 65 536; `0` disables. Single-item commands (`get`, `metadata`) never truncate — they emit a stderr advisory when over the cap. List envelope shape: @@ -144,7 +132,7 @@ Routine verb shapes (list / get / create / update), every flag, and output JSON - **table fields.** `table get` never returns fields on its own — pass `--include fields` (compact) or use `table fields ` (list envelope). `table metadata ` adds FKs + dimensions (heavier). `table update` patches table-level metadata only; physical columns aren't editable here. - **field has no `list`.** Fields are per-table — get them via `table get --include fields`. Never enumerate fields across a whole db (context blow-up). `field summary` is live cardinality `{field_id, count, distincts}`; `field values` is the cached distinct set (`has_more_values: true` ⇒ truncated cache). `field update` patches metadata only; `base_type` isn't editable. - **card.** `dataset_query` is the **flat** `mbql/query` value, not a legacy `{type:"query",query:…}` envelope (→ `mbql` skill). `--export-format csv|xlsx` streams the raw export (pipe to a file), bypassing the JSON envelope. `archive` is the only delete; unarchive with `update --body '{"archived":false}'`. `visualization_settings` keys are scoped by `display` and aren't pre-flighted — see the `viz` skill. -- **dashboard.** Dashcards round-trip through `PUT /api/dashboard/:id` (no per-dashcard endpoint): `update-dashcard ` patches one safely; `update --body '{"dashcards":[…]}'` replaces the whole set (omitted ids are deleted server-side; use negative ids for new cards). `create`/`update` pre-flight every positive `card_id` against live server state and exit **2** with `{ok:false,errors:[…]}` on a bad ref — non-bypassable (no `--skip-validate`). `dashboard get ` (or `--full`) hydrates dashcards/tabs; `list` omits them. +- **dashboard.** Dashcards round-trip through `PUT /api/dashboard/:id` (no per-dashcard endpoint): `update-dashcard ` patches one safely; `update --body '{"dashcards":[…]}'` replaces the whole set (omitted ids are deleted server-side; use negative ids for new cards). `create` accepts the **same** `dashcards` array in its initial body, so you can lay out the whole dashboard in one call — negative ids for new cards, and `card_id:null` plus a `visualization_settings.virtual_card` block (`{display:"text"|"heading"|"link"|…}`) for non-question cards. `create`/`update` pre-flight every positive `card_id` against live server state and exit **2** with `{ok:false,errors:[…]}` on a bad ref — non-bypassable (no `--skip-validate`). `dashboard get ` (or `--full`) hydrates dashcards/tabs; `list` omits them. - **snippet `--archived` is a swap, not a union** — list returns _either_ active _or_ archived rows, never both. (Same shape for `--filter archived` on dashboard/collection.) - **segment / measure** `update` and `archive` require a non-blank `revision_message` (audit-logged); the CLI does not synthesize it on `update`. `archive` defaults to `"Archived via mb CLI"` — override with `--revision-message`. `definition` is a flat MBQL clause (→ `mbql` skill): segment = a filter, measure = exactly one aggregation. - **collection ``** accepts four forms only — positive int, `root`, `trash`, or a 21-char entity_id — anything else is a client-side `ConfigError`. `collection items` auto-paginates (cap with `--limit`, which then omits `total`). `collection tree` is **JSON-only** — `--format text` is rejected. @@ -157,11 +145,10 @@ Routine verb shapes (list / get / create / update), every flag, and output JSON ## Specialized skills (load on demand) -This core file is enough for any single-command task. Load the relevant skill **proactively** when intent matches — don't wing an MBQL body, the workspace lifecycle, a transform body, or the git-sync workflow from this overview alone. Load each via `mb skills get `. +This core file is enough for any single-command task. Load the relevant skill **proactively** when intent matches — don't wing an MBQL body, a transform body, or the git-sync workflow from this overview alone. Load each via `mb skills get `. - **`mbql`** — authoring or fixing any MBQL query body: `mb query`, a card `dataset_query`, a transform `source.query`, a measure/segment `definition`, "aggregate and group by", reading `--dry-run` errors. The query-body reference. - **`viz`** — choosing a card's `display` and authoring `visualization_settings`: "make it a bar chart", "set the pie dimension/metric", "format this column as currency", "the card renders as a table instead of a chart". The presentation counterpart to `mbql`. -- **`workspace`** — "spin up a workspace", "provision", "start a local Metabase against my prod", anything `mb workspace …`. **Mandatory** before `workspace start` — ask the user about Remote Sync up front (the bind mount is create-time only). - **`transform`** — "create a transform", "run a transform", authoring transform body JSON, run inspection. - **`git-sync`** — "import the latest changes", "export to git", "git sync", "dirty check", "stash before pulling". @@ -169,9 +156,9 @@ If a task spans more than one, load each. Specialized skills assume the conventi ## Don't -- **Don't run `mb auth login` for the user** — authentication is theirs (see §Auth). The only exception is saving a workspace child's credentials, and even there pipe the key on stdin. -- Don't paste credentials, license tokens, or warehouse passwords in chat. Have the user run the storing command. +- **Don't run `mb auth login` for the user** — authentication is theirs (see §Auth). +- Don't paste credentials or warehouse passwords in chat. Have the user run the storing command. - Don't put `--profile` before the verb chain — the CLI parses it as a subcommand and errors out. -- Don't omit `--wait` on `workspace start` / `transform run` / `workspace database provision` for interactive flows; the next step will race the operation. +- Don't omit `--wait` on `transform run` / `git-sync import` for interactive flows; the next step will race the operation. - Don't drop a JSON-envelope verb's output raw into another flag. Extract with `--json | jq -r '.'`. - Don't add a third-party HTTP library or shell into `curl` against `/api/...` when a `mb ` exists — that bypasses retries, schema validation, and credential redaction. diff --git a/skill-data/git-sync/SKILL.md b/skill-data/git-sync/SKILL.md index bea045e..0475746 100644 --- a/skill-data/git-sync/SKILL.md +++ b/skill-data/git-sync/SKILL.md @@ -1,6 +1,6 @@ --- name: git-sync -description: Round-trip Metabase content (cards, dashboards, transforms, snippets, collections) between an instance and a git remote via `mb git-sync …` — status, dirty / has-remote-changes checks, import (with first-fresh-workspace exception), export (with branch guard + working-tree drift), branches, stash, add/remove a collection from sync. Load when the user wants to "import the latest changes", "export to git", "git sync", "dirty check", "stash before pulling", "add a collection to sync", or anything `mb git-sync …`. +description: Round-trip Metabase content (cards, dashboards, transforms, snippets, collections) between an instance and a git remote via `mb git-sync …` — status, dirty / has-remote-changes checks, import, export (with branch guard), branches, stash, add/remove a collection from sync. Load when the user wants to "import the latest changes", "export to git", "git sync", "dirty check", "stash before pulling", "add a collection to sync", or anything `mb git-sync …`. allowed-tools: Read, Write, Edit, Bash, AskUserQuestion --- @@ -75,25 +75,6 @@ Workflow: 2. `git-sync has-remote-changes` — confirm there's actually something to import. 3. `git-sync import --branch ` — runs to terminal status by default. -### First import on a fresh workspace - -After `workspace start --repo …` brings up a brand-new workspace, the repo content **must be applied** before any other work — without it the instance has none of the repo content and subsequent edits will diverge from what's on disk. - -The container runs a boot-time auto-import on first start, so in most cases the import has already completed by the time `workspace start --wait` returns. Check `git-sync status` first — if `current_task.sync_task_type == "import"` with `status == "successful"` and `.branch` matches the host's branch, you're done; skip the explicit call (it's a wasted round-trip). Only run the explicit `git-sync import` when the auto-import hasn't landed yet. - -When you do need the explicit import, the first one on a fresh instance can report `status: conflict` (typically `conflicts: ["Transforms"]`) even when nothing is dirty — the boot-time auto-import sometimes leaves a stale task record that the first explicit import collides with. Retry the same command once; the second call usually succeeds. If it keeps reporting conflict, `git-sync import --force` is safe in this specific case because the workspace is empty — there's no instance-side work for `--force` to discard. (This is a narrow exception to the usual "confirm with the user before `--force`" rule.) - -```bash -HOST_BRANCH=$(git -C symbolic-ref --short HEAD) -SYNC_STATUS=$(mb git-sync status --profile --json) -if ! echo "$SYNC_STATUS" | jq -e --arg b "$HOST_BRANCH" \ - '.current_task.sync_task_type == "import" and .current_task.status == "successful" and (.branch == $b)' >/dev/null; then - mb git-sync import --branch "$HOST_BRANCH" --profile --json \ - || mb git-sync import --branch "$HOST_BRANCH" --profile --json \ - || mb git-sync import --branch "$HOST_BRANCH" --force --profile --json -fi -``` - ## Export (instance → remote) ```bash @@ -111,61 +92,26 @@ Pushes Metabase-side changes back to the configured remote. `-m` is the commit m Workflow: -1. **Branch guard** (below) — confirm the workspace isn't tracking `main`/`master`, or that the user has explicitly accepted exporting to it. +1. **Branch guard** (below) — confirm the instance isn't tracking `main`/`master`, or that the user has explicitly accepted exporting to it. 2. `git-sync is-dirty` — confirm there's something to export. 3. `git-sync export -m "..."` — pushes and polls. 4. (Optional) `git-sync status` — verify `dirty: false` after. -5. **Working-tree drift** (below) — if this is a `--repo` bind-mount workspace, the host repo's working tree + index will lag behind the new HEAD. Surface this and offer to realign. ### Branch guard: don't export to main/master without confirmation -Workspace work is conventionally done on a feature branch — exporting to `main` (or `master`) commits team-shared content directly. Before `git-sync export`, check the tracked branch and if it's `main`/`master`, ask the user whether to switch first. - -Reading the current branch: +Sync work is conventionally done on a feature branch — exporting to `main` (or `master`) commits team-shared content directly. Before `git-sync export`, check the tracked branch and if it's `main`/`master`, ask the user whether to switch first. -- For a `--repo` bind-mount workspace, `git -C symbolic-ref --short HEAD` is the most reliable read — that's what the workspace's `remote-sync-branch` was bound to at start time. -- Otherwise: `mb git-sync status --profile --json | jq -r '.branch'`. +Read the current branch with `mb git-sync status --profile --json | jq -r '.branch'`. If the branch is `main` or `master`, prompt with `AskUserQuestion`: -> "The workspace is tracking `` — exporting commits straight to it. Switch to a feature branch first?" +> "The instance is tracking `` — exporting commits straight to it. Switch to a feature branch first?" > -> 1. **Create a feature branch via the workspace** — agent suggests a name (e.g., `agent/`); run `mb git-sync create-branch --profile `. This exports current dirty state to the new branch and switches the workspace's tracked branch to it; subsequent `git-sync export` calls go to that branch. -> 2. **Switch the host's branch first (bind-mount workspaces)** — `git -C checkout -b ` on the host, then pass `--branch ` on the next `git-sync export` so the export targets the new branch (the workspace's `remote-sync-branch` setting won't auto-update from a host-side checkout). -> 3. **Proceed on `main`/`master`** — explicitly accepted; surface the resulting commit (`git -C log --oneline -1`) afterwards so the user can amend or revert. +> 1. **Create a feature branch** — agent suggests a name (e.g., `agent/`); run `mb git-sync create-branch --profile `. This exports current dirty state to the new branch and switches the instance's tracked branch to it; subsequent `git-sync export` calls go to that branch. +> 2. **Proceed on `main`/`master`** — explicitly accepted. Skip the prompt only if the user's instructions already specified the branch (e.g., they explicitly said "export to main" or named a feature branch). Don't silently default to whatever `remote-sync-branch` happens to point at. -### Post-export: working-tree drift on `--repo` bind-mount workspaces - -When the workspace exports against a host bind mount, the in-container serializer writes the new commit object directly into the bind-mounted `.git/` (creating tree/blob objects and advancing the branch ref) but **does not update the host's working tree or index**. After a successful export, the host repo state is: - -- HEAD: the new export commit. -- Index: still matches the _previous_ HEAD (whatever the user had staged before). -- Working tree: still matches the _previous_ HEAD. - -`git status` then shows "Changes to be committed" that look like the export's content reverting back — purely a display artifact, not an actual revert. The container does this on purpose to avoid clobbering work-in-progress on the host. **Realigning is _applying_ the new HEAD's content to your worktree, not discarding work** — the new commit was written by the exporter, not by your local edits, and your tree/index are stale relative to the new HEAD until you realign. - -**Surface this to the user** after an export against a `--repo` workspace — don't leave them staring at a confusing `git status`. Offer to realign. - -**Prefer `git restore` over `git reset --hard`.** When the only "changes" are the drift artifact (no real local edits), `git restore` does the same job and isn't classified as a destructive operation by Claude Code's permission system — `git reset --hard` is, and gets blocked even after a user-confirmation dialog: - -```bash -git -C restore --staged --worktree . # non-destructive; aligns index + working tree to HEAD -``` - -This is the right default after a `git-sync export` realignment when the user had nothing else staged. If `git status` shows a mix of drift artifacts and real pending work, fall back to the stash sequence: - -```bash -git -C stash --include-untracked -git -C restore --staged --worktree . -git -C stash pop -``` - -`git reset --hard HEAD` is the canonical equivalent and still valid — but **confirm with the user** before running it, and expect Claude Code to gate it as destructive even after the dialog. `git restore --staged --worktree .` produces the same end-state with less friction. - -Or pull in the new files selectively with `git -C checkout HEAD -- `. Quick check that this is what you're seeing: `git -C diff --cached HEAD~1 --stat` returns empty (the index matches the parent commit, not the new HEAD). - ## Branches ```bash @@ -191,6 +137,5 @@ Use `wait` after `import --no-wait` / `export --no-wait`. Use `cancel-task` if a - Don't drive `git-sync` against a Metabase instance that doesn't have remote-sync configured — every verb returns an error pointing at the missing `remote-sync-*` settings. To check: `mb setting get remote-sync-url --profile --json`. - Don't author content directly via `card create` / `transform create` and then assume `git-sync export` will commit it cleanly — the instance and repo can drift if you mix direct API writes with sync-tracked changes. If you do, follow direct writes immediately with `git-sync export -m "..."` to keep them in step. - Don't omit `-m` on `export` if the user wants a meaningful commit message — the default server-generated message is generic. -- Don't `git-sync export` to `main`/`master` without explicit user confirmation — workspace work is conventionally on a feature branch. See "Branch guard" above. -- Don't pretend the host's `git status` is clean after `git-sync export` against a `--repo` bind mount — the export advances HEAD but leaves the working tree + index behind. See "Working-tree drift" above. +- Don't `git-sync export` to `main`/`master` without explicit user confirmation — sync work is conventionally on a feature branch. See "Branch guard" above. - Don't reach for `mb setting set` to mark a collection as remote-synced — that endpoint writes single-key settings, not the bulk `collections` map. Use `mb git-sync add-collection ` / `mb git-sync remove-collection ` (see "Adding / removing a directory (collection) to sync" above), and remember the toggle cascades to descendants. diff --git a/skill-data/mbql/SKILL.md b/skill-data/mbql/SKILL.md index d739a15..b1ebca1 100644 --- a/skill-data/mbql/SKILL.md +++ b/skill-data/mbql/SKILL.md @@ -87,6 +87,8 @@ mb query --file q.json --profile --json # 3. validate + `path` is a JSON Pointer into the body (`/stages/0/aggregation/0`); `message` is the validator error. Exit codes: `0` valid + ran, `2` validation failed / malformed body, `1` server-side error after a valid pre-flight. +A successful run emits the **full `/api/dataset` envelope** — `data.rows`, `data.cols`, plus heavy `data.results_metadata` and per-column fingerprints. For ad-hoc exploration project `--fields data.rows` (add `data.cols` if you need column names); without it even a few rows render as hundreds of lines of metadata. (`mb query` also runs a **native** body — `{database, type:"native", native:{query:"SELECT …"}}` — which skips pre-flight and is the quickest way to eyeball raw warehouse data; same `--fields data.rows` advice applies.) + `--skip-validate` bypasses the pre-flight and sends as-is — use only when the bundled schema disagrees with what the server actually accepts (drift / false negative). Mutually exclusive with `--dry-run`. The same flag exists on `card create/update` and `transform create/update`. ## Where MBQL 5 is consumed diff --git a/skill-data/transform/SKILL.md b/skill-data/transform/SKILL.md index 26c1b88..5cf91c3 100644 --- a/skill-data/transform/SKILL.md +++ b/skill-data/transform/SKILL.md @@ -8,7 +8,7 @@ allowed-tools: Read, Write, Edit, Bash, AskUserQuestion A **transform** persists the result of a query (native SQL or MBQL) to a warehouse table the user can read from cards, dashboards, and other transforms. It runs on a schedule (via `transform-job`) or on-demand (`transform run`). -This skill covers the create-and-run flow. The general flag conventions, body-input precedence, and output flags live in the `core` skill (`mb skills get core`). If you're authoring a transform inside a workspace, also load the `workspace` skill for the canonical-vs-isolation-schema rule. +This skill covers the create-and-run flow. The general flag conventions, body-input precedence, and output flags live in the `core` skill (`mb skills get core`). ## Body shape @@ -53,13 +53,14 @@ mb transform run "$TRANSFORM_ID" --wait --profile --json Notes: -- `` comes from `mb database list --profile --json`. Database ids are per-instance — a workspace child re-numbers them independently of the parent. -- Target `schema` is the **canonical** name (e.g. `public`). In a workspace, the QP rewrites it to the per-workspace isolation schema (`mb__isolation__`) at execution time — don't hard-code that prefix. +- `` comes from `mb database list --profile --json`. Database ids are per-instance. +- Target `schema` is the schema the result table is written into (e.g. `public`). - `--wait` on `transform run` polls until status is `succeeded` or `failed`. Without it you only get `{message: "Transform run started", run_id, final: null}` and have to poll yourself. - The `--json` envelope is shape-stable: `{message, run_id, final}`. `final` is always present — `null` when `--wait` is omitted or the run never started, otherwise a full `TransformRun` object with `status` and `message`. On a failed run (`final.status` ∈ {`failed`, `timeout`, `canceled`}) the CLI exits 1 and writes a one-line summary `transform run failed` to stderr; the failure detail lives only in `final.message` on stdout, so `jq -r '.final.message'` is where to look. - The heredoc with single-quoted `'EOF'` prevents shell from interpolating any `$vars` inside the SQL. - `transform create --json` returns the agent-facing compact projection: `{id, name, description, source_type, target: {type, database, schema, name}, target_db_id}`. Read `target.schema`/`target.name` directly off the create output — no follow-up `transform get` needed to verify where the transform will write. - If a transform with the same `name` already has a YAML representation on disk under the configured remote-sync repo, `create` mints a `_2` suffix on the exported filename (the new transform gets a fresh `entity_id`; the prior one isn't touched). For "iterate on the same concept" workflows, prefer `transform update ` — see "Iterating on a failing transform" below. +- **`collection_id` only accepts a collection in the `:transforms` namespace.** Transforms aren't filed next to cards and dashboards — passing a normal analytics collection id (the kind a dashboard lives in) fails create/update with `collection_id: A Transform can only go in Collections in the :transforms namespace.` Omit `collection_id` to leave the transform uncollected (the common case), or pass a collection created in the transforms namespace. Cards and dashboards you build **on top of** the transform's output table go in ordinary collections as usual — so "put the transform and its dashboard in collection X" generally means _X holds the dashboard + cards; the transform stays in the transforms namespace._ ## Inspect @@ -68,7 +69,16 @@ mb transform list --profile --json mb transform get --profile --full --json # full transform incl. last run summary ``` -After a run, the materialized table is queryable via `mb` (`card create` against it, native query against `.`, etc.). Columns and types are inferred from the result set; if you change the SELECT shape, drop the table first or the next run will fail on a column-mismatch error. +After a run, the materialized table physically exists in the warehouse, but Metabase doesn't know about it yet. **Native SQL** (a native `card`, or `mb query` against `.`) reads it immediately — native runs straight against the warehouse. **MBQL and the Metabase UI cannot reference it until the instance syncs**, because they address tables and columns by numeric id and a brand-new table has none. To build MBQL cards on a fresh output table: + +```bash +mb database sync-schema --profile --json # async — returns {status:"ok"} at once +# poll until the new table appears (sync is not instant): +mb database schema-tables --profile --json --fields id,name +mb table get --include fields --profile --json # then grab the field ids +``` + +Columns and types are inferred from the result set; if you change the SELECT shape, drop the table first (`transform delete-table `) or the next run will fail on a column-mismatch error. A changed shape also needs a re-sync before MBQL sees the new/renamed columns. ## Inspect runs and cancel an in-flight run @@ -107,10 +117,11 @@ Column "TAGS" not found; SQL statement: UPDATE "TRANSFORM" SET "TAGS" = (), "UPDATED_AT" = NOW() WHERE "ID" = ? [42122-214] ``` -Two specific footguns: +Three specific footguns: - **`tags` is not a key on the REST API.** The serdes/YAML representation uses `tags`; the REST contract uses `tag_ids` (an array of integer ids). If you pulled a YAML representation and want to PUT it, translate `tags: [...]` → `tag_ids: [...]` first (or omit it entirely if you're not changing tag membership). - **`source_type`, `target_db_id`, `target_table_id`, `entity_id`** are derived/computed by the server. They appear in GET responses for the agent's benefit; the server doesn't accept them on update. +- **`collection_id` must be a `:transforms`-namespace collection** — a regular card/dashboard collection id is rejected with `A Transform can only go in Collections in the :transforms namespace.` Omit it unless you have one (see the create notes above). Round-tripping the existing value is safe; setting it to an ordinary collection is what fails. Right shape — patch only what changes: @@ -193,5 +204,4 @@ A schedule lives in a separate resource (`transform-job`) and references one or - Don't put `transform run` calls in tight polling loops — pass `--wait` and let the CLI handle the polling. Manual loops without `--wait` will hammer the server. - Don't author MBQL 4 (the legacy nested `{ type: "query", query: {...} }` shape) by hand — pull a sample with `mb transform get --full --json`. MBQL 5 (`lib/type: "mbql/query"`) **is** authorable by hand thanks to the `mb query --print-schema` + `--dry-run` feedback loop; for non-trivial pipelines you may still prefer building in the UI and exporting. -- Don't write the workspace isolation schema into `target.schema` or SQL. See the `workspace` skill for the canonical-name rule. - Don't paste a `transform get` body into `transform update` — the PUT endpoint only accepts writable keys, and unknown keys (notably `tags`, `source_type`, `entity_id`, `created_at`, `last_run`) leak as raw SQL errors. See "Update body: send only writable keys" above. Use `tag_ids` (not `tags`) on the REST contract. diff --git a/skill-data/workspace/SKILL.md b/skill-data/workspace/SKILL.md deleted file mode 100644 index de33e8c..0000000 --- a/skill-data/workspace/SKILL.md +++ /dev/null @@ -1,390 +0,0 @@ ---- -name: workspace -description: Enterprise workspace lifecycle for `mb` — create, provision databases, start (with Remote Sync wiring + branch guard), save child credentials as a profile, diagnose. Load when the user touches `mb workspace …` — "spin up a workspace", "provision a database", "start a local Metabase against my prod", "save the child's API key", "diagnose a workspace that won't start", or anything Enterprise workspaces. -allowed-tools: Read, Write, Edit, Bash, AskUserQuestion ---- - -# Workspaces (Enterprise) - -A **workspace** is a child Metabase instance bound to a parent's databases. Local lifecycle is `mb workspace `; the parent is reached via a profile (the parent's profile — typically `prod` / `staging`). Each provisioned database gets a per-workspace isolation schema on the warehouse, and the QP rewrites references from canonical names (`public.foo`) to that isolation schema (`mb__isolation__.foo`) on the fly. Cards, transforms, and queries authored in the workspace target canonical names; the rewrite is invisible to the author. - -This skill covers the full lifecycle. The general flag conventions, auth setup, and output flags live in the `core` skill; load that first (`mb skills get core`). - -## Always ask about Remote Sync before starting - -Before running `mb workspace start`, **ask the user how they want Remote Sync wired**. The bind mount is set at container-create time — you cannot add it later without a recreate, so this decision belongs at start time. Use `AskUserQuestion` with three options: - -> "How should I wire Remote Sync for this workspace?" -> -> 1. **Current directory** — bind-mount the directory you're running Claude from (`pwd`) as `file:///mnt/repo` and set the workspace to remote-sync against it (read-write). Pick this when the conversation is happening inside the sync repo. -> 2. **Custom path** — you specify a different host directory; same wiring as option 1. -> 3. **No sync** — start the workspace without a repo bind mount; you can configure remote-sync against a remote URL later via `setting set`. - -Default-suggest option 1 if the current working directory looks like a git repo (a `.git/` is present). Otherwise default-suggest option 3 and let the user volunteer a path. - -Map the answer to flags on `workspace start`: - -| Choice | Flags to add to `workspace start` | -| ----------------- | ----------------------------------------------------------------- | -| Current directory | `--repo "$(pwd)"` | -| Custom path | `--repo ` | -| No sync | (omit `--repo` — no bind mount, no remote-sync settings injected) | - -The `--repo` flag (a) bind-mounts the host path into the container at `/mnt/repo`, and (b) injects three settings into the workspace's config.yml at boot: `remote-sync-url=file:///mnt/repo`, `remote-sync-branch=`, `remote-sync-type=read-write`. The branch defaults to the current branch of the host repo (read via `git -C symbolic-ref --short HEAD`); override with `--repo-branch `. Switch to read-only with `--repo-mode read-only` (also makes the bind mount read-only). - -Do not skip this question — silently picking "no sync" loses the user's repo context, and silently picking "current directory" pushes work into a repo they didn't intend. - -## Branch guard before `--repo` - -When the user picks a `--repo` option (current dir or custom path), check the host's branch before `workspace start`. `--repo` reads `git -C symbolic-ref --short HEAD` and injects it as the workspace's `remote-sync-branch` setting; that branch then becomes the default target for every subsequent `git-sync import` and `git-sync export`. If the host is on `main` (or `master`), every export commits straight to it — usually not what the user wants for ephemeral workspace work. - -```bash -HOST_BRANCH=$(git -C symbolic-ref --short HEAD) -``` - -If `HOST_BRANCH` is `main` or `master`, ask the user via `AskUserQuestion`: - -> "The host repo is on `` — the workspace will track and export to that branch by default. Switch to a feature branch first?" -> -> 1. **Create + checkout a feature branch on the host** — agent suggests a name (e.g., `agent/`); run `git -C checkout -b ` then proceed with `workspace start --repo …` so the workspace tracks ``. -> 2. **Pin the workspace to a specific branch** — pass `--repo-branch ` on `workspace start` to override host HEAD. The branch must exist **locally** in the bind-mounted host repo before `workspace start` (create it first with `git -C branch ` or `git -C checkout -b `); it does **not** need to exist on `origin`. Local-only branches are fine — the workspace never pushes, and the remote side gets created on the user's first `git push` later. -> 3. **Proceed on `main`/`master`** — explicitly accepted; downstream `git-sync export` will commit to that branch unless overridden per-call. - -Skip this question only when the user's instructions already named the branch (e.g., they explicitly asked to work against `main`). The same guard applies later at `git-sync export` time — see the `git-sync` skill, "Branch guard". - -## Quick start (copy-pasteable, end-to-end) - -When a parent profile + license are in place, this whole sequence runs in one go. Replace the four shell vars; pick whether to bind-mount a sync repo with `REPO_FLAGS` per the question above. - -```bash -PARENT= # e.g. prod — the parent profile name -WS_NAME= # e.g. my_nice_ws — also reused as the child profile name -DB_ID= # parent database id from `mb database list --profile $PARENT --json` -SCHEMAS= # comma-separated; no "all" wildcard -REPO_FLAGS=(--repo "$(pwd)") # OR (--repo /path/to/sync-repo) OR () for no sync - -# 0. Branch guard (only when REPO_FLAGS is non-empty). If the host repo is on -# main/master, ask the user before continuing — see "Branch guard before --repo" -# above. Skip when REPO_FLAGS is () (no sync = no branch). -if [ ${#REPO_FLAGS[@]} -gt 0 ]; then - HOST_BRANCH=$(git -C "$(pwd)" symbolic-ref --short HEAD) - case "$HOST_BRANCH" in main|master) ;; # ask user; not auto-resolvable - esac -fi - -# 1. Create empty workspace, capture id -WS_ID=$(mb workspace create --name "$WS_NAME" --profile "$PARENT" --json | jq -r '.id') - -# 2. Provision a database into it (blocks on :provisioned) -mb workspace database provision "$WS_ID" "$DB_ID" \ - --schemas "$SCHEMAS" \ - --wait \ - --profile "$PARENT" - -# 3. Start the child container, block on state=running. -# With REPO_FLAGS set, the child boots already wired to the local repo: -# bind-mounted at /mnt/repo, remote-sync-url=file:///mnt/repo, branch from HEAD. -mb workspace start "$WS_ID" --wait --profile "$PARENT" "${REPO_FLAGS[@]}" - -# 4. Save the child's API key as its own profile (use the workspace name as profile name). -# This is the documented exception to "the agent doesn't run auth login" — the child -# key was minted by the parent the human authorized, and reading it via -# `workspace credentials` is the supported path. -WS_URL=$(mb workspace url "$WS_ID" --json | jq -r '.url') -WS_API_KEY=$(mb workspace credentials "$WS_ID" --json | jq -r '.api_key') -printf '%s' "$WS_API_KEY" | mb auth login \ - --url "$WS_URL" \ - --profile "$WS_NAME" \ - --json - -# 5. Smoke test: list child databases -mb database list --profile "$WS_NAME" --json - -# 6. (If REPO_FLAGS was set) Verify sync is wired: -mb setting get remote-sync-url --profile "$WS_NAME" --json # → "file:///mnt/repo" -mb git-sync status --profile "$WS_NAME" --json # → branch, dirty, current task - -# 7. (If REPO_FLAGS was set) Apply the repo to the fresh workspace. The container's -# boot-time auto-import usually handles this — the step-6 `git-sync status` shows -# whether it landed. If `current_task` is not a successful `import` for the host -# branch, run an explicit import. The status-check + retry-then-force guard lives -# in the git-sync skill, "First import on a fresh workspace". Skipping the import is -# *not* safe — without it the instance has none of the repo content and edits diverge. -``` - -After step 5, drive the child via `mb --profile $WS_NAME` for everything (cards, transforms, queries, …). To author a transform on the workspace, load the `transform` skill (`mb skills get transform`). To use the sync flow (import host commits, export instance changes), load the `git-sync` skill (`mb skills get git-sync`). - -## Setup (steps in order) - -### 1. Parent profile - -```bash -mb auth status --profile --json -``` - -If a profile is missing or expired, **stop and ask the operator** to run, themselves: - -> Please run `mb auth login --url --profile ` from your terminal and tell me the profile name when you're done. - -Don't run `auth login` for them and don't suggest a URL — they pick. Verify with `mb auth status --profile --json` once they confirm. If multiple parent profiles exist and the user hasn't named one, use `AskUserQuestion` to disambiguate. - -### 2. License - -```bash -mb workspace license status --json -``` - -If `present: false`, ask the operator to run, themselves: - -```bash -echo "" | mb workspace license set -``` - -A workspace child cannot start without a parent license — it inherits feature gates from the parent. - -### 3. Find or create a workspace - -```bash -mb workspace list --profile --json -``` - -- Empty → create one (below). -- One workspace → use its `id`. Surface name + id to the user. -- Multiple → `AskUserQuestion`. - -Create: - -```bash -mb workspace create --name "" --profile --json -``` - -Note the returned `id`. The workspace is empty; you must provision at least one database before `start` will succeed. - -### 4. Provision databases - -A workspace needs at least one provisioned database. Source databases come from the parent. - -```bash -mb database list --profile --json -``` - -For each source database, decide which schemas to expose. Enumerate the schemas the parent already syncs for that database: - -```bash -mb table list --db-id --profile --json \ - | jq -r '[.data[].schema] | unique | .[]' -``` - -Provision (one db per call; `--schemas` is required, no "all" wildcard): - -```bash -mb workspace database provision \ - --schemas , \ - --wait \ - --profile -``` - -`--wait` blocks until status is `provisioned`. Repeat per source database. - -Verify all are ready: - -```bash -mb workspace list --profile --full --json \ - | jq '.data[] | select(.id==) | .databases' -``` - -Every entry's `status` must be `provisioned`. - -## Start - -Before running `start`, ask the user about Remote Sync (see "Always ask about Remote Sync before starting" at the top of this file). The bind mount is decided at container-create time and cannot be added later without recreate. - -### Pick a free port up front - -Despite the `--port` flag's "auto-shifts up if taken" hint, in practice `workspace start` fails with `docker start failed for metabase-workspace-` when the host port is occupied — typically by a stale workspace container from a prior session. **List local containers first** and pass an explicit free `--port`: - -```bash -mb workspace ps # → currently-running workspace containers + their host ports -docker ps --filter "name=metabase-workspace" \ - --format "{{.Names}}\t{{.Ports}}\t{{.Status}}" # also surfaces stopped containers -``` - -If 3000 is taken, pass e.g. `--port 3322`. The child's URL in `workspace credentials` and `workspace url` reflects the chosen port automatically. - -```bash -# No sync: -mb workspace start --wait --profile - -# With sync against the current directory: -mb workspace start --repo "$(pwd)" --wait --profile - -# With sync against a custom path, branch override, read-only: -mb workspace start --repo /path/to/repo --repo-branch dev --repo-mode read-only --wait --profile -``` - -`--wait` blocks until `state: "running"`. Don't omit it for interactive bring-up — without it the next step (saving credentials as a child profile) races the container's HTTP listener and you'll get spurious connection errors. - -- `--port ` — host port (default 3000; **does not** auto-shift reliably — pass an explicit free port if 3000 might be taken). -- `--wait` — block until `/api/health` reports ready before returning. -- `--no-pull` — skip `docker pull` (image already present). -- `--no-metadata` — skip the warehouse metadata export. -- `--force` — recreate even if a container for this workspace exists. Preserves the app db. -- `--timeout ` — per-phase readiness deadline (default 240000). Covers the post-create config-consumption wait, (with `--wait`) the `/api/health` probe, and (with `--metadata`) the metadata-import status poll on the child. Bump if the first cold boot exceeds the default — image pull + JVM startup can stretch on slow disks/networks. -- `--repo ` — bind-mount a host directory at `/mnt/repo` and inject `remote-sync-url=file:///mnt/repo` into config.yml. -- `--repo-branch ` — `remote-sync-branch` value. Default: current branch of the host repo (`git symbolic-ref --short HEAD`). -- `--repo-mode ` — `read-write` (default) or `read-only`. Also flips the bind mount's mount mode. - -**Notes on `--repo`:** - -- `--repo` is honored only on container create. To change the mount on an existing container you must `start --force` (which recreates), passing `--repo` again. The app db volume persists, so users/sessions/saved questions survive. -- The host path must be a directory and must already exist. The CLI does not create or initialize a git repo for you. -- For `--repo-branch` auto-detection, the path needs to be a git repo (a `.git/` ancestor); otherwise pass `--repo-branch` explicitly. -- The `--repo-branch` value must name a branch that already exists **locally** in the host repo. Local-only branches (never pushed to `origin`) are fine — the workspace operates against the bind-mounted working tree, never pushes anywhere itself, and the remote side is created on the user's first `git push` later. If the branch doesn't exist locally yet, create it before `workspace start`: `git -C branch ` (or `checkout -b ` if you also want to switch HEAD). -- File-permission gotcha (Linux only): the Metabase container runs as uid 2000 by default; the host directory must be writable by that uid for `git-sync export` to succeed. macOS Docker Desktop / OrbStack / Colima handle this via their file-sharing layer. - -## Interact with a running workspace - -`url` and `credentials` both return JSON envelopes. Extract fields with `jq`: - -```bash -mb workspace url --json -# → {"workspace_id": ..., "url": "http://localhost:3000"} - -mb workspace credentials --json -# → {"email": ..., "password": ..., "api_key": ...} -``` - -Save the child's API key as its own named profile. **Always pipe the key on stdin** (the CLI rejects `--api-key "$VAR"`). - -```bash -WS_URL=$(mb workspace url --json | jq -r '.url') -WS_API_KEY=$(mb workspace credentials --json | jq -r '.api_key') -printf '%s' "$WS_API_KEY" | mb auth login \ - --url "$WS_URL" \ - --profile \ - --json -``` - -Convention: use the workspace name as the profile name (`my_nice_ws` workspace → `my_nice_ws` profile). Then drive the child with the same CLI verbs: - -```bash -mb database list --profile --json -mb card list --profile --json -mb transform list --profile --json -``` - -To create and run a transform in the workspace, load the `transform` skill. The `` referenced there comes from `mb database list --profile --json` — the child re-numbers databases independently of the parent. - -## Open the UI - -``` -http://localhost: # default 3000; honors `--port` from `workspace start` -http://localhost:/admin/transforms/ -``` - -Log in with the **admin email + password** from `workspace credentials` (the API key authenticates as a synthetic api-key user, not as the admin — many UI screens hide content from the api-key user). - -**Don't open the URL before `state: "running"`** — the Metabase setup wizard will hijack it and create a fresh app db, bypassing the workspace bring-up. - -## Lifecycle - -| User intent | Command | -| --------------------------------- | -------------------------------------------------------------- | -| List local workspace containers | `mb workspace ps` | -| Tail logs | `mb workspace logs --tail 200` | -| Follow logs | `mb workspace logs --follow` | -| Read admin email/password/API key | `mb workspace credentials --json` | -| Stop (preserves app db) | `mb workspace stop ` | -| Restart | `mb workspace start --force --wait --profile ` | -| Remove container + app db | `mb workspace delete --yes` | -| Remove container, keep app db | `mb workspace delete --keep-volume --yes` | - -The supported restart path is `stop` + `start --force` (or `start --force` directly). The app db volume persists across `stop`/`start` cycles, so users/sessions/saved questions survive. `delete`, `start --force`, and `stop` are destructive enough to confirm before running unless the user explicitly asked for them. - -## Diagnose - -Pick the symptom. - -### `start` succeeds but the database isn't visible in the UI - -```bash -mb workspace logs --tail 300 | grep -iE "advanced-config|workspace|error" -``` - -| Log signal | Cause | Fix | -| ---------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `Spec assertion failed ... :input ... :output` | Parent emits keys the child's spec doesn't accept (server-side). | File against the parent. Not a CLI issue. | -| `Connection refused` / `unknown host` against the warehouse host | Container can't reach the source DB. | Source DB credentials configured on the parent use a host that doesn't resolve from inside docker. Use a routable hostname. | -| `Invalid token` / `License expired` | EE license bad or unset on the parent (forwarded into the child). | Re-set on the parent: `mb workspace license set` (operator pastes). | - -### `workspace credentials` returns values that don't authenticate - -Symptom: right after `workspace start`, the API key returned by `mb workspace credentials ` is rejected by the child (`Unauthenticated` on `/api/user/current`, or `Invalid or unauthorized API key` from `mb auth login --skip-verify` followed by any verb). The admin password from the same response also fails (`did not match stored password`). The values inside the container's `/mw-config/credentials.json` match what the parent reports, but the child's app db has different state. - -This is a parent↔child credential drift bug — the parent's record for the workspace can desync from the child's app db, especially after a rapid `start` → `start --force` sequence on the same port. **`start --force` alone does not fix it** (the volume persists across the recreate; the api-key already exists from the prior init and the new credentials.json is ignored). - -Recovery (works reliably): - -```bash -mb workspace delete --yes # destroys container + volume; keeps parent record + provisioned dbs -mb workspace start --port --wait --profile # different port from the bad attempt -mb workspace credentials --json | jq -r '.api_key' \ - | xargs -I{} curl -s -H "x-api-key: {}" http://localhost:/api/user/current # smoke check -``` - -Why "different port": empirically, restarting on the same port after the drifted attempt can cling to the same broken state; switching ports forces a clean parent-side handoff. If you must reuse the original port, `workspace delete --yes` plus a brief pause (a few seconds) before `start` increases the success rate. - -`workspace delete --yes` is destructive — it drops the container _and_ the app db volume — but in the bring-up window (before any user content has been imported) there's nothing to lose. The provisioned-database records on the parent survive the delete and don't need to be re-created. - -### Container exited shortly after `start` - -```bash -mb workspace ps -``` - -`Exited (137)` → OOM. Bump Docker host memory to ≥ 6 GB. - -- Colima: `colima stop && colima start --memory 6 --cpu 2` -- Docker Desktop: Settings → Resources → Memory. - -Then `mb workspace start --force --wait --profile `. - -### `Endpoint not found — is this a Metabase instance?` - -The parent doesn't expose `/api/ee/workspace-manager/*`. Either: - -- Parent is OSS (no EE). -- Parent has no license, or license lacks the workspace feature. -- Parent is on a Metabase version that predates workspaces. - -Confirm the URL points at the right instance with `mb auth status --profile --json`. If the URL is correct, the parent simply lacks the workspace feature — pick a different instance. - -### `workspace has no databases — provision at least one before starting` - -`mb workspace list --profile --full --json` will show the workspace with `databases: []`. Run a `provision` (step 4) and retry. - -### `workspace ... is not ready: database X=provisioning` - -Provisioning is async on the parent. Re-run the original `provision` with `--wait`, or poll: - -```bash -mb workspace list --profile --full --json \ - | jq '.data[] | select(.id==) | .databases[] | {database_id, status}' -``` - -### Workspace UI demands the setup wizard - -You opened the URL before health passed and walked through the wizard, which created a fresh app db and bypassed the workspace bring-up. `mb workspace delete --yes` then `start --wait` again. Don't open the URL before `state: "running"`. - -### `git status` on the host shows confusing "staged changes" after `git-sync export` - -The in-container exporter writes the new commit object directly into the bind-mounted `.git/` and advances HEAD, but does not update the host's working tree or index. The host then shows the export's content as "Changes to be committed" reverting to the prior commit — display artifact, not a real revert. The non-destructive realignment is `git -C restore --staged --worktree .` (only touches paths that disagree with HEAD; refuses on unmerged paths; does not move HEAD). See the `git-sync` skill, "Working-tree drift on `--repo` bind-mount workspaces" for the full decision tree (when to stash first, when `reset --hard` is acceptable). - -## Don't (workspace-specific) - -- Don't run raw `docker` commands against the workspace container — use the `mb workspace` subcommands. They wrap the right labels, volumes, network, and lifecycle hooks. -- Don't open the workspace URL before `state: "running"` — the setup wizard will hijack it. -- Don't try to share an API key across workspaces — each child mints its own. Save credentials per-workspace under a profile named after the workspace. -- Don't write the workspace's isolation schema (`mb__isolation__`) into transform/card SQL or `target.schema`. Author against the **canonical** schema (e.g. `public`); the QP rewrites at execution time. Hard-coding the isolation prefix breaks portability across workspaces and bypasses the rewrite contract. -- Don't run `workspace start` without first asking the user about Remote Sync (current dir / custom path / no sync). The bind mount is set at create time; "I'll add it after start" is not supported. -- Don't run `workspace start --repo ` when the host repo is on `main`/`master` without first asking the user (see "Branch guard before `--repo`"). The host's HEAD becomes the workspace's `remote-sync-branch`, so every subsequent export targets `main` by default. diff --git a/skills/metabase-cli/SKILL.md b/skills/metabase-cli/SKILL.md index c95b517..f463d41 100644 --- a/skills/metabase-cli/SKILL.md +++ b/skills/metabase-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: metabase-cli -description: Drive a Metabase instance from the terminal via the `mb` CLI — auth, databases, cards, dashboards, transforms, queries, search, git-sync, Enterprise workspaces. Discovery entry; load the full guide with `mb skills get core`. +description: Drive a Metabase instance from the terminal via the `mb` CLI — auth, databases, cards, dashboards, transforms, queries, search, git-sync. Discovery entry; load the full guide with `mb skills get core`. allowed-tools: Bash, Read, Write, Edit, AskUserQuestion hidden: true --- diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 67c3524..9745676 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -84,7 +84,7 @@ export default defineMetabaseCommand({ if (args["skip-verify"]) { const location = await writeProfile({ url, apiKey }, profileName); if (location.backend === "file") { - warn(keyringFallbackWarning(location, "credentials")); + warn(keyringFallbackWarning(location)); } renderSummary( { @@ -109,7 +109,7 @@ export default defineMetabaseCommand({ const location = await writeProfile({ url, apiKey }, profileName); if (location.backend === "file") { - warn(keyringFallbackWarning(location, "credentials")); + warn(keyringFallbackWarning(location)); } await writeProbeResult(profileName, { user: result.user, server: result.server }); diff --git a/src/commands/skills/get.ts b/src/commands/skills/get.ts index 610a4ed..0cd049f 100644 --- a/src/commands/skills/get.ts +++ b/src/commands/skills/get.ts @@ -46,7 +46,7 @@ export default defineMetabaseCommand({ examples: [ "mb skills get core", "mb skills get core --full", - "mb skills get workspace,transform --json", + "mb skills get git-sync,transform --json", "mb skills get --all --json", ], run({ args, ctx }) { diff --git a/src/commands/workspace/create.ts b/src/commands/workspace/create.ts deleted file mode 100644 index 05cca8a..0000000 --- a/src/commands/workspace/create.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Workspace, WorkspaceCreateInput, workspaceView } from "../../domain/workspace"; -import { renderSummary } 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" }, - capabilities: { minVersion: 62, tokenFeature: "workspaces" }, - args: { - ...outputFlags, - ...profileFlag, - ...connectionFlags, - ...bodyInputFlags, - name: { type: "string", description: "Workspace name (alternative to --body / --file)" }, - }, - outputSchema: Workspace, - examples: [ - 'mb workspace create --name "analytics"', - 'echo \'{"name":"analytics"}\' | mb workspace create', - "mb 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, - }); - const hasDatabases = (created.databases ?? []).length > 0; - const hint = hasDatabases ? "" : " No databases yet — run `mb workspace database provision`."; - renderSummary( - created, - workspaceView, - `Created workspace ${created.id} "${created.name}".${hint}`, - ctx, - ); - }, -}); diff --git a/src/commands/workspace/credentials.ts b/src/commands/workspace/credentials.ts deleted file mode 100644 index 53c8242..0000000 --- a/src/commands/workspace/credentials.ts +++ /dev/null @@ -1,75 +0,0 @@ -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", - }, - capabilities: null, - args: { - ...outputFlags, - id: { type: "positional", description: "Workspace id", required: true }, - }, - outputSchema: WorkspaceCredentialsResult, - examples: ["mb workspace credentials 1", "mb 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/database/deprovision.ts b/src/commands/workspace/database/deprovision.ts deleted file mode 100644 index 14e71aa..0000000 --- a/src/commands/workspace/database/deprovision.ts +++ /dev/null @@ -1,48 +0,0 @@ -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", - }, - capabilities: { minVersion: 62, tokenFeature: "workspaces" }, - 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: [ - "mb workspace database deprovision 1 5 --yes", - "mb 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}?`, - successMessage: `Deprovisioned database ${databaseId} from workspace ${workspaceId}.`, - abortMessage: `Aborted; database ${databaseId} was not deprovisioned.`, - 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 deleted file mode 100644 index c5fdeb6..0000000 --- a/src/commands/workspace/database/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -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/parse-schemas.ts b/src/commands/workspace/database/parse-schemas.ts deleted file mode 100644 index 9ffea81..0000000 --- a/src/commands/workspace/database/parse-schemas.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ConfigError } from "../../../core/errors"; -import { parseCsv } from "../../../runtime/csv"; - -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; -} diff --git a/src/commands/workspace/database/provision.ts b/src/commands/workspace/database/provision.ts deleted file mode 100644 index ae2e29d..0000000 --- a/src/commands/workspace/database/provision.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Workspace, WorkspaceProvisionInput, workspaceView } from "../../../domain/workspace"; -import { ConfigError } from "../../../core/errors"; -import { renderSummary } 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 { parseWaitFlags, waitFlags } from "../../wait-flags"; - -import { parseSchemasCsv } from "./parse-schemas"; -import { waitForDatabaseProvisioned } from "./wait"; - -export default defineMetabaseCommand({ - meta: { - name: "provision", - description: "Provision a database into a workspace", - }, - capabilities: { minVersion: 62, tokenFeature: "workspaces" }, - 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 (alternative to --body / --file)", - required: false, - }, - }, - outputSchema: Workspace, - examples: [ - "mb workspace database provision 1 5 --schemas analytics,github", - "mb workspace database provision 1 5 --schemas analytics --wait", - "mb workspace database provision 1 --file provision.json", - ], - async run({ args, ctx, getClient }) { - const workspaceId = parseId(args.id); - const databaseIdArg = args["db-id"]; - const schemasFlag = args.schemas; - const wait = parseWaitFlags(args); - - let body: WorkspaceProvisionInput; - if (databaseIdArg !== undefined && databaseIdArg !== "") { - const databaseId = parseId(databaseIdArg, "db-id"); - if (schemasFlag === undefined || schemasFlag === "") { - throw new ConfigError("--schemas is required when providing a db-id"); - } - const input_schemas = parseSchemasCsv(schemasFlag); - body = WorkspaceProvisionInput.parse({ database_id: databaseId, input_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; - const message = wait.enabled - ? `Provisioned database ${body.database_id} into workspace ${workspaceId}.` - : `Started provisioning database ${body.database_id} into workspace ${workspaceId}; rerun with --wait to block until ready.`; - renderSummary(final, workspaceView, message, ctx); - }, -}); diff --git a/src/commands/workspace/database/update.ts b/src/commands/workspace/database/update.ts deleted file mode 100644 index 6f38714..0000000 --- a/src/commands/workspace/database/update.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Workspace, WorkspaceUpdateDatabaseInput, workspaceView } from "../../../domain/workspace"; -import { renderSummary } 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 { parseWaitFlags, waitFlags } from "../../wait-flags"; - -import { parseSchemasCsv } from "./parse-schemas"; -import { waitForDatabaseProvisioned } from "./wait"; - -export default defineMetabaseCommand({ - meta: { - name: "update", - description: - "Update a workspace's database (deprovisions then re-provisions with new input schemas)", - }, - capabilities: { minVersion: 62, tokenFeature: "workspaces" }, - 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: [ - "mb workspace database update 1 5 --schemas analytics,github", - "mb workspace database update 1 5 --schemas analytics --wait", - "mb 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 input_schemas = parseSchemasCsv(schemasFlag); - body = WorkspaceUpdateDatabaseInput.parse({ input_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; - const message = wait.enabled - ? `Re-provisioned database ${databaseId} in workspace ${workspaceId}.` - : `Started re-provisioning database ${databaseId} in workspace ${workspaceId}; rerun with --wait to block until ready.`; - renderSummary(final, workspaceView, message, ctx); - }, -}); diff --git a/src/commands/workspace/database/wait.ts b/src/commands/workspace/database/wait.ts deleted file mode 100644 index 17e88fa..0000000 --- a/src/commands/workspace/database/wait.ts +++ /dev/null @@ -1,36 +0,0 @@ -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/delete.ts b/src/commands/workspace/delete.ts deleted file mode 100644 index 065ced5..0000000 --- a/src/commands/workspace/delete.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { z } from "zod"; - -import { - checkDockerReady, - containerNameFor, - removeContainer, - removeVolume, - volumeNameFor, -} from "../../core/docker"; -import type { ResourceView } from "../../domain/view"; -import { renderSummary } from "../../output/render"; -import { promptConfirm } from "../../output/prompt"; -import { outputFlags } from "../flags"; -import { parseId } from "../parse-id"; -import { defineMetabaseCommand } from "../runtime"; - -export const DeleteResult = 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 DeleteResult = z.infer; - -const deleteResultView: ResourceView = { - compactPick: DeleteResult.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: "delete", - description: "Stop and remove the local container + app-db volume (does not affect remote)", - }, - capabilities: null, - 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: DeleteResult, - examples: ["mb workspace delete 1 --yes", "mb workspace delete 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 && process.stdin.isTTY === true) { - 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: DeleteResult = { - workspace_id: workspaceId, - container_name: containerName, - volume_name: volumeName, - removed_container: removedContainer, - removed_volume: removedVolume, - }; - let message: string; - if (!result.removed_container) { - message = `No container found for workspace ${workspaceId} — nothing to remove.`; - } else if (result.removed_volume) { - message = `Removed workspace ${workspaceId}: container ${containerName} and app-db volume ${volumeName}.`; - } else { - message = `Removed workspace ${workspaceId}: container ${containerName} (volume ${volumeName} kept).`; - } - renderSummary(result, deleteResultView, message, ctx); - }, -}); diff --git a/src/commands/workspace/index.ts b/src/commands/workspace/index.ts deleted file mode 100644 index 6cb9623..0000000 --- a/src/commands/workspace/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -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), - database: () => import("./database").then((mod) => mod.default), - start: () => import("./start").then((mod) => mod.default), - stop: () => import("./stop").then((mod) => mod.default), - delete: () => import("./delete").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), - license: () => import("./license").then((mod) => mod.default), - }, -}); diff --git a/src/commands/workspace/license/index.ts b/src/commands/workspace/license/index.ts deleted file mode 100644 index 1e73e0f..0000000 --- a/src/commands/workspace/license/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineCommand } from "citty"; - -export default defineCommand({ - meta: { - name: "license", - description: "Manage the Metabase Enterprise license token used by workspace start", - }, - subCommands: { - set: () => import("./set").then((m) => m.default), - status: () => import("./status").then((m) => m.default), - remove: () => import("./remove").then((m) => m.default), - }, -}); diff --git a/src/commands/workspace/license/remove.test.ts b/src/commands/workspace/license/remove.test.ts deleted file mode 100644 index 06630cd..0000000 --- a/src/commands/workspace/license/remove.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { runCommand } from "citty"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -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 licenseRemoveCommand from "./remove"; -import { readLicense, writeLicense } from "../../../core/auth/storage"; -import { setupTempConfigHome, type TempConfigHome } from "../../../core/auth/temp-config-home"; - -describe("license remove command", () => { - let home: TempConfigHome; - - beforeEach(() => { - hoisted.store.clear(); - home = setupTempConfigHome(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - home.cleanup(); - }); - - it("--yes clears the token", async () => { - await writeLicense("token"); - expect(await readLicense()).toBe("token"); - - vi.spyOn(process.stdout, "write").mockImplementation(() => true); - await runCommand(licenseRemoveCommand, { rawArgs: ["--yes"] }); - expect(await readLicense()).toBeNull(); - }); -}); diff --git a/src/commands/workspace/license/remove.ts b/src/commands/workspace/license/remove.ts deleted file mode 100644 index 3c6f450..0000000 --- a/src/commands/workspace/license/remove.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { z } from "zod"; - -import { clearLicense } from "../../../core/auth/storage"; -import type { ResourceView } from "../../../domain/view"; -import { promptConfirm } from "../../../output/prompt"; -import { renderSummary } from "../../../output/render"; -import { outputFlags } from "../../flags"; -import { defineMetabaseCommand } from "../../runtime"; - -export const LicenseRemoveResult = z.object({ - removed: z.boolean(), - aborted: z.boolean(), -}); -export type LicenseRemoveResultJson = z.infer; - -const licenseRemoveView: ResourceView = { - compactPick: LicenseRemoveResult, - tableColumns: [ - { key: "removed", label: "Removed" }, - { key: "aborted", label: "Aborted" }, - ], -}; - -export default defineMetabaseCommand({ - meta: { name: "remove", description: "Remove the stored license token" }, - capabilities: null, - args: { - ...outputFlags, - yes: { type: "boolean", description: "Skip confirmation", default: false }, - }, - outputSchema: LicenseRemoveResult, - examples: ["mb workspace license remove --yes"], - async run({ args, ctx }) { - if (!args.yes && process.stdin.isTTY === true) { - const ok = await promptConfirm({ - message: "Remove stored license token?", - initialValue: false, - }); - if (!ok) { - renderSummary( - { removed: false, aborted: true }, - licenseRemoveView, - "Left the stored license token in place.", - ctx, - ); - return; - } - } - - const removed = await clearLicense(); - const message = removed ? "License token removed." : "No license token was stored."; - renderSummary({ removed, aborted: false }, licenseRemoveView, message, ctx); - }, -}); diff --git a/src/commands/workspace/license/set.ts b/src/commands/workspace/license/set.ts deleted file mode 100644 index d10f466..0000000 --- a/src/commands/workspace/license/set.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { z } from "zod"; - -import { keyringFallbackWarning, writeLicense } from "../../../core/auth/storage"; -import { readEnvLicenseToken } from "../../../core/config"; -import { ConfigError } from "../../../core/errors"; -import type { ResourceView } from "../../../domain/view"; -import { warn } from "../../../output/notice"; -import { promptPassword } from "../../../output/prompt"; -import { renderSummary } from "../../../output/render"; -import { readInput } from "../../../runtime/input"; -import { outputFlags } from "../../flags"; -import { defineMetabaseCommand } from "../../runtime"; - -export const LicenseSetResult = z.object({ - stored: z.literal(true), -}); -export type LicenseSetResultJson = z.infer; - -const licenseSetView: ResourceView = { - compactPick: LicenseSetResult, - tableColumns: [{ key: "stored", label: "Stored" }], -}; - -export default defineMetabaseCommand({ - meta: { name: "set", description: "Store a Metabase license token" }, - capabilities: null, - args: { - ...outputFlags, - token: { - type: "positional", - description: "License token (visible in shell history; pipe on stdin instead)", - required: false, - }, - }, - outputSchema: LicenseSetResult, - examples: [ - "echo $METABASE_LICENSE_TOKEN | mb workspace license set", - "mb workspace license set < token.txt", - "mb workspace license set $METABASE_LICENSE_TOKEN", - ], - async run({ args, ctx }) { - const token = await resolveToken(args.token); - const location = await writeLicense(token); - - if (location.backend === "file") { - warn(keyringFallbackWarning(location, "license")); - } - - const result: LicenseSetResultJson = { stored: true }; - renderSummary(result, licenseSetView, "License token stored.", ctx); - }, -}); - -async function resolveToken(positional: string | undefined): Promise { - if (positional) { - warn( - "warning: license token passed as positional is visible in shell history and process listings — pipe the token on stdin or set METABASE_LICENSE_TOKEN instead", - ); - return positional; - } - const piped = (await readInput({ required: false })).trim(); - if (piped) { - return piped; - } - const envToken = readEnvLicenseToken(); - if (envToken) { - return envToken; - } - return promptForToken(); -} - -async function promptForToken(): Promise { - if (!process.stdin.isTTY) { - throw new ConfigError( - "license token, piped stdin, or METABASE_LICENSE_TOKEN required when stdin is not a TTY", - ); - } - return promptPassword({ - message: "License token", - mask: "•", - validate: (input) => (input ? undefined : "License token is required"), - }); -} diff --git a/src/commands/workspace/license/status.test.ts b/src/commands/workspace/license/status.test.ts deleted file mode 100644 index b261f0e..0000000 --- a/src/commands/workspace/license/status.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 licenseStatusCommand, { LicenseStatus } from "./status"; -import { writeLicense } 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("license status command", () => { - let home: TempConfigHome; - - beforeEach(() => { - hoisted.store.clear(); - home = setupTempConfigHome(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - home.cleanup(); - }); - - it("emits present=false when no license", async () => { - const capture = captureStdout(); - await runCommand(licenseStatusCommand, { rawArgs: ["--json"] }); - expect(capture.parse(LicenseStatus)).toEqual({ present: false }); - }); - - it("emits present=true after license is set; never reveals the token", async () => { - await writeLicense("very-secret-token-xyz"); - const capture = captureStdout(); - await runCommand(licenseStatusCommand, { rawArgs: ["--json"] }); - expect(capture.parse(LicenseStatus)).toEqual({ present: true }); - expect(capture.chunks.join("")).not.toContain("very-secret-token-xyz"); - }); -}); diff --git a/src/commands/workspace/license/status.ts b/src/commands/workspace/license/status.ts deleted file mode 100644 index 50457e3..0000000 --- a/src/commands/workspace/license/status.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { z } from "zod"; - -import { readLicense } from "../../../core/auth/storage"; -import type { ResourceView } from "../../../domain/view"; -import { renderSummary } from "../../../output/render"; -import { outputFlags } from "../../flags"; -import { defineMetabaseCommand } from "../../runtime"; - -export const LicenseStatus = z.object({ - present: z.boolean(), -}); -export type LicenseStatusJson = z.infer; - -const licenseStatusView: ResourceView = { - compactPick: LicenseStatus, - tableColumns: [{ key: "present", label: "Present" }], -}; - -export default defineMetabaseCommand({ - meta: { - name: "status", - description: "Show whether a license token is stored (does not reveal value)", - }, - capabilities: null, - args: { ...outputFlags }, - outputSchema: LicenseStatus, - examples: ["mb workspace license status", "mb workspace license status --json"], - async run({ ctx }) { - const present = (await readLicense()) !== null; - const summary = present ? "A license token is stored." : "No license token stored."; - renderSummary({ present }, licenseStatusView, summary, ctx); - }, -}); diff --git a/src/commands/workspace/list.ts b/src/commands/workspace/list.ts deleted file mode 100644 index 1976b94..0000000 --- a/src/commands/workspace/list.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/commands/workspace/logs.ts b/src/commands/workspace/logs.ts deleted file mode 100644 index 3b2a000..0000000 --- a/src/commands/workspace/logs.ts +++ /dev/null @@ -1,56 +0,0 @@ -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`)", - }, - capabilities: null, - 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: [ - "mb workspace logs 1", - "mb workspace logs 1 --follow", - "mb 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 \`mb 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 deleted file mode 100644 index 4666064..0000000 --- a/src/commands/workspace/ps.ts +++ /dev/null @@ -1,83 +0,0 @@ -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)", - }, - capabilities: null, - args: { ...outputFlags }, - outputSchema: LocalWorkspaceListEnvelope, - examples: ["mb workspace ps", "mb 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/start.test.ts b/src/commands/workspace/start.test.ts deleted file mode 100644 index df5e298..0000000 --- a/src/commands/workspace/start.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { tryParseTag } from "../../core/version/tag"; - -import { resolveDefaultImage } from "./start"; - -describe("resolveDefaultImage", () => { - it("uses the head build when the server version is unknown", () => { - expect(resolveDefaultImage(null)).toBe("metabase/metabase-enterprise-head:latest"); - }); - - it("uses the head build for versions older than the first workspaces release", () => { - expect(resolveDefaultImage(tryParseTag("v1.61.2"))).toBe( - "metabase/metabase-enterprise-head:latest", - ); - }); - - it("uses the released enterprise build from the first workspaces release onward", () => { - expect(resolveDefaultImage(tryParseTag("v1.62.0"))).toBe("metabase/metabase-enterprise:latest"); - expect(resolveDefaultImage(tryParseTag("v1.63.5"))).toBe("metabase/metabase-enterprise:latest"); - }); -}); diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts deleted file mode 100644 index 493a648..0000000 --- a/src/commands/workspace/start.ts +++ /dev/null @@ -1,526 +0,0 @@ -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, - pullImage, - removeContainer, - runWorkspaceContainer, - scrubContainerConfig, - waitForConfigConsumed, -} from "../../core/docker"; -import { ConfigError, errorMessage } from "../../core/errors"; -import { type Client, createClient } from "../../core/http/client"; -import { probeHealth } from "../../core/http/probe"; -import { localUrl } from "../../core/url"; -import type { ParsedVersion } from "../../core/version/tag"; -import { - REPO_SYNC_MODES, - type RepoSettings, - RepoSyncMode, - type WorkspaceCredentials, - buildCredentialsJson, - generateWorkspaceCredentials, - injectCredentialsIntoConfig, - injectRepoSettingsIntoConfig, -} from "../../core/workspace-credentials"; -import type { ResourceView } from "../../domain/view"; -import { Workspace } from "../../domain/workspace"; -import { warn } from "../../output/notice"; -import { renderSummary } 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"; -import { defineMetabaseCommand } from "../runtime"; - -const ENTERPRISE_IMAGE = "metabase/metabase-enterprise"; -const ENTERPRISE_HEAD_IMAGE = "metabase/metabase-enterprise-head"; -const LATEST_TAG = "latest"; -const WORKSPACES_MIN_VERSION = 62; -const DEFAULT_HOST_PORT = 3000; -// 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; -const DEFAULT_REPO_MODE: RepoSyncMode = "read-write"; -const REPO_FILE_URL = `file://${CONTAINER_REPO_DIR}`; -// The POST spools the multi-MB body to a temp file synchronously before -// returning 202, so the 30s default is too tight; reuse the readiness budget -// for both the upload and the subsequent status poll. -const METADATA_IMPORT_TIMEOUT_MS = DEFAULT_READY_TIMEOUT_MS; -const METADATA_POLL_INTERVAL_MS = 250; -const METADATA_POLL_MAX_INTERVAL_MS = 5_000; - -const MetadataImportEnqueued = z.object({ - queued: z.literal(true), - "import-id": z.string(), -}); - -const MetadataImportStatus = z.object({ - id: z.string(), - status: z.enum(["queued", "running", "ok", "error"]), - "enqueued-at": z.string(), - "started-at": z.string().nullable(), - "finished-at": z.string().nullable(), - "wall-ms": z.number().nullable(), - error: z.string().nullable(), -}); - -export const StartResult = z.object({ - workspace_id: z.number().int().positive(), - workspace_name: z.string(), - container_name: z.string(), - state: z.enum(["running", "starting"]), - 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", - }, - capabilities: { minVersion: WORKSPACES_MIN_VERSION, tokenFeature: "workspaces" }, - 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 when this flag is omitted, fails on collision when set explicitly)`, - }, - image: { - type: "string", - description: `Docker image to run. Default: ${ENTERPRISE_IMAGE}:${LATEST_TAG} once Metabase v${WORKSPACES_MIN_VERSION} is released, otherwise ${ENTERPRISE_HEAD_IMAGE}:${LATEST_TAG}.`, - }, - wait: { - type: "boolean", - description: - "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: { - type: "string", - description: `Per-phase readiness deadline in ms — covers post-create config consumption, (with --wait) the /api/health probe, and (with --metadata) the metadata-import status poll. Default: ${DEFAULT_READY_TIMEOUT_MS}.`, - default: String(DEFAULT_READY_TIMEOUT_MS), - }, - pull: { - type: "boolean", - description: "Pull the image before starting", - default: true, - }, - metadata: { - type: "boolean", - description: - "Fetch the workspace's warehouse metadata from the parent and POST it to the child instance once it is healthy", - default: true, - }, - force: { - type: "boolean", - 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: { - 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: [ - "mb workspace start 1", - "mb workspace start 1 --wait", - "mb workspace start 1 --port 3100", - "mb workspace start 1 --image metabase/metabase-enterprise:latest --no-pull", - "mb workspace start 1 --force", - "mb workspace start 1 --repo /path/to/sync-repo --wait", - "mb workspace start 1 --repo /path/to/sync-repo --repo-branch dev --repo-mode read-only", - ], - async run({ args, ctx, getClient, getResolvedConfig, getServerInfo }) { - const workspaceId = parseId(args.id); - const containerName = containerNameFor(workspaceId); - const requestedPort = parseOptionalInteger(args.port, { name: "--port", min: 1 }); - const readyTimeoutMs = parseInteger(args.timeout ?? String(DEFAULT_READY_TIMEOUT_MS), { - name: "--timeout", - min: 1000, - }); - const client = await getClient(); - const resolved = await getResolvedConfig(); - const licenseToken = await resolveLicenseToken({}); - const serverInfo = await getServerInfo(); - const image = args.image ?? resolveDefaultImage(serverInfo?.version ?? null); - - await checkDockerReady(); - await ensureNoExistingContainer(workspaceId, containerName, args.force); - - const pullPromise = args.pull ? pullImage(image) : Promise.resolve(); - - const workspace = await client.requestParsed( - Workspace, - `/api/ee/workspace-manager/${workspaceId}`, - ); - assertAllDatabasesProvisioned(workspace); - - const hostPort = await resolveHostPort(requestedPort); - - // Repo resolution overlaps with the parent config/metadata 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 bundle = assembleBootBundle(parentConfigYaml, workspaceId, repoOptions); - - await pullPromise; - - await runWorkspaceContainer({ - workspaceId, - workspaceName: workspace.name, - profile: resolved.profile, - parentUrl: resolved.url, - image, - hostPort, - configYaml: bundle.configYaml, - credentialsJson: bundle.credentialsJson, - licenseToken, - bindMounts: repoOptions === null ? [] : [repoOptions.bindMount], - }); - - const state = await finalizeContainer({ - workspaceId, - hostPort, - credentials: bundle.credentials, - metadataJson, - wait: args.wait, - timeoutMs: readyTimeoutMs, - }); - - const result: StartResult = { - workspace_id: workspaceId, - workspace_name: workspace.name, - container_name: containerName, - state, - host_port: hostPort, - url: localUrl(hostPort), - image, - }; - const message = - result.state === "running" - ? `Workspace ${workspaceId} "${workspace.name}" is running at ${result.url}.` - : `Workspace ${workspaceId} "${workspace.name}" is starting — container is up, instance still booting. Check \`mb workspace ps\` or rerun with --wait.`; - renderSummary(result, startResultView, message, ctx); - }, -}); - -export function resolveDefaultImage(version: ParsedVersion | null): string { - // Workspaces ship in v62, which isn't on the released metabase-enterprise repo - // yet — until the parent reports a released version, track master via the head - // build; once released, use the published enterprise image. - const isReleased = version !== null && version.major >= WORKSPACES_MIN_VERSION; - const repo = isReleased ? ENTERPRISE_IMAGE : ENTERPRISE_HEAD_IMAGE; - return `${repo}:${LATEST_TAG}`; -} - -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( - workspaceId: number, - containerName: string, - force: boolean, -): Promise { - if (force) { - await removeContainer(containerName); - return; - } - 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 \`mb workspace stop ${workspaceId}\` first, or use --force to recreate it.`, - ); -} - -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 fetchConfigYaml(client: Client, workspaceId: number): Promise { - const response = await client.requestRaw(`/api/ee/workspace-manager/${workspaceId}/config`, { - expectContentType: "binary", - }); - return response.text(); -} - -async function fetchMetadataJson(client: Client, workspaceId: number): Promise { - const response = await client.requestRaw( - `/api/ee/workspace-manager/${workspaceId}/metadata/export`, - { - expectContentType: "binary", - query: { "with-databases": true, "with-tables": true, "with-fields": true }, - }, - ); - return new Uint8Array(await response.arrayBuffer()); -} - -interface BootBundle { - configYaml: string; - credentialsJson: Uint8Array; - credentials: WorkspaceCredentials; -} - -// The 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). -function assembleBootBundle( - parentConfigYaml: string, - workspaceId: number, - repoOptions: ResolvedRepoOptions | null, -): BootBundle { - const credentials = generateWorkspaceCredentials(workspaceId); - const withCredentials = injectCredentialsIntoConfig(parentConfigYaml, credentials); - const configYaml = - repoOptions !== null - ? injectRepoSettingsIntoConfig(withCredentials, repoOptions.repo) - : withCredentials; - return { configYaml, credentialsJson: buildCredentialsJson(credentials), credentials }; -} - -interface FinalizeContainerInput { - workspaceId: number; - hostPort: number; - credentials: WorkspaceCredentials; - metadataJson: Uint8Array | null; - wait: boolean; - timeoutMs: number; -} - -async function finalizeContainer(input: FinalizeContainerInput): Promise { - // 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(input.workspaceId, input.timeoutMs); - try { - await scrubContainerConfig(input.workspaceId); - } catch (error) { - warn(`could not scrub in-container config.yml: ${errorMessage(error)}`); - } - - // 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 reachedHealth = input.wait || input.metadataJson !== null; - if (reachedHealth) { - await waitForHealth(input.hostPort, input.timeoutMs); - } - if (input.metadataJson !== null) { - await importMetadataIntoChild( - input.hostPort, - input.credentials, - input.metadataJson, - input.timeoutMs, - ); - } - return reachedHealth ? "running" : "starting"; -} - -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, - }, - ); -} - -async function importMetadataIntoChild( - hostPort: number, - credentials: WorkspaceCredentials, - metadataJson: Uint8Array, - pollTimeoutMs: number, -): Promise { - const childClient = createClient({ - url: localUrl(hostPort), - apiKey: credentials.api_key.key, - }); - const enqueued = await childClient.requestParsed( - MetadataImportEnqueued, - "/api/ee/serialization/metadata/import", - { - method: "POST", - body: metadataJson, - timeoutMs: METADATA_IMPORT_TIMEOUT_MS, - }, - ); - const importId = enqueued["import-id"]; - const final = await pollUntil( - () => - childClient.requestParsed( - MetadataImportStatus, - `/api/ee/serialization/metadata/import/${importId}`, - ), - (status) => status.status === "ok" || status.status === "error", - { - intervalMs: METADATA_POLL_INTERVAL_MS, - maxIntervalMs: METADATA_POLL_MAX_INTERVAL_MS, - backoff: "exponential", - timeoutMs: pollTimeoutMs, - }, - ); - if (final.status === "error") { - const detail = final.error !== null ? `: ${final.error}` : ""; - throw new Error(`metadata import failed (id=${importId})${detail}`); - } -} - -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/commands/workspace/stop.ts b/src/commands/workspace/stop.ts deleted file mode 100644 index 72d98e7..0000000 --- a/src/commands/workspace/stop.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { z } from "zod"; - -import { - checkDockerReady, - containerLifecycleStatus, - containerNameFor, - stopContainer, -} from "../../core/docker"; -import type { ResourceView } from "../../domain/view"; -import { renderSummary } 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)", - }, - capabilities: null, - args: { - ...outputFlags, - id: { type: "positional", description: "Workspace id", required: true }, - }, - outputSchema: StopResult, - examples: ["mb workspace stop 1", "mb 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, - }; - let message: string; - if (result.stopped) { - message = `Stopped workspace ${workspaceId} (container ${containerName}).`; - } else if (result.prior_state !== null) { - message = `Workspace ${workspaceId} container is already ${result.prior_state} — nothing to stop.`; - } else { - message = `No container found for workspace ${workspaceId} — nothing to stop.`; - } - renderSummary(result, stopResultView, message, ctx); - }, -}); diff --git a/src/commands/workspace/url.ts b/src/commands/workspace/url.ts deleted file mode 100644 index 5ef87ec..0000000 --- a/src/commands/workspace/url.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { z } from "zod"; - -import { checkDockerReady, requireWorkspaceContainerLocation } from "../../core/docker"; -import { localUrl } from "../../core/url"; -import type { ResourceView } from "../../domain/view"; -import { renderSummary } 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, - 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", - }, - capabilities: null, - args: { - ...outputFlags, - id: { type: "positional", description: "Workspace id", required: true }, - }, - outputSchema: UrlResult, - examples: ["mb workspace url 1", "mb workspace url 1 --json"], - async run({ args, ctx }) { - const workspaceId = parseId(args.id); - - await checkDockerReady(); - const { hostPort } = await requireWorkspaceContainerLocation(workspaceId); - - const result: UrlResult = { - workspace_id: workspaceId, - url: localUrl(hostPort), - }; - renderSummary(result, urlResultView, result.url, ctx); - }, -}); diff --git a/src/core/auth/profile-record.ts b/src/core/auth/profile-record.ts index 31338a3..acebf20 100644 --- a/src/core/auth/profile-record.ts +++ b/src/core/auth/profile-record.ts @@ -39,6 +39,5 @@ export type ProfileRecord = z.infer; export const ProfilesFile = z.object({ profiles: z.array(ProfileRecord), - license: z.string().nullable(), }); export type ProfilesFile = z.infer; diff --git a/src/core/auth/storage.test.ts b/src/core/auth/storage.test.ts index cfba791..083f711 100644 --- a/src/core/auth/storage.test.ts +++ b/src/core/auth/storage.test.ts @@ -21,7 +21,6 @@ vi.mock("@napi-rs/keyring", async () => { import * as storage from "./storage"; const { - clearLicense, clearProfile, consumeLegacyStorageWarning, keyringFallbackWarning, @@ -29,10 +28,8 @@ const { listProfileNames, listProfileRecords, profilesFilePath, - readLicense, readProfile, readProfileRecord, - writeLicense, writeProbeFailure, writeProbeResult, writeProfile, @@ -83,7 +80,6 @@ describe("profiles (keyring backend)", () => { lastFailure: null, }, ], - license: null, }); }); @@ -129,7 +125,7 @@ describe("profiles (keyring backend)", () => { expect(await clearProfile("missing")).toBe(false); }); - it("deletes profiles.json when the last profile and license are gone", async () => { + it("deletes profiles.json when the last profile is gone", async () => { if (process.platform === "win32") { return; } @@ -183,13 +179,6 @@ describe("profiles (file fallback when keyring is broken)", () => { await writeProfile({ url: "https://m.example.com", apiKey: "secret" }); expect(await readProfile()).toEqual({ url: "https://m.example.com", apiKey: "secret" }); }); - - it("stores the license inline in profiles.json when the keyring is broken", async () => { - await writeLicense("license-token"); - const file = parseJson(readFileSync(profilesFilePath(), "utf8"), ProfilesFile); - expect(file).toEqual({ profiles: [], license: "license-token" }); - expect(await readLicense()).toBe("license-token"); - }); }); describe("readProfileRecord and listProfileRecords", () => { @@ -296,40 +285,6 @@ describe("writeProbeResult and writeProbeFailure", () => { }); }); -describe("license", () => { - let home: TempConfigHome; - - beforeEach(() => { - hoisted.store.clear(); - hoisted.controls.broken = false; - home = setupTempConfigHome(); - }); - - afterEach(() => { - home.cleanup(); - }); - - it("round-trips via the keyring (license stays null inline)", async () => { - await writeLicense("license-token"); - expect(await readLicense()).toBe("license-token"); - expect(hoisted.store.get("metabase-cli:license")).toBe("license-token"); - }); - - it("clearLicense removes the keyring entry", async () => { - await writeLicense("license-token"); - expect(await clearLicense()).toBe(true); - expect(await readLicense()).toBeNull(); - expect(await clearLicense()).toBe(false); - }); - - it("license is independent of profile clears", async () => { - await writeProfile({ url: "https://m.example.com", apiKey: "k" }); - await writeLicense("license-token"); - expect(await clearProfile()).toBe(true); - expect(await readLicense()).toBe("license-token"); - }); -}); - describe("METABASE_CLI_DISABLE_KEYRING", () => { let home: TempConfigHome; @@ -372,7 +327,7 @@ describe("keyringFallbackWarning", () => { account: "profile:default:apiKey", reason: "disabled", }; - expect(keyringFallbackWarning(location, "credentials")).toBe( + expect(keyringFallbackWarning(location)).toBe( "warning: OS keychain disabled via METABASE_CLI_DISABLE_KEYRING; credentials stored as plaintext at /tmp/profiles.json", ); }); @@ -381,11 +336,11 @@ describe("keyringFallbackWarning", () => { const location: FileLocation = { backend: "file", path: "/tmp/profiles.json", - account: "license", + account: "profile:default:apiKey", reason: "unavailable", }; - expect(keyringFallbackWarning(location, "license")).toBe( - "warning: OS keychain unavailable; license stored as plaintext at /tmp/profiles.json", + expect(keyringFallbackWarning(location)).toBe( + "warning: OS keychain unavailable; credentials stored as plaintext at /tmp/profiles.json", ); }); }); diff --git a/src/core/auth/storage.ts b/src/core/auth/storage.ts index ebad949..d637ae5 100644 --- a/src/core/auth/storage.ts +++ b/src/core/auth/storage.ts @@ -30,12 +30,10 @@ export const LEGACY_STORAGE_NOTICE = "Old profile storage detected and ignored; re-run `mb auth login` for each profile."; export type ProfileApiKeyAccount = `profile:${string}:apiKey`; -export type LicenseAccount = "license"; -export type CredentialAccount = ProfileApiKeyAccount | LicenseAccount; +export type CredentialAccount = ProfileApiKeyAccount; export const account = { profileApiKey: (profile: string): ProfileApiKeyAccount => `profile:${profile}:apiKey`, - license: "license", } as const; export interface KeyringLocation { @@ -46,8 +44,6 @@ export interface KeyringLocation { export type KeyringFallbackReason = "disabled" | "unavailable"; -export type KeyringFallbackSubject = "credentials" | "license"; - export interface FileLocation { backend: "file"; path: string; @@ -133,7 +129,7 @@ async function readProfilesFile(): Promise { } catch (error) { if (isNotFoundError(error)) { await detectLegacyArtifacts(); - return { profiles: [], license: null }; + return { profiles: [] }; } throw error; } @@ -143,7 +139,7 @@ async function readProfilesFile(): Promise { } if (parsed.error instanceof ValidationError) { legacyWarningPending = true; - return { profiles: [], license: null }; + return { profiles: [] }; } throw parsed.error; } @@ -171,7 +167,7 @@ async function fileExists(path: string): Promise { async function writeProfilesFile(file: ProfilesFile): Promise { const path = profilesFilePath(); - if (file.profiles.length === 0 && file.license === null) { + if (file.profiles.length === 0) { await fs.unlink(path).catch(() => undefined); await cleanupLegacyFiles(); return; @@ -204,15 +200,12 @@ function fileLocation(key: CredentialAccount): FileLocation { }; } -export function keyringFallbackWarning( - location: FileLocation, - subject: KeyringFallbackSubject, -): string { +export function keyringFallbackWarning(location: FileLocation): string { const cause = location.reason === "disabled" ? "OS keychain disabled via METABASE_CLI_DISABLE_KEYRING" : "OS keychain unavailable"; - return `warning: ${cause}; ${subject} stored as plaintext at ${location.path}`; + return `warning: ${cause}; credentials stored as plaintext at ${location.path}`; } async function persistApiKey(name: string, apiKey: string): Promise { @@ -349,35 +342,3 @@ export async function clearProfile(name: string = DEFAULT_PROFILE): Promise { - const fromKeyring = tryReadKeyring(account.license); - if (typeof fromKeyring === "string") { - return fromKeyring; - } - const file = await readProfilesFile(); - return file.license; -} - -export async function writeLicense(token: string): Promise { - const key = account.license; - const file = await readProfilesFile(); - if (trySetKeyring(key, token)) { - if (file.license !== null) { - await writeProfilesFile({ ...file, license: null }); - } - return { backend: "keyring", service: KEYRING_SERVICE, account: key }; - } - await writeProfilesFile({ ...file, license: token }); - return fileLocation(key); -} - -export async function clearLicense(): Promise { - const removedFromKeyring = tryRemoveKeyring(account.license); - const file = await readProfilesFile(); - const hadInline = file.license !== null; - if (hadInline) { - await writeProfilesFile({ ...file, license: null }); - } - return removedFromKeyring === true || hadInline; -} diff --git a/src/core/config.ts b/src/core/config.ts index 293dcc8..d8a12d4 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,11 +1,10 @@ -import { DEFAULT_PROFILE, readLicense, readProfile, readProfileRecord } from "./auth/storage"; +import { DEFAULT_PROFILE, readProfile, readProfileRecord } from "./auth/storage"; import { ConfigError } from "./errors"; import { normalizeUrl } from "./url"; const ENV_URL = "METABASE_URL"; const ENV_API_KEY = "METABASE_API_KEY"; const ENV_PROFILE = "METABASE_PROFILE"; -const ENV_LICENSE_TOKEN = "METABASE_LICENSE_TOKEN"; const ENV_SKIP_PREFLIGHT = "METABASE_CLI_SKIP_PREFLIGHT"; export const SKIP_PREFLIGHT_ENV = ENV_SKIP_PREFLIGHT; @@ -29,10 +28,6 @@ export interface ResolvedConfig { source: ConfigSource; } -export interface LicenseFlags { - token?: string; -} - export interface EnvCredentials { url: string | null; apiKey: string | null; @@ -58,10 +53,6 @@ export function readEnvCredentials(): EnvCredentials { }; } -export function readEnvLicenseToken(): string | null { - return process.env[ENV_LICENSE_TOKEN] ?? null; -} - export async function resolveConfig(flags: ConfigFlags): Promise { const profile = resolveProfileName(flags.profile); const env = readEnvCredentials(); @@ -89,19 +80,6 @@ export async function resolveConfig(flags: ConfigFlags): Promise }; } -export async function resolveLicenseToken(flags: LicenseFlags): Promise { - const flag = flags.token; - const env = readEnvLicenseToken(); - const stored = !flag && !env ? await readLicense() : null; - const value = flag ?? env ?? stored; - if (!value) { - throw new ConfigError( - `No license token. Pass --token, set ${ENV_LICENSE_TOKEN}, or store one with \`mb workspace license set\`.`, - ); - } - return value; -} - async function failureHintForProfile(profile: string): Promise { const record = await readProfileRecord(profile); if (record === null || record.lastFailure === null) { diff --git a/src/core/docker.test.ts b/src/core/docker.test.ts deleted file mode 100644 index 188b412..0000000 --- a/src/core/docker.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - containerNameFor, - DockerError, - parseContainerLine, - parseContainerLines, - volumeNameFor, -} from "./docker"; -import { MetabaseError } from "./errors"; - -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]); - }); -}); - -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 deleted file mode 100644 index 4908d2b..0000000 --- a/src/core/docker.ts +++ /dev/null @@ -1,606 +0,0 @@ -import { z } from "zod"; - -import { parseJson } from "../runtime/json"; -import { pollUntil } from "../runtime/poll"; -import { - ProcessNotFoundError, - runProcess, - runProcessBinary, - streamProcess, -} from "../runtime/process"; -import { buildTar, extractSingleFileFromTar, type TarEntry } from "../runtime/tar"; - -import { ConfigError, errorMessage, MetabaseError } 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_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 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 -// 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; - -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 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, dockerExitCode: number | null, stderr: string) { - super(message); - this.name = "DockerError"; - 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 { - 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 NamedVolumeMount { - volume: string; - container: string; -} - -export interface BindMount { - hostPath: string; - containerPath: string; - readOnly?: boolean; -} - -export interface PortMapping { - hostPort: number; - containerPort: number; -} - -export interface CreateContainerOptions { - containerName: string; - image: string; - port: PortMapping; - namedVolumes: readonly NamedVolumeMount[]; - bindMounts: readonly BindMount[]; - envVars: Record; - labels: Record; -} - -export interface WorkspaceContainerSpec { - workspaceId: number; - workspaceName: string; - profile: string; - parentUrl: string; - image: string; - hostPort: number; - configYaml: string; - credentialsJson: Uint8Array; - licenseToken: string; - bindMounts: readonly BindMount[]; -} - -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; -} - -interface DockerExecOptions { - env?: NodeJS.ProcessEnv; - stdin?: Uint8Array | string; -} - -interface DockerRunOptions extends DockerExecOptions { - ignorePattern?: RegExp; -} - -async function dockerExec( - args: readonly string[], - options: DockerExecOptions = {}, -): Promise { - try { - return await runProcess(DOCKER_BIN, args, options); - } catch (error) { - if (error instanceof ProcessNotFoundError) { - throw new DockerNotInstalledError(); - } - throw error; - } -} - -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 { - 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 runDocker( - ["ps", "-a", "--filter", `name=^${containerName}$`, "--format", "{{.State}}"], - "docker ps failed", - ); - 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, ""); -} - -// 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 { - const containerName = containerNameFor(spec.workspaceId); - await createContainer({ - containerName, - 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), - }); - 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}`, - ); -} - -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 }, - { - type: "file", - name: `${CONTAINER_CONFIG_DIR_BASENAME}/${CONFIG_FILENAME}`, - content: spec.configYaml, - mode: BUNDLE_FILE_MODE, - }, - { - type: "file", - name: `${CONTAINER_CONFIG_DIR_BASENAME}/${CREDENTIALS_FILENAME}`, - content: spec.credentialsJson, - mode: BUNDLE_FILE_MODE, - }, - ]; - return buildTar(entries); -} - -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(spec: WorkspaceContainerSpec): 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`, - // Workspace metadata is imported from the parent on start; the child has no - // need to run scheduled syncs/fingerprinting/scans against its warehouses. - MB_DISABLE_SCHEDULER: "true", - JAVA_OPTS: "-Xmx2g", - }; -} - -async function createContainer(options: CreateContainerOptions): Promise { - const args: string[] = [ - "create", - "--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.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); - } - args.push(options.image); - - const env: NodeJS.ProcessEnv = { ...process.env, ...options.envVars }; - 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 { - await runDocker(["stop", containerName], `docker stop ${containerName} failed`, { - ignorePattern: NO_SUCH_CONTAINER_PATTERN, - }); -} - -export async function removeContainer(containerName: string): Promise { - 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 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 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 runDocker( - [ - "ps", - "-a", - "--filter", - `name=^${containerName}$`, - "--filter", - `label=${LABEL_ID}`, - "--format", - "{{json .}}", - ], - "docker ps failed", - ); - const summaries = parseContainerLines(result.stdout); - 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 \`mb 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, -): 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/errors.ts b/src/core/errors.ts index a1e749a..f66ee96 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -14,7 +14,6 @@ export type ErrorCategory = | "config" | "capability" | "abort" - | "docker" | "unknown"; export interface NetworkErrorDetail { diff --git a/src/core/paths.ts b/src/core/paths.ts index 1f2e763..c772462 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -3,9 +3,7 @@ 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). +// Resolves the per-user CLI config directory, XDG/AppData-idiomatic per platform: // // macOS / Linux: $XDG_CONFIG_HOME/metabase-cli (default ~/.config/metabase-cli) // Windows: %APPDATA%/metabase-cli (default ~/AppData/Roaming/metabase-cli) diff --git a/src/core/skills.test.ts b/src/core/skills.test.ts index e971a50..76f301a 100644 --- a/src/core/skills.test.ts +++ b/src/core/skills.test.ts @@ -131,9 +131,9 @@ describe("discoverSkills", () => { writeSkill(temp.skillData, "core", { name: "core", description: "Core." }, "core body"); writeSkill( temp.skillData, - "workspace", - { name: "workspace", description: "Workspaces." }, - "ws body", + "transform", + { name: "transform", description: "Transforms." }, + "transform body", ); expect(discoverSkills([temp.skills, temp.skillData])).toEqual([ @@ -145,10 +145,10 @@ describe("discoverSkills", () => { dir: join(temp.skills, "metabase-cli"), }, { - name: "workspace", - description: "Workspaces.", + name: "transform", + description: "Transforms.", hidden: false, - dir: join(temp.skillData, "workspace"), + dir: join(temp.skillData, "transform"), }, ]); }); @@ -246,10 +246,10 @@ describe("availableSkillNames", () => { it('formats the visible skill list as "available: a, b"', () => { const skills: SkillInfo[] = [ { name: "core", description: "", hidden: false, dir: "/x/core" }, - { name: "workspace", description: "", hidden: false, dir: "/x/workspace" }, + { name: "transform", description: "", hidden: false, dir: "/x/transform" }, { name: "metabase-cli", description: "", hidden: true, dir: "/x/metabase-cli" }, ]; - expect(availableSkillNames(skills)).toBe("available: core, workspace"); + expect(availableSkillNames(skills)).toBe("available: core, transform"); }); it('falls back to "available: none" when no visible skills exist', () => { @@ -263,7 +263,7 @@ describe("availableSkillNames", () => { describe("findSkillByName", () => { const skills: SkillInfo[] = [ { name: "core", description: "Core.", hidden: false, dir: "/x/core" }, - { name: "workspace", description: "Workspaces.", hidden: false, dir: "/x/workspace" }, + { name: "transform", description: "Transforms.", hidden: false, dir: "/x/transform" }, ]; it("returns the matching skill", () => { @@ -272,7 +272,7 @@ describe("findSkillByName", () => { it("throws ConfigError with the available list when the name is unknown", () => { expect(() => findSkillByName(skills, "nope")).toThrow( - new ConfigError("unknown skill name: nope (available: core, workspace)"), + new ConfigError("unknown skill name: nope (available: core, transform)"), ); }); }); @@ -280,18 +280,18 @@ describe("findSkillByName", () => { describe("selectSkillsByNames", () => { const skills: SkillInfo[] = [ { name: "core", description: "", hidden: false, dir: "/x/core" }, - { name: "workspace", description: "", hidden: false, dir: "/x/workspace" }, + { name: "git-sync", description: "", hidden: false, dir: "/x/git-sync" }, { name: "transform", description: "", hidden: false, dir: "/x/transform" }, ]; it("returns selected skills in the requested order", () => { - expect(selectSkillsByNames(skills, ["workspace", "core"])).toEqual([skills[1], skills[0]]); + expect(selectSkillsByNames(skills, ["git-sync", "core"])).toEqual([skills[1], skills[0]]); }); it("throws ConfigError listing missing names and the available set", () => { expect(() => selectSkillsByNames(skills, ["core", "nope", "also-missing"])).toThrow( new ConfigError( - "unknown skill name(s): nope, also-missing (available: core, workspace, transform)", + "unknown skill name(s): nope, also-missing (available: core, git-sync, transform)", ), ); }); diff --git a/src/core/workspace-credentials.test.ts b/src/core/workspace-credentials.test.ts deleted file mode 100644 index 5536587..0000000 --- a/src/core/workspace-credentials.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { assert, 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, - injectRepoSettingsIntoConfig, -} 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: - - schema: 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: [{ schema: "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); - assert(thrown instanceof ConfigError, "expected ConfigError"); - 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: [{ schema: "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); - assert(thrown instanceof ConfigError, "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 deleted file mode 100644 index cd91b46..0000000 --- a/src/core/workspace-credentials.ts +++ /dev/null @@ -1,133 +0,0 @@ -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); -} - -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"); -} diff --git a/src/domain/workspace.ts b/src/domain/workspace.ts deleted file mode 100644 index de7d7b3..0000000 --- a/src/domain/workspace.ts +++ /dev/null @@ -1,103 +0,0 @@ -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_namespace: z.string(), - input_schemas: z.array(z.string().min(1)), - 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 schemas = - entry.input_schemas.length === 0 ? "" : ` [${entry.input_schemas.join(", ")}]`; - return `${entry.database_id} (${entry.status})${schemas}`; - }) - .join("; "); -} diff --git a/src/main.ts b/src/main.ts index e22b220..192b4c7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,7 +22,6 @@ 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/src/output/render.ts b/src/output/render.ts index 8798073..fa7de93 100644 --- a/src/output/render.ts +++ b/src/output/render.ts @@ -27,7 +27,7 @@ export function renderItem(item: T, view: ResourceView, opts: RenderOption } // Default text/human view prints `summaryText` — a bare scalar for single-value lookups -// (setting get, workspace url, git-sync is-dirty) so the result composes in a shell +// (setting get, git-sync is-dirty) so the result composes in a shell // (`URL=$(mb … --format text)`), or an action-confirmation sentence for mutations // ("Archived card 1 …"). `--json`, `--fields`, and `--full` fall through to renderItem, which // emits structured JSON under `--json` and the selected/all fields as key/value lines in text. diff --git a/src/runtime/port.test.ts b/src/runtime/port.test.ts deleted file mode 100644 index 4186528..0000000 --- a/src/runtime/port.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -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 port", async () => { - const port = await pickFreePortViaOS(); - expect(await isPortFree(port)).toBe(true); - }); - - 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); - }); -}); - -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, "0.0.0.0"); - }); -} diff --git a/src/runtime/port.ts b/src/runtime/port.ts deleted file mode 100644 index bf8ca07..0000000 --- a/src/runtime/port.ts +++ /dev/null @@ -1,29 +0,0 @@ -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)); - }); - // 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"); - }); -} - -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/tar.test.ts b/src/runtime/tar.test.ts deleted file mode 100644 index 4b79bbf..0000000 --- a/src/runtime/tar.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -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, extractSingleFileFromTar, TarParseError } 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 }); - } - }); -}); - -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 deleted file mode 100644 index 6ed6182..0000000 --- a/src/runtime/tar.ts +++ /dev/null @@ -1,198 +0,0 @@ -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), - }; -} - -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). -// 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/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml index beda7d1..0fcf031 100644 --- a/tests/e2e/docker-compose.yml +++ b/tests/e2e/docker-compose.yml @@ -13,7 +13,7 @@ services: retries: 30 metabase: - image: ${METABASE_E2E_IMAGE:-metabase/metabase-dev:feature-workspaces-v2} + image: ${METABASE_E2E_IMAGE:-metabase/metabase-enterprise-head:latest} pull_policy: always depends_on: data-db: diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index 8cc5cdc..568c444 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -103,21 +103,6 @@ describe("__manifest e2e", () => { "git-sync create-branch", "git-sync add-collection", "git-sync remove-collection", - "workspace list", - "workspace create", - "workspace database provision", - "workspace database update", - "workspace database deprovision", - "workspace start", - "workspace stop", - "workspace delete", - "workspace logs", - "workspace url", - "workspace credentials", - "workspace ps", - "workspace license set", - "workspace license status", - "workspace license remove", "setup", "snippet list", "snippet get", @@ -143,15 +128,9 @@ describe("__manifest e2e", () => { "skills path", ]); - // Streaming commands legitimately have no outputSchema — they pipe raw bytes - // (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); - if (!streamingCommands.has(entry.command)) { - expect(entry.outputSchema, `missing outputSchema for ${entry.command}`).not.toBeNull(); - } + expect(entry.outputSchema, `missing outputSchema for ${entry.command}`).not.toBeNull(); } }); }); diff --git a/tests/e2e/server-gate.ts b/tests/e2e/server-gate.ts index aee0463..dfbcb86 100644 --- a/tests/e2e/server-gate.ts +++ b/tests/e2e/server-gate.ts @@ -10,7 +10,7 @@ import { resolveAssumeHead } from "./defaults"; // Head/nightly builds report version tag "vUNKNOWN" → version: null, which would skip every // version-gated suite. The matrix runner sets METABASE_CLI_E2E_ASSUME_HEAD on the head lanes -// so those suites run against head (the only place head-only features like workspaces exist). +// so those suites run against head (where the newest features land first). // The premium token-feature is still checked against the live probe, so the override only // relaxes the version that genuinely can't be parsed on head. const HEAD_ASSUMED_MAJOR = 9999; diff --git a/tests/e2e/skills.e2e.test.ts b/tests/e2e/skills.e2e.test.ts index e5efd3c..d05a126 100644 --- a/tests/e2e/skills.e2e.test.ts +++ b/tests/e2e/skills.e2e.test.ts @@ -7,14 +7,7 @@ import { parseJson } from "../../src/runtime/json"; import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; -const BUNDLED_VISIBLE_NAMES = [ - "core", - "git-sync", - "mbql", - "transform", - "visualization", - "workspace", -] as const; +const BUNDLED_VISIBLE_NAMES = ["core", "git-sync", "mbql", "transform", "visualization"] as const; describe("skills e2e", () => { const tempDirs: string[] = []; @@ -29,7 +22,7 @@ describe("skills e2e", () => { return dir; } - it("list returns the six bundled non-hidden skills, sorted by name", async () => { + it("list returns the five bundled non-hidden skills, sorted by name", async () => { const result = await runCli({ args: ["skills", "list", "--json"], configHome: await makeIsolatedConfigHome(), @@ -103,13 +96,13 @@ describe("skills e2e", () => { it("get accepts comma-separated names", async () => { const result = await runCli({ - args: ["skills", "get", "workspace,transform", "--json"], + args: ["skills", "get", "git-sync,transform", "--json"], configHome: await makeIsolatedConfigHome(), }); expect(result.exitCode, result.stderr).toBe(0); const envelope = parseJson(result.stdout, SkillGetEnvelope); - expect(envelope.data.map((s) => s.name)).toEqual(["workspace", "transform"]); + expect(envelope.data.map((s) => s.name)).toEqual(["git-sync", "transform"]); }); it("get rejects an unknown skill name with exit 2 and a ConfigError message listing available names", async () => { @@ -120,7 +113,7 @@ describe("skills e2e", () => { expect(result.exitCode).toBe(2); expect(result.stderr).toContain( - "unknown skill name(s): does-not-exist (available: core, git-sync, mbql, transform, visualization, workspace)", + "unknown skill name(s): does-not-exist (available: core, git-sync, mbql, transform, visualization)", ); }); diff --git a/tests/e2e/version.e2e.test.ts b/tests/e2e/version.e2e.test.ts index 08db1d9..25fdfa0 100644 --- a/tests/e2e/version.e2e.test.ts +++ b/tests/e2e/version.e2e.test.ts @@ -51,10 +51,6 @@ async function seedProbedProfile(configHome: string, major: number): Promise { const tempDirs: string[] = []; @@ -147,39 +143,6 @@ describe("version preflight e2e", () => { ); expect(localCapabilities).toEqual({ uuid: null, upgrade: null }); }); - - it("manifest gates server-touching workspace commands at v62 and reports null for local-only ones", async () => { - const result = await runCli({ - args: ["__manifest"], - configHome: await makeIsolatedConfigHome(), - }); - - expect(result.exitCode, result.stderr).toBe(0); - - const manifest = parseJson(result.stdout, Manifest, { source: "__manifest" }); - const workspaceCapabilities = Object.fromEntries( - manifest.commands - .filter((entry) => entry.command.startsWith("workspace ")) - .map((entry) => [entry.command, entry.capabilities]), - ); - expect(workspaceCapabilities).toEqual({ - "workspace list": WORKSPACE_CAPABILITIES, - "workspace create": WORKSPACE_CAPABILITIES, - "workspace start": WORKSPACE_CAPABILITIES, - "workspace stop": null, - "workspace delete": null, - "workspace ps": null, - "workspace logs": null, - "workspace url": null, - "workspace credentials": null, - "workspace database provision": WORKSPACE_CAPABILITIES, - "workspace database deprovision": WORKSPACE_CAPABILITIES, - "workspace database update": WORKSPACE_CAPABILITIES, - "workspace license set": null, - "workspace license status": null, - "workspace license remove": null, - }); - }); }); describe("version preflight enforcement e2e", () => { diff --git a/tests/e2e/workspace-license.e2e.test.ts b/tests/e2e/workspace-license.e2e.test.ts deleted file mode 100644 index 4d06d87..0000000 --- a/tests/e2e/workspace-license.e2e.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; - -import { LicenseRemoveResult } from "../../src/commands/workspace/license/remove"; -import { LicenseSetResult } from "../../src/commands/workspace/license/set"; -import { LicenseStatus } from "../../src/commands/workspace/license/status"; -import { parseJson } from "../../src/runtime/json"; - -import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; - -// A syntactically valid dev-shaped token — `mb_dev_` + 57 hex chars per Metabase's -// RemoteCheckedToken regex. Storage commands never validate against Metabase, so -// any opaque string works; this format guards against a future stricter check. -const DUMMY_DEV_TOKEN = "mb_dev_0123456789abcdef0123456789abcdef0123456789abcdef0123456789a"; - -describe("license storage 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("set with piped stdin → status reflects present without leaking the token", async () => { - const configHome = await makeIsolatedConfigHome(); - - const set = await runCli({ - args: ["workspace", "license", "set", "--json"], - stdin: DUMMY_DEV_TOKEN, - configHome, - }); - - expect(set.exitCode, set.stderr).toBe(0); - expect(set.stdout).not.toContain(DUMMY_DEV_TOKEN); - expect(set.stderr).not.toContain(DUMMY_DEV_TOKEN); - expect(parseJson(set.stdout, LicenseSetResult)).toEqual({ stored: true }); - - const status = await runCli({ - args: ["workspace", "license", "status", "--json"], - configHome, - }); - - expect(status.exitCode, status.stderr).toBe(0); - expect(status.stdout).not.toContain(DUMMY_DEV_TOKEN); - expect(parseJson(status.stdout, LicenseStatus)).toEqual({ present: true }); - }); - - it("remove --yes clears the token and is idempotent on a second remove", async () => { - const configHome = await makeIsolatedConfigHome(); - - await runCli({ - args: ["workspace", "license", "set", "--json"], - stdin: DUMMY_DEV_TOKEN, - configHome, - }); - - const firstRemove = await runCli({ - args: ["workspace", "license", "remove", "--yes", "--json"], - configHome, - }); - expect(firstRemove.exitCode, firstRemove.stderr).toBe(0); - expect(parseJson(firstRemove.stdout, LicenseRemoveResult)).toEqual({ - removed: true, - aborted: false, - }); - - const status = await runCli({ - args: ["workspace", "license", "status", "--json"], - configHome, - }); - expect(parseJson(status.stdout, LicenseStatus)).toEqual({ present: false }); - - const secondRemove = await runCli({ - args: ["workspace", "license", "remove", "--yes", "--json"], - configHome, - }); - expect(secondRemove.exitCode, secondRemove.stderr).toBe(0); - expect(parseJson(secondRemove.stdout, LicenseRemoveResult)).toEqual({ - removed: false, - aborted: false, - }); - }); -}); - -// Future EE-feature tests that require a real dev token + matching token-check -// server go inside this gate. Today no CLI command pushes the license to a -// connected Metabase instance, so the gate stays empty — but the pattern is -// here so adding such a command lands tests under the same gate without -// touching docker-compose or CLAUDE.md again. -const realToken = process.env["MB_PREMIUM_EMBEDDING_TOKEN"] ?? ""; -const realStoreUrl = process.env["METASTORE_DEV_SERVER_URL"] ?? ""; -const licenseGateActive = realToken !== "" && realStoreUrl !== ""; - -const describeIfLicensed = licenseGateActive ? describe : describe.skip; - -describeIfLicensed( - "license EE integration e2e (set MB_PREMIUM_EMBEDDING_TOKEN + METASTORE_DEV_SERVER_URL to enable)", - () => { - // Implementations land here when a CLI command exists that exercises an - // EE-gated Metabase endpoint. The token is read from process.env at the - // top of this module and threaded into runCli({ stdin: realToken }) only — - // never logged, never asserted on, never written to disk. - it.todo("activates a premium feature on the connected instance"); - }, -); diff --git a/tests/e2e/workspace-local.e2e.test.ts b/tests/e2e/workspace-local.e2e.test.ts deleted file mode 100644 index 9861697..0000000 --- a/tests/e2e/workspace-local.e2e.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { readdir } from "node:fs/promises"; -import { tmpdir } from "node:os"; - -import { afterAll, assert, beforeAll, describe, expect, it } from "vitest"; - -import { WorkspaceCredentialsResult } from "../../src/commands/workspace/credentials"; -import { LocalWorkspaceListEnvelope } from "../../src/commands/workspace/ps"; -import { DeleteResult } from "../../src/commands/workspace/delete"; -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 { SEEDED } from "./seed/seeded"; -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-enterprise-head:latest"; -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", "delete", 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), - String(SEEDED.warehouseDbId), - "--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 === SEEDED.warehouseDbId, - ); - assert( - provisioned !== undefined, - `warehouse database ${SEEDED.warehouseDbId} missing from provisioned workspace`, - ); - expect({ - database_id: provisioned.database_id, - input_schemas: provisioned.input_schemas, - status: provisioned.status, - hasOutputNamespace: provisioned.output_namespace.length > 0, - }).toEqual({ - database_id: SEEDED.warehouseDbId, - input_schemas: [ANALYTICS_SCHEMA], - status: "provisioned", - hasOutputNamespace: true, - }); - } - - it( - "start spins up a healthy local container; ps + url + stop + remove cycle through it", - async () => { - assert(licenseToken, "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: ["workspace", "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", - "--wait", - "--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. 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([]); - - // 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(), - 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, - }); - - // 8. Delete tears down the container + the app-db volume. - const remove = await runCli({ - args: ["workspace", "delete", 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, DeleteResult); - 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, - }); - - // 9. 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-")); -} diff --git a/tests/e2e/workspace.e2e.test.ts b/tests/e2e/workspace.e2e.test.ts deleted file mode 100644 index 7c0dfeb..0000000 --- a/tests/e2e/workspace.e2e.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { afterEach, assert, 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 { SEEDED } from "./seed/seeded"; -import { requireServer } from "./server-gate"; -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"; - -const skipReason = requireServer({ minVersion: 62, tokenFeature: "workspaces" }); - -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, 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), - String(SEEDED.warehouseDbId), - "--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 === SEEDED.warehouseDbId); - assert( - entry, - `expected workspace ${workspace.id} to contain database ${SEEDED.warehouseDbId}, 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, - hasOutputNamespace: entry.output_namespace.length > 0, - }).toEqual({ - database_id: SEEDED.warehouseDbId, - input_schemas: [ANALYTICS_SCHEMA], - status: "provisioned", - hasOutputNamespace: 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), - String(SEEDED.warehouseDbId), - "--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(SEEDED.warehouseDbId), - "--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(SEEDED.warehouseDbId), - "--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("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(SEEDED.warehouseDbId), - "--body", - JSON.stringify({ - database_id: SEEDED.warehouseDbId, - input: [{ schema: 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); - }); -});