Skip to content
Merged
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
2 changes: 1 addition & 1 deletion cli/knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
"ignore": ["src/generated/**"],
"ignoreExportsUsedInFile": true,
"ignoreDependencies": ["undici"],
"ignoreBinaries": ["less"]
"ignoreBinaries": ["less", "duckdb"]
}
2 changes: 2 additions & 0 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -96,6 +97,7 @@ export function buildMainCommand(): CommandDef {
catalogs: catalogsCommand,
query: queryCommand,
schema: schemaCommand,
duckdb: duckdbCommand,
append: appendCommand,
upload: uploadCommand,
upsert: upsertCommand,
Expand Down
71 changes: 71 additions & 0 deletions cli/src/commands/duckdb.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<DuckdbInput>({
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),
});
19 changes: 19 additions & 0 deletions cli/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
27 changes: 27 additions & 0 deletions cli/tests/commands-duckdb.test.ts
Original file line number Diff line number Diff line change
@@ -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);');
});
});
2 changes: 1 addition & 1 deletion cli/tests/completion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
Loading