From 750c0e366f2074c35975ca192aebb4f87a7bc731 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 23 Oct 2025 11:47:08 -0400 Subject: [PATCH] Add extension settings to be requested on install (#9802) --- docs/extensions/index.md | 34 +++ docs/get-started/configuration.md | 11 +- package-lock.json | 41 ++++ package.json | 1 + packages/cli/package.json | 3 +- .../cli/src/commands/extensions/install.ts | 4 + packages/cli/src/config/extension.test.ts | 211 ++++++++++++++++++ packages/cli/src/config/extension.ts | 57 ++++- .../extensions/extensionSettings.test.ts | 167 ++++++++++++++ .../config/extensions/extensionSettings.ts | 96 ++++++++ packages/cli/src/config/extensions/update.ts | 6 +- .../cli/src/test-utils/createExtension.ts | 4 +- packages/cli/src/utils/envVarResolver.ts | 26 ++- 13 files changed, 641 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/config/extensions/extensionSettings.test.ts create mode 100644 packages/cli/src/config/extensions/extensionSettings.ts diff --git a/docs/extensions/index.md b/docs/extensions/index.md index d80d9af7ca..e07930dcf4 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -177,6 +177,40 @@ When Gemini CLI starts, it loads all the extensions and merges their configurations. If there are any conflicts, the workspace configuration takes precedence. +### Settings + +Extensions can define settings that the user will be prompted to provide upon +installation. This is useful for things like API keys, URLs, or other +configuration that the extension needs to function. + +To define settings, add a `settings` array to your `gemini-extension.json` file. +Each object in the array should have the following properties: + +- `name`: A user-friendly name for the setting. +- `description`: A description of the setting and what it's used for. +- `envVar`: The name of the environment variable that the setting will be stored + as. + +**Example** + +```json +{ + "name": "my-api-extension", + "version": "1.0.0", + "settings": [ + { + "name": "API Key", + "description": "Your API key for the service.", + "envVar": "MY_API_KEY" + } + ] +} +``` + +When a user installs this extension, they will be prompted to enter their API +key. The value will be saved to a `.env` file in the extension's directory +(e.g., `/.gemini/extensions/my-api-extension/.env`). + ### Custom commands Extensions can provide [custom commands](../cli/custom-commands.md) by placing diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 6b7cabc331..1ee9eb7468 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -65,11 +65,12 @@ locations for these files: controls over users' Gemini CLI setups. **Note on environment variables in settings:** String values within your -`settings.json` files can reference environment variables using either -`$VAR_NAME` or `${VAR_NAME}` syntax. These variables will be automatically -resolved when the settings are loaded. For example, if you have an environment -variable `MY_API_TOKEN`, you could use it in `settings.json` like this: -`"apiKey": "$MY_API_TOKEN"`. +`settings.json` and `gemini-extension.json` files can reference environment +variables using either `$VAR_NAME` or `${VAR_NAME}` syntax. These variables will +be automatically resolved when the settings are loaded. For example, if you have +an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like +this: `"apiKey": "$MY_API_TOKEN"`. Additionally, each extension can have its own +`.env` file in its directory, which will be loaded automatically. > **Note for Enterprise Users:** For guidance on deploying and managing Gemini > CLI in a corporate environment, please see the diff --git a/package-lock.json b/package-lock.json index ce67774cbc..dee492eb85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", + "@types/prompts": "^2.4.9", "@types/shell-quote": "^1.7.5", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", @@ -4295,6 +4296,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prompts": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz", + "integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "kleur": "^3.0.3" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -11384,6 +11396,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -13651,6 +13672,19 @@ "dev": true, "license": "MIT" }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -15002,6 +15036,12 @@ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", "license": "MIT" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -17965,6 +18005,7 @@ "lowlight": "^3.3.0", "mnemonist": "^0.40.3", "open": "^10.1.2", + "prompts": "^2.4.2", "react": "^19.1.0", "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", diff --git a/package.json b/package.json index 416d83d0e0..c0a3885231 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@types/mime-types": "^3.0.1", "@types/minimatch": "^5.1.2", "@types/mock-fs": "^4.13.4", + "@types/prompts": "^2.4.9", "@types/shell-quote": "^1.7.5", "@vitest/coverage-v8": "^3.1.1", "@vitest/eslint-plugin": "^1.3.4", diff --git a/packages/cli/package.json b/packages/cli/package.json index 1087f409e2..a2e62e4a33 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -38,6 +38,7 @@ "comment-json": "^4.2.5", "diff": "^7.0.0", "dotenv": "^17.1.0", + "extract-zip": "^2.0.1", "fzf": "^0.5.2", "glob": "^10.4.5", "highlight.js": "^11.11.1", @@ -47,6 +48,7 @@ "lowlight": "^3.3.0", "mnemonist": "^0.40.3", "open": "^10.1.2", + "prompts": "^2.4.2", "react": "^19.1.0", "read-package-up": "^11.0.0", "shell-quote": "^1.8.3", @@ -56,7 +58,6 @@ "strip-json-comments": "^3.1.1", "tar": "^7.5.1", "undici": "^7.10.0", - "extract-zip": "^2.0.1", "update-notifier": "^7.3.1", "wrap-ansi": "9.0.2", "yargs": "^17.7.2", diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index fbe953c11f..58120f084e 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -16,6 +16,7 @@ import { } from '@google/gemini-cli-core'; import { getErrorMessage } from '../../utils/errors.js'; import { stat } from 'node:fs/promises'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; interface InstallArgs { source: string; @@ -69,6 +70,9 @@ export async function handleInstall(args: InstallArgs) { const name = await installOrUpdateExtension( installMetadata, requestConsent, + process.cwd(), + undefined, + promptForSetting, ); debugLogger.log(`Extension "${name}" installed successfully and enabled.`); } catch (error) { diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index d87df818c9..7bc6bd116c 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -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 => 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 => '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, diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index e5a1cff5bc..482f7eabbb 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -35,7 +35,6 @@ import { type JsonObject, } from './extensions/variables.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; -import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { randomUUID, createHash } from 'node:crypto'; import { cloneFromGit, @@ -45,15 +44,24 @@ import { import type { LoadExtensionContext } from './extensions/variableSchema.js'; import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; import chalk from 'chalk'; +import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import type { ConfirmationRequest } from '../ui/types.js'; import { escapeAnsiCtrlCodes } from '../ui/utils/textUtils.js'; +import { + getEnvContents, + maybePromptForSettings, + type ExtensionSetting, +} from './extensions/extensionSettings.js'; export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; +export const EXTENSION_SETTINGS_FILENAME = '.env'; + export const INSTALL_WARNING_MESSAGE = '**The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.**'; + /** * Extension definition as written to disk in gemini-extension.json files. * This should *not* be referenced outside of the logic for reading files. @@ -61,12 +69,13 @@ export const INSTALL_WARNING_MESSAGE = * outside of the loading process that data needs to be stored on the * GeminiCLIExtension class defined in Core. */ -interface ExtensionConfig { +export interface ExtensionConfig { name: string; version: string; mcpServers?: Record; contextFileName?: string | string[]; excludeTools?: string[]; + settings?: ExtensionSetting[]; } export interface ExtensionUpdateInfo { @@ -93,6 +102,10 @@ export class ExtensionStorage { return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME); } + getEnvFilePath(): string { + return path.join(this.getExtensionDir(), EXTENSION_SETTINGS_FILENAME); + } + static getUserExtensionsDir(): string { const storage = new Storage(os.homedir()); return storage.getExtensionsDir(); @@ -182,7 +195,8 @@ export function loadExtension( extensionEnablementManager, }); - config = resolveEnvVarsInObject(config); + const customEnv = getEnvContents(new ExtensionStorage(config.name)); + config = resolveEnvVarsInObject(config, customEnv); if (config.mcpServers) { config.mcpServers = Object.fromEntries( @@ -371,13 +385,14 @@ export async function installOrUpdateExtension( requestConsent: (consent: string) => Promise, cwd: string = process.cwd(), previousExtensionConfig?: ExtensionConfig, + requestSetting?: (setting: ExtensionSetting) => Promise, ): Promise { const isUpdate = !!previousExtensionConfig; const telemetryConfig = getTelemetryConfig(cwd); let newExtensionConfig: ExtensionConfig | null = null; let localSourcePath: string | undefined; const extensionEnablementManager = new ExtensionEnablementManager(); - + // path.join(tempDir, EXTENSION_SETTINGS_FILENAME) try { const settings = loadSettings(cwd).merged; if (!isWorkspaceTrusted(settings).isTrusted) { @@ -451,6 +466,25 @@ export async function installOrUpdateExtension( extensionEnablementManager, }); + if (isUpdate && previousExtensionConfig && installMetadata.autoUpdate) { + const oldSettings = new Set( + previousExtensionConfig.settings?.map((s) => s.name) || [], + ); + const newSettings = new Set( + newExtensionConfig.settings?.map((s) => s.name) || [], + ); + + const settingsAreEqual = + oldSettings.size === newSettings.size && + [...oldSettings].every((value) => newSettings.has(value)); + + if (!settingsAreEqual) { + throw new Error( + `Extension "${newExtensionConfig.name}" has settings changes and cannot be auto-updated. Please update manually.`, + ); + } + } + const newExtensionName = newExtensionConfig.name; if (!isUpdate) { const installedExtensions = loadExtensions( @@ -476,12 +510,25 @@ export async function installOrUpdateExtension( const extensionStorage = new ExtensionStorage(newExtensionName); const destinationPath = extensionStorage.getExtensionDir(); - + let previousSettings: Record | undefined; if (isUpdate) { + previousSettings = getEnvContents(extensionStorage); await uninstallExtension(newExtensionName, isUpdate, cwd); } await fs.promises.mkdir(destinationPath, { recursive: true }); + if (requestSetting !== undefined) { + if (isUpdate && previousExtensionConfig) { + await maybePromptForSettings( + newExtensionConfig, + requestSetting, + previousExtensionConfig, + previousSettings, + ); + } else if (!isUpdate) { + await maybePromptForSettings(newExtensionConfig, requestSetting); + } + } if ( installMetadata.type === 'local' || diff --git a/packages/cli/src/config/extensions/extensionSettings.test.ts b/packages/cli/src/config/extensions/extensionSettings.test.ts new file mode 100644 index 0000000000..e05c573f3b --- /dev/null +++ b/packages/cli/src/config/extensions/extensionSettings.test.ts @@ -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(); + 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'); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts new file mode 100644 index 0000000000..dbc28f8e07 --- /dev/null +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -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, + previousExtensionConfig?: ExtensionConfig, + previousSettings?: Record, +): Promise { + 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 = { ...(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 = {}; + 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 { + 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 { + let customEnv: Record = {}; + if (fsSync.existsSync(extensionStorage.getEnvFilePath())) { + const envFile = fsSync.readFileSync( + extensionStorage.getEnvFilePath(), + 'utf-8', + ); + customEnv = dotenv.parse(envFile); + } + return customEnv; +} diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts index 6f09fd7703..99d80eac5b 100644 --- a/packages/cli/src/config/extensions/update.ts +++ b/packages/cli/src/config/extensions/update.ts @@ -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(), diff --git a/packages/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts index 6500a6d1f5..452138e959 100644 --- a/packages/cli/src/test-utils/createExtension.ts +++ b/packages/cli/src/test-utils/createExtension.ts @@ -14,6 +14,7 @@ import { type MCPServerConfig, type ExtensionInstallMetadata, } from '@google/gemini-cli-core'; +import type { ExtensionSetting } from '../config/extensions/extensionSettings.js'; export function createExtension({ extensionsDir = 'extensions-dir', @@ -23,12 +24,13 @@ export function createExtension({ contextFileName = undefined as string | undefined, mcpServers = {} as Record, installMetadata = undefined as ExtensionInstallMetadata | undefined, + settings = undefined as ExtensionSetting[] | undefined, } = {}): string { const extDir = path.join(extensionsDir, name); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name, version, contextFileName, mcpServers }), + JSON.stringify({ name, version, contextFileName, mcpServers, settings }), ); if (addContextFile) { diff --git a/packages/cli/src/utils/envVarResolver.ts b/packages/cli/src/utils/envVarResolver.ts index d6d50d0206..7374024e95 100644 --- a/packages/cli/src/utils/envVarResolver.ts +++ b/packages/cli/src/utils/envVarResolver.ts @@ -17,10 +17,16 @@ * resolveEnvVarsInString("URL: ${BASE_URL}/api") // Returns "URL: https://api.example.com/api" * resolveEnvVarsInString("Missing: $UNDEFINED_VAR") // Returns "Missing: $UNDEFINED_VAR" */ -export function resolveEnvVarsInString(value: string): string { +export function resolveEnvVarsInString( + value: string, + customEnv?: Record, +): string { const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} return value.replace(envVarRegex, (match, varName1, varName2) => { const varName = varName1 || varName2; + if (customEnv && typeof customEnv[varName] === 'string') { + return customEnv[varName]!; + } if (process && process.env && typeof process.env[varName] === 'string') { return process.env[varName]!; } @@ -47,8 +53,11 @@ export function resolveEnvVarsInString(value: string): string { * }; * const resolved = resolveEnvVarsInObject(config); */ -export function resolveEnvVarsInObject(obj: T): T { - return resolveEnvVarsInObjectInternal(obj, new WeakSet()); +export function resolveEnvVarsInObject( + obj: T, + customEnv?: Record, +): T { + return resolveEnvVarsInObjectInternal(obj, new WeakSet(), customEnv); } /** @@ -61,6 +70,7 @@ export function resolveEnvVarsInObject(obj: T): T { function resolveEnvVarsInObjectInternal( obj: T, visited: WeakSet, + customEnv?: Record, ): T { if ( obj === null || @@ -72,7 +82,7 @@ function resolveEnvVarsInObjectInternal( } if (typeof obj === 'string') { - return resolveEnvVarsInString(obj) as unknown as T; + return resolveEnvVarsInString(obj, customEnv) as unknown as T; } if (Array.isArray(obj)) { @@ -84,7 +94,7 @@ function resolveEnvVarsInObjectInternal( visited.add(obj); const result = obj.map((item) => - resolveEnvVarsInObjectInternal(item, visited), + resolveEnvVarsInObjectInternal(item, visited, customEnv), ) as unknown as T; visited.delete(obj); return result; @@ -101,7 +111,11 @@ function resolveEnvVarsInObjectInternal( const newObj = { ...obj } as T; for (const key in newObj) { if (Object.prototype.hasOwnProperty.call(newObj, key)) { - newObj[key] = resolveEnvVarsInObjectInternal(newObj[key], visited); + newObj[key] = resolveEnvVarsInObjectInternal( + newObj[key], + visited, + customEnv, + ); } } visited.delete(obj as object);