Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
02967eb
workspaces commands
alxnddr May 6, 2026
df33d00
run workspace command
alxnddr May 6, 2026
5f843a3
fix
alxnddr May 6, 2026
7d7eb50
polish workspaces commands
alxnddr May 6, 2026
d7ae16e
fix
alxnddr May 6, 2026
3ef8911
commands
alxnddr May 7, 2026
5d0de2e
strict querying
alxnddr May 7, 2026
ab07e25
tests
alxnddr May 7, 2026
9af92d7
skip
alxnddr May 7, 2026
5ab9ca6
metadata
alxnddr May 7, 2026
a358813
bug fixes
alxnddr May 8, 2026
2af0be9
update command
alxnddr May 8, 2026
5e12c24
fixes
alxnddr May 8, 2026
eaec321
fixes
alxnddr May 8, 2026
66774cc
fixes
alxnddr May 8, 2026
78747d1
more commands
alxnddr May 8, 2026
4f5cd01
fix
alxnddr May 8, 2026
1725498
lint
alxnddr May 8, 2026
a3b3a5f
more commands
alxnddr May 8, 2026
645461e
update contract
alxnddr May 8, 2026
4acbfab
transforms runs
alxnddr May 8, 2026
397221d
more commands
alxnddr May 8, 2026
b946573
commands
alxnddr May 8, 2026
d3616dd
fix
alxnddr May 8, 2026
68d980f
more commands
alxnddr May 8, 2026
4eae6a4
fixes
alxnddr May 9, 2026
4e732ec
fix
alxnddr May 9, 2026
2371a38
improvement
alxnddr May 11, 2026
80b2b17
metadata
alxnddr May 11, 2026
2c53ab6
try disabling the scheduler
alxnddr May 11, 2026
28a297a
docker no pull default
alxnddr May 11, 2026
496e69b
ids
alxnddr May 11, 2026
63ed35a
Revert "docker no pull default"
alxnddr May 11, 2026
c1d5eea
fix collections
alxnddr May 11, 2026
e883119
fix settings
alxnddr May 11, 2026
5296e09
fix settings
alxnddr May 12, 2026
b88ecb1
fix
alxnddr May 12, 2026
aa383a1
commands fixes
alxnddr May 12, 2026
89f3505
fix
alxnddr May 12, 2026
a2b3e1e
fix
alxnddr May 12, 2026
a2ab47d
fixes
alxnddr May 12, 2026
650653c
fixes
alxnddr May 12, 2026
dc82ae4
fix
alxnddr May 12, 2026
ae94d55
fix
alxnddr May 12, 2026
d9c31ec
remote sync
alxnddr May 12, 2026
935a64c
fix
alxnddr May 12, 2026
fbed89b
git sync
alxnddr May 12, 2026
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
942 changes: 887 additions & 55 deletions README.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,25 @@
"lint:fix": "oxlint --fix",
"format": "oxfmt",
"format:check": "oxfmt --check",
"sync:representations": "bun run scripts/sync-representations.ts",
"prepublishOnly": "tsdown && publint"
},
"dependencies": {
"@clack/prompts": "^0.8.2",
"@napi-rs/keyring": "^1.3.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"citty": "^0.2.2",
"cli-table3": "^0.6.5",
"yaml": "^2.8.4",
"zod": "^4.0.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.0",
"execa": "^9.5.2",
"fast-check": "^4.7.0",
"js-yaml": "^4.1.0",
"oxfmt": "^0.47.0",
"oxlint": "^1.62.0",
"publint": "^0.3.0",
Expand Down
95 changes: 95 additions & 0 deletions scripts/sync-representations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Idempotent — CI runs this and asserts no diff. Requires `npm` and `tar` on PATH.
import { execFileSync } from "node:child_process";
import { promises as fs } from "node:fs";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import yaml from "js-yaml";
import { z } from "zod";

import { isNotFoundError } from "../src/core/errors";

const YamlObject = z.record(z.string(), z.unknown());

const REPRESENTATIONS_VERSION = "1.1.7";

const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(SCRIPT_DIR, "..");
const DATA_DIR = resolve(REPO_ROOT, "src/core/schema/data");
const COMMON_DIR = resolve(DATA_DIR, "schemas/common");

async function main(): Promise<void> {
const tarball = await npmPack(REPRESENTATIONS_VERSION);
const extracted = await extractTarball(tarball);
try {
await syncCommonSchemas(extracted);
await copyLicense(extracted);
} finally {
await cleanupDir(extracted);
}
// eslint-disable-next-line no-console -- script
console.log(`Synced @metabase/representations@${REPRESENTATIONS_VERSION}`);
}

async function syncCommonSchemas(packageRoot: string): Promise<void> {
const sourceDir = resolve(packageRoot, "core-spec/v1/schemas/common");
await fs.rm(resolve(DATA_DIR, "schemas"), { recursive: true, force: true });
await fs.mkdir(COMMON_DIR, { recursive: true });

const files = await fs.readdir(sourceDir);
await Promise.all(files.filter((f) => f.endsWith(".yaml")).map((f) => convertOne(sourceDir, f)));
}

async function convertOne(sourceDir: string, filename: string): Promise<void> {
const text = await fs.readFile(join(sourceDir, filename), "utf8");
const parsed = YamlObject.parse(yaml.load(text));
const { $schema: _ignored, ...body } = parsed;
const targetName = filename.replace(/\.yaml$/u, ".json");
await fs.writeFile(join(COMMON_DIR, targetName), JSON.stringify(body, null, 2) + "\n", "utf8");
}

async function copyLicense(packageRoot: string): Promise<void> {
const sourceLicense = join(packageRoot, "LICENSE.txt");
let text: string;
try {
text = await fs.readFile(sourceLicense, "utf8");
} catch (error) {
if (isNotFoundError(error)) {
return;
}
throw error;
}
await fs.writeFile(join(DATA_DIR, "LICENSE.txt"), text, "utf8");
}

async function cleanupDir(dir: string): Promise<void> {
try {
await fs.rm(dir, { recursive: true, force: true });
} catch (error) {
// eslint-disable-next-line no-console -- script
console.warn(`failed to clean up ${dir}: ${error instanceof Error ? error.message : error}`);
}
}

async function npmPack(version: string): Promise<string> {
const dir = mkdtempSync(join(tmpdir(), "representations-pack-"));
const stdout = execFileSync("npm", ["pack", `@metabase/representations@${version}`, "--silent"], {
cwd: dir,
encoding: "utf8",
});
const filename = stdout.trim().split("\n").pop();
if (filename === undefined || filename === "") {
throw new Error(`npm pack produced no output for @metabase/representations@${version}`);
}
return join(dir, filename);
}

async function extractTarball(tarballPath: string): Promise<string> {
const dir = mkdtempSync(join(tmpdir(), "representations-extract-"));
execFileSync("tar", ["-xzf", tarballPath, "-C", dir]);
return join(dir, "package");
}

await main();
47 changes: 47 additions & 0 deletions src/commands/api-key/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ApiKey, ApiKeyCreateInput, apiKeyView } from "../../domain/api-key";
import { renderItem } from "../../output/render";
import { readBody } from "../../runtime/body";
import { bodyInputFlags } from "../body-flags";
import { requireBothOrNeither } from "../flag-pair";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

export default defineMetabaseCommand({
meta: { name: "create", description: "Create a new API key" },
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
...bodyInputFlags,
name: { type: "string", description: "API key name (alternative to --body / --file)" },
"group-id": {
type: "string",
description: "Permission group id (alternative to --body / --file)",
},
},
outputSchema: ApiKey,
examples: [
'metabase api-key create --name "deploy-bot" --group-id 2',
'echo \'{"name":"k","group_id":2}\' | metabase api-key create',
"metabase api-key create --file key.json",
],
async run({ args, ctx, getClient }) {
const pair = requireBothOrNeither(
{ name: "--name", value: args.name },
{ name: "--group-id", value: args["group-id"] },
);
const body = pair
? ApiKeyCreateInput.parse({
name: pair.first,
group_id: parseId(pair.second, "--group-id"),
})
: await readBody({ flag: args.body, file: args.file }, ApiKeyCreateInput);
const client = await getClient();
const created = await client.requestParsed(ApiKey, "/api/api-key", {
method: "POST",
body,
});
renderItem(created, apiKeyView, ctx);
},
});
8 changes: 8 additions & 0 deletions src/commands/api-key/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineCommand } from "citty";

export default defineCommand({
meta: { name: "api-key", description: "Manage Metabase API keys" },
subCommands: {
create: () => import("./create").then((mod) => mod.default),
},
});
1 change: 1 addition & 0 deletions src/commands/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default defineCommand({
subCommands: {
login: () => import("./login").then((m) => m.default),
status: () => import("./status").then((m) => m.default),
list: () => import("./list").then((m) => m.default),
logout: () => import("./logout").then((m) => m.default),
},
});
94 changes: 94 additions & 0 deletions src/commands/auth/list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { runCommand } from "citty";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ZodType } from "zod";

import { parseJson } from "../../runtime/json";

const hoisted = vi.hoisted(() => ({
store: new Map<string, string>(),
controls: { broken: false },
}));

vi.mock("@napi-rs/keyring", async () => {
const { createKeyringMockModule } = await import("../../core/auth/keyring-mock");
return createKeyringMockModule(hoisted);
});

import authListCommand, { AuthProfileListEnvelope } from "./list";
import { clearProfile, writeProfile } from "../../core/auth/storage";
import { setupTempConfigHome, type TempConfigHome } from "../../core/auth/temp-config-home";

interface CapturedStdout {
chunks: string[];
parse: <T>(schema: ZodType<T>) => 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: <T>(schema: ZodType<T>) => parseJson(chunks.join(""), schema, { source: "stdout" }),
};
}

describe("auth list command", () => {
let home: TempConfigHome;

beforeEach(() => {
hoisted.store.clear();
home = setupTempConfigHome();
});

afterEach(() => {
vi.restoreAllMocks();
home.cleanup();
});

it("emits an empty envelope when no profiles are stored", async () => {
const capture = captureStdout();
await runCommand(authListCommand, { rawArgs: ["--json"] });
expect(capture.parse(AuthProfileListEnvelope)).toEqual({
data: [],
returned: 0,
total: 0,
});
});

it("lists every stored profile with sanitized URL and present=true", async () => {
await writeProfile({ url: "https://staging.example.com/path?x=1", apiKey: "k1" }, "staging");
await writeProfile({ url: "https://prod.example.com", apiKey: "k2" }, "prod");

const capture = captureStdout();
await runCommand(authListCommand, { rawArgs: ["--json"] });
expect(capture.parse(AuthProfileListEnvelope)).toEqual({
data: [
{ profile: "prod", url: "https://prod.example.com", present: true },
{ profile: "staging", url: "https://staging.example.com", present: true },
],
returned: 2,
total: 2,
});
});

it("drops a profile from the list after clearProfile", async () => {
await writeProfile({ url: "https://a.example.com", apiKey: "a" }, "a");
await writeProfile({ url: "https://b.example.com", apiKey: "b" }, "b");
await clearProfile("a");

const capture = captureStdout();
await runCommand(authListCommand, { rawArgs: ["--json"] });
expect(capture.parse(AuthProfileListEnvelope)).toEqual({
data: [{ profile: "b", url: "https://b.example.com", present: true }],
returned: 1,
total: 1,
});
});
});
Loading
Loading