diff --git a/packages/cli/src/commands/extensions/utils.ts b/packages/cli/src/commands/extensions/utils.ts index 26e47b912b7..91cac60537f 100644 --- a/packages/cli/src/commands/extensions/utils.ts +++ b/packages/cli/src/commands/extensions/utils.ts @@ -13,7 +13,6 @@ import { import type { ExtensionConfig } from '../../config/extension.js'; import prompts from 'prompts'; import { - promptForSetting, updateSetting, type ExtensionSetting, getScopedEnvContents, @@ -50,6 +49,24 @@ const defaultRequestConfirmation: RequestConfirmationCallback = async ( return response.confirm; }; +export async function promptForSetting( + setting: ExtensionSetting, +): Promise { + let description = setting.description; + if (setting.defaultValue !== undefined) { + const displayValue = setting.sensitive ? '******' : setting.defaultValue; + description += ` [default: ${displayValue}]`; + } + + const response = await prompts({ + type: setting.sensitive ? 'password' : 'text', + name: 'value', + message: `${setting.name}\n${description}`, + initial: setting.defaultValue, + }); + return response.value; +} + export async function getExtensionManager() { const workspaceDir = process.cwd(); const extensionManager = new ExtensionManager({ diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index 23df066db17..0a437c85a37 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -27,6 +27,7 @@ export interface ExtensionSetting { envVar: string; // NOTE: If no value is set, this setting will be considered NOT sensitive. sensitive?: boolean; + defaultValue?: string; } const getKeychainStorageName = ( @@ -155,6 +156,7 @@ export async function promptForSetting( type: setting.sensitive ? 'password' : 'text', name: 'value', message: `${setting.name}\n${setting.description}`, + initial: setting.defaultValue, }); return response.value; } diff --git a/packages/cli/src/ui/components/ConfigExtensionDialog.test.tsx b/packages/cli/src/ui/components/ConfigExtensionDialog.test.tsx new file mode 100644 index 00000000000..3cf80fdb429 --- /dev/null +++ b/packages/cli/src/ui/components/ConfigExtensionDialog.test.tsx @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { ConfigExtensionDialog } from './ConfigExtensionDialog.js'; +import { + configureExtension, + type ConfigLogger, +} from '../../commands/extensions/utils.js'; +import type { ExtensionManager } from '../../config/extension-manager.js'; +import { act } from 'react'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; + +// Mock the utils +vi.mock('../../commands/extensions/utils.js', () => ({ + configureExtension: vi.fn(), + configureSpecificSetting: vi.fn(), + configureAllExtensions: vi.fn(), +})); + +describe('ConfigExtensionDialog', () => { + const mockExtensionManager = {} as ExtensionManager; + const mockLogger: ConfigLogger = { + log: vi.fn(), + error: vi.fn(), + }; + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should display default value in prompt', async () => { + // Setup mock to request a setting with default value + (configureExtension as Mock).mockImplementation( + async ( + _mgr, + _name, + _scope, + _logger, + requestSetting, + _requestConfirmation, + ) => { + await requestSetting({ + name: 'testSetting', + description: 'Test Description', + envVar: 'TEST_VAR', + defaultValue: 'default123', + }); + }, + ); + + const { lastFrame, unmount } = render( + + + , + ); + + await waitFor(() => { + expect(lastFrame()).toContain('Test Description'); + expect(lastFrame()).toContain('[default: default123]'); + }); + + unmount(); + }); + + it('should mask sensitive default value in prompt', async () => { + (configureExtension as Mock).mockImplementation( + async ( + _mgr, + _name, + _scope, + _logger, + requestSetting, + _requestConfirmation, + ) => { + await requestSetting({ + name: 'sensitiveSetting', + description: 'Sensitive Description', + envVar: 'SENSITIVE_VAR', + sensitive: true, + defaultValue: 'secret123', + }); + }, + ); + + const { lastFrame, unmount } = render( + + + , + ); + + await waitFor(() => { + expect(lastFrame()).toContain('Sensitive Description'); + expect(lastFrame()).toContain('[default: ******]'); + expect(lastFrame()).not.toContain('secret123'); + }); + + unmount(); + }); + + it('should use default value when input is empty', async () => { + let resolvedValue: string | undefined; + + (configureExtension as Mock).mockImplementation( + async ( + _mgr, + _name, + _scope, + _logger, + requestSetting, + _requestConfirmation, + ) => { + resolvedValue = await requestSetting({ + name: 'testSetting', + description: 'Test Description', + envVar: 'TEST_VAR', + defaultValue: 'default123', + }); + }, + ); + + const { stdin, unmount, lastFrame } = render( + + + , + ); + + await waitFor(() => { + expect(lastFrame()).toContain('Test Description'); + }); + + // Press Enter without typing anything + await act(async () => { + stdin.write('\r'); + }); + + await waitFor(() => { + expect(resolvedValue).toBe('default123'); + }); + + unmount(); + }); + + it('should use input value even if default exists', async () => { + let resolvedValue: string | undefined; + + (configureExtension as Mock).mockImplementation( + async ( + _mgr, + _name, + _scope, + _logger, + requestSetting, + _requestConfirmation, + ) => { + resolvedValue = await requestSetting({ + name: 'testSetting', + description: 'Test Description', + envVar: 'TEST_VAR', + defaultValue: 'default123', + }); + }, + ); + + const { stdin, unmount, lastFrame } = render( + + + , + ); + + await waitFor(() => { + expect(lastFrame()).toContain('Test Description'); + }); + + // Type 'custom' and Enter + await act(async () => { + for (const char of 'custom') { + stdin.write(char); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }); + + await act(async () => { + stdin.write('\r'); + }); + + await waitFor(() => { + expect(resolvedValue).toBe('custom'); + }); + + unmount(); + }); + + it('should work normally without default value', async () => { + let resolvedValue: string | undefined; + + (configureExtension as Mock).mockImplementation( + async ( + _mgr, + _name, + _scope, + _logger, + requestSetting, + _requestConfirmation, + ) => { + resolvedValue = await requestSetting({ + name: 'testSetting', + description: 'Test Description', + envVar: 'TEST_VAR', + // No defaultValue + }); + }, + ); + + const { stdin, unmount, lastFrame } = render( + + + , + ); + + await waitFor(() => { + expect(lastFrame()).toContain('Test Description'); + expect(lastFrame()).not.toContain('[default:'); + }); + + // Type 'custom' and Enter + await act(async () => { + for (const char of 'custom') { + stdin.write(char); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }); + + await act(async () => { + stdin.write('\r'); + }); + + await waitFor(() => { + expect(resolvedValue).toBe('custom'); + }); + + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/ConfigExtensionDialog.tsx b/packages/cli/src/ui/components/ConfigExtensionDialog.tsx index bbecf440f5d..a5cb991dce3 100644 --- a/packages/cli/src/ui/components/ConfigExtensionDialog.tsx +++ b/packages/cli/src/ui/components/ConfigExtensionDialog.tsx @@ -266,12 +266,25 @@ export const ConfigExtensionDialog: React.FC = ({ {state.setting.description || state.setting.envVar} + {state.setting.defaultValue !== undefined && ( + + {' '} + [default:{' '} + {state.setting.sensitive ? '******' : state.setting.defaultValue}] + + )} {'> '} { + if (val === '' && state.setting.defaultValue !== undefined) { + handleSettingSubmit(state.setting.defaultValue); + } else { + handleSettingSubmit(val); + } + }} focus={true} placeholder={`Enter value for ${state.setting.name}`} />