diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 37c82e01..af73a266 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -15,7 +15,9 @@ import { disableExtension, enableExtension, installExtension, + loadExtension, loadExtensions, + performWorkspaceExtensionMigration, uninstallExtension, updateExtension, } from './extension.js'; @@ -46,6 +48,7 @@ vi.mock('child_process', async (importOriginal) => { execSync: vi.fn(), }; }); + const EXTENSIONS_DIRECTORY_NAME = path.join('.gemini', 'extensions'); describe('loadExtensions', () => { @@ -430,6 +433,80 @@ describe('uninstallExtension', () => { }); }); +describe('performWorkspaceExtensionMigration', () => { + let tempWorkspaceDir: string; + let tempHomeDir: string; + + beforeEach(() => { + tempWorkspaceDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), + ); + tempHomeDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-test-home-'), + ); + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + }); + + afterEach(() => { + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + fs.rmSync(tempHomeDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('should install the extensions in the user directory', async () => { + const workspaceExtensionsDir = path.join( + tempWorkspaceDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + const ext1Path = createExtension(workspaceExtensionsDir, 'ext1', '1.0.0'); + const ext2Path = createExtension(workspaceExtensionsDir, 'ext2', '1.0.0'); + const extensionsToMigrate = [ + loadExtension(ext1Path)!, + loadExtension(ext2Path)!, + ]; + const failed = + await performWorkspaceExtensionMigration(extensionsToMigrate); + + expect(failed).toEqual([]); + + const userExtensionsDir = path.join(tempHomeDir, '.gemini', 'extensions'); + const userExt1Path = path.join(userExtensionsDir, 'ext1'); + const extensions = loadExtensions(tempWorkspaceDir); + + expect(extensions).toHaveLength(2); + const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME); + expect(fs.existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: ext1Path, + type: 'local', + }); + }); + + it('should return the names of failed installations', async () => { + const workspaceExtensionsDir = path.join( + tempWorkspaceDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); + + const ext1Path = createExtension(workspaceExtensionsDir, 'ext1', '1.0.0'); + + const extensions = [ + loadExtension(ext1Path)!, + { + path: '/ext/path/1', + config: { name: 'ext2', version: '1.0.0' }, + contextFiles: [], + }, + ]; + + const failed = await performWorkspaceExtensionMigration(extensions); + expect(failed).toEqual(['ext2']); + }); +}); + function createExtension( extensionsDir: string, name: string, diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 93c390d1..ca5258c6 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -77,15 +77,51 @@ export class ExtensionStorage { } } +export function getWorkspaceExtensions(workspaceDir: string): Extension[] { + return loadExtensionsFromDir(workspaceDir); +} + +async function copyExtension( + source: string, + destination: string, +): Promise { + await fs.promises.cp(source, destination, { recursive: true }); +} + +export async function performWorkspaceExtensionMigration( + extensions: Extension[], +): Promise { + const failedInstallNames: string[] = []; + + for (const extension of extensions) { + try { + const installMetadata: ExtensionInstallMetadata = { + source: extension.path, + type: 'local', + }; + await installExtension(installMetadata); + } catch (_) { + failedInstallNames.push(extension.config.name); + } + } + return failedInstallNames; +} + export function loadExtensions(workspaceDir: string): Extension[] { - const allExtensions = [ - ...loadExtensionsFromDir(workspaceDir), - ...loadExtensionsFromDir(os.homedir()), - ]; + const settings = loadSettings(workspaceDir).merged; + const disabledExtensions = settings.extensions?.disabled ?? []; + const allExtensions = [...loadUserExtensions()]; + + if (!settings.extensionManagement) { + allExtensions.push(...getWorkspaceExtensions(workspaceDir)); + } const uniqueExtensions = new Map(); for (const extension of allExtensions) { - if (!uniqueExtensions.has(extension.config.name)) { + if ( + !uniqueExtensions.has(extension.config.name) && + !disabledExtensions.includes(extension.config.name) + ) { uniqueExtensions.set(extension.config.name, extension); } } @@ -283,18 +319,6 @@ async function cloneFromGit( } } -/** - * Copies an extension from a source to a destination path. - * @param source The source path of the extension. - * @param destination The destination path to copy the extension to. - */ -async function copyExtension( - source: string, - destination: string, -): Promise { - await fs.promises.cp(source, destination, { recursive: true }); -} - export async function installExtension( installMetadata: ExtensionInstallMetadata, cwd: string = process.cwd(), diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index bff7f487..28d6c700 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -122,6 +122,10 @@ describe('Settings Loading and Merging', () => { mcpServers: {}, includeDirectories: [], chatCompression: {}, + extensions: { + disabled: [], + workspacesWithMigrationNudge: [], + }, }); expect(settings.errors.length).toBe(0); }); @@ -157,6 +161,10 @@ describe('Settings Loading and Merging', () => { mcpServers: {}, includeDirectories: [], chatCompression: {}, + extensions: { + disabled: [], + workspacesWithMigrationNudge: [], + }, }); }); @@ -192,6 +200,10 @@ describe('Settings Loading and Merging', () => { mcpServers: {}, includeDirectories: [], chatCompression: {}, + extensions: { + disabled: [], + workspacesWithMigrationNudge: [], + }, }); }); @@ -225,6 +237,10 @@ describe('Settings Loading and Merging', () => { mcpServers: {}, includeDirectories: [], chatCompression: {}, + extensions: { + disabled: [], + workspacesWithMigrationNudge: [], + }, }); }); @@ -264,6 +280,10 @@ describe('Settings Loading and Merging', () => { mcpServers: {}, includeDirectories: [], chatCompression: {}, + extensions: { + disabled: [], + workspacesWithMigrationNudge: [], + }, }); }); @@ -315,6 +335,10 @@ describe('Settings Loading and Merging', () => { mcpServers: {}, includeDirectories: [], chatCompression: {}, + extensions: { + disabled: [], + workspacesWithMigrationNudge: [], + }, }); }); @@ -377,6 +401,10 @@ describe('Settings Loading and Merging', () => { '/system/dir', ], chatCompression: {}, + extensions: { + disabled: [], + workspacesWithMigrationNudge: [], + }, }); }); @@ -537,6 +565,7 @@ describe('Settings Loading and Merging', () => { ); const settings = loadSettings(MOCK_WORKSPACE_DIR); + expect(settings.user.settings.excludedProjectEnvVars).toEqual([ 'DEBUG', 'NODE_ENV', @@ -944,6 +973,10 @@ describe('Settings Loading and Merging', () => { mcpServers: {}, includeDirectories: [], chatCompression: {}, + extensions: { + disabled: [], + workspacesWithMigrationNudge: [], + }, }); // Check that error objects are populated in settings.errors @@ -1316,6 +1349,10 @@ describe('Settings Loading and Merging', () => { mcpServers: {}, includeDirectories: [], chatCompression: {}, + extensions: { + disabled: [], + workspacesWithMigrationNudge: [], + }, }); }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 58664910..10c751c6 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -133,6 +133,28 @@ function mergeSettings( ...(safeWorkspace.chatCompression || {}), ...(system.chatCompression || {}), }, + extensions: { + ...(systemDefaults.extensions || {}), + ...(user.extensions || {}), + ...(safeWorkspace.extensions || {}), + ...(system.extensions || {}), + disabled: [ + ...new Set([ + ...(systemDefaults.extensions?.disabled || []), + ...(user.extensions?.disabled || []), + ...(safeWorkspace.extensions?.disabled || []), + ...(system.extensions?.disabled || []), + ]), + ], + workspacesWithMigrationNudge: [ + ...new Set([ + ...(systemDefaults.extensions?.workspacesWithMigrationNudge || []), + ...(user.extensions?.workspacesWithMigrationNudge || []), + ...(safeWorkspace.extensions?.workspacesWithMigrationNudge || []), + ...(system.extensions?.workspacesWithMigrationNudge || []), + ]), + ], + }, }; } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index c9e845b9..dd8b624e 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -572,6 +572,16 @@ export const SETTINGS_SCHEMA = { description: 'List of disabled extensions.', showInDialog: false, }, + workspacesWithMigrationNudge: { + type: 'array', + label: 'Workspaces with Migration Nudge', + category: 'Extensions', + requiresRestart: false, + default: [] as string[], + description: + 'List of workspaces for which the migration nudge has been shown.', + showInDialog: false, + }, }, }, skipNextSpeakerCheck: { diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index 6972a88c..27883ceb 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -103,6 +103,8 @@ import { SettingsDialog } from './components/SettingsDialog.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from '../utils/events.js'; import { isNarrowWidth } from './utils/isNarrowWidth.js'; +import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js'; +import { WorkspaceMigrationDialog } from './components/WorkspaceMigrationDialog.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; // Maximum number of queued messages to display in UI to prevent performance issues @@ -223,6 +225,12 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { >(); const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [isProcessing, setIsProcessing] = useState(false); + const { + showWorkspaceMigrationDialog, + workspaceExtensions, + onWorkspaceMigrationDialogOpen, + onWorkspaceMigrationDialogClose, + } = useWorkspaceMigration(settings); useEffect(() => { const unsubscribe = ideContext.subscribeToIdeContext(setIdeContextState); @@ -1018,8 +1026,13 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { ))} )} - - {shouldShowIdePrompt && currentIDE ? ( + {showWorkspaceMigrationDialog ? ( + + ) : shouldShowIdePrompt && currentIDE ? ( { ... (+ - {messageQueue.length - - MAX_DISPLAYED_QUEUED_MESSAGES}{' '} + {messageQueue.length - MAX_DISPLAYED_QUEUED_MESSAGES} more) diff --git a/packages/cli/src/ui/components/WorkspaceMigrationDialog.tsx b/packages/cli/src/ui/components/WorkspaceMigrationDialog.tsx new file mode 100644 index 00000000..c53de1cf --- /dev/null +++ b/packages/cli/src/ui/components/WorkspaceMigrationDialog.tsx @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text, useInput } from 'ink'; +import { + type Extension, + performWorkspaceExtensionMigration, +} from '../../config/extension.js'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { Colors } from '../colors.js'; +import { useState } from 'react'; + +export function WorkspaceMigrationDialog(props: { + workspaceExtensions: Extension[]; + onOpen: () => void; + onClose: () => void; +}) { + const { workspaceExtensions, onOpen, onClose } = props; + const [migrationComplete, setMigrationComplete] = useState(false); + const [failedExtensions, setFailedExtensions] = useState([]); + onOpen(); + const onMigrate = async () => { + const failed = + await performWorkspaceExtensionMigration(workspaceExtensions); + setFailedExtensions(failed); + setMigrationComplete(true); + }; + + useInput((input) => { + if (migrationComplete && input === 'q') { + process.exit(0); + } + }); + + if (migrationComplete) { + return ( + + {failedExtensions.length > 0 ? ( + <> + + The following extensions failed to migrate. Please try installing + them manually. To see other changes, Gemini CLI must be restarted. + Press {"'q'"} to quit. + + + {failedExtensions.map((failed) => ( + - {failed} + ))} + + + ) : ( + + Migration complete. To see changes, Gemini CLI must be restarted. + Press {"'q'"} to quit. + + )} + + ); + } + + return ( + + Workspace-level extensions are deprecated{'\n'} + Would you like to install them at the user level? + + The extension definition will remain in your workspace directory. + + + If you opt to skip, you can install them manually using the extensions + install command. + + + + {workspaceExtensions.map((extension) => ( + - {extension.config.name} + ))} + + + { + if (value === 'migrate') { + onMigrate(); + } else { + onClose(); + } + }} + /> + + + ); +} diff --git a/packages/cli/src/ui/hooks/useWorkspaceMigration.ts b/packages/cli/src/ui/hooks/useWorkspaceMigration.ts new file mode 100644 index 00000000..a4aa1b6c --- /dev/null +++ b/packages/cli/src/ui/hooks/useWorkspaceMigration.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; +import { + type Extension, + getWorkspaceExtensions, +} from '../../config/extension.js'; +import { type LoadedSettings, SettingScope } from '../../config/settings.js'; +import process from 'node:process'; + +export function useWorkspaceMigration(settings: LoadedSettings) { + const [showWorkspaceMigrationDialog, setShowWorkspaceMigrationDialog] = + useState(false); + const [workspaceExtensions, setWorkspaceExtensions] = useState( + [], + ); + + useEffect(() => { + if (!settings.merged.extensionManagement) { + return; + } + const cwd = process.cwd(); + const extensions = getWorkspaceExtensions(cwd); + if ( + extensions.length > 0 && + !settings.merged.extensions?.workspacesWithMigrationNudge?.includes(cwd) + ) { + setWorkspaceExtensions(extensions); + setShowWorkspaceMigrationDialog(true); + console.log(settings.merged.extensions); + } + }, [settings.merged.extensions, settings.merged.extensionManagement]); + + const onWorkspaceMigrationDialogOpen = () => { + const userSettings = settings.forScope(SettingScope.User); + const extensionSettings = userSettings.settings.extensions || { + disabled: [], + }; + const workspacesWithMigrationNudge = + extensionSettings.workspacesWithMigrationNudge || []; + + const cwd = process.cwd(); + if (!workspacesWithMigrationNudge.includes(cwd)) { + workspacesWithMigrationNudge.push(cwd); + } + + extensionSettings.workspacesWithMigrationNudge = + workspacesWithMigrationNudge; + settings.setValue(SettingScope.User, 'extensions', extensionSettings); + }; + + const onWorkspaceMigrationDialogClose = () => { + setShowWorkspaceMigrationDialog(false); + }; + + return { + showWorkspaceMigrationDialog, + workspaceExtensions, + onWorkspaceMigrationDialogOpen, + onWorkspaceMigrationDialogClose, + }; +}