mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
refactor(cli): remove Vertex AI auth step and add gcloud project listing retry
The Vertex AI auth step only offered one option (use existing ADC credentials) making it a redundant click. Remove it and go straight to project selection. When gcloud project listing fails (e.g. expired credentials), show a diagnostic message and offer a "Retry" option instead of silently falling back to an empty list. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b00c1a11a9
commit
6962e7e4c9
2 changed files with 160 additions and 106 deletions
|
|
@ -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,49 @@ 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(io.stdout()).toContain('Could not list Google Cloud projects with gcloud');
|
||||
expect(io.stdout()).toContain('gcloud auth login --update-adc');
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Which Google Cloud project should KTX use for Vertex AI?'),
|
||||
options: expect.arrayContaining([{ value: 'retry', label: 'Retry loading Google Cloud projects' }]),
|
||||
}),
|
||||
);
|
||||
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 +523,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?'),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -101,9 +101,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 +145,6 @@ type VertexConfigChoice =
|
|||
}
|
||||
| { status: 'back' | 'missing-input' };
|
||||
|
||||
type VertexAuthChoice = { status: 'ready' } | { status: 'back' };
|
||||
|
||||
interface GcloudProjectChoice {
|
||||
projectId: string;
|
||||
name?: string;
|
||||
|
|
@ -170,34 +165,30 @@ async function defaultReadGcloudProject(): Promise<string | undefined> {
|
|||
}
|
||||
|
||||
async function defaultListGcloudProjects(): Promise<GcloudProjectChoice[]> {
|
||||
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 +486,6 @@ async function chooseBackend(
|
|||
return { status: 'ready', backend: choice === 'vertex' ? 'vertex' : 'anthropic', prompted: true };
|
||||
}
|
||||
|
||||
async function chooseVertexAuth(
|
||||
args: KtxSetupModelArgs,
|
||||
deps: KtxSetupModelDeps,
|
||||
): Promise<VertexAuthChoice> {
|
||||
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 +541,72 @@ 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.',
|
||||
].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;
|
||||
try {
|
||||
projects = await (deps.listGcloudProjects ?? defaultListGcloudProjects)();
|
||||
} catch (error) {
|
||||
listFailed = true;
|
||||
io.stdout.write(`${formatGcloudProjectListFailure(error)}\n`);
|
||||
}
|
||||
|
||||
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}`,
|
||||
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 +861,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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue