From 6e643ae290463afb0f4fbd83a2317da8aed0df1d Mon Sep 17 00:00:00 2001 From: Leo-Paul Goffic Date: Wed, 8 Jul 2026 17:25:22 +0200 Subject: [PATCH] feat: add `altertable duckdb` command --- cli/knip.json | 2 +- cli/src/cli.ts | 2 + cli/src/commands/duckdb.ts | 71 +++++++++++++++++++++++++++++++ cli/src/lib/auth.ts | 19 +++++++++ cli/tests/commands-duckdb.test.ts | 27 ++++++++++++ cli/tests/completion.test.ts | 2 +- 6 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 cli/src/commands/duckdb.ts create mode 100644 cli/tests/commands-duckdb.test.ts diff --git a/cli/knip.json b/cli/knip.json index 69679f1..82713e8 100644 --- a/cli/knip.json +++ b/cli/knip.json @@ -5,5 +5,5 @@ "ignore": ["src/generated/**"], "ignoreExportsUsedInFile": true, "ignoreDependencies": ["undici"], - "ignoreBinaries": ["less"] + "ignoreBinaries": ["less", "duckdb"] } diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 4a5084a..466b1c3 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -15,6 +15,7 @@ import { loginCommand, logoutCommand } from "@/commands/login.ts"; import { profileCommand } from "@/commands/profile.ts"; import { contextCommand } from "@/commands/context.ts"; import { catalogsCommand } from "@/commands/catalogs.ts"; +import { duckdbCommand } from "@/commands/duckdb.ts"; import { appendCommand } from "@/commands/lakehouse/append.ts"; import { queryCommand } from "@/commands/lakehouse/query.ts"; import { schemaCommand } from "@/commands/lakehouse/schema.ts"; @@ -96,6 +97,7 @@ export function buildMainCommand(): CommandDef { catalogs: catalogsCommand, query: queryCommand, schema: schemaCommand, + duckdb: duckdbCommand, append: appendCommand, upload: uploadCommand, upsert: upsertCommand, diff --git a/cli/src/commands/duckdb.ts b/cli/src/commands/duckdb.ts new file mode 100644 index 0000000..f09405a --- /dev/null +++ b/cli/src/commands/duckdb.ts @@ -0,0 +1,71 @@ +import { spawnSync } from "node:child_process"; +import { getLoginLakehouseCredentials, type LakehouseCredentials } from "@/lib/auth.ts"; +import { configureVerify } from "@/lib/configure-verify.ts"; +import { ConfigurationError } from "@/lib/errors.ts"; +import { defineLocalCommand } from "@/lib/operation-command-builders.ts"; +import { stringArg } from "@/lib/operation-codec.ts"; + +const LOGIN_PROMPT = "Log in with 'altertable login' to use altertable duckdb."; + +function escapeSql(value: string): string { + return value.replaceAll("'", "''"); +} + +function quoteIdentifier(value: string): string { + return `"${value.replaceAll('"', '""')}"`; +} + +export function buildDuckdbAttachSnippet( + credentials: LakehouseCredentials, + catalog: string, +): string { + const connection = `user=${escapeSql(credentials.user)} password=${escapeSql(credentials.password)} catalog=${escapeSql(catalog)}`; + return `INSTALL altertable FROM community; +LOAD altertable; +ATTACH +'${connection}' +AS ${quoteIdentifier(catalog)} (TYPE ALTERTABLE);`; +} + +type DuckdbInput = { catalog: string }; + +async function runDuckdb(input: DuckdbInput): Promise { + if (!Bun.which("duckdb")) { + throw new ConfigurationError( + "duckdb is not installed. Install it from https://duckdb.org/install/ and try again.", + ); + } + + const verify = await configureVerify(["lakehouse"]); + if (!verify.verified.lakehouse) { + throw new ConfigurationError(LOGIN_PROMPT); + } + + const credentials = getLoginLakehouseCredentials(); + if (!credentials) { + throw new ConfigurationError(LOGIN_PROMPT); + } + + const snippet = buildDuckdbAttachSnippet(credentials, input.catalog); + const result = spawnSync("duckdb", ["-cmd", snippet], { stdio: "inherit" }); + if (result.error) { + throw new ConfigurationError(`Failed to launch duckdb: ${result.error.message}`); + } +} + +export const duckdbCommand = defineLocalCommand({ + id: "duckdb", + output: "none", + meta: { + name: "duckdb", + description: "Open a DuckDB shell attached to a lakehouse catalog.", + examples: ["altertable duckdb my_catalog"], + }, + args: { + catalog: { type: "positional", description: "Catalog to attach", required: true }, + }, + parse({ args }) { + return { catalog: stringArg(args, "catalog") }; + }, + local: (input) => runDuckdb(input), +}); diff --git a/cli/src/lib/auth.ts b/cli/src/lib/auth.ts index dd27191..d54668c 100644 --- a/cli/src/lib/auth.ts +++ b/cli/src/lib/auth.ts @@ -70,6 +70,25 @@ export function getLakehouseAuthHeader(): string { ); } +export type LakehouseCredentials = { user: string; password: string }; + +function decodeBasicToken(token: string): LakehouseCredentials | undefined { + const decoded = Buffer.from(token, "base64").toString("utf8"); + const [user, password] = decoded.split(":"); + if (!user || !password) { + return undefined; + } + return { user, password }; +} + +export function getLoginLakehouseCredentials(): LakehouseCredentials | undefined { + if (storedLakehouseCredentialsExpired()) { + return undefined; + } + const token = secretGet("lakehouse/basic-token"); + return token ? decodeBasicToken(token) : undefined; +} + export function getManagementAuthHeader(): string { const envKey = process.env.ALTERTABLE_API_KEY ?? ""; if (envKey) { diff --git a/cli/tests/commands-duckdb.test.ts b/cli/tests/commands-duckdb.test.ts new file mode 100644 index 0000000..f99c77b --- /dev/null +++ b/cli/tests/commands-duckdb.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { buildDuckdbAttachSnippet } from "@/commands/duckdb.ts"; + +describe("buildDuckdbAttachSnippet", () => { + test("embeds credentials and catalog into the ATTACH connection string", () => { + const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, "sales"); + expect(snippet).toContain("INSTALL altertable FROM community;"); + expect(snippet).toContain("LOAD altertable;"); + expect(snippet).toContain("'user=alice password=s3cret catalog=sales'"); + expect(snippet).toContain('AS "sales" (TYPE ALTERTABLE);'); + }); + + test("quotes the catalog identifier so a hyphen is not parsed as subtraction", () => { + const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, "my-catalog"); + expect(snippet).toContain('AS "my-catalog" (TYPE ALTERTABLE);'); + }); + + test("escapes single quotes so a value cannot break out of the SQL string", () => { + const snippet = buildDuckdbAttachSnippet({ user: "a'b", password: "p'w" }, "c'at"); + expect(snippet).toContain("'user=a''b password=p''w catalog=c''at'"); + }); + + test("escapes double quotes in the catalog identifier", () => { + const snippet = buildDuckdbAttachSnippet({ user: "alice", password: "s3cret" }, 'we"ird'); + expect(snippet).toContain('AS "we""ird" (TYPE ALTERTABLE);'); + }); +}); diff --git a/cli/tests/completion.test.ts b/cli/tests/completion.test.ts index 075a761..2235954 100644 --- a/cli/tests/completion.test.ts +++ b/cli/tests/completion.test.ts @@ -267,7 +267,7 @@ describe("completion command", () => { const spec = buildCompletionSpec(buildMainCommand()); const output = await runCompletion(buildMainCommand, "bash"); const topLevelCount = flattenTopLevelNames(spec).length; - expect(topLevelCount).toBe(14); + expect(topLevelCount).toBe(15); expect(output).toContain("completion"); expect(output).toContain("GET"); });