diff --git a/packages/cli/src/setup-models.test.ts b/packages/cli/src/setup-models.test.ts index e4425d69..d9fef97d 100644 --- a/packages/cli/src/setup-models.test.ts +++ b/packages/cli/src/setup-models.test.ts @@ -283,9 +283,9 @@ describe('setup Anthropic model step', () => { expect(io.stdout()).toContain('LLM ready: yes (claude-sonnet-4-6)'); }); - it('uses existing Vertex AI credentials without offering to run gcloud auth', async () => { + it('uses existing Vertex AI credentials without an extra auth choice', async () => { const io = makeIo(); - const prompts = makePromptAdapter({ selectValues: ['vertex', 'existing', 'local-gcp-project', 'claude-sonnet-4-6'] }); + const prompts = makePromptAdapter({ selectValues: ['vertex', 'local-gcp-project', 'claude-sonnet-4-6'] }); const readGcloudProject = vi.fn(async () => 'local-gcp-project'); const listGcloudProjects = vi.fn(async () => [ { projectId: 'local-gcp-project', name: 'Local project' }, @@ -306,13 +306,9 @@ describe('setup Anthropic model step', () => { ); expect(result.status).toBe('ready'); - expect(prompts.select).toHaveBeenCalledWith( + expect(prompts.select).not.toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('How should KTX authenticate with Google Vertex AI?'), - options: [ - { value: 'existing', label: 'Use existing gcloud/Application Default Credentials' }, - { value: 'back', label: 'Back' }, - ], }), ); expect(readGcloudProject).toHaveBeenCalled(); @@ -358,9 +354,45 @@ describe('setup Anthropic model step', () => { }); }); + it('skips the Vertex AI auth choice when Application Default Credentials are the only option', async () => { + const io = makeIo(); + const prompts = makePromptAdapter({ selectValues: ['vertex', 'local-gcp-project', 'claude-sonnet-4-6'] }); + const healthCheck = vi.fn(async () => ({ ok: true as const })); + + const result = await runKtxSetupAnthropicModelStep( + { projectDir: tempDir, inputMode: 'auto', skipLlm: false }, + io.io, + { + prompts, + env: {}, + readGcloudProject: vi.fn(async () => 'local-gcp-project'), + listGcloudProjects: vi.fn(async () => [{ projectId: 'local-gcp-project', name: 'Local project' }]), + healthCheck, + }, + ); + + expect(result.status).toBe('ready'); + expect(prompts.select).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('How should KTX authenticate with Google Vertex AI?'), + }), + ); + expect(prompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Which Google Cloud project should KTX use for Vertex AI?'), + }), + ); + expect(healthCheck).toHaveBeenCalledWith( + expect.objectContaining({ + backend: 'vertex', + vertex: { project: 'local-gcp-project', location: 'us-east5' }, + }), + ); + }); + it('lets users choose a different visible gcloud project for Vertex AI', async () => { const io = makeIo(); - const prompts = makePromptAdapter({ selectValues: ['vertex', 'existing', 'other-gcp-project', 'claude-sonnet-4-6'] }); + const prompts = makePromptAdapter({ selectValues: ['vertex', 'other-gcp-project', 'claude-sonnet-4-6'] }); const healthCheck = vi.fn(async () => ({ ok: true as const })); const result = await runKtxSetupAnthropicModelStep( @@ -395,7 +427,7 @@ describe('setup Anthropic model step', () => { it('allows manual Vertex AI project entry when gcloud project listing is empty', async () => { const io = makeIo(); const prompts = makePromptAdapter({ - selectValues: ['vertex', 'existing', 'manual', 'claude-sonnet-4-6'], + selectValues: ['vertex', 'manual', 'claude-sonnet-4-6'], textValues: ['manual-gcp-project'], }); const healthCheck = vi.fn(async () => ({ ok: true as const })); @@ -434,8 +466,66 @@ describe('setup Anthropic model step', () => { ); }); + it('lets users retry Vertex AI project listing after gcloud auth fails', async () => { + const io = makeIo(); + const prompts = makePromptAdapter({ selectValues: ['vertex', 'retry', 'other-gcp-project', 'claude-sonnet-4-6'] }); + const listGcloudProjects = vi + .fn() + .mockRejectedValueOnce(new Error('Reauthentication failed. cannot prompt during non-interactive execution.')) + .mockResolvedValueOnce([ + { projectId: 'local-gcp-project', name: 'Local project' }, + { projectId: 'other-gcp-project', name: 'Other project' }, + ]); + const healthCheck = vi.fn(async () => ({ ok: true as const })); + + const result = await runKtxSetupAnthropicModelStep( + { projectDir: tempDir, inputMode: 'auto', skipLlm: false }, + io.io, + { + prompts, + env: {}, + readGcloudProject: vi.fn(async () => 'local-gcp-project'), + listGcloudProjects, + healthCheck, + }, + ); + + expect(result.status).toBe('ready'); + expect(listGcloudProjects).toHaveBeenCalledTimes(2); + expect(prompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Could not list Google Cloud projects with gcloud'), + options: expect.arrayContaining([{ value: 'retry', label: 'Retry loading Google Cloud projects' }]), + }), + ); + expect(prompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + `${String.fromCharCode(0x1b)}[33mCould not list Google Cloud projects with gcloud`, + ), + }), + ); + expect(prompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('gcloud auth login --update-adc'), + }), + ); + expect(prompts.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining( + `${String.fromCharCode(0x1b)}[33mRun \`gcloud auth login --update-adc\``, + ), + }), + ); + expect(healthCheck).toHaveBeenCalledWith( + expect.objectContaining({ + vertex: { project: 'other-gcp-project', location: 'us-east5' }, + }), + ); + }); + it('returns from Vertex AI project selection Back to provider selection', async () => { - const prompts = makePromptAdapter({ selectValues: ['vertex', 'existing', 'back', 'back'] }); + const prompts = makePromptAdapter({ selectValues: ['vertex', 'back', 'back'] }); const result = await runKtxSetupAnthropicModelStep( { projectDir: tempDir, inputMode: 'auto', skipLlm: false }, @@ -450,7 +540,7 @@ describe('setup Anthropic model step', () => { expect(result.status).toBe('back'); expect(prompts.select).toHaveBeenNthCalledWith( - 4, + 3, expect.objectContaining({ message: expect.stringContaining('Which LLM provider should KTX use?'), }), diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index e4c7fcd2..784a1d18 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -20,6 +20,12 @@ import { type KtxSetupPromptOption, } from './setup-prompts.js'; +const ESC = String.fromCharCode(0x1b); + +function yellow(text: string): string { + return `${ESC}[33m${text}${ESC}[39m`; +} + export interface KtxSetupModelArgs { projectDir: string; inputMode: 'auto' | 'disabled'; @@ -101,9 +107,6 @@ const ANTHROPIC_MODEL_PROMPT_CONTEXT = 'KTX uses this as the default model for ingest agents that turn schemas, SQL, BI metadata, and docs ' + 'into semantic-layer sources and wiki context.'; -const VERTEX_AUTH_PROMPT_CONTEXT = - 'KTX uses Google Cloud Application Default Credentials for local Vertex AI access and does not store Google ' + - 'credentials in ktx.yaml. If needed, run gcloud auth application-default login before continuing.'; const VERTEX_PROJECT_PROMPT_CONTEXT = 'KTX stores the selected Google Cloud project ID in ktx.yaml and uses Application Default Credentials for ' + 'access. Project visibility depends on the signed-in Google account and organization permissions.'; @@ -148,8 +151,6 @@ type VertexConfigChoice = } | { status: 'back' | 'missing-input' }; -type VertexAuthChoice = { status: 'ready' } | { status: 'back' }; - interface GcloudProjectChoice { projectId: string; name?: string; @@ -170,34 +171,30 @@ async function defaultReadGcloudProject(): Promise { } async function defaultListGcloudProjects(): Promise { - try { - const { stdout } = await execFileAsync('gcloud', ['projects', 'list', '--format=json(projectId,name)'], { - encoding: 'utf8', - }); - const parsed = JSON.parse(stdout.trim() || '[]') as unknown; - if (!Array.isArray(parsed)) { - return []; - } - - return parsed - .map((item): GcloudProjectChoice | undefined => { - if (!item || typeof item !== 'object') { - return undefined; - } - const record = item as { projectId?: unknown; name?: unknown }; - if (typeof record.projectId !== 'string' || !record.projectId.trim()) { - return undefined; - } - const name = typeof record.name === 'string' && record.name.trim() ? record.name.trim() : undefined; - return { - projectId: record.projectId.trim(), - ...(name ? { name } : {}), - }; - }) - .filter((project): project is GcloudProjectChoice => Boolean(project)); - } catch { + const { stdout } = await execFileAsync('gcloud', ['projects', 'list', '--format=json(projectId,name)'], { + encoding: 'utf8', + }); + const parsed = JSON.parse(stdout.trim() || '[]') as unknown; + if (!Array.isArray(parsed)) { return []; } + + return parsed + .map((item): GcloudProjectChoice | undefined => { + if (!item || typeof item !== 'object') { + return undefined; + } + const record = item as { projectId?: unknown; name?: unknown }; + if (typeof record.projectId !== 'string' || !record.projectId.trim()) { + return undefined; + } + const name = typeof record.name === 'string' && record.name.trim() ? record.name.trim() : undefined; + return { + projectId: record.projectId.trim(), + ...(name ? { name } : {}), + }; + }) + .filter((project): project is GcloudProjectChoice => Boolean(project)); } export async function fetchAnthropicModels( @@ -495,28 +492,6 @@ async function chooseBackend( return { status: 'ready', backend: choice === 'vertex' ? 'vertex' : 'anthropic', prompted: true }; } -async function chooseVertexAuth( - args: KtxSetupModelArgs, - deps: KtxSetupModelDeps, -): Promise { - if (args.inputMode === 'disabled' || args.vertexProject || args.vertexLocation) { - return { status: 'ready' }; - } - - const prompts = deps.prompts ?? createPromptAdapter(); - const choice = await prompts.select({ - message: `How should KTX authenticate with Google Vertex AI?\n\n${VERTEX_AUTH_PROMPT_CONTEXT}`, - options: [ - { value: 'existing', label: 'Use existing gcloud/Application Default Credentials' }, - { value: 'back', label: 'Back' }, - ], - }); - if (choice === 'back') { - return { status: 'back' }; - } - return { status: 'ready' }; -} - function resolveProvidedVertexRef( label: 'project' | 'location', ref: string, @@ -572,51 +547,80 @@ function formatGcloudProjectLabel(project: GcloudProjectChoice, currentProject: return `${project.projectId}${name}${current}`; } +function formatGcloudProjectListFailure(error: unknown): string { + const stderr = typeof (error as { stderr?: unknown })?.stderr === 'string' ? (error as { stderr: string }).stderr : ''; + const message = error instanceof Error ? error.message : ''; + const details = `${stderr}\n${message}`; + const reason = /reauthentication failed|cannot prompt/i.test(details) + ? 'gcloud needs reauthentication before it can list projects.' + : 'gcloud returned an error while listing projects.'; + return [ + `Could not list Google Cloud projects with gcloud: ${reason}`, + 'Run `gcloud auth login --update-adc` in another terminal, then choose Retry loading Google Cloud projects.', + ] + .map((line) => yellow(line)) + .join('\n'); +} + async function chooseInteractiveVertexProject( currentProject: string | undefined, io: KtxCliIo, deps: KtxSetupModelDeps, ): Promise<{ status: 'ready'; ref: string; value: string } | { status: 'back' | 'missing-input' }> { const prompts = deps.prompts ?? createPromptAdapter(); - let projects: GcloudProjectChoice[] = []; - try { - projects = await (deps.listGcloudProjects ?? defaultListGcloudProjects)(); - } catch { - io.stderr.write('Could not list Google Cloud projects with gcloud. Enter a project ID manually or choose Back.\n'); - } + while (true) { + let projects: GcloudProjectChoice[] = []; + let listFailed = false; + let listFailureMessage: string | undefined; + try { + projects = await (deps.listGcloudProjects ?? defaultListGcloudProjects)(); + } catch (error) { + listFailed = true; + listFailureMessage = formatGcloudProjectListFailure(error); + } - const orderedProjects = orderGcloudProjects(projects, currentProject); - if (orderedProjects.length === 0) { - io.stdout.write('│ gcloud did not return any visible Google Cloud projects. Enter a project ID manually or choose Back.\n'); - } + const orderedProjects = orderGcloudProjects(projects, currentProject); + if (orderedProjects.length === 0 && !listFailed) { + io.stdout.write('│ gcloud did not return any visible Google Cloud projects. Enter a project ID manually or choose Back.\n'); + } - const choice = await prompts.select({ - message: `Which Google Cloud project should KTX use for Vertex AI?\n\n${VERTEX_PROJECT_PROMPT_CONTEXT}`, - options: [ - ...orderedProjects.map((project) => ({ - value: project.projectId, - label: formatGcloudProjectLabel(project, currentProject), - })), - { value: 'manual', label: 'Enter a project ID manually' }, - { value: 'back', label: 'Back' }, - ], - }); - if (choice === 'back') { - return { status: 'back' }; - } - if (choice === 'manual') { - const manual = await prompts.text({ - message: withTextInputNavigation('Google Cloud project ID'), - placeholder: currentProject ?? orderedProjects[0]?.projectId, + const choice = await prompts.select({ + message: `Which Google Cloud project should KTX use for Vertex AI?\n\n${[ + VERTEX_PROJECT_PROMPT_CONTEXT, + listFailureMessage, + ] + .filter((value): value is string => Boolean(value)) + .join('\n\n')}`, + options: [ + ...orderedProjects.map((project) => ({ + value: project.projectId, + label: formatGcloudProjectLabel(project, currentProject), + })), + ...(listFailed ? [{ value: 'retry', label: 'Retry loading Google Cloud projects' }] : []), + { value: 'manual', label: 'Enter a project ID manually' }, + { value: 'back', label: 'Back' }, + ], }); - if (manual === undefined) { + if (choice === 'back') { return { status: 'back' }; } - const project = normalizeGcloudProjectId(manual); - return project ? { status: 'ready', ref: project, value: project } : { status: 'missing-input' }; - } + if (choice === 'retry') { + continue; + } + if (choice === 'manual') { + const manual = await prompts.text({ + message: withTextInputNavigation('Google Cloud project ID'), + placeholder: currentProject ?? orderedProjects[0]?.projectId, + }); + if (manual === undefined) { + return { status: 'back' }; + } + const project = normalizeGcloudProjectId(manual); + return project ? { status: 'ready', ref: project, value: project } : { status: 'missing-input' }; + } - return { status: 'ready', ref: choice, value: choice }; + return { status: 'ready', ref: choice, value: choice }; + } } async function chooseVertexConfig( @@ -871,15 +875,6 @@ export async function runKtxSetupAnthropicModelStep( : attemptArgs; if (backendChoice.backend === 'vertex') { - const auth = await chooseVertexAuth(backendArgs, deps); - if (auth.status === 'back' && backendChoice.prompted) { - attemptArgs = buildInteractiveRetryArgs(args); - continue; - } - if (auth.status !== 'ready') { - return { status: auth.status, projectDir: args.projectDir }; - } - const vertex = await chooseVertexConfig(backendArgs, io, deps); if (vertex.status === 'back' && backendChoice.prompted) { attemptArgs = buildInteractiveRetryArgs(args);