Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 69 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,19 @@ Against a server older than v62 the CLI detects the missing OAuth support and fa

On success the server is probed once — the rendered output shows the user, role (`Admin`/`User`), and Metabase version, and the same values are cached in `<configDir>/profiles.json` so later commands skip re-probing. Failure of either the auth probe (`/api/user/current`) or the server probe (`/api/session/properties`) rejects the login; an existing profile keeps its last-known-good credential and gains a `lastFailure` entry.

| Flag | Description |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | Metabase URL, including any subpath if the instance is hosted under one (`https://my.org.com/metabase`). Falls back to `MB_URL`, then prompts. |
| `--api-key <value>` | API key. Skips the browser flow. Visible in shell history — pipe on stdin instead. |
| `--client-id <id>` | Pre-registered OAuth client id (only needed when dynamic client registration is disabled on the server). |
| `--profile <name>`, `-p` | Profile to write to (default: `default`). |
| `--skip-verify` | Save without contacting the server (no probe, no cache). |
| Flag | Description |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | Metabase URL, including any subpath if the instance is hosted under one (`https://my.org.com/metabase`). Falls back to `MB_URL`, then prompts. |
| `--api-key <value>` | API key. Skips the browser flow. Visible in shell history — pipe on stdin instead. |
| `--client-id <id>` | Pre-registered OAuth client id (only needed when dynamic client registration is disabled on the server). |
| `--profile <name>`, `-p` | Profile to write to (default: `default`). |
| `--skip-verify` | Save without contacting the server (no probe, no cache). |
| `--workspace` | Request a workspace-manager-scoped login: the stored token can only manage workspaces — every other endpoint 403s server-side. Browser OAuth only (an API key cannot carry a scope), so it requires a TTY and refuses `--api-key`/`MB_API_KEY`. Requires a server advertising the `mb:workspace-manager` scope. |

Non-interactive (non-TTY) login requires an API key; resolution order: `--api-key` → piped stdin → `MB_API_KEY` (first non-empty wins). Without one, non-interactive login fails rather than prompting.

A workspace-scoped profile that hits a 403 on a content command gets an error naming the scope and the fix (re-login without `--workspace`, or use a workspace profile) instead of a bare "Forbidden."

```sh
mb auth login # interactive: browser or API key
echo "$MB_KEY" | mb auth login --url https://m.example.com
Expand Down Expand Up @@ -1487,6 +1490,64 @@ mb git-sync remove-collection 12
mb git-sync remove-collection 12 --json --profile prod
```

## Workspaces

Lifecycle for EE workspaces via `/api/ee/workspace-manager` (Metabase v62+, `workspaces` premium feature). A workspace attaches databases and provisions warehouse isolation — a temporary schema + user per database — so work runs against real data without touching prod schemas. Alias: `mb ws`.

### `mb workspace list`

```sh
mb workspace list
mb workspace list --json
```

### `mb workspace get <id>`

Includes per-database provisioning status (`unprovisioned` | `provisioning` | `provisioned` | `deprovisioning`), input schemas, and the isolated output namespace.

```sh
mb workspace get 1 --json
```

### `mb workspace create`

Creates the workspace and provisions isolation for each database (blocking). Each database must be eligible for workspaces (driver support + the `database-enable-workspaces` database setting).

```sh
mb workspace create --name ws-reports --database-ids 1
mb workspace create --name ws-etl --database-ids 1,2 --json
mb workspace create --name transform-work --database-ids 1 --spawn
```

| Flag | Description |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `--name <name>` | Workspace name. |
| `--database-ids <ids>` | Database ids to attach, comma separated. |
| `--spawn` | Also spawn a workspace Metabase; the returned credential is saved to the `ws-<id>` profile (never printed) and made the default. |
| `--keep-existing-auth` | Proceed despite broader same-server credentials in the profile store. Interactive only — refused in non-interactive contexts. |

Before creating, the profile store is swept for broader credentials against the same server: any API key profile, or any OAuth profile wider than `mb:workspace-manager`. Interactive runs offer to revoke the offenders (local clear + best-effort server-side token revocation; API keys must additionally be deleted in Admin settings); non-interactive runs hard-refuse so an agent can't proceed while a full-power credential is in reach.

With `--spawn` the parent asks its instance manager for a workspace Metabase (`spawn_instance: true`) and the CLI consumes the returned `url` + `api_key`: the credential is written straight to the profile store as `ws-<id>` — never to stdout, `--json`, or an agent transcript — and that profile becomes the default, so bare `mb` targets the new workspace from then on (explicit `--profile`/`MB_PROFILE` still win). Destroying the workspace (or `mb auth logout` on the profile) unhooks the default again. Because an exported `MB_API_KEY` outranks stored profiles, `--spawn` refuses to run while it is set — it would silently shadow the profile it is about to install.

### `mb workspace destroy <id>`

Destroy is the only irreversible moment, so it closes the work-loss window first: when a local profile named `ws-<id>` exists, the child is checked for unsynced work (`remote-sync is-dirty`) and a dirty workspace is auto-exported to its target branch before teardown. `--discard` skips the check and the export; a machine without the workspace's profile warns and proceeds (destroy is the billing-stop lever — ops cleanup must work from anywhere), while a profile whose child can't be reached refuses without `--discard`.

Then each provisioned database's warehouse isolation is torn down and the workspace removed; the matching local profile is dropped on success. Prompts unless `--yes`. Refuses (409) while any database is still provisioning/deprovisioning unless `--ignore-pending`, which removes the records and leaves those warehouse objects behind; unreachable-warehouse leftovers are reported as `orphaned_resources`.

```sh
mb workspace destroy 1 --yes
mb workspace destroy 1 --yes --discard
mb workspace destroy 1 --yes --ignore-pending
```

| Flag | Description |
| ------------------ | ----------------------------------------------------------------------- |
| `--yes` | Skip confirmation. |
| `--discard` | Destroy without exporting unsynced work (skips the is-dirty check). |
| `--ignore-pending` | Remove workspace records even while databases are mid-(de)provisioning. |

## Instance setup

Bootstrapping a fresh, not-yet-configured Metabase instance.
Expand Down Expand Up @@ -1687,7 +1748,7 @@ Exit codes: `0` success, `2` `ConfigError` (missing name, unknown name, `MB_SKIL
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MB_URL` | Default URL for `auth login` and config resolution. |
| `MB_API_KEY` | Default API key (makes `auth login` non-interactive, skipping the browser flow; not stored). |
| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. |
| `MB_PROFILE` | Default profile when `--profile` is omitted. Falls back to the stored default pointer (set by `workspace create --spawn`), then `default`. |
| `MB_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. |
| `MB_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_CLI_DISABLE_KEYRING` | When set to `1`, skips the OS keychain and stores credentials as plaintext in the profiles file. |
Expand Down
76 changes: 71 additions & 5 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import { normalizeUrl } from "../../core/url";
import { ParsedVersionSchema } from "../../core/version/tag";
import { ProbedUser } from "../../core/auth/profile-record";
import type { ResourceView } from "../../domain/view";
import { tryDiscoverMetadata, type OAuthServerMetadata } from "../../core/http/oauth";
import {
OAUTH_SCOPE,
tryDiscoverMetadata,
WORKSPACE_MANAGER_SCOPE,
type OAuthServerMetadata,
} from "../../core/http/oauth";
import { warn } from "../../output/notice";
import { promptPassword, promptSelect, promptText } from "../../output/prompt";
import { renderSummary } from "../../output/render";
Expand All @@ -38,6 +43,7 @@ export const LoginResult = z.object({
authenticated: z.boolean(),
user: ProbedUser.nullable(),
version: ParsedVersionSchema.nullable(),
scope: z.string().nullable(),
});
export type LoginResultJson = z.infer<typeof LoginResult>;

Expand All @@ -54,6 +60,11 @@ const loginView: ResourceView<LoginResultJson> = {
{ key: "user", label: "Logged in as", format: (value) => renderUserName(value) },
{ key: "user", label: "Role", format: (value) => renderUserRole(value) },
{ key: "version", label: "Version", format: (value) => renderVersionTag(value) },
{
key: "scope",
label: "Scope",
format: (value) => (typeof value === "string" ? value : EMPTY_CELL),
},
],
};

Expand All @@ -76,12 +87,19 @@ export default defineMetabaseCommand({
default: false,
description: "Save without contacting the server",
},
workspace: {
type: "boolean",
default: false,
description:
"Request a workspace-manager-scoped login (workspace CRUD only, browser OAuth required)",
},
},
outputSchema: LoginResult,
examples: [
"mb auth login --url https://metabase.example.com",
"echo $MB_API_KEY | mb auth login --url https://metabase.example.com",
"mb auth login --profile staging --url https://staging.example.com",
"mb auth login --workspace --profile parent --url https://metabase.example.com",
],
async run({ args, ctx }) {
const profileName = await resolveLoginProfile(args.profile);
Expand All @@ -94,6 +112,30 @@ export default defineMetabaseCommand({
}

const url = await resolveUrl(args.url, env.url);

if (args.workspace) {
assertWorkspaceLoginPossible(args.apiKey, env.apiKey);
const metadata = await probeOAuthSupport(url, WORKSPACE_MANAGER_SCOPE);
if (metadata === null) {
throw new ConfigError(
`this Metabase does not support workspace-scoped login (no ${WORKSPACE_MANAGER_SCOPE} scope advertised)`,
);
}
const credential = await oauthLogin(
{
baseUrl: url,
metadata,
scope: WORKSPACE_MANAGER_SCOPE,
...(args.clientId !== undefined && { clientId: args.clientId }),
},
{ openBrowser, onAuthorizeUrl: announceAuthorizeUrl, now: () => Date.now() },
);
await completeLogin(profileName, url, credential, args["skip-verify"], ctx, () =>
writeOAuthProfile(url, credential, profileName),
);
return;
}

const apiKey = await nonInteractiveApiKey(args.apiKey, env.apiKey);

if (apiKey !== null) {
Expand Down Expand Up @@ -146,11 +188,32 @@ export default defineMetabaseCommand({

type LoginMethod = "oauth" | "apiKey";

// A workspace-scoped grant exists to keep content-mutating credentials off the machine; an API
// key can't carry a scope, so every key-shaped path is refused rather than silently ignored.
function assertWorkspaceLoginPossible(flagKey: string | undefined, envKey: string | null): void {
if (flagKey !== undefined) {
throw new ConfigError(
"--workspace login is browser-OAuth only; an API key cannot carry a scope — omit --api-key",
);
}
if (envKey !== null) {
throw new ConfigError(
"--workspace login is browser-OAuth only; unset MB_API_KEY so it cannot shadow the scoped login",
);
}
if (!process.stdin.isTTY) {
throw new ConfigError("--workspace login opens a browser and requires a TTY");
}
}

// Reaching the server but finding no CLI-capable OAuth server (pre-v63) degrades to the API key
// prompt; not reaching it at all is an error worth stopping on before any credential is collected.
async function probeOAuthSupport(url: string): Promise<OAuthServerMetadata | null> {
async function probeOAuthSupport(
url: string,
requiredScope?: string,
): Promise<OAuthServerMetadata | null> {
try {
return await tryDiscoverMetadata(url);
return await tryDiscoverMetadata(url, requiredScope);
} catch (error) {
if (error instanceof ConfigError) {
throw error;
Expand Down Expand Up @@ -213,10 +276,11 @@ async function completeLogin(
ctx: CommonContext,
persist: PersistCredential,
): Promise<void> {
const scope = credential.kind === "oauth" ? credential.scope : null;
if (skipVerify) {
await persistWithWarning(persist);
renderSummary(
{ profile: profileName, url, authenticated: false, user: null, version: null },
{ profile: profileName, url, authenticated: false, user: null, version: null, scope },
loginView,
`Saved credentials for profile "${profileName}" (${url}) without verifying.`,
ctx,
Expand All @@ -238,16 +302,18 @@ async function completeLogin(
const role = renderUserRole(result.user);
const versionTag = renderVersionTag(result.server.version);
const serverClause = versionTag === EMPTY_CELL ? "" : ` Server ${versionTag}.`;
const scopeClause = scope === null || scope === OAUTH_SCOPE ? "" : ` Scope: ${scope}.`;
renderSummary(
{
profile: profileName,
url,
authenticated: true,
user: result.user,
version: result.server.version,
scope,
},
loginView,
`Logged in to ${url} as ${who} (${role}). Saved to profile "${profileName}".${serverClause}`,
`Logged in to ${url} as ${who} (${role}). Saved to profile "${profileName}".${serverClause}${scopeClause}`,
ctx,
);
}
Expand Down
1 change: 1 addition & 0 deletions src/commands/auth/logout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const OAUTH_CRED: OAuthCredential = {
refreshToken: "ref-1",
expiresAt: "2099-01-01T00:00:00.000Z",
clientId: "client-1",
scope: "mb:full",
};

const METADATA: OAuthServerMetadata = {
Expand Down
4 changes: 2 additions & 2 deletions src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
consumeKeychainResidualWarning,
readProfileCredential,
} from "../../core/auth/storage";
import { resolveProfileName } from "../../core/config";
import { resolveActiveProfileName } from "../../core/config";
import { errorMessage } from "../../core/errors";
import type { ResourceView } from "../../domain/view";
import { warn } from "../../output/notice";
Expand Down Expand Up @@ -42,7 +42,7 @@ export default defineMetabaseCommand({
outputSchema: LogoutResult,
examples: ["mb auth logout --yes", "mb auth logout --profile staging --yes"],
async run({ args, ctx }) {
const profileName = resolveProfileName(args.profile);
const profileName = await resolveActiveProfileName(args.profile);

if (!args.yes && process.stdin.isTTY === true) {
const ok = await promptConfirm({
Expand Down
1 change: 1 addition & 0 deletions src/commands/auth/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ describe("auth status command", () => {
refreshToken: "ref",
expiresAt: "2026-06-08T13:00:00.000Z",
clientId: "c1",
scope: "mb:full",
});
const capture = captureStdout();
await runCommand(authStatusCommand, { rawArgs: ["--profile", "default", "--json"] });
Expand Down
4 changes: 2 additions & 2 deletions src/commands/auth/status.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod";

import { readProfileRecord } from "../../core/auth/storage";
import { resolveProfileName } from "../../core/config";
import { resolveActiveProfileName } from "../../core/config";
import { displayUrl } from "../../core/url";
import { ParsedVersionSchema } from "../../core/version/tag";
import {
Expand Down Expand Up @@ -57,7 +57,7 @@ export default defineMetabaseCommand({
outputSchema: AuthStatus,
examples: ["mb auth status --json", "mb auth status --profile staging"],
async run({ args, ctx }) {
const profileName = resolveProfileName(args.profile);
const profileName = await resolveActiveProfileName(args.profile);
const record = await readProfileRecord(profileName);

if (record === null) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/git-sync/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { gitSyncWaitFlags, parseWaitFlags } from "../wait-flags";

import { formatSyncTask, pollSyncTask, REMOTE_SYNC_PATHS, throwIfFailedTask } from "./poll-task";

const SyncExportKickoff = z.object({
export const SyncExportKickoff = z.object({
message: z.string(),
task_id: z.number().int().positive(),
});
Expand Down
Loading
Loading