mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-08 20:30:53 -07:00
Add extension settings to be requested on install (#9802)
This commit is contained in:
167
packages/cli/src/config/extensions/extensionSettings.test.ts
Normal file
167
packages/cli/src/config/extensions/extensionSettings.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
maybePromptForSettings,
|
||||
promptForSetting,
|
||||
type ExtensionSetting,
|
||||
} from './extensionSettings.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
import { ExtensionStorage } from '../extension.js';
|
||||
import prompts from 'prompts';
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
vi.mock('prompts');
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
...mockedOs,
|
||||
homedir: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('extensionSettings', () => {
|
||||
let tempHomeDir: string;
|
||||
let extensionDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`;
|
||||
extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
|
||||
// Spy and mock the method, but also create the directory so we can write to it.
|
||||
vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
|
||||
extensionDir,
|
||||
);
|
||||
fs.mkdirSync(extensionDir, { recursive: true });
|
||||
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
|
||||
vi.mocked(prompts).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('maybePromptForSettings', () => {
|
||||
const mockRequestSetting = vi.fn(
|
||||
async (setting: ExtensionSetting) => `mock-${setting.envVar}`,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequestSetting.mockClear();
|
||||
});
|
||||
|
||||
it('should do nothing if settings are undefined', async () => {
|
||||
const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' };
|
||||
await maybePromptForSettings(config, mockRequestSetting);
|
||||
expect(mockRequestSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing if settings are empty', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [],
|
||||
};
|
||||
await maybePromptForSettings(config, mockRequestSetting);
|
||||
expect(mockRequestSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call requestSetting for each setting', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2' },
|
||||
],
|
||||
};
|
||||
await maybePromptForSettings(config, mockRequestSetting);
|
||||
expect(mockRequestSetting).toHaveBeenCalledTimes(2);
|
||||
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]);
|
||||
expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]);
|
||||
});
|
||||
|
||||
it('should write the .env file with the correct content', async () => {
|
||||
const config: ExtensionConfig = {
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{ name: 's1', description: 'd1', envVar: 'VAR1' },
|
||||
{ name: 's2', description: 'd2', envVar: 'VAR2' },
|
||||
],
|
||||
};
|
||||
await maybePromptForSettings(config, mockRequestSetting);
|
||||
|
||||
const expectedEnvPath = path.join(extensionDir, '.env');
|
||||
const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
|
||||
const expectedContent = 'VAR1=mock-VAR1\nVAR2=mock-VAR2\n';
|
||||
|
||||
expect(actualContent).toBe(expectedContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptForSetting', () => {
|
||||
// it('should use prompts with type "password" for sensitive settings', async () => {
|
||||
// const setting: ExtensionSetting = {
|
||||
// name: 'API Key',
|
||||
// description: 'Your secret key',
|
||||
// envVar: 'API_KEY',
|
||||
// sensitive: true,
|
||||
// };
|
||||
// vi.mocked(prompts).mockResolvedValue({ value: 'secret-key' });
|
||||
|
||||
// const result = await promptForSetting(setting);
|
||||
|
||||
// expect(prompts).toHaveBeenCalledWith({
|
||||
// type: 'password',
|
||||
// name: 'value',
|
||||
// message: 'API Key\nYour secret key',
|
||||
// });
|
||||
// expect(result).toBe('secret-key');
|
||||
// });
|
||||
|
||||
it('should use prompts with type "text" for non-sensitive settings', async () => {
|
||||
const setting: ExtensionSetting = {
|
||||
name: 'Username',
|
||||
description: 'Your public username',
|
||||
envVar: 'USERNAME',
|
||||
// sensitive: false,
|
||||
};
|
||||
vi.mocked(prompts).mockResolvedValue({ value: 'test-user' });
|
||||
|
||||
const result = await promptForSetting(setting);
|
||||
|
||||
expect(prompts).toHaveBeenCalledWith({
|
||||
type: 'text',
|
||||
name: 'value',
|
||||
message: 'Username\nYour public username',
|
||||
});
|
||||
expect(result).toBe('test-user');
|
||||
});
|
||||
|
||||
it('should default to "text" if sensitive is undefined', async () => {
|
||||
const setting: ExtensionSetting = {
|
||||
name: 'Username',
|
||||
description: 'Your public username',
|
||||
envVar: 'USERNAME',
|
||||
};
|
||||
vi.mocked(prompts).mockResolvedValue({ value: 'test-user' });
|
||||
|
||||
const result = await promptForSetting(setting);
|
||||
|
||||
expect(prompts).toHaveBeenCalledWith({
|
||||
type: 'text',
|
||||
name: 'value',
|
||||
message: 'Username\nYour public username',
|
||||
});
|
||||
expect(result).toBe('test-user');
|
||||
});
|
||||
});
|
||||
});
|
||||
96
packages/cli/src/config/extensions/extensionSettings.ts
Normal file
96
packages/cli/src/config/extensions/extensionSettings.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
import { ExtensionStorage } from '../extension.js';
|
||||
import type { ExtensionConfig } from '../extension.js';
|
||||
|
||||
import prompts from 'prompts';
|
||||
|
||||
export interface ExtensionSetting {
|
||||
name: string;
|
||||
description: string;
|
||||
envVar: string;
|
||||
}
|
||||
|
||||
export async function maybePromptForSettings(
|
||||
extensionConfig: ExtensionConfig,
|
||||
requestSetting: (setting: ExtensionSetting) => Promise<string>,
|
||||
previousExtensionConfig?: ExtensionConfig,
|
||||
previousSettings?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const { name: extensionName, settings } = extensionConfig;
|
||||
const envFilePath = new ExtensionStorage(extensionName).getEnvFilePath();
|
||||
|
||||
if (!settings || settings.length === 0) {
|
||||
// No settings for this extension. Clear any existing .env file.
|
||||
if (fsSync.existsSync(envFilePath)) {
|
||||
await fs.writeFile(envFilePath, '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let settingsToPrompt = settings;
|
||||
if (previousExtensionConfig) {
|
||||
const oldSettings = new Set(
|
||||
previousExtensionConfig.settings?.map((s) => s.name) || [],
|
||||
);
|
||||
settingsToPrompt = settingsToPrompt.filter((s) => !oldSettings.has(s.name));
|
||||
}
|
||||
|
||||
const allSettings: Record<string, string> = { ...(previousSettings ?? {}) };
|
||||
|
||||
if (settingsToPrompt && settingsToPrompt.length > 0) {
|
||||
for (const setting of settingsToPrompt) {
|
||||
const answer = await requestSetting(setting);
|
||||
allSettings[setting.envVar] = answer;
|
||||
}
|
||||
}
|
||||
|
||||
const validEnvVars = new Set(settings.map((s) => s.envVar));
|
||||
const finalSettings: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(allSettings)) {
|
||||
if (validEnvVars.has(key)) {
|
||||
finalSettings[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
let envContent = '';
|
||||
for (const [key, value] of Object.entries(finalSettings)) {
|
||||
envContent += `${key}=${value}\n`;
|
||||
}
|
||||
|
||||
await fs.writeFile(envFilePath, envContent);
|
||||
}
|
||||
|
||||
export async function promptForSetting(
|
||||
setting: ExtensionSetting,
|
||||
): Promise<string> {
|
||||
const response = await prompts({
|
||||
// type: setting.sensitive ? 'password' : 'text',
|
||||
type: 'text',
|
||||
name: 'value',
|
||||
message: `${setting.name}\n${setting.description}`,
|
||||
});
|
||||
return response.value;
|
||||
}
|
||||
|
||||
export function getEnvContents(
|
||||
extensionStorage: ExtensionStorage,
|
||||
): Record<string, string> {
|
||||
let customEnv: Record<string, string> = {};
|
||||
if (fsSync.existsSync(extensionStorage.getEnvFilePath())) {
|
||||
const envFile = fsSync.readFileSync(
|
||||
extensionStorage.getEnvFilePath(),
|
||||
'utf-8',
|
||||
);
|
||||
customEnv = dotenv.parse(envFile);
|
||||
}
|
||||
return customEnv;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { type ExtensionEnablementManager } from './extensionEnablement.js';
|
||||
import { promptForSetting } from './extensionSettings.js';
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
name: string;
|
||||
@@ -66,18 +67,19 @@ export async function updateExtension(
|
||||
|
||||
const tempDir = await ExtensionStorage.createTmpDir();
|
||||
try {
|
||||
const previousExtensionConfig = await loadExtensionConfig({
|
||||
const previousExtensionConfig = loadExtensionConfig({
|
||||
extensionDir: extension.path,
|
||||
workspaceDir: cwd,
|
||||
extensionEnablementManager,
|
||||
});
|
||||
|
||||
await installOrUpdateExtension(
|
||||
installMetadata,
|
||||
requestConsent,
|
||||
cwd,
|
||||
previousExtensionConfig,
|
||||
promptForSetting,
|
||||
);
|
||||
|
||||
const updatedExtensionStorage = new ExtensionStorage(extension.name);
|
||||
const updatedExtension = loadExtension({
|
||||
extensionDir: updatedExtensionStorage.getExtensionDir(),
|
||||
|
||||
Reference in New Issue
Block a user