From c7c5f63a666315d570be84db923e853435b07544 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 14 May 2026 16:21:18 +0200 Subject: [PATCH] feat(cli): extend `ktx connection test` to every supported driver (#92) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): extend `ktx connection test` to every supported driver Dispatch by driver: native DBs now call `connector.testConnection()` (was `introspect(dryRun)`), looker/notion/metabase hit their auth endpoints, and dbt/metricflow/lookml run `git ls-remote` via the existing `testRepoConnection` helper. Unknown drivers exit 1 with a listing of supported ones. * feat(cli): add `ktx connection test --all` summary list Tests every configured connection in parallel and renders a single Clack-style list (◇/│/◆/└, green ✓ / red ✗) consistent with sl list, with per-row detail and a passed/failed footer. Exits non-zero if any connection fails. Single-id `ktx connection test` output is preserved. * fix(cli): read metabase status url from api_url `ktx status` was probing `url` / `base_url` on metabase connections, but ktx.yaml stores it as `api_url`, so the field always reported "url not set". Read `api_url` directly and align the warning text with the actual key. --- .../cli/src/commands/connection-commands.ts | 18 +- packages/cli/src/connection.test.ts | 395 +++++++++++++++--- packages/cli/src/connection.ts | 368 +++++++++++++--- packages/cli/src/index.test.ts | 2 +- packages/cli/src/ingest.test.ts | 2 +- packages/cli/src/io/symbols.ts | 8 + packages/cli/src/print-command-tree.test.ts | 2 +- packages/cli/src/setup-databases.test.ts | 2 +- packages/cli/src/standalone-smoke.test.ts | 2 +- packages/cli/src/status-project.ts | 4 +- packages/context/src/scan/index.ts | 1 + packages/context/src/scan/types.ts | 6 + 12 files changed, 683 insertions(+), 127 deletions(-) diff --git a/packages/cli/src/commands/connection-commands.ts b/packages/cli/src/commands/connection-commands.ts index d814ffe9..181e8905 100644 --- a/packages/cli/src/commands/connection-commands.ts +++ b/packages/cli/src/commands/connection-commands.ts @@ -33,12 +33,24 @@ export function registerConnectionCommands(program: Command, context: KtxCliComm connection .command('test') .description('Test a configured connection') - .argument('', 'KTX connection id') - .action(async (connectionId: string, _options: unknown, command) => { + .argument('[connectionId]', 'KTX connection id (omit when --all is set)') + .option('--all', 'Test every configured connection and print a summary list') + .action(async (connectionId: string | undefined, options: { all?: boolean }, command) => { + const all = options.all === true; + if (all && connectionId !== undefined) { + command.error('error: --all cannot be combined with a connection id argument'); + } + if (!all && connectionId === undefined) { + command.error('error: missing required argument (or pass --all)'); + } + if (all) { + await runConnectionArgs(context, { command: 'test-all', projectDir: resolveCommandProjectDir(command) }); + return; + } await runConnectionArgs(context, { command: 'test', projectDir: resolveCommandProjectDir(command), - connectionId, + connectionId: connectionId as string, }); }); } diff --git a/packages/cli/src/connection.test.ts b/packages/cli/src/connection.test.ts index 0d592b00..920512f5 100644 --- a/packages/cli/src/connection.test.ts +++ b/packages/cli/src/connection.test.ts @@ -1,12 +1,16 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import type { MetabaseRuntimeClient } from '@ktx/context/ingest'; +import type { LookerClient, MetabaseRuntimeClient, NotionClient } from '@ktx/context/ingest'; import { initKtxProject, parseKtxProjectConfig, serializeKtxProjectConfig } from '@ktx/context/project'; -import type { KtxConnectionDriver, KtxScanConnector, KtxSchemaSnapshot } from '@ktx/context/scan'; +import type { KtxConnectionDriver, KtxScanConnector } from '@ktx/context/scan'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runKtxConnection } from './connection.js'; +function stripAnsi(s: string): string { + return s.replace(/\[[0-9;]*m/g, ''); +} + function makeIo() { let stdout = ''; let stderr = ''; @@ -28,28 +32,11 @@ function makeIo() { }; } -function snapshotFor(driver: KtxConnectionDriver, tableNames: string[]): KtxSchemaSnapshot { - return { - connectionId: 'warehouse', - driver, - extractedAt: '2026-04-29T00:00:00.000Z', - scope: {}, - metadata: {}, - tables: tableNames.map((name) => ({ - catalog: null, - db: null, - name, - kind: 'table', - comment: null, - estimatedRows: null, - columns: [], - foreignKeys: [], - })), - }; -} - -function nativeConnector(driver: KtxConnectionDriver, tableNames: string[]) { - const introspect = vi.fn(async () => snapshotFor(driver, tableNames)); +function nativeConnector( + driver: KtxConnectionDriver, + testResult: { success: true } | { success: false; error: string } = { success: true }, +) { + const testConnection = vi.fn(async () => testResult); const cleanup = vi.fn(async () => undefined); const connector: KtxScanConnector = { id: `${driver}:warehouse`, @@ -65,10 +52,13 @@ function nativeConnector(driver: KtxConnectionDriver, tableNames: string[]) { formalForeignKeys: false, estimatedRowCounts: false, }, - introspect, + introspect: vi.fn(async () => { + throw new Error('introspect should not be called from connection test'); + }), + testConnection, cleanup, }; - return { connector, introspect, cleanup }; + return { connector, testConnection, cleanup }; } describe('runKtxConnection', () => { @@ -119,13 +109,13 @@ describe('runKtxConnection', () => { expect(io.stdout()).not.toContain('ktx connection add'); }); - it('tests a configured connection through the native scan connector', async () => { + it('tests a native connection by calling connector.testConnection (not introspect)', async () => { const projectDir = join(tempDir, 'project'); await initKtxProject({ projectDir, projectName: 'warehouse' }); await writeConnections(projectDir, { warehouse: { driver: 'sqlite' }, }); - const { connector, introspect, cleanup } = nativeConnector('sqlite', ['customers', 'orders']); + const { connector, testConnection, cleanup } = nativeConnector('sqlite'); const createScanConnector = vi.fn(async () => connector); const io = makeIo(); @@ -136,20 +126,31 @@ describe('runKtxConnection', () => { ).resolves.toBe(0); expect(createScanConnector).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'warehouse'); - expect(introspect).toHaveBeenCalledWith( - { - connectionId: 'warehouse', - driver: 'sqlite', - mode: 'structural', - dryRun: true, - detectRelationships: false, - }, - { runId: 'connection-test-warehouse' }, - ); + expect(testConnection).toHaveBeenCalledTimes(1); + expect(connector.introspect).not.toHaveBeenCalled(); expect(cleanup).toHaveBeenCalledTimes(1); expect(io.stdout()).toContain('Connection test passed: warehouse'); expect(io.stdout()).toContain('Driver: sqlite'); - expect(io.stdout()).toContain('Tables: 2'); + expect(io.stdout()).toContain('Status: ok'); + }); + + it('reports the connector error and still cleans up when native testConnection fails', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + warehouse: { driver: 'sqlite' }, + }); + const { connector, cleanup } = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' }); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, { + createScanConnector: vi.fn(async () => connector), + }), + ).resolves.toBe(1); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(io.stderr()).toContain('database file is unreadable'); }); it('tests a configured Metabase connection through the Metabase runtime client', async () => { @@ -198,41 +199,305 @@ describe('runKtxConnection', () => { expect(io.stderr()).toBe(''); }); - it('cleans up the native scan connector when connection testing fails', async () => { + it('tests a Looker connection through the Looker client', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + bi_looker: { + driver: 'looker', + base_url: 'https://looker.example.test', + client_id: 'cid', + client_secret: 'csecret', // pragma: allowlist secret + }, + }); + const testConnection = vi.fn(async () => ({ + success: true as const, + metadata: { displayName: 'Alice Analyst', userId: '42' }, + })); + const createLookerClient = vi.fn(async (): Promise> => ({ testConnection })); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test', projectDir, connectionId: 'bi_looker' }, io.io, { createLookerClient }), + ).resolves.toBe(0); + + expect(createLookerClient).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'bi_looker'); + expect(testConnection).toHaveBeenCalledTimes(1); + expect(io.stdout()).toContain('Connection test passed: bi_looker'); + expect(io.stdout()).toContain('Driver: looker'); + expect(io.stdout()).toContain('User: Alice Analyst'); + }); + + it('falls back to userId when Looker metadata has no display name', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + bi_looker: { + driver: 'looker', + base_url: 'https://looker.example.test', + client_id: 'cid', + client_secret: 'csecret', // pragma: allowlist secret + }, + }); + const createLookerClient = vi.fn(async (): Promise> => ({ + testConnection: vi.fn(async () => ({ + success: true as const, + metadata: { displayName: null, userId: '42' }, + })), + })); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test', projectDir, connectionId: 'bi_looker' }, io.io, { createLookerClient }), + ).resolves.toBe(0); + expect(io.stdout()).toContain('User: 42'); + }); + + it('reports the Looker error when testConnection fails', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + bi_looker: { + driver: 'looker', + base_url: 'https://looker.example.test', + client_id: 'cid', + client_secret: 'csecret', // pragma: allowlist secret + }, + }); + const createLookerClient = vi.fn(async (): Promise> => ({ + testConnection: vi.fn(async () => ({ success: false as const, error: 'invalid client_id' })), + })); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test', projectDir, connectionId: 'bi_looker' }, io.io, { createLookerClient }), + ).resolves.toBe(1); + expect(io.stderr()).toContain('Looker connection test failed: invalid client_id'); + }); + + it('tests a Notion connection by retrieving the bot user', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + docs: { + driver: 'notion', + auth_token: 'secret_token', // pragma: allowlist secret + crawl_mode: 'all_accessible', + }, + }); + const retrieveBotUser = vi.fn(async () => ({ id: 'bot-1', name: 'Analytics Bot' })); + const createNotionClient = vi.fn(async (): Promise> => ({ retrieveBotUser })); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test', projectDir, connectionId: 'docs' }, io.io, { createNotionClient }), + ).resolves.toBe(0); + + expect(createNotionClient).toHaveBeenCalledWith(expect.objectContaining({ projectDir }), 'docs'); + expect(retrieveBotUser).toHaveBeenCalledTimes(1); + expect(io.stdout()).toContain('Connection test passed: docs'); + expect(io.stdout()).toContain('Driver: notion'); + expect(io.stdout()).toContain('Bot: Analytics Bot'); + }); + + it('falls back to bot id when Notion bot has no name', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + docs: { + driver: 'notion', + auth_token: 'secret_token', // pragma: allowlist secret + crawl_mode: 'all_accessible', + }, + }); + const createNotionClient = vi.fn(async (): Promise> => ({ + retrieveBotUser: vi.fn(async () => ({ id: 'bot-1', name: null })), + })); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test', projectDir, connectionId: 'docs' }, io.io, { createNotionClient }), + ).resolves.toBe(0); + expect(io.stdout()).toContain('Bot: bot-1'); + }); + + it('tests a dbt connection via testRepoConnection (success)', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + process.env.DBT_TOKEN = 'gh_token_abc'; // pragma: allowlist secret + await writeConnections(projectDir, { + 'dbt-main': { + driver: 'dbt', + repo_url: 'https://github.com/example/dbt-project', + auth_token_ref: 'env:DBT_TOKEN', + }, + }); + const testRepoConnection = vi.fn(async () => ({ ok: true as const })); + const io = makeIo(); + + try { + await expect( + runKtxConnection({ command: 'test', projectDir, connectionId: 'dbt-main' }, io.io, { testRepoConnection }), + ).resolves.toBe(0); + + expect(testRepoConnection).toHaveBeenCalledWith({ + repoUrl: 'https://github.com/example/dbt-project', + authToken: 'gh_token_abc', + }); + expect(io.stdout()).toContain('Connection test passed: dbt-main'); + expect(io.stdout()).toContain('Driver: dbt'); + expect(io.stdout()).toContain('Repo: https://github.com/example/dbt-project'); + } finally { + delete process.env.DBT_TOKEN; + } + }); + + it('reports the git error when testRepoConnection fails for dbt', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + 'dbt-main': { + driver: 'dbt', + repo_url: 'https://github.com/example/dbt-project', + }, + }); + const testRepoConnection = vi.fn(async () => ({ ok: false as const, error: 'fatal: auth failed' })); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test', projectDir, connectionId: 'dbt-main' }, io.io, { testRepoConnection }), + ).resolves.toBe(1); + + expect(testRepoConnection).toHaveBeenCalledWith({ + repoUrl: 'https://github.com/example/dbt-project', + authToken: null, + }); + expect(io.stderr()).toContain('dbt repository check failed: fatal: auth failed'); + }); + + it('tests a LookML connection via testRepoConnection with camelCase repoUrl', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + lookml_main: { + driver: 'lookml', + repoUrl: 'https://github.com/example/lookml', + }, + }); + const testRepoConnection = vi.fn(async () => ({ ok: true as const })); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test', projectDir, connectionId: 'lookml_main' }, io.io, { testRepoConnection }), + ).resolves.toBe(0); + expect(testRepoConnection).toHaveBeenCalledWith({ + repoUrl: 'https://github.com/example/lookml', + authToken: null, + }); + expect(io.stdout()).toContain('Driver: lookml'); + expect(io.stdout()).toContain('Repo: https://github.com/example/lookml'); + }); + + it('tests a MetricFlow connection via the nested metricflow block', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + mf_main: { + driver: 'metricflow', + metricflow: { repoUrl: 'https://github.com/example/metricflow' }, + }, + }); + const testRepoConnection = vi.fn(async () => ({ ok: true as const })); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test', projectDir, connectionId: 'mf_main' }, io.io, { testRepoConnection }), + ).resolves.toBe(0); + expect(testRepoConnection).toHaveBeenCalledWith({ + repoUrl: 'https://github.com/example/metricflow', + authToken: null, + }); + expect(io.stdout()).toContain('Driver: metricflow'); + }); + + it('--all: prints a single coherent list with one row per connection', async () => { const projectDir = join(tempDir, 'project'); await initKtxProject({ projectDir, projectName: 'warehouse' }); await writeConnections(projectDir, { warehouse: { driver: 'sqlite' }, + docs: { driver: 'notion', auth_token: 'secret_token', crawl_mode: 'all_accessible' }, // pragma: allowlist secret }); - const cleanup = vi.fn(async () => undefined); - const connector: KtxScanConnector = { - id: 'sqlite:warehouse', - driver: 'sqlite', - capabilities: { - structuralIntrospection: true, - tableSampling: false, - columnSampling: false, - columnStats: false, - readOnlySql: false, - nestedAnalysis: false, - eventStreamDiscovery: false, - formalForeignKeys: false, - estimatedRowCounts: false, - }, - introspect: vi.fn(async () => { - throw new Error('database file is unreadable'); - }), - cleanup, - }; + const { connector } = nativeConnector('sqlite'); + const createScanConnector = vi.fn(async () => connector); + const createNotionClient = vi.fn(async (): Promise> => ({ + retrieveBotUser: vi.fn(async () => ({ id: 'bot-1', name: 'Docs Bot' })), + })); const io = makeIo(); await expect( - runKtxConnection({ command: 'test', projectDir, connectionId: 'warehouse' }, io.io, { - createScanConnector: vi.fn(async () => connector), - }), + runKtxConnection({ command: 'test-all', projectDir }, io.io, { createScanConnector, createNotionClient }), + ).resolves.toBe(0); + + const out = stripAnsi(io.stdout()); + expect(out).toContain('connection test --all'); + expect(out).toMatch(/docs\s+notion\s+✓ ok\s+Bot: Docs Bot/); + expect(out).toMatch(/warehouse\s+sqlite\s+✓ ok\s+Status: ok/); + expect(out).toContain('2 tested'); + expect(out).toContain('2 passed'); + expect(out).not.toContain('failed'); + expect(io.stderr()).toBe(''); + }); + + it('--all: marks failing connections, keeps passing ones, and returns non-zero', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + warehouse: { driver: 'sqlite' }, + broken: { driver: 'sqlite' }, + }); + const okConnector = nativeConnector('sqlite').connector; + const failConnector = nativeConnector('sqlite', { success: false, error: 'database file is unreadable' }).connector; + const createScanConnector = vi.fn(async (_p, connectionId: string) => + connectionId === 'broken' ? failConnector : okConnector, + ); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test-all', projectDir }, io.io, { createScanConnector }), ).resolves.toBe(1); - expect(cleanup).toHaveBeenCalledTimes(1); - expect(io.stderr()).toContain('database file is unreadable'); + const out = stripAnsi(io.stdout()); + expect(out).toMatch(/broken\s+sqlite\s+✗ failed\s+database file is unreadable/); + expect(out).toMatch(/warehouse\s+sqlite\s+✓ ok\s+Status: ok/); + expect(out).toContain('1 passed'); + expect(out).toContain('1 failed'); + expect(io.stderr()).toBe(''); + }); + + it('--all: shows an empty-state message when no connections are configured', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + const io = makeIo(); + + await expect(runKtxConnection({ command: 'test-all', projectDir }, io.io)).resolves.toBe(0); + + const out = stripAnsi(io.stdout()); + expect(out).toContain('connection test --all'); + expect(out).toContain('No connections configured. Run `ktx setup` to add one.'); + }); + + it('rejects unknown drivers with a helpful error', async () => { + const projectDir = join(tempDir, 'project'); + await initKtxProject({ projectDir, projectName: 'warehouse' }); + await writeConnections(projectDir, { + mystery: { driver: 'duckdb' }, + }); + const io = makeIo(); + + await expect( + runKtxConnection({ command: 'test', projectDir, connectionId: 'mystery' }, io.io), + ).resolves.toBe(1); + expect(io.stderr()).toContain('uses driver "duckdb"'); + expect(io.stderr()).toContain('Supported:'); }); }); diff --git a/packages/cli/src/connection.ts b/packages/cli/src/connection.ts index cf0b512b..c65fc3c3 100644 --- a/packages/cli/src/connection.ts +++ b/packages/cli/src/connection.ts @@ -1,12 +1,21 @@ import { DEFAULT_METABASE_CLIENT_CONFIG, + DefaultLookerConnectionClientFactory, DefaultMetabaseConnectionClientFactory, + type LookerClient, type MetabaseRuntimeClient, + type NotionBotInfo, + NotionClient, + createLocalLookerCredentialResolver, metabaseRuntimeConfigFromLocalConnection, + testRepoConnection, } from '@ktx/context/ingest'; +import { parseNotionConnectionConfig, resolveNotionConnectionAuthToken } from '@ktx/context/connections'; +import { resolveKtxConfigReference } from '@ktx/context/core'; import { type KtxLocalProject, loadKtxProject } from '@ktx/context/project'; import type { KtxScanConnector } from '@ktx/context/scan'; import type { KtxCliIo } from './index.js'; +import { bold, dim, green, red, SYMBOLS } from './io/symbols.js'; import { createKtxCliScanConnector } from './local-scan-connectors.js'; import { profileMark } from './startup-profile.js'; @@ -14,18 +23,37 @@ profileMark('module:connection'); export type KtxConnectionArgs = | { command: 'list'; projectDir: string } - | { command: 'test'; projectDir: string; connectionId: string }; + | { command: 'test'; projectDir: string; connectionId: string } + | { command: 'test-all'; projectDir: string }; -interface KtxConnectionDeps { +type MetabaseTestPort = Pick; +type LookerTestPort = Pick; +type NotionTestPort = Pick; +type TestRepoConnection = typeof testRepoConnection; + +export interface KtxConnectionDeps { createScanConnector?: typeof createKtxCliScanConnector; - createMetabaseClient?: typeof createDefaultMetabaseClient; + createMetabaseClient?: (project: KtxLocalProject, connectionId: string) => Promise; + createLookerClient?: (project: KtxLocalProject, connectionId: string) => Promise; + createNotionClient?: (project: KtxLocalProject, connectionId: string) => Promise; + testRepoConnection?: TestRepoConnection; } -async function cleanupConnector(connector: KtxScanConnector | null): Promise { - if (connector?.cleanup) { - await connector.cleanup(); - } -} +const SUPPORTED_TEST_DRIVERS = [ + 'sqlite', + 'postgres', + 'mysql', + 'clickhouse', + 'sqlserver', + 'bigquery', + 'snowflake', + 'metabase', + 'looker', + 'notion', + 'dbt', + 'metricflow', + 'lookml', +]; function normalizedConnectionDriver(project: KtxLocalProject, connectionId: string): string { return String(project.config.connections[connectionId]?.driver ?? '') @@ -37,33 +65,29 @@ async function testNativeConnection( project: KtxLocalProject, connectionId: string, createScanConnector: typeof createKtxCliScanConnector, -): Promise<{ driver: string; tableCount: number }> { +): Promise<{ driver: string }> { let connector: KtxScanConnector | null = null; try { connector = await createScanConnector(project, connectionId); - const snapshot = await connector.introspect( - { - connectionId, - driver: connector.driver, - mode: 'structural', - dryRun: true, - detectRelationships: false, - }, - { runId: `connection-test-${connectionId}` }, - ); - return { - driver: connector.driver, - tableCount: snapshot.tables.length, - }; + if (!connector.testConnection) { + throw new Error(`Connector for "${connectionId}" does not implement testConnection`); + } + const result = await connector.testConnection(); + if (!result.success) { + throw new Error(result.error ?? 'connection test failed'); + } + return { driver: connector.driver }; } finally { - await cleanupConnector(connector); + if (connector?.cleanup) { + await connector.cleanup(); + } } } async function createDefaultMetabaseClient( project: KtxLocalProject, connectionId: string, -): Promise> { +): Promise { const factory = new DefaultMetabaseConnectionClientFactory( (metabaseConnectionId) => metabaseRuntimeConfigFromLocalConnection( @@ -78,30 +102,282 @@ async function createDefaultMetabaseClient( async function testMetabaseConnection( project: KtxLocalProject, connectionId: string, - createMetabaseClient: typeof createDefaultMetabaseClient, -): Promise<{ driver: 'metabase'; databaseCount: number }> { - let client: Pick | null = null; + createClient: (project: KtxLocalProject, connectionId: string) => Promise, +): Promise<{ databaseCount: number }> { + let client: MetabaseTestPort | null = null; try { - client = await createMetabaseClient(project, connectionId); + client = await createClient(project, connectionId); const testResult = await client.testConnection(); if (!testResult.success) { - throw new Error( - `Metabase connection test failed: ${testResult.error ?? testResult.message ?? 'unknown error'}`, - ); + throw new Error(`Metabase connection test failed: ${testResult.error ?? testResult.message ?? 'unknown error'}`); } - const databases = await client.getDatabases(); const databaseCount = databases.filter((database) => database.is_sample !== true).length; if (databaseCount === 0) { throw new Error('Metabase auth worked but no usable databases were returned'); } - - return { driver: 'metabase', databaseCount }; + return { databaseCount }; } finally { await client?.cleanup(); } } +async function createDefaultLookerClient( + project: KtxLocalProject, + connectionId: string, +): Promise { + const factory = new DefaultLookerConnectionClientFactory(createLocalLookerCredentialResolver(project)); + return (await factory.createClient(connectionId)) as unknown as LookerTestPort; +} + +async function testLookerConnection( + project: KtxLocalProject, + connectionId: string, + createClient: (project: KtxLocalProject, connectionId: string) => Promise, +): Promise<{ user: string }> { + const client = await createClient(project, connectionId); + const result = await client.testConnection(); + if (!result.success) { + throw new Error(`Looker connection test failed: ${result.error ?? 'unknown error'}`); + } + const metadata = (result.metadata ?? {}) as { displayName?: string | null; userId?: string }; + const user = (metadata.displayName ?? metadata.userId ?? 'unknown').trim() || 'unknown'; + return { user }; +} + +async function createDefaultNotionClient( + project: KtxLocalProject, + connectionId: string, +): Promise { + const connection = project.config.connections[connectionId]; + if (!connection) { + throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`); + } + const parsed = parseNotionConnectionConfig(connection); + const token = await resolveNotionConnectionAuthToken(parsed); + return new NotionClient(token); +} + +function describeNotionBot(bot: NotionBotInfo): string { + const name = typeof bot.name === 'string' ? bot.name.trim() : ''; + if (name) return name; + const id = typeof bot.id === 'string' ? bot.id.trim() : ''; + return id || 'unknown'; +} + +async function testNotionConnection( + project: KtxLocalProject, + connectionId: string, + createClient: (project: KtxLocalProject, connectionId: string) => Promise, +): Promise<{ bot: string }> { + const client = await createClient(project, connectionId); + const bot = await client.retrieveBotUser(); + return { bot: describeNotionBot(bot) }; +} + +interface GitConnectionFields { + repoUrl: string; + authToken: string | null; +} + +function extractGitConnectionFields( + project: KtxLocalProject, + connectionId: string, + driver: string, +): GitConnectionFields { + const connection = project.config.connections[connectionId]; + if (!connection) { + throw new Error(`Connection "${connectionId}" is not configured in ktx.yaml`); + } + const stringField = (value: unknown): string | null => + typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; + const record = + driver === 'metricflow' && typeof connection.metricflow === 'object' && connection.metricflow !== null + ? (connection.metricflow as Record) + : (connection as Record); + const repoUrl = driver === 'dbt' ? stringField(record.repo_url) : stringField(record.repoUrl); + if (!repoUrl) { + const field = driver === 'dbt' ? 'repo_url' : 'repoUrl'; + throw new Error(`Connection "${connectionId}" (driver: ${driver}) is missing ${field}`); + } + const literalToken = stringField(record.auth_token); + const ref = stringField(record.auth_token_ref); + const resolvedRef = ref ? resolveKtxConfigReference(ref, process.env) : null; + return { repoUrl, authToken: literalToken ?? resolvedRef ?? null }; +} + +async function testGitRepoConnection( + project: KtxLocalProject, + connectionId: string, + driver: string, + runTest: TestRepoConnection, +): Promise<{ repoUrl: string }> { + const { repoUrl, authToken } = extractGitConnectionFields(project, connectionId, driver); + const result = await runTest({ repoUrl, authToken }); + if (!result.ok) { + throw new Error(`${driver} repository check failed: ${result.error}`); + } + return { repoUrl }; +} + +interface DriverTestOutcome { + driver: string; + detailKey: string; + detailValue: string; +} + +async function testConnectionByDriver( + project: KtxLocalProject, + connectionId: string, + deps: KtxConnectionDeps, +): Promise { + const driver = normalizedConnectionDriver(project, connectionId); + if (!driver) { + throw new Error(`Connection "${connectionId}" has no \`driver\` field in ktx.yaml`); + } + + if (driver === 'metabase') { + const result = await testMetabaseConnection( + project, + connectionId, + deps.createMetabaseClient ?? createDefaultMetabaseClient, + ); + return { driver, detailKey: 'Databases', detailValue: String(result.databaseCount) }; + } + + if (driver === 'looker') { + const result = await testLookerConnection( + project, + connectionId, + deps.createLookerClient ?? createDefaultLookerClient, + ); + return { driver, detailKey: 'User', detailValue: result.user }; + } + + if (driver === 'notion') { + const result = await testNotionConnection( + project, + connectionId, + deps.createNotionClient ?? createDefaultNotionClient, + ); + return { driver, detailKey: 'Bot', detailValue: result.bot }; + } + + if (driver === 'dbt' || driver === 'metricflow' || driver === 'lookml') { + const result = await testGitRepoConnection( + project, + connectionId, + driver, + deps.testRepoConnection ?? testRepoConnection, + ); + return { driver, detailKey: 'Repo', detailValue: result.repoUrl }; + } + + if ( + driver === 'sqlite' || + driver === 'sqlite3' || + driver === 'postgres' || + driver === 'postgresql' || + driver === 'mysql' || + driver === 'clickhouse' || + driver === 'sqlserver' || + driver === 'bigquery' || + driver === 'snowflake' + ) { + const result = await testNativeConnection( + project, + connectionId, + deps.createScanConnector ?? createKtxCliScanConnector, + ); + return { driver: result.driver, detailKey: 'Status', detailValue: 'ok' }; + } + + throw new Error( + `Connection "${connectionId}" uses driver "${driver}", which has no test implementation in ktx. Supported: ${SUPPORTED_TEST_DRIVERS.join(', ')}.`, + ); +} + +interface ConnectionTestRow { + connectionId: string; + driver: string; + ok: boolean; + detail: string; +} + +function visualWidth(text: string): number { + // styleText wraps content in ANSI escape sequences; strip them before measuring. + return text.replace(/\[[0-9;]*m/g, '').length; +} + +function padVisual(text: string, width: number): string { + const pad = width - visualWidth(text); + return pad > 0 ? `${text}${' '.repeat(pad)}` : text; +} + +function renderTestAll(io: KtxCliIo, rows: ReadonlyArray): void { + io.stdout.write(`${SYMBOLS.barStart} connection test --all\n`); + io.stdout.write(`${SYMBOLS.bar}\n`); + + if (rows.length === 0) { + io.stdout.write(`${SYMBOLS.barEnd} No connections configured. Run \`ktx setup\` to add one.\n`); + return; + } + + const okLabel = green('✓ ok'); + const failLabel = red('✗ failed'); + const idWidth = Math.max(...rows.map((r) => r.connectionId.length)); + const driverWidth = Math.max(...rows.map((r) => r.driver.length)); + const statusWidth = Math.max(visualWidth(okLabel), visualWidth(failLabel)); + + for (const row of rows) { + const id = bold(padVisual(row.connectionId, idWidth)); + const driver = dim(padVisual(row.driver, driverWidth)); + const status = padVisual(row.ok ? okLabel : failLabel, statusWidth); + const detail = dim(row.detail); + io.stdout.write(`${SYMBOLS.bar} ${SYMBOLS.item} ${id} ${driver} ${status} ${detail}\n`); + } + + const failed = rows.filter((r) => !r.ok).length; + const passed = rows.length - failed; + io.stdout.write(`${SYMBOLS.bar}\n`); + const summary = + failed === 0 + ? `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)}` + : `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)} ${dim(SYMBOLS.middot)} ${red(`${failed} failed`)}`; + io.stdout.write(`${SYMBOLS.barEnd} ${summary}\n`); +} + +async function runTestAll( + project: KtxLocalProject, + io: KtxCliIo, + deps: KtxConnectionDeps, +): Promise { + const entries = Object.entries(project.config.connections).sort(([a], [b]) => a.localeCompare(b)); + const rows = await Promise.all( + entries.map(async ([connectionId, connection]): Promise => { + const declaredDriver = String(connection.driver ?? '').trim().toLowerCase() || 'unknown'; + try { + const outcome = await testConnectionByDriver(project, connectionId, deps); + return { + connectionId, + driver: outcome.driver || declaredDriver, + ok: true, + detail: `${outcome.detailKey}: ${outcome.detailValue}`, + }; + } catch (error) { + return { + connectionId, + driver: declaredDriver, + ok: false, + detail: error instanceof Error ? error.message : String(error), + }; + } + }), + ); + renderTestAll(io, rows); + return rows.some((row) => !row.ok) ? 1 : 0; +} + export async function runKtxConnection( args: KtxConnectionArgs, io: KtxCliIo = process, @@ -127,26 +403,14 @@ export async function runKtxConnection( return 0; } - if (normalizedConnectionDriver(project, args.connectionId) === 'metabase') { - const result = await testMetabaseConnection( - project, - args.connectionId, - deps.createMetabaseClient ?? createDefaultMetabaseClient, - ); - io.stdout.write(`Connection test passed: ${args.connectionId}\n`); - io.stdout.write(`Driver: ${result.driver}\n`); - io.stdout.write(`Databases: ${result.databaseCount}\n`); - return 0; + if (args.command === 'test-all') { + return await runTestAll(project, io, deps); } - const result = await testNativeConnection( - project, - args.connectionId, - deps.createScanConnector ?? createKtxCliScanConnector, - ); + const { driver, detailKey, detailValue } = await testConnectionByDriver(project, args.connectionId, deps); io.stdout.write(`Connection test passed: ${args.connectionId}\n`); - io.stdout.write(`Driver: ${result.driver}\n`); - io.stdout.write(`Tables: ${result.tableCount}\n`); + io.stdout.write(`Driver: ${driver}\n`); + io.stdout.write(`${detailKey}: ${detailValue}\n`); return 0; } catch (error) { io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index fff5fb09..1e2da422 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -1517,7 +1517,7 @@ describe('runKtxCli', () => { expect(helpIo.stdout()).toContain('Usage: ktx connection'); expect(helpIo.stdout()).toContain('list'); - expect(helpIo.stdout()).toContain('test '); + expect(helpIo.stdout()).toContain('test [options] [connectionId]'); for (const removed of ['add', 'remove', 'map', 'mapping', 'metabase', 'notion']) { expect(helpIo.stdout()).not.toMatch(new RegExp(`\\b${removed}\\b`)); } diff --git a/packages/cli/src/ingest.test.ts b/packages/cli/src/ingest.test.ts index 55e7007c..7626918c 100644 --- a/packages/cli/src/ingest.test.ts +++ b/packages/cli/src/ingest.test.ts @@ -278,7 +278,7 @@ describe('runKtxIngest', () => { { databasesDeps: { testConnection: async (_projectDir, _connectionId, io) => { - io.stdout.write('Driver: postgres\nTables: 1\n'); + io.stdout.write('Driver: postgres\nStatus: ok\n'); return 0; }, scanConnection: async () => 0, diff --git a/packages/cli/src/io/symbols.ts b/packages/cli/src/io/symbols.ts index 8fa88aa4..f80c2b79 100644 --- a/packages/cli/src/io/symbols.ts +++ b/packages/cli/src/io/symbols.ts @@ -35,3 +35,11 @@ export function bold(text: string): string { export function gray(text: string): string { return styleText('gray', text); } + +export function green(text: string): string { + return styleText('green', text); +} + +export function red(text: string): string { + return styleText('red', text); +} diff --git a/packages/cli/src/print-command-tree.test.ts b/packages/cli/src/print-command-tree.test.ts index 86ef451e..9bbfa0a8 100644 --- a/packages/cli/src/print-command-tree.test.ts +++ b/packages/cli/src/print-command-tree.test.ts @@ -16,7 +16,7 @@ describe('renderKtxCommandTree', () => { expect(topLevel).toContain(expected); } - expect(output).toContain('│ └── test '); + expect(output).toContain('│ └── test [connectionId]'); expect(output).not.toContain('│ ├── add'); expect(output).not.toContain('│ ├── remove'); expect(output).not.toContain('│ ├── map'); diff --git a/packages/cli/src/setup-databases.test.ts b/packages/cli/src/setup-databases.test.ts index 6672633d..cc69e6cc 100644 --- a/packages/cli/src/setup-databases.test.ts +++ b/packages/cli/src/setup-databases.test.ts @@ -1361,7 +1361,7 @@ describe('setup databases step', () => { const testConnection = vi.fn(async (_projectDir: string, _connectionId: string, commandIo: KtxCliIo) => { commandIo.stdout.write('Connection test passed: postgres-warehouse\n'); commandIo.stdout.write('Driver: postgres\n'); - commandIo.stdout.write('Tables: 2\n'); + commandIo.stdout.write('Status: ok\n'); return 0; }); const scanConnection = vi.fn(async (_projectDir: string, _connectionId: string, commandIo: KtxCliIo) => { diff --git a/packages/cli/src/standalone-smoke.test.ts b/packages/cli/src/standalone-smoke.test.ts index 537f373c..3b657780 100644 --- a/packages/cli/src/standalone-smoke.test.ts +++ b/packages/cli/src/standalone-smoke.test.ts @@ -195,7 +195,7 @@ describe('standalone built ktx CLI smoke', () => { expectProjectStderr(connectionTest, projectDir); expect(connectionTest.stdout).toContain('Connection test passed: warehouse'); expect(connectionTest.stdout).toContain('Driver: sqlite'); - expect(connectionTest.stdout).toContain('Tables: 2'); + expect(connectionTest.stdout).toContain('Status: ok'); const ingest = await runBuiltCli(['ingest', 'warehouse', '--project-dir', projectDir, '--fast', '--no-input']); expectProjectStderr(ingest, projectDir); diff --git a/packages/cli/src/status-project.ts b/packages/cli/src/status-project.ts index 6c6cc4e3..7a272183 100644 --- a/packages/cli/src/status-project.ts +++ b/packages/cli/src/status-project.ts @@ -289,9 +289,9 @@ function buildConnectionStatus( return warn('repoUrl not set', 'Rerun `ktx setup`'); } case 'metabase': { - const url = (conn as Record).url ?? (conn as Record).base_url; + const url = (conn as Record).api_url; if (typeof url === 'string' && url.length > 0) return ok(`url: ${url}`); - return warn('url not set', 'Rerun `ktx setup`'); + return warn('api_url not set', 'Rerun `ktx setup`'); } case 'looker': case 'lookml': { diff --git a/packages/context/src/scan/index.ts b/packages/context/src/scan/index.ts index 09e815bb..e7207b49 100644 --- a/packages/context/src/scan/index.ts +++ b/packages/context/src/scan/index.ts @@ -369,6 +369,7 @@ export type { KtxQueryResult, KtxReadOnlyQueryInput, KtxResolvedCredentialEnvelope, + KtxConnectorTestResult, KtxScanArtifactPaths, KtxScanConnector, KtxScanContext, diff --git a/packages/context/src/scan/types.ts b/packages/context/src/scan/types.ts index 2a9cad00..c21d21bf 100644 --- a/packages/context/src/scan/types.ts +++ b/packages/context/src/scan/types.ts @@ -283,12 +283,18 @@ export interface KtxTableListEntry { kind: 'table' | 'view'; } +export interface KtxConnectorTestResult { + success: boolean; + error?: string; +} + export interface KtxScanConnector { id: string; driver: KtxConnectionDriver; capabilities: KtxConnectorCapabilities; eventStreamDiscovery?: KtxEventStreamDiscoveryPort; introspect(input: KtxScanInput, ctx: KtxScanContext): Promise; + testConnection?(): Promise; sampleColumn?(input: KtxColumnSampleInput, ctx: KtxScanContext): Promise; sampleTable?(input: KtxTableSampleInput, ctx: KtxScanContext): Promise; columnStats?(input: KtxColumnStatsInput, ctx: KtxScanContext): Promise;