mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 20:14:44 -07:00
Add extension settings to be requested on install (#9802)
This commit is contained in:
@@ -35,6 +35,7 @@ import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
import { createExtension } from '../test-utils/createExtension.js';
|
||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
import { join } from 'node:path';
|
||||
import type { ExtensionSetting } from './extensions/extensionSettings.js';
|
||||
|
||||
const mockGit = {
|
||||
clone: vi.fn(),
|
||||
@@ -340,6 +341,36 @@ describe('extension tests', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should resolve environment variables from an extension .env file', () => {
|
||||
const extDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: {
|
||||
API_KEY: '$MY_API_KEY',
|
||||
STATIC_VALUE: 'no-substitution',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const envFilePath = path.join(extDir, '.env');
|
||||
fs.writeFileSync(envFilePath, 'MY_API_KEY=test-key-from-file\n');
|
||||
|
||||
const extensions = loadExtensions(new ExtensionEnablementManager());
|
||||
|
||||
expect(extensions).toHaveLength(1);
|
||||
const extension = extensions[0];
|
||||
const serverConfig = extension.mcpServers!['test-server'];
|
||||
expect(serverConfig.env).toBeDefined();
|
||||
expect(serverConfig.env!['API_KEY']).toBe('test-key-from-file');
|
||||
expect(serverConfig.env!['STATIC_VALUE']).toBe('no-substitution');
|
||||
});
|
||||
|
||||
it('should handle missing environment variables gracefully', () => {
|
||||
const userExtensionsDir = path.join(
|
||||
tempHomeDir,
|
||||
@@ -1033,6 +1064,186 @@ This extension will run the following MCP servers:
|
||||
expect(mockRequestConsent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prompt for settings if promptForSettings', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'API Key',
|
||||
description: 'Your API key for the service.',
|
||||
envVar: 'MY_API_KEY',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const promptForSettingsMock = vi.fn(
|
||||
async (_: ExtensionSetting): Promise<string> => Promise.resolve(''),
|
||||
);
|
||||
await installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
process.cwd(),
|
||||
undefined,
|
||||
promptForSettingsMock,
|
||||
);
|
||||
|
||||
expect(promptForSettingsMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not prompt for settings if promptForSettings is false', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'API Key',
|
||||
description: 'Your API key for the service.',
|
||||
envVar: 'MY_API_KEY',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await installOrUpdateExtension(
|
||||
{ source: sourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should only prompt for new settings on update, and preserve old settings', async () => {
|
||||
// 1. Create and install the "old" version of the extension.
|
||||
const oldSourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir, // Create it in a temp location first
|
||||
name: 'my-local-extension',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'API Key',
|
||||
description: 'Your API key for the service.',
|
||||
envVar: 'MY_API_KEY',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Install it so it exists in the userExtensionsDir
|
||||
await installOrUpdateExtension(
|
||||
{ source: oldSourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
process.cwd(),
|
||||
undefined,
|
||||
async () => 'old-api-key',
|
||||
);
|
||||
|
||||
const envPath = new ExtensionStorage(
|
||||
'my-local-extension',
|
||||
).getEnvFilePath();
|
||||
expect(fs.existsSync(envPath)).toBe(true);
|
||||
let envContent = fs.readFileSync(envPath, 'utf-8');
|
||||
expect(envContent).toContain('MY_API_KEY=old-api-key');
|
||||
|
||||
// 2. Create the "new" version of the extension in a new source directory.
|
||||
const newSourceExtDir = createExtension({
|
||||
extensionsDir: path.join(tempHomeDir, 'new-source'), // Another temp location
|
||||
name: 'my-local-extension', // Same name
|
||||
version: '1.1.0', // New version
|
||||
settings: [
|
||||
{
|
||||
name: 'API Key',
|
||||
description: 'Your API key for the service.',
|
||||
envVar: 'MY_API_KEY',
|
||||
},
|
||||
{
|
||||
name: 'New Setting',
|
||||
description: 'A new setting.',
|
||||
envVar: 'NEW_SETTING',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const previousExtensionConfig = loadExtensionConfig({
|
||||
extensionDir: path.join(userExtensionsDir, 'my-local-extension'),
|
||||
workspaceDir: process.cwd(),
|
||||
extensionEnablementManager: new ExtensionEnablementManager(),
|
||||
});
|
||||
|
||||
const promptForSettingsMock = vi.fn(
|
||||
async (_: ExtensionSetting): Promise<string> => 'new-setting-value',
|
||||
);
|
||||
|
||||
// 3. Call installOrUpdateExtension to perform the update.
|
||||
await installOrUpdateExtension(
|
||||
{ source: newSourceExtDir, type: 'local' },
|
||||
async (_) => true,
|
||||
process.cwd(),
|
||||
previousExtensionConfig,
|
||||
promptForSettingsMock,
|
||||
);
|
||||
|
||||
expect(promptForSettingsMock).toHaveBeenCalledTimes(1);
|
||||
expect(promptForSettingsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'New Setting' }),
|
||||
);
|
||||
|
||||
expect(fs.existsSync(envPath)).toBe(true);
|
||||
envContent = fs.readFileSync(envPath, 'utf-8');
|
||||
expect(envContent).toContain('MY_API_KEY=old-api-key');
|
||||
expect(envContent).toContain('NEW_SETTING=new-setting-value');
|
||||
});
|
||||
|
||||
it('should fail auto-update if settings have changed', async () => {
|
||||
// 1. Install initial version with autoUpdate: true
|
||||
const oldSourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-auto-update-ext',
|
||||
version: '1.0.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'OLD_SETTING',
|
||||
envVar: 'OLD_SETTING',
|
||||
description: 'An old setting',
|
||||
},
|
||||
],
|
||||
});
|
||||
await installOrUpdateExtension(
|
||||
{ source: oldSourceExtDir, type: 'local', autoUpdate: true },
|
||||
async () => true,
|
||||
);
|
||||
|
||||
// 2. Create new version with different settings
|
||||
const newSourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
name: 'my-auto-update-ext',
|
||||
version: '1.1.0',
|
||||
settings: [
|
||||
{
|
||||
name: 'NEW_SETTING',
|
||||
envVar: 'NEW_SETTING',
|
||||
description: 'A new setting',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const previousExtensionConfig = loadExtensionConfig({
|
||||
extensionDir: path.join(userExtensionsDir, 'my-auto-update-ext'),
|
||||
workspaceDir: process.cwd(),
|
||||
extensionEnablementManager: new ExtensionEnablementManager(),
|
||||
});
|
||||
|
||||
// 3. Attempt to update and assert it fails
|
||||
await expect(
|
||||
installOrUpdateExtension(
|
||||
{ source: newSourceExtDir, type: 'local', autoUpdate: true },
|
||||
async () => true,
|
||||
process.cwd(),
|
||||
previousExtensionConfig,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
'Extension "my-auto-update-ext" has settings changes and cannot be auto-updated. Please update manually.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error for invalid extension names', async () => {
|
||||
const sourceExtDir = createExtension({
|
||||
extensionsDir: tempHomeDir,
|
||||
|
||||
Reference in New Issue
Block a user