Skip to content

Commit 56d6319

Browse files
chrstnbkunal-10-cloud
authored andcommitted
Add support for updating extension sources and names (google-gemini#21715)
1 parent 34c7351 commit 56d6319

15 files changed

+473
-6
lines changed

docs/extensions/reference.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ The manifest file defines the extension's behavior and configuration.
123123
},
124124
"contextFileName": "GEMINI.md",
125125
"excludeTools": ["run_shell_command"],
126+
"migratedTo": "https://github.com/new-owner/new-extension-repo",
126127
"plan": {
127128
"directory": ".gemini/plans"
128129
}
@@ -138,6 +139,9 @@ The manifest file defines the extension's behavior and configuration.
138139
- `version`: The version of the extension.
139140
- `description`: A short description of the extension. This will be displayed on
140141
[geminicli.com/extensions](https://geminicli.com/extensions).
142+
- `migratedTo`: The URL of the new repository source for the extension. If this
143+
is set, the CLI will automatically check this new source for updates and
144+
migrate the extension's installation to the new source if an update is found.
141145
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
142146
server, and the value is the server configuration. These servers will be
143147
loaded on startup just like MCP servers defined in a

docs/extensions/releasing.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,29 @@ jobs:
152152
release/linux.arm64.my-tool.tar.gz
153153
release/win32.arm64.my-tool.zip
154154
```
155+
156+
## Migrating an Extension Repository
157+
158+
If you need to move your extension to a new repository (e.g., from a personal
159+
account to an organization) or rename it, you can use the `migratedTo` property
160+
in your `gemini-extension.json` file to seamlessly transition your users.
161+
162+
1. **Create the new repository**: Setup your extension in its new location.
163+
2. **Update the old repository**: In your original repository, update the
164+
`gemini-extension.json` file to include the `migratedTo` property, pointing
165+
to the new repository URL, and bump the version number. You can optionally
166+
change the `name` of your extension at this time in the new repository.
167+
```json
168+
{
169+
"name": "my-extension",
170+
"version": "1.1.0",
171+
"migratedTo": "https://github.com/new-owner/new-extension-repo"
172+
}
173+
```
174+
3. **Release the update**: Publish this new version in your old repository.
175+
176+
When users check for updates, the Gemini CLI will detect the `migratedTo` field,
177+
verify that the new repository contains a valid extension update, and
178+
automatically update their local installation to track the new source and name
179+
moving forward. All extension settings will automatically migrate to the new
180+
installation.

packages/cli/src/config/extension-manager.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,4 +345,144 @@ describe('ExtensionManager', () => {
345345
}
346346
});
347347
});
348+
349+
describe('Extension Renaming', () => {
350+
it('should support renaming an extension during update', async () => {
351+
// 1. Setup existing extension
352+
const oldName = 'old-name';
353+
const newName = 'new-name';
354+
const extDir = path.join(userExtensionsDir, oldName);
355+
fs.mkdirSync(extDir, { recursive: true });
356+
fs.writeFileSync(
357+
path.join(extDir, 'gemini-extension.json'),
358+
JSON.stringify({ name: oldName, version: '1.0.0' }),
359+
);
360+
fs.writeFileSync(
361+
path.join(extDir, 'metadata.json'),
362+
JSON.stringify({ type: 'local', source: extDir }),
363+
);
364+
365+
await extensionManager.loadExtensions();
366+
367+
// 2. Create a temporary "new" version with a different name
368+
const newSourceDir = fs.mkdtempSync(
369+
path.join(tempHomeDir, 'new-source-'),
370+
);
371+
fs.writeFileSync(
372+
path.join(newSourceDir, 'gemini-extension.json'),
373+
JSON.stringify({ name: newName, version: '1.1.0' }),
374+
);
375+
fs.writeFileSync(
376+
path.join(newSourceDir, 'metadata.json'),
377+
JSON.stringify({ type: 'local', source: newSourceDir }),
378+
);
379+
380+
// 3. Update the extension
381+
await extensionManager.installOrUpdateExtension(
382+
{ type: 'local', source: newSourceDir },
383+
{ name: oldName, version: '1.0.0' },
384+
);
385+
386+
// 4. Verify old directory is gone and new one exists
387+
expect(fs.existsSync(path.join(userExtensionsDir, oldName))).toBe(false);
388+
expect(fs.existsSync(path.join(userExtensionsDir, newName))).toBe(true);
389+
390+
// Verify the loaded state is updated
391+
const extensions = extensionManager.getExtensions();
392+
expect(extensions.some((e) => e.name === newName)).toBe(true);
393+
expect(extensions.some((e) => e.name === oldName)).toBe(false);
394+
});
395+
396+
it('should carry over enablement status when renaming', async () => {
397+
const oldName = 'old-name';
398+
const newName = 'new-name';
399+
const extDir = path.join(userExtensionsDir, oldName);
400+
fs.mkdirSync(extDir, { recursive: true });
401+
fs.writeFileSync(
402+
path.join(extDir, 'gemini-extension.json'),
403+
JSON.stringify({ name: oldName, version: '1.0.0' }),
404+
);
405+
fs.writeFileSync(
406+
path.join(extDir, 'metadata.json'),
407+
JSON.stringify({ type: 'local', source: extDir }),
408+
);
409+
410+
// Enable it
411+
const enablementManager = extensionManager.getEnablementManager();
412+
enablementManager.enable(oldName, true, tempHomeDir);
413+
414+
await extensionManager.loadExtensions();
415+
const extension = extensionManager.getExtensions()[0];
416+
expect(extension.isActive).toBe(true);
417+
418+
const newSourceDir = fs.mkdtempSync(
419+
path.join(tempHomeDir, 'new-source-'),
420+
);
421+
fs.writeFileSync(
422+
path.join(newSourceDir, 'gemini-extension.json'),
423+
JSON.stringify({ name: newName, version: '1.1.0' }),
424+
);
425+
fs.writeFileSync(
426+
path.join(newSourceDir, 'metadata.json'),
427+
JSON.stringify({ type: 'local', source: newSourceDir }),
428+
);
429+
430+
await extensionManager.installOrUpdateExtension(
431+
{ type: 'local', source: newSourceDir },
432+
{ name: oldName, version: '1.0.0' },
433+
);
434+
435+
// Verify new name is enabled
436+
expect(enablementManager.isEnabled(newName, tempHomeDir)).toBe(true);
437+
// Verify old name is removed from enablement
438+
expect(enablementManager.readConfig()[oldName]).toBeUndefined();
439+
});
440+
441+
it('should prevent renaming if the new name conflicts with an existing extension', async () => {
442+
// Setup two extensions
443+
const ext1Dir = path.join(userExtensionsDir, 'ext1');
444+
fs.mkdirSync(ext1Dir, { recursive: true });
445+
fs.writeFileSync(
446+
path.join(ext1Dir, 'gemini-extension.json'),
447+
JSON.stringify({ name: 'ext1', version: '1.0.0' }),
448+
);
449+
fs.writeFileSync(
450+
path.join(ext1Dir, 'metadata.json'),
451+
JSON.stringify({ type: 'local', source: ext1Dir }),
452+
);
453+
454+
const ext2Dir = path.join(userExtensionsDir, 'ext2');
455+
fs.mkdirSync(ext2Dir, { recursive: true });
456+
fs.writeFileSync(
457+
path.join(ext2Dir, 'gemini-extension.json'),
458+
JSON.stringify({ name: 'ext2', version: '1.0.0' }),
459+
);
460+
fs.writeFileSync(
461+
path.join(ext2Dir, 'metadata.json'),
462+
JSON.stringify({ type: 'local', source: ext2Dir }),
463+
);
464+
465+
await extensionManager.loadExtensions();
466+
467+
// Try to update ext1 to name 'ext2'
468+
const newSourceDir = fs.mkdtempSync(
469+
path.join(tempHomeDir, 'new-source-'),
470+
);
471+
fs.writeFileSync(
472+
path.join(newSourceDir, 'gemini-extension.json'),
473+
JSON.stringify({ name: 'ext2', version: '1.1.0' }),
474+
);
475+
fs.writeFileSync(
476+
path.join(newSourceDir, 'metadata.json'),
477+
JSON.stringify({ type: 'local', source: newSourceDir }),
478+
);
479+
480+
await expect(
481+
extensionManager.installOrUpdateExtension(
482+
{ type: 'local', source: newSourceDir },
483+
{ name: 'ext1', version: '1.0.0' },
484+
),
485+
).rejects.toThrow(/already installed/);
486+
});
487+
});
348488
});

packages/cli/src/config/extension-manager.ts

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ export class ExtensionManager extends ExtensionLoader {
129129
this.requestSetting = options.requestSetting ?? undefined;
130130
}
131131

132+
getEnablementManager(): ExtensionEnablementManager {
133+
return this.extensionEnablementManager;
134+
}
135+
132136
setRequestConsent(
133137
requestConsent: (consent: string) => Promise<boolean>,
134138
): void {
@@ -271,17 +275,28 @@ Would you like to attempt to install via "git clone" instead?`,
271275
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
272276

273277
const newExtensionName = newExtensionConfig.name;
278+
const previousName = previousExtensionConfig?.name ?? newExtensionName;
274279
const previous = this.getExtensions().find(
275-
(installed) => installed.name === newExtensionName,
280+
(installed) => installed.name === previousName,
276281
);
282+
const nameConflict = this.getExtensions().find(
283+
(installed) =>
284+
installed.name === newExtensionName &&
285+
installed.name !== previousName,
286+
);
287+
277288
if (isUpdate && !previous) {
278289
throw new Error(
279-
`Extension "${newExtensionName}" was not already installed, cannot update it.`,
290+
`Extension "${previousName}" was not already installed, cannot update it.`,
280291
);
281292
} else if (!isUpdate && previous) {
282293
throw new Error(
283294
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
284295
);
296+
} else if (isUpdate && nameConflict) {
297+
throw new Error(
298+
`Cannot update to "${newExtensionName}" because an extension with that name is already installed.`,
299+
);
285300
}
286301

287302
const newHasHooks = fs.existsSync(
@@ -298,6 +313,11 @@ Would you like to attempt to install via "git clone" instead?`,
298313
path.join(localSourcePath, 'skills'),
299314
);
300315
const previousSkills = previous?.skills ?? [];
316+
const isMigrating = Boolean(
317+
previous &&
318+
previous.installMetadata &&
319+
previous.installMetadata.source !== installMetadata.source,
320+
);
301321

302322
await maybeRequestConsentOrFail(
303323
newExtensionConfig,
@@ -307,19 +327,46 @@ Would you like to attempt to install via "git clone" instead?`,
307327
previousHasHooks,
308328
newSkills,
309329
previousSkills,
330+
isMigrating,
310331
);
311332
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
312333
const destinationPath = new ExtensionStorage(
313334
newExtensionName,
314335
).getExtensionDir();
336+
337+
if (
338+
(!isUpdate || newExtensionName !== previousName) &&
339+
fs.existsSync(destinationPath)
340+
) {
341+
throw new Error(
342+
`Cannot install extension "${newExtensionName}" because a directory with that name already exists. Please remove it manually.`,
343+
);
344+
}
345+
315346
let previousSettings: Record<string, string> | undefined;
316-
if (isUpdate) {
347+
let wasEnabledGlobally = false;
348+
let wasEnabledWorkspace = false;
349+
if (isUpdate && previousExtensionConfig) {
350+
const previousExtensionId = previous?.installMetadata
351+
? getExtensionId(previousExtensionConfig, previous.installMetadata)
352+
: extensionId;
317353
previousSettings = await getEnvContents(
318354
previousExtensionConfig,
319-
extensionId,
355+
previousExtensionId,
320356
this.workspaceDir,
321357
);
322-
await this.uninstallExtension(newExtensionName, isUpdate);
358+
if (newExtensionName !== previousName) {
359+
wasEnabledGlobally = this.extensionEnablementManager.isEnabled(
360+
previousName,
361+
homedir(),
362+
);
363+
wasEnabledWorkspace = this.extensionEnablementManager.isEnabled(
364+
previousName,
365+
this.workspaceDir,
366+
);
367+
this.extensionEnablementManager.remove(previousName);
368+
}
369+
await this.uninstallExtension(previousName, isUpdate);
323370
}
324371

325372
await fs.promises.mkdir(destinationPath, { recursive: true });
@@ -392,6 +439,18 @@ Would you like to attempt to install via "git clone" instead?`,
392439
CoreToolCallStatus.Success,
393440
),
394441
);
442+
443+
if (newExtensionName !== previousName) {
444+
if (wasEnabledGlobally) {
445+
await this.enableExtension(newExtensionName, SettingScope.User);
446+
}
447+
if (wasEnabledWorkspace) {
448+
await this.enableExtension(
449+
newExtensionName,
450+
SettingScope.Workspace,
451+
);
452+
}
453+
}
395454
} else {
396455
await logExtensionInstallEvent(
397456
this.telemetryConfig,
@@ -873,6 +932,7 @@ Would you like to attempt to install via "git clone" instead?`,
873932
path: effectiveExtensionPath,
874933
contextFiles,
875934
installMetadata,
935+
migratedTo: config.migratedTo,
876936
mcpServers: config.mcpServers,
877937
excludeTools: config.excludeTools,
878938
hooks,

packages/cli/src/config/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export interface ExtensionConfig {
4242
*/
4343
directory?: string;
4444
};
45+
/**
46+
* Used to migrate an extension to a new repository source.
47+
*/
48+
migratedTo?: string;
4549
}
4650

4751
export interface ExtensionUpdateInfo {
Lines changed: 13 additions & 0 deletions
Loading

packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ of extensions. Please carefully inspect any extension and its source code before
2424
understand the permissions it requires and the actions it may perform."
2525
`;
2626

27+
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = `
28+
"Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates.
29+
30+
The extension you are about to install may have been created by a third-party developer and sourced
31+
from a public repository. Google does not vet, endorse, or guarantee the functionality or security
32+
of extensions. Please carefully inspect any extension and its source code before installing to
33+
understand the permissions it requires and the actions it may perform."
34+
`;
35+
2736
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = `
2837
"Installing extension "test-ext".
2938
This extension will run the following MCP servers:

packages/cli/src/config/extensions/consent.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,25 @@ describe('consent', () => {
287287
expect(requestConsent).toHaveBeenCalledTimes(1);
288288
});
289289

290+
it('should request consent if extension is migrated', async () => {
291+
const requestConsent = vi.fn().mockResolvedValue(true);
292+
await maybeRequestConsentOrFail(
293+
baseConfig,
294+
requestConsent,
295+
false,
296+
{ ...baseConfig, name: 'old-ext' },
297+
false,
298+
[],
299+
[],
300+
true,
301+
);
302+
303+
expect(requestConsent).toHaveBeenCalledTimes(1);
304+
let consentString = requestConsent.mock.calls[0][0] as string;
305+
consentString = normalizePathsForSnapshot(consentString, tempDir);
306+
await expectConsentSnapshot(consentString);
307+
});
308+
290309
it('should request consent if skills change', async () => {
291310
const skill1Dir = path.join(tempDir, 'skill1');
292311
const skill2Dir = path.join(tempDir, 'skill2');

0 commit comments

Comments
 (0)