mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
refactor(cli): remove Vertex AI auth step and add gcloud retry (#84)
* 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> * refactor(cli): show gcloud project listing errors inline in the prompt Move the gcloud failure message into the select prompt instead of writing it to stdout separately, and highlight it with yellow ANSI coloring so users notice the remediation steps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b00c1a11a9
commit
5b4ba73e64
2 changed files with 191 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,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?'),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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<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 +492,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 +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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue