From 8cc97fa5c8be9d5fe043f8b2e0e4d94a238e613a Mon Sep 17 00:00:00 2001 From: James Swirhun Date: Mon, 15 Dec 2025 16:28:22 -0700 Subject: [PATCH] feat: add MotherDuck support to CLI Add --mother-duck-token option to create-duckdb command to enable MotherDuck connections. Addresses feedback from PR #124. Changes: - Add --mother-duck-token flag for MotherDuck API token with MOTHERDUCK_TOKEN environment variable support - Remove -d short option from --database-path to avoid conflict with global --debug flag - Wire motherDuckToken through createDuckDbConnectionCommand - Add comprehensive test coverage for token option (CLI flag, environment variable, and without token) The token is properly wired through from CLI option (or env var) to the connection configuration. Commander.js automatically converts --mother-duck-token to motherDuckToken in the options object, matching the DuckDBConnectionOptions interface. Signed-off-by: James Swirhun --- src/cli.ts | 12 ++- test/commands/connections.spec.ts | 124 ++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index e0d6426..b9a86af 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -231,7 +231,17 @@ export function createCLI(): Command { .command('create-duckdb') .description('add a new DuckDB database connection') .argument('') - .option('-d, --database-path ') + .addOption( + new Option( + '--database-path ', + 'path to DuckDB database file or MotherDuck database (e.g., "md:my_database")' + ) + ) + .addOption( + new Option('--mother-duck-token ', 'MotherDuck API token').env( + 'MOTHERDUCK_TOKEN' + ) + ) .action(createDuckDbConnectionCommand); connections diff --git a/test/commands/connections.spec.ts b/test/commands/connections.spec.ts index eeea863..d14b06d 100644 --- a/test/commands/connections.spec.ts +++ b/test/commands/connections.spec.ts @@ -25,6 +25,8 @@ import {Command} from '@commander-js/extra-typings'; import {createCLI} from '../../src/cli'; import path from 'path'; import {errorMessage} from '../../src/util'; +import fs from 'fs'; +import os from 'os'; let cli: Command; let args: string[]; @@ -95,5 +97,127 @@ describe('commands', () => { ); }); }); + + describe('create-duckdb', () => { + let tempConfigPath: string; + + beforeEach(() => { + // Create a temporary config file for each test + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'malloy-cli-test-') + ); + tempConfigPath = path.join(tempDir, 'config.json'); + fs.writeFileSync( + tempConfigPath, + JSON.stringify({connections: []}, null, 2) + ); + }); + + afterEach(() => { + // Clean up temporary config file + if (tempConfigPath && fs.existsSync(tempConfigPath)) { + const configDir = path.dirname(tempConfigPath); + fs.unlinkSync(tempConfigPath); + fs.rmdirSync(configDir); + } + }); + + it('creates a DuckDB connection with motherDuckToken from command line', async () => { + await runWith( + '-c', + tempConfigPath, + 'connections', + 'create-duckdb', + 'test-motherduck', + '--database-path', + 'md:my_database', + '--mother-duck-token', + 'test-token-123' + ); + + // Verify the connection was created with the token + const configContent = JSON.parse( + fs.readFileSync(tempConfigPath, 'utf-8') + ); + const connection = configContent.connections.find( + (c: {name: string}) => c.name === 'test-motherduck' + ); + expect(connection).toBeDefined(); + expect(connection.backend).toBe('duckdb'); + expect(connection.databasePath).toBe('md:my_database'); + expect(connection.motherDuckToken).toBe('test-token-123'); + }); + + it('creates a DuckDB connection with motherDuckToken from environment variable', async () => { + const originalEnv = process.env.MOTHERDUCK_TOKEN; + process.env.MOTHERDUCK_TOKEN = 'env-token-456'; + + try { + await runWith( + '-c', + tempConfigPath, + 'connections', + 'create-duckdb', + 'test-motherduck-env', + '--database-path', + 'md:' + ); + + // Verify the connection was created with the token from env + const configContent = JSON.parse( + fs.readFileSync(tempConfigPath, 'utf-8') + ); + const connection = configContent.connections.find( + (c: {name: string}) => c.name === 'test-motherduck-env' + ); + expect(connection).toBeDefined(); + expect(connection.backend).toBe('duckdb'); + expect(connection.databasePath).toBe('md:'); + expect(connection.motherDuckToken).toBe('env-token-456'); + } finally { + // Restore original environment variable + if (originalEnv !== undefined) { + process.env.MOTHERDUCK_TOKEN = originalEnv; + } else { + delete process.env.MOTHERDUCK_TOKEN; + } + } + }); + + it('creates a DuckDB connection without motherDuckToken', async () => { + // Clear any existing MOTHERDUCK_TOKEN from the environment + const originalEnv = process.env.MOTHERDUCK_TOKEN; + delete process.env.MOTHERDUCK_TOKEN; + + try { + await runWith( + '-c', + tempConfigPath, + 'connections', + 'create-duckdb', + 'test-duckdb-local', + '--database-path', + '/path/to/local.duckdb' + ); + + // Verify the connection was created without the token + const configContent = JSON.parse( + fs.readFileSync(tempConfigPath, 'utf-8') + ); + const connection = configContent.connections.find( + (c: {name: string}) => c.name === 'test-duckdb-local' + ); + expect(connection).toBeDefined(); + expect(connection.backend).toBe('duckdb'); + expect(connection.databasePath).toBe('/path/to/local.duckdb'); + expect(connection.motherDuckToken).toBeUndefined(); + } finally { + // Restore original environment variable + if (originalEnv !== undefined) { + process.env.MOTHERDUCK_TOKEN = originalEnv; + } + } + }); + }); }); });