mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
Add support for updating extension sources and names (#21715)
This commit is contained in:
@@ -123,6 +123,7 @@ The manifest file defines the extension's behavior and configuration.
|
||||
},
|
||||
"contextFileName": "GEMINI.md",
|
||||
"excludeTools": ["run_shell_command"],
|
||||
"migratedTo": "https://github.com/new-owner/new-extension-repo",
|
||||
"plan": {
|
||||
"directory": ".gemini/plans"
|
||||
}
|
||||
@@ -138,6 +139,9 @@ The manifest file defines the extension's behavior and configuration.
|
||||
- `version`: The version of the extension.
|
||||
- `description`: A short description of the extension. This will be displayed on
|
||||
[geminicli.com/extensions](https://geminicli.com/extensions).
|
||||
- `migratedTo`: The URL of the new repository source for the extension. If this
|
||||
is set, the CLI will automatically check this new source for updates and
|
||||
migrate the extension's installation to the new source if an update is found.
|
||||
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
|
||||
server, and the value is the server configuration. These servers will be
|
||||
loaded on startup just like MCP servers defined in a
|
||||
|
||||
@@ -152,3 +152,29 @@ jobs:
|
||||
release/linux.arm64.my-tool.tar.gz
|
||||
release/win32.arm64.my-tool.zip
|
||||
```
|
||||
|
||||
## Migrating an Extension Repository
|
||||
|
||||
If you need to move your extension to a new repository (e.g., from a personal
|
||||
account to an organization) or rename it, you can use the `migratedTo` property
|
||||
in your `gemini-extension.json` file to seamlessly transition your users.
|
||||
|
||||
1. **Create the new repository**: Setup your extension in its new location.
|
||||
2. **Update the old repository**: In your original repository, update the
|
||||
`gemini-extension.json` file to include the `migratedTo` property, pointing
|
||||
to the new repository URL, and bump the version number. You can optionally
|
||||
change the `name` of your extension at this time in the new repository.
|
||||
```json
|
||||
{
|
||||
"name": "my-extension",
|
||||
"version": "1.1.0",
|
||||
"migratedTo": "https://github.com/new-owner/new-extension-repo"
|
||||
}
|
||||
```
|
||||
3. **Release the update**: Publish this new version in your old repository.
|
||||
|
||||
When users check for updates, the Gemini CLI will detect the `migratedTo` field,
|
||||
verify that the new repository contains a valid extension update, and
|
||||
automatically update their local installation to track the new source and name
|
||||
moving forward. All extension settings will automatically migrate to the new
|
||||
installation.
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "old-ext" to a new repository, renaming to "test-ext", 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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -361,6 +361,10 @@ export interface GeminiCLIExtension {
|
||||
*/
|
||||
directory?: string;
|
||||
};
|
||||
/**
|
||||
* Used to migrate an extension to a new repository source.
|
||||
*/
|
||||
migratedTo?: string;
|
||||
}
|
||||
|
||||
export interface ExtensionInstallMetadata {
|
||||
|
||||
65
pr-description.md
Normal file
65
pr-description.md
Normal file
@@ -0,0 +1,65 @@
|
||||
## Summary
|
||||
|
||||
This PR implements a seamless migration path for extensions to move to a new
|
||||
repository and optionally change their name without stranding existing users.
|
||||
|
||||
When an extension author sets the `migratedTo` field in their
|
||||
`gemini-extension.json` and publishes an update to their old repository, the CLI
|
||||
will detect this during the next update check. The CLI will then automatically
|
||||
download the extension from the new repository, explicitly warn the user about
|
||||
the migration (and any renaming) during the consent step, and seamlessly migrate
|
||||
the installation and enablement status while cleaning up the old installation.
|
||||
|
||||
## Details
|
||||
|
||||
- **Configuration:** Added `migratedTo` property to `ExtensionConfig` and
|
||||
`GeminiCLIExtension` to track the new repository URL.
|
||||
- **Update checking & downloading:** Updated `checkForExtensionUpdate` and
|
||||
`updateExtension` to inspect the `migratedTo` field. If present, the CLI
|
||||
queries the new repository URL for an update and swaps the installation source
|
||||
so the update resolves from the new location.
|
||||
- **Migration & renaming logic (`ExtensionManager`):**
|
||||
- `installOrUpdateExtension` now fully supports renaming. It transfers global
|
||||
and workspace enablement states from the old extension name to the new one
|
||||
and deletes the old extension directory.
|
||||
- Added safeguards to block renaming if the new name conflicts with a
|
||||
different, already-installed extension or if the destination directory
|
||||
already exists.
|
||||
- Exposed `getEnablementManager()` to `ExtensionManager` for better typing
|
||||
during testing.
|
||||
- **Consent messaging:** Refactored `maybeRequestConsentOrFail` to compute an
|
||||
`isMigrating` flag (by detecting a change in the installation source). The
|
||||
`extensionConsentString` output now explicitly informs users with messages
|
||||
like: _"Migrating extension 'old-name' to a new repository, renaming to
|
||||
'new-name', and installing updates."_
|
||||
- **Documentation:** Documented the `migratedTo` field in
|
||||
`docs/extensions/reference.md` and added a comprehensive guide in
|
||||
`docs/extensions/releasing.md` explaining how extension maintainers can
|
||||
transition users using this feature.
|
||||
- **Testing:** Added extensive unit tests across `extension-manager.test.ts`,
|
||||
`consent.test.ts`, `github.test.ts`, and `update.test.ts` to cover the new
|
||||
migration and renaming logic.
|
||||
|
||||
## Related issues
|
||||
|
||||
N/A
|
||||
|
||||
## How to validate
|
||||
|
||||
1. **Unit tests:** Run all related tests to confirm everything passes:
|
||||
```bash
|
||||
npm run test -w @google/gemini-cli -- src/config/extensions/github.test.ts
|
||||
npm run test -w @google/gemini-cli -- src/config/extensions/update.test.ts
|
||||
npm run test -w @google/gemini-cli -- src/config/extensions/consent.test.ts
|
||||
npm run test -w @google/gemini-cli -- src/config/extension-manager.test.ts
|
||||
```
|
||||
2. **End-to-end migration test:**
|
||||
- Install a local or git extension.
|
||||
- Update its `gemini-extension.json` to include a `migratedTo` field pointing
|
||||
to a _different_ test repository.
|
||||
- Run `gemini extensions check` to confirm it detects the update from the new
|
||||
source.
|
||||
- Run `gemini extensions update <extension>`.
|
||||
- Verify that the consent prompt explicitly mentions the migration.
|
||||
- Verify that the new extension is installed, the old directory is deleted,
|
||||
and its enablement status carried over.
|
||||
Reference in New Issue
Block a user