Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/cli/src/commands/extensions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
import type { ExtensionConfig } from '../../config/extension.js';
import prompts from 'prompts';
import {
promptForSetting,
updateSetting,
type ExtensionSetting,
getScopedEnvContents,
Expand Down Expand Up @@ -50,6 +49,24 @@ const defaultRequestConfirmation: RequestConfirmationCallback = async (
return response.confirm;
};

export async function promptForSetting(
setting: ExtensionSetting,
): Promise<string> {
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({
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/config/extensions/extensionSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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;
}
Expand Down
275 changes: 275 additions & 0 deletions packages/cli/src/ui/components/ConfigExtensionDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<KeypressProvider>
<ConfigExtensionDialog
extensionManager={mockExtensionManager}
onClose={mockOnClose}
extensionName="test-ext"
loggerAdapter={mockLogger}
/>
</KeypressProvider>,
);

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(
<KeypressProvider>
<ConfigExtensionDialog
extensionManager={mockExtensionManager}
onClose={mockOnClose}
extensionName="test-ext"
loggerAdapter={mockLogger}
/>
</KeypressProvider>,
);

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(
<KeypressProvider>
<ConfigExtensionDialog
extensionManager={mockExtensionManager}
onClose={mockOnClose}
extensionName="test-ext"
loggerAdapter={mockLogger}
/>
</KeypressProvider>,
);

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(
<KeypressProvider>
<ConfigExtensionDialog
extensionManager={mockExtensionManager}
onClose={mockOnClose}
extensionName="test-ext"
loggerAdapter={mockLogger}
/>
</KeypressProvider>,
);

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(
<KeypressProvider>
<ConfigExtensionDialog
extensionManager={mockExtensionManager}
onClose={mockOnClose}
extensionName="test-ext"
loggerAdapter={mockLogger}
/>
</KeypressProvider>,
);

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();
});
});
15 changes: 14 additions & 1 deletion packages/cli/src/ui/components/ConfigExtensionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,12 +266,25 @@ export const ConfigExtensionDialog: React.FC<ConfigExtensionDialogProps> = ({
</Text>
<Text color={theme.text.secondary}>
{state.setting.description || state.setting.envVar}
{state.setting.defaultValue !== undefined && (
<Text color={theme.text.secondary}>
{' '}
[default:{' '}
{state.setting.sensitive ? '******' : state.setting.defaultValue}]
</Text>
)}
</Text>
<Box flexDirection="row" marginTop={1}>
<Text color={theme.text.accent}>{'> '}</Text>
<TextInput
buffer={settingBuffer}
onSubmit={handleSettingSubmit}
onSubmit={(val) => {
if (val === '' && state.setting.defaultValue !== undefined) {
handleSettingSubmit(state.setting.defaultValue);
} else {
handleSettingSubmit(val);
}
}}
focus={true}
placeholder={`Enter value for ${state.setting.name}`}
/>
Expand Down