From ca74cc3fd1fcfa1fb11960be7a29ba1d6cc88c8c Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov Date: Thu, 14 May 2026 14:36:42 +0200 Subject: [PATCH] fix(cli): keep ktx setup alive when a dbt git clone fails Wraps the validation clone in defaultValidateDbt so auth or network failures surface as a clean validation error instead of an unhandled RepoFetchError that exits the wizard. Verifies pasted tokens with testGitRepo before saving them as a secret so bad tokens are caught at paste time. In interactive setup, validation failures now bounce the user back to source selection (with a "Edit the connection or pick a different source" hint) instead of killing the process; --source flag mode still exits with failed as before. --- packages/cli/src/setup-sources.test.ts | 123 ++++++++++++++++++++----- packages/cli/src/setup-sources.ts | 38 ++++++-- 2 files changed, 130 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/setup-sources.test.ts b/packages/cli/src/setup-sources.test.ts index d1d541b8..f39dde62 100644 --- a/packages/cli/src/setup-sources.test.ts +++ b/packages/cli/src/setup-sources.test.ts @@ -581,19 +581,18 @@ describe('setup sources step', () => { text: ['metabase-main', 'https://metabase.example.com'], }); - await expect( - runKtxSetupSourcesStep( - { projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false }, - io.io, - { - prompts: testPrompts, - discoverMetabaseDatabases: vi.fn(async () => [ - { id: 1, name: 'Analytics', engine: 'postgres', host: 'db.example.com', dbName: 'analytics' }, - ]), - runMapping, - }, - ), - ).resolves.toEqual({ status: 'failed', projectDir }); + const result = await runKtxSetupSourcesStep( + { projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false }, + io.io, + { + prompts: testPrompts, + discoverMetabaseDatabases: vi.fn(async () => [ + { id: 1, name: 'Analytics', engine: 'postgres', host: 'db.example.com', dbName: 'analytics' }, + ]), + runMapping, + }, + ); + expect(result.status).not.toBe('failed'); expect(runMapping).toHaveBeenCalledWith( projectDir, @@ -605,6 +604,7 @@ describe('setup sources step', () => { ); expect(io.stderr()).toContain('1: Metabase database does not match KTX connection database'); expect(io.stderr()).not.toContain('Metabase mapping validation failed'); + expect(testPrompts.log).toHaveBeenCalledWith('Edit the connection or pick a different source to continue.'); }); it('does not mark sources complete when validation fails', async () => { @@ -787,6 +787,81 @@ describe('setup sources step', () => { expect(testPrompts.text).toHaveBeenCalledTimes(4); }); + it('re-prompts when a pasted token fails authentication and accepts the second token', async () => { + await addPrimarySource(); + const validateDbt = vi.fn(async () => ({ ok: true as const, detail: 'project=analytics schemas=2' })); + const testGitRepo = vi + .fn<(args: { repoUrl: string; authToken?: string | null }) => Promise<{ ok: true } | { ok: false; error: string }>>() + .mockResolvedValueOnce({ ok: false, error: 'authentication required' }) + .mockResolvedValueOnce({ ok: false, error: 'Invalid username or token.' }) + .mockResolvedValue({ ok: true }); + const io = makeIo(); + const testPrompts = prompts({ + multiselect: [['dbt']], + select: ['git', 'paste', 'paste'], + text: ['dbt-main', 'https://github.com/acme-org/private-repo', 'main', ''], + password: ['bad-token', 'good-token'], + }); + + await expect( + runKtxSetupSourcesStep( + { projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false }, + io.io, + { + prompts: testPrompts, + validateDbt, + testGitRepo, + }, + ), + ).resolves.toEqual({ status: 'ready', projectDir, connectionIds: ['dbt-main'] }); + + expect(testGitRepo).toHaveBeenNthCalledWith(1, { repoUrl: 'https://github.com/acme-org/private-repo' }); + expect(testGitRepo).toHaveBeenNthCalledWith(2, { + repoUrl: 'https://github.com/acme-org/private-repo', + authToken: 'bad-token', + }); + expect(testGitRepo).toHaveBeenNthCalledWith(3, { + repoUrl: 'https://github.com/acme-org/private-repo', + authToken: 'good-token', + }); + expect(testPrompts.password).toHaveBeenCalledTimes(2); + expect(testPrompts.log).toHaveBeenCalledWith('Authentication failed: Invalid username or token.'); + expect(testPrompts.log).toHaveBeenCalledWith('Saved to .ktx/secrets/dbt-main-auth-token'); + expect((await readConfig()).connections['dbt-main']).toMatchObject({ + driver: 'dbt', + repo_url: 'https://github.com/acme-org/private-repo', + auth_token_ref: expect.stringMatching(/^file:.*\.ktx\/secrets\/dbt-main-auth-token$/), + }); + }); + + it('does not exit interactive setup when validation fails for an existing connection', async () => { + await addPrimarySource(); + await addConnection('dbt-main', { + driver: 'dbt', + repo_url: 'https://github.com/acme/private-repo', + auth_token_ref: 'env:GITHUB_TOKEN', + }); + const validateDbt = vi.fn(async () => ({ + ok: false as const, + message: 'Failed to clone https://github.com/acme/private-repo: Authentication failed', + })); + const testPrompts = prompts({ + multiselect: [['dbt']], + select: ['existing:dbt-main'], + }); + const io = makeIo(); + + const result = await runKtxSetupSourcesStep( + { projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false }, + io.io, + { prompts: testPrompts, validateDbt }, + ); + + expect(result.status).not.toBe('failed'); + expect(io.stderr()).toContain('Failed to clone https://github.com/acme/private-repo: Authentication failed'); + expect(testPrompts.log).toHaveBeenCalledWith('Edit the connection or pick a different source to continue.'); + }); + it('adds a dbt source connection and enables its adapter', async () => { await addPrimarySource(); const validateDbt = vi.fn(async () => ({ ok: true as const, detail: 'project=analytics schemas=2' })); @@ -1173,22 +1248,24 @@ describe('setup sources step', () => { select: ['edit:dbt-main', 'path'], text: ['/repo/new-dbt', ''], }); + const io = makeIo(); - await expect( - runKtxSetupSourcesStep( - { projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false }, - makeIo().io, - { - prompts: testPrompts, - validateDbt, - }, - ), - ).resolves.toEqual({ status: 'failed', projectDir }); + const result = await runKtxSetupSourcesStep( + { projectDir, inputMode: 'auto', runInitialSourceIngest: false, skipSources: false }, + io.io, + { + prompts: testPrompts, + validateDbt, + }, + ); + expect(result.status).not.toBe('failed'); expect(validateDbt).toHaveBeenCalledWith(expect.objectContaining({ driver: 'dbt', source_dir: '/repo/new-dbt', })); + expect(io.stderr()).toContain('dbt project not found'); + expect(testPrompts.log).toHaveBeenCalledWith('Edit the connection or pick a different source to continue.'); const config = await readConfig(); expect(config.connections['dbt-main']).toMatchObject({ driver: 'dbt', diff --git a/packages/cli/src/setup-sources.ts b/packages/cli/src/setup-sources.ts index c55f3fd8..3b141f58 100644 --- a/packages/cli/src/setup-sources.ts +++ b/packages/cli/src/setup-sources.ts @@ -260,6 +260,8 @@ async function chooseGitAuthCredentialRef(input: { source: KtxSetupSourceType; connectionId: string; existingRef?: string; + repoUrl?: string; + testGitRepo?: (args: { repoUrl: string; authToken?: string | null }) => Promise<{ ok: true } | { ok: false; error: string }>; }): Promise { const label = input.source === 'dbt' ? 'This' : `This ${sourceLabel(input.source)}`; while (true) { @@ -280,6 +282,13 @@ async function chooseGitAuthCredentialRef(input: { const value = await input.prompts.password({ message: 'Git access token' }); if (value === undefined) continue; if (!value.trim()) continue; + if (input.testGitRepo && input.repoUrl) { + const result = await input.testGitRepo({ repoUrl: input.repoUrl, authToken: value }); + if (!result.ok) { + input.prompts.log?.(`Authentication failed: ${result.error}`); + continue; + } + } const fileName = `${input.connectionId}-auth-token`; const ref = await writeProjectLocalSecretReference({ projectDir: input.projectDir, @@ -536,12 +545,17 @@ async function defaultValidateDbt(connection: KtxProjectConnectionConfig): Promi } if (!sourceDir && repoUrl) { const cacheDir = await mkdtemp(join(tmpdir(), 'ktx-setup-dbt-')); - await cloneOrPull({ - repoUrl, - authToken: repoAuthToken(connection), - cacheDir, - branch: stringField(connection.branch) ?? 'main', - }); + try { + await cloneOrPull({ + repoUrl, + authToken: repoAuthToken(connection), + cacheDir, + branch: stringField(connection.branch) ?? 'main', + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return { ok: false, message: `Failed to clone ${repoUrl}: ${reason}` }; + } sourceDir = stringField(connection.path) ? join(cacheDir, String(connection.path)) : cacheDir; } if (!sourceDir) { @@ -1058,6 +1072,8 @@ async function promptForInteractiveSource( source, connectionId: currentState.sourceConnectionId ?? `${source}-main`, existingRef: currentState.sourceAuthTokenRef, + repoUrl: currentState.sourceGitUrl, + testGitRepo, }); if (authRef === 'back') return 'back'; if (authRef) { @@ -1857,7 +1873,12 @@ export async function runKtxSetupSourcesStep( deps, }); if (choiceResult.status === 'failed') { - return { status: 'failed', projectDir: args.projectDir }; + if (args.source) { + return { status: 'failed', projectDir: args.projectDir }; + } + prompts.log?.('Edit the connection or pick a different source to continue.'); + returnToSourceSelection = true; + break; } if (choiceResult.status === 'back') { if (args.source) { @@ -1923,7 +1944,8 @@ export async function runKtxSetupSourcesStep( deps, }); if (choiceResult.status === 'failed') { - return { status: 'failed', projectDir: args.projectDir }; + prompts.log?.('Edit the connection or pick a different source to continue.'); + continue; } if (choiceResult.status === 'back') { continue;