Add support for updating extension sources and names (#21715)

This commit is contained in:
christine betts
2026-03-09 19:31:31 -04:00
committed by GitHub
parent 215f8f3f15
commit 43eb74ac59
15 changed files with 473 additions and 6 deletions
@@ -345,4 +345,144 @@ describe('ExtensionManager', () => {
}
});
});
describe('Extension Renaming', () => {
it('should support renaming an extension during update', async () => {
// 1. Setup existing extension
const oldName = 'old-name';
const newName = 'new-name';
const extDir = path.join(userExtensionsDir, oldName);
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, 'gemini-extension.json'),
JSON.stringify({ name: oldName, version: '1.0.0' }),
);
fs.writeFileSync(
path.join(extDir, 'metadata.json'),
JSON.stringify({ type: 'local', source: extDir }),
);
await extensionManager.loadExtensions();
// 2. Create a temporary "new" version with a different name
const newSourceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'new-source-'),
);
fs.writeFileSync(
path.join(newSourceDir, 'gemini-extension.json'),
JSON.stringify({ name: newName, version: '1.1.0' }),
);
fs.writeFileSync(
path.join(newSourceDir, 'metadata.json'),
JSON.stringify({ type: 'local', source: newSourceDir }),
);
// 3. Update the extension
await extensionManager.installOrUpdateExtension(
{ type: 'local', source: newSourceDir },
{ name: oldName, version: '1.0.0' },
);
// 4. Verify old directory is gone and new one exists
expect(fs.existsSync(path.join(userExtensionsDir, oldName))).toBe(false);
expect(fs.existsSync(path.join(userExtensionsDir, newName))).toBe(true);
// Verify the loaded state is updated
const extensions = extensionManager.getExtensions();
expect(extensions.some((e) => e.name === newName)).toBe(true);
expect(extensions.some((e) => e.name === oldName)).toBe(false);
});
it('should carry over enablement status when renaming', async () => {
const oldName = 'old-name';
const newName = 'new-name';
const extDir = path.join(userExtensionsDir, oldName);
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, 'gemini-extension.json'),
JSON.stringify({ name: oldName, version: '1.0.0' }),
);
fs.writeFileSync(
path.join(extDir, 'metadata.json'),
JSON.stringify({ type: 'local', source: extDir }),
);
// Enable it
const enablementManager = extensionManager.getEnablementManager();
enablementManager.enable(oldName, true, tempHomeDir);
await extensionManager.loadExtensions();
const extension = extensionManager.getExtensions()[0];
expect(extension.isActive).toBe(true);
const newSourceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'new-source-'),
);
fs.writeFileSync(
path.join(newSourceDir, 'gemini-extension.json'),
JSON.stringify({ name: newName, version: '1.1.0' }),
);
fs.writeFileSync(
path.join(newSourceDir, 'metadata.json'),
JSON.stringify({ type: 'local', source: newSourceDir }),
);
await extensionManager.installOrUpdateExtension(
{ type: 'local', source: newSourceDir },
{ name: oldName, version: '1.0.0' },
);
// Verify new name is enabled
expect(enablementManager.isEnabled(newName, tempHomeDir)).toBe(true);
// Verify old name is removed from enablement
expect(enablementManager.readConfig()[oldName]).toBeUndefined();
});
it('should prevent renaming if the new name conflicts with an existing extension', async () => {
// Setup two extensions
const ext1Dir = path.join(userExtensionsDir, 'ext1');
fs.mkdirSync(ext1Dir, { recursive: true });
fs.writeFileSync(
path.join(ext1Dir, 'gemini-extension.json'),
JSON.stringify({ name: 'ext1', version: '1.0.0' }),
);
fs.writeFileSync(
path.join(ext1Dir, 'metadata.json'),
JSON.stringify({ type: 'local', source: ext1Dir }),
);
const ext2Dir = path.join(userExtensionsDir, 'ext2');
fs.mkdirSync(ext2Dir, { recursive: true });
fs.writeFileSync(
path.join(ext2Dir, 'gemini-extension.json'),
JSON.stringify({ name: 'ext2', version: '1.0.0' }),
);
fs.writeFileSync(
path.join(ext2Dir, 'metadata.json'),
JSON.stringify({ type: 'local', source: ext2Dir }),
);
await extensionManager.loadExtensions();
// Try to update ext1 to name 'ext2'
const newSourceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'new-source-'),
);
fs.writeFileSync(
path.join(newSourceDir, 'gemini-extension.json'),
JSON.stringify({ name: 'ext2', version: '1.1.0' }),
);
fs.writeFileSync(
path.join(newSourceDir, 'metadata.json'),
JSON.stringify({ type: 'local', source: newSourceDir }),
);
await expect(
extensionManager.installOrUpdateExtension(
{ type: 'local', source: newSourceDir },
{ name: 'ext1', version: '1.0.0' },
),
).rejects.toThrow(/already installed/);
});
});
});
+65 -5
View File
@@ -129,6 +129,10 @@ export class ExtensionManager extends ExtensionLoader {
this.requestSetting = options.requestSetting ?? undefined;
}
getEnablementManager(): ExtensionEnablementManager {
return this.extensionEnablementManager;
}
setRequestConsent(
requestConsent: (consent: string) => Promise<boolean>,
): void {
@@ -271,17 +275,28 @@ Would you like to attempt to install via "git clone" instead?`,
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
const newExtensionName = newExtensionConfig.name;
const previousName = previousExtensionConfig?.name ?? newExtensionName;
const previous = this.getExtensions().find(
(installed) => installed.name === newExtensionName,
(installed) => installed.name === previousName,
);
const nameConflict = this.getExtensions().find(
(installed) =>
installed.name === newExtensionName &&
installed.name !== previousName,
);
if (isUpdate && !previous) {
throw new Error(
`Extension "${newExtensionName}" was not already installed, cannot update it.`,
`Extension "${previousName}" was not already installed, cannot update it.`,
);
} else if (!isUpdate && previous) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
} else if (isUpdate && nameConflict) {
throw new Error(
`Cannot update to "${newExtensionName}" because an extension with that name is already installed.`,
);
}
const newHasHooks = fs.existsSync(
@@ -298,6 +313,11 @@ Would you like to attempt to install via "git clone" instead?`,
path.join(localSourcePath, 'skills'),
);
const previousSkills = previous?.skills ?? [];
const isMigrating = Boolean(
previous &&
previous.installMetadata &&
previous.installMetadata.source !== installMetadata.source,
);
await maybeRequestConsentOrFail(
newExtensionConfig,
@@ -307,19 +327,46 @@ Would you like to attempt to install via "git clone" instead?`,
previousHasHooks,
newSkills,
previousSkills,
isMigrating,
);
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
const destinationPath = new ExtensionStorage(
newExtensionName,
).getExtensionDir();
if (
(!isUpdate || newExtensionName !== previousName) &&
fs.existsSync(destinationPath)
) {
throw new Error(
`Cannot install extension "${newExtensionName}" because a directory with that name already exists. Please remove it manually.`,
);
}
let previousSettings: Record<string, string> | undefined;
if (isUpdate) {
let wasEnabledGlobally = false;
let wasEnabledWorkspace = false;
if (isUpdate && previousExtensionConfig) {
const previousExtensionId = previous?.installMetadata
? getExtensionId(previousExtensionConfig, previous.installMetadata)
: extensionId;
previousSettings = await getEnvContents(
previousExtensionConfig,
extensionId,
previousExtensionId,
this.workspaceDir,
);
await this.uninstallExtension(newExtensionName, isUpdate);
if (newExtensionName !== previousName) {
wasEnabledGlobally = this.extensionEnablementManager.isEnabled(
previousName,
homedir(),
);
wasEnabledWorkspace = this.extensionEnablementManager.isEnabled(
previousName,
this.workspaceDir,
);
this.extensionEnablementManager.remove(previousName);
}
await this.uninstallExtension(previousName, isUpdate);
}
await fs.promises.mkdir(destinationPath, { recursive: true });
@@ -392,6 +439,18 @@ Would you like to attempt to install via "git clone" instead?`,
CoreToolCallStatus.Success,
),
);
if (newExtensionName !== previousName) {
if (wasEnabledGlobally) {
await this.enableExtension(newExtensionName, SettingScope.User);
}
if (wasEnabledWorkspace) {
await this.enableExtension(
newExtensionName,
SettingScope.Workspace,
);
}
}
} else {
await logExtensionInstallEvent(
this.telemetryConfig,
@@ -873,6 +932,7 @@ Would you like to attempt to install via "git clone" instead?`,
path: effectiveExtensionPath,
contextFiles,
installMetadata,
migratedTo: config.migratedTo,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
hooks,
+4
View File
@@ -42,6 +42,10 @@ export interface ExtensionConfig {
*/
directory?: string;
};
/**
* Used to migrate an extension to a new repository source.
*/
migratedTo?: string;
}
export interface ExtensionUpdateInfo {
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="122" viewBox="0 0 920 122">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="122" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Migrating extension &quot;old-ext&quot; to a new repository, renaming to &quot;test-ext&quot;, and installing updates. </text>
<text x="0" y="36" fill="#cdcd00" textLength="891" lengthAdjust="spacingAndGlyphs">The extension you are about to install may have been created by a third-party developer and sourced</text>
<text x="0" y="53" fill="#cdcd00" textLength="882" lengthAdjust="spacingAndGlyphs">from a public repository. Google does not vet, endorse, or guarantee the functionality or security</text>
<text x="0" y="70" fill="#cdcd00" textLength="846" lengthAdjust="spacingAndGlyphs">of extensions. Please carefully inspect any extension and its source code before installing to</text>
<text x="0" y="87" fill="#cdcd00" textLength="630" lengthAdjust="spacingAndGlyphs">understand the permissions it requires and the actions it may perform.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -24,6 +24,15 @@ of extensions. Please carefully inspect any extension and its source code before
understand the permissions it requires and the actions it may perform."
`;
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = `
"Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates.
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."
`;
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = `
"Installing extension "test-ext".
This extension will run the following MCP servers:
@@ -287,6 +287,25 @@ describe('consent', () => {
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should request consent if extension is migrated', async () => {
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(
baseConfig,
requestConsent,
false,
{ ...baseConfig, name: 'old-ext' },
false,
[],
[],
true,
);
expect(requestConsent).toHaveBeenCalledTimes(1);
let consentString = requestConsent.mock.calls[0][0] as string;
consentString = normalizePathsForSnapshot(consentString, tempDir);
await expectConsentSnapshot(consentString);
});
it('should request consent if skills change', async () => {
const skill1Dir = path.join(tempDir, 'skill1');
const skill2Dir = path.join(tempDir, 'skill2');
+23 -1
View File
@@ -148,11 +148,30 @@ async function extensionConsentString(
extensionConfig: ExtensionConfig,
hasHooks: boolean,
skills: SkillDefinition[] = [],
previousName?: string,
wasMigrated?: boolean,
): Promise<string> {
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
const output: string[] = [];
const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
output.push(`Installing extension "${sanitizedConfig.name}".`);
if (wasMigrated) {
if (previousName && previousName !== sanitizedConfig.name) {
output.push(
`Migrating extension "${previousName}" to a new repository, renaming to "${sanitizedConfig.name}", and installing updates.`,
);
} else {
output.push(
`Migrating extension "${sanitizedConfig.name}" to a new repository and installing updates.`,
);
}
} else if (previousName && previousName !== sanitizedConfig.name) {
output.push(
`Renaming extension "${previousName}" to "${sanitizedConfig.name}" and installing updates.`,
);
} else {
output.push(`Installing extension "${sanitizedConfig.name}".`);
}
if (mcpServerEntries.length) {
output.push('This extension will run the following MCP servers:');
@@ -231,11 +250,14 @@ export async function maybeRequestConsentOrFail(
previousHasHooks?: boolean,
skills: SkillDefinition[] = [],
previousSkills: SkillDefinition[] = [],
isMigrating: boolean = false,
) {
const extensionConsent = await extensionConsentString(
extensionConfig,
hasHooks,
skills,
previousExtensionConfig?.name,
isMigrating,
);
if (previousExtensionConfig) {
const previousExtensionConsent = await extensionConsentString(
@@ -285,6 +285,23 @@ describe('github.ts', () => {
ExtensionUpdateState.NOT_UPDATABLE,
);
});
it('should check migratedTo source if present and return UPDATE_AVAILABLE', async () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'new-url' } },
]);
mockGit.listRemote.mockResolvedValue('hash\tHEAD');
mockGit.revparse.mockResolvedValue('hash');
const ext = {
path: '/path',
migratedTo: 'new-url',
installMetadata: { type: 'git', source: 'old-url' },
} as unknown as GeminiCLIExtension;
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
});
});
describe('downloadFromGitHubRelease', () => {
@@ -203,6 +203,24 @@ export async function checkForExtensionUpdate(
) {
return ExtensionUpdateState.NOT_UPDATABLE;
}
if (extension.migratedTo) {
const migratedState = await checkForExtensionUpdate(
{
...extension,
installMetadata: { ...installMetadata, source: extension.migratedTo },
migratedTo: undefined,
},
extensionManager,
);
if (
migratedState === ExtensionUpdateState.UPDATE_AVAILABLE ||
migratedState === ExtensionUpdateState.UP_TO_DATE
) {
return ExtensionUpdateState.UPDATE_AVAILABLE;
}
}
try {
if (installMetadata.type === 'git') {
const git = simpleGit(extension.path);
@@ -184,6 +184,54 @@ describe('Extension Update Logic', () => {
});
});
it('should migrate source if migratedTo is set and an update is available', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
Promise.resolve({
name: 'test-extension',
version: '1.0.0',
}),
);
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockResolvedValue({
...mockExtension,
version: '1.1.0',
});
vi.mocked(checkForExtensionUpdate).mockResolvedValue(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
const extensionWithMigratedTo = {
...mockExtension,
migratedTo: 'https://new-source.com/repo.git',
};
await updateExtension(
extensionWithMigratedTo,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
);
expect(checkForExtensionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
installMetadata: expect.objectContaining({
source: 'https://new-source.com/repo.git',
}),
}),
mockExtensionManager,
);
expect(
mockExtensionManager.installOrUpdateExtension,
).toHaveBeenCalledWith(
expect.objectContaining({
source: 'https://new-source.com/repo.git',
}),
expect.anything(),
);
});
it('should set state to UPDATED if enableExtensionReloading is true', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
Promise.resolve({
@@ -55,6 +55,24 @@ export async function updateExtension(
});
throw new Error(`Extension is linked so does not need to be updated`);
}
if (extension.migratedTo) {
const migratedState = await checkForExtensionUpdate(
{
...extension,
installMetadata: { ...installMetadata, source: extension.migratedTo },
migratedTo: undefined,
},
extensionManager,
);
if (
migratedState === ExtensionUpdateState.UPDATE_AVAILABLE ||
migratedState === ExtensionUpdateState.UP_TO_DATE
) {
installMetadata.source = extension.migratedTo;
}
}
const originalVersion = extension.version;
const tempDir = await ExtensionStorage.createTmpDir();