fix(cli): keep ktx setup alive when a dbt git clone fails (#88)

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.
This commit is contained in:
Andrey Avtomonov 2026-05-14 14:39:50 +02:00 committed by GitHub
parent 6c4623f2ff
commit 52dd89481c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 130 additions and 31 deletions

View file

@ -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',

View file

@ -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<string | undefined | 'back'> {
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;