Add prompt to migrate workspace extensions (#7065)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
christine betts
2025-08-27 00:43:02 +00:00
committed by GitHub
parent be48414518
commit c79f145b37
8 changed files with 377 additions and 21 deletions

View File

@@ -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,

View File

@@ -77,15 +77,51 @@ export class ExtensionStorage {
}
}
export function getWorkspaceExtensions(workspaceDir: string): Extension[] {
return loadExtensionsFromDir(workspaceDir);
}
async function copyExtension(
source: string,
destination: string,
): Promise<void> {
await fs.promises.cp(source, destination, { recursive: true });
}
export async function performWorkspaceExtensionMigration(
extensions: Extension[],
): Promise<string[]> {
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<string, Extension>();
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<void> {
await fs.promises.cp(source, destination, { recursive: true });
}
export async function installExtension(
installMetadata: ExtensionInstallMetadata,
cwd: string = process.cwd(),

View File

@@ -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: [],
},
});
});
});

View File

@@ -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 || []),
]),
],
},
};
}

View File

@@ -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: {

View File

@@ -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<boolean>(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) => {
))}
</Box>
)}
{shouldShowIdePrompt && currentIDE ? (
{showWorkspaceMigrationDialog ? (
<WorkspaceMigrationDialog
workspaceExtensions={workspaceExtensions}
onOpen={onWorkspaceMigrationDialogOpen}
onClose={onWorkspaceMigrationDialogClose}
/>
) : shouldShowIdePrompt && currentIDE ? (
<IdeIntegrationNudge
ide={currentIDE}
onComplete={handleIdePromptComplete}
@@ -1167,8 +1180,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<Box paddingLeft={2}>
<Text dimColor>
... (+
{messageQueue.length -
MAX_DISPLAYED_QUEUED_MESSAGES}{' '}
{messageQueue.length - MAX_DISPLAYED_QUEUED_MESSAGES}
more)
</Text>
</Box>

View File

@@ -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<string[]>([]);
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 (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.Gray}
padding={1}
>
{failedExtensions.length > 0 ? (
<>
<Text>
The following extensions failed to migrate. Please try installing
them manually. To see other changes, Gemini CLI must be restarted.
Press {"'q'"} to quit.
</Text>
<Box flexDirection="column" marginTop={1} marginLeft={2}>
{failedExtensions.map((failed) => (
<Text key={failed}>- {failed}</Text>
))}
</Box>
</>
) : (
<Text>
Migration complete. To see changes, Gemini CLI must be restarted.
Press {"'q'"} to quit.
</Text>
)}
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={Colors.Gray}
padding={1}
>
<Text bold>Workspace-level extensions are deprecated{'\n'}</Text>
<Text>Would you like to install them at the user level?</Text>
<Text>
The extension definition will remain in your workspace directory.
</Text>
<Text>
If you opt to skip, you can install them manually using the extensions
install command.
</Text>
<Box flexDirection="column" marginTop={1} marginLeft={2}>
{workspaceExtensions.map((extension) => (
<Text key={extension.config.name}>- {extension.config.name}</Text>
))}
</Box>
<Box marginTop={1}>
<RadioButtonSelect
items={[
{ label: 'Install all', value: 'migrate' },
{ label: 'Skip', value: 'skip' },
]}
onSelect={(value: string) => {
if (value === 'migrate') {
onMigrate();
} else {
onClose();
}
}}
/>
</Box>
</Box>
);
}

View File

@@ -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<Extension[]>(
[],
);
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,
};
}