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",
|
"contextFileName": "GEMINI.md",
|
||||||
"excludeTools": ["run_shell_command"],
|
"excludeTools": ["run_shell_command"],
|
||||||
|
"migratedTo": "https://github.com/new-owner/new-extension-repo",
|
||||||
"plan": {
|
"plan": {
|
||||||
"directory": ".gemini/plans"
|
"directory": ".gemini/plans"
|
||||||
}
|
}
|
||||||
@@ -138,6 +139,9 @@ The manifest file defines the extension's behavior and configuration.
|
|||||||
- `version`: The version of the extension.
|
- `version`: The version of the extension.
|
||||||
- `description`: A short description of the extension. This will be displayed on
|
- `description`: A short description of the extension. This will be displayed on
|
||||||
[geminicli.com/extensions](https://geminicli.com/extensions).
|
[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
|
- `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
|
server, and the value is the server configuration. These servers will be
|
||||||
loaded on startup just like MCP servers defined in a
|
loaded on startup just like MCP servers defined in a
|
||||||
|
|||||||
@@ -152,3 +152,29 @@ jobs:
|
|||||||
release/linux.arm64.my-tool.tar.gz
|
release/linux.arm64.my-tool.tar.gz
|
||||||
release/win32.arm64.my-tool.zip
|
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;
|
this.requestSetting = options.requestSetting ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEnablementManager(): ExtensionEnablementManager {
|
||||||
|
return this.extensionEnablementManager;
|
||||||
|
}
|
||||||
|
|
||||||
setRequestConsent(
|
setRequestConsent(
|
||||||
requestConsent: (consent: string) => Promise<boolean>,
|
requestConsent: (consent: string) => Promise<boolean>,
|
||||||
): void {
|
): void {
|
||||||
@@ -271,17 +275,28 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
|
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
|
||||||
|
|
||||||
const newExtensionName = newExtensionConfig.name;
|
const newExtensionName = newExtensionConfig.name;
|
||||||
|
const previousName = previousExtensionConfig?.name ?? newExtensionName;
|
||||||
const previous = this.getExtensions().find(
|
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) {
|
if (isUpdate && !previous) {
|
||||||
throw new Error(
|
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) {
|
} else if (!isUpdate && previous) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
`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(
|
const newHasHooks = fs.existsSync(
|
||||||
@@ -298,6 +313,11 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
path.join(localSourcePath, 'skills'),
|
path.join(localSourcePath, 'skills'),
|
||||||
);
|
);
|
||||||
const previousSkills = previous?.skills ?? [];
|
const previousSkills = previous?.skills ?? [];
|
||||||
|
const isMigrating = Boolean(
|
||||||
|
previous &&
|
||||||
|
previous.installMetadata &&
|
||||||
|
previous.installMetadata.source !== installMetadata.source,
|
||||||
|
);
|
||||||
|
|
||||||
await maybeRequestConsentOrFail(
|
await maybeRequestConsentOrFail(
|
||||||
newExtensionConfig,
|
newExtensionConfig,
|
||||||
@@ -307,19 +327,46 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
previousHasHooks,
|
previousHasHooks,
|
||||||
newSkills,
|
newSkills,
|
||||||
previousSkills,
|
previousSkills,
|
||||||
|
isMigrating,
|
||||||
);
|
);
|
||||||
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
|
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
|
||||||
const destinationPath = new ExtensionStorage(
|
const destinationPath = new ExtensionStorage(
|
||||||
newExtensionName,
|
newExtensionName,
|
||||||
).getExtensionDir();
|
).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;
|
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(
|
previousSettings = await getEnvContents(
|
||||||
previousExtensionConfig,
|
previousExtensionConfig,
|
||||||
extensionId,
|
previousExtensionId,
|
||||||
this.workspaceDir,
|
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 });
|
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||||
@@ -392,6 +439,18 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
CoreToolCallStatus.Success,
|
CoreToolCallStatus.Success,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (newExtensionName !== previousName) {
|
||||||
|
if (wasEnabledGlobally) {
|
||||||
|
await this.enableExtension(newExtensionName, SettingScope.User);
|
||||||
|
}
|
||||||
|
if (wasEnabledWorkspace) {
|
||||||
|
await this.enableExtension(
|
||||||
|
newExtensionName,
|
||||||
|
SettingScope.Workspace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await logExtensionInstallEvent(
|
await logExtensionInstallEvent(
|
||||||
this.telemetryConfig,
|
this.telemetryConfig,
|
||||||
@@ -873,6 +932,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
path: effectiveExtensionPath,
|
path: effectiveExtensionPath,
|
||||||
contextFiles,
|
contextFiles,
|
||||||
installMetadata,
|
installMetadata,
|
||||||
|
migratedTo: config.migratedTo,
|
||||||
mcpServers: config.mcpServers,
|
mcpServers: config.mcpServers,
|
||||||
excludeTools: config.excludeTools,
|
excludeTools: config.excludeTools,
|
||||||
hooks,
|
hooks,
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export interface ExtensionConfig {
|
|||||||
*/
|
*/
|
||||||
directory?: string;
|
directory?: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Used to migrate an extension to a new repository source.
|
||||||
|
*/
|
||||||
|
migratedTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionUpdateInfo {
|
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."
|
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`] = `
|
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = `
|
||||||
"Installing extension "test-ext".
|
"Installing extension "test-ext".
|
||||||
This extension will run the following MCP servers:
|
This extension will run the following MCP servers:
|
||||||
|
|||||||
@@ -287,6 +287,25 @@ describe('consent', () => {
|
|||||||
expect(requestConsent).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('should request consent if skills change', async () => {
|
||||||
const skill1Dir = path.join(tempDir, 'skill1');
|
const skill1Dir = path.join(tempDir, 'skill1');
|
||||||
const skill2Dir = path.join(tempDir, 'skill2');
|
const skill2Dir = path.join(tempDir, 'skill2');
|
||||||
|
|||||||
@@ -148,11 +148,30 @@ async function extensionConsentString(
|
|||||||
extensionConfig: ExtensionConfig,
|
extensionConfig: ExtensionConfig,
|
||||||
hasHooks: boolean,
|
hasHooks: boolean,
|
||||||
skills: SkillDefinition[] = [],
|
skills: SkillDefinition[] = [],
|
||||||
|
previousName?: string,
|
||||||
|
wasMigrated?: boolean,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
|
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
|
||||||
const output: string[] = [];
|
const output: string[] = [];
|
||||||
const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
|
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) {
|
if (mcpServerEntries.length) {
|
||||||
output.push('This extension will run the following MCP servers:');
|
output.push('This extension will run the following MCP servers:');
|
||||||
@@ -231,11 +250,14 @@ export async function maybeRequestConsentOrFail(
|
|||||||
previousHasHooks?: boolean,
|
previousHasHooks?: boolean,
|
||||||
skills: SkillDefinition[] = [],
|
skills: SkillDefinition[] = [],
|
||||||
previousSkills: SkillDefinition[] = [],
|
previousSkills: SkillDefinition[] = [],
|
||||||
|
isMigrating: boolean = false,
|
||||||
) {
|
) {
|
||||||
const extensionConsent = await extensionConsentString(
|
const extensionConsent = await extensionConsentString(
|
||||||
extensionConfig,
|
extensionConfig,
|
||||||
hasHooks,
|
hasHooks,
|
||||||
skills,
|
skills,
|
||||||
|
previousExtensionConfig?.name,
|
||||||
|
isMigrating,
|
||||||
);
|
);
|
||||||
if (previousExtensionConfig) {
|
if (previousExtensionConfig) {
|
||||||
const previousExtensionConsent = await extensionConsentString(
|
const previousExtensionConsent = await extensionConsentString(
|
||||||
|
|||||||
@@ -285,6 +285,23 @@ describe('github.ts', () => {
|
|||||||
ExtensionUpdateState.NOT_UPDATABLE,
|
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', () => {
|
describe('downloadFromGitHubRelease', () => {
|
||||||
|
|||||||
@@ -203,6 +203,24 @@ export async function checkForExtensionUpdate(
|
|||||||
) {
|
) {
|
||||||
return ExtensionUpdateState.NOT_UPDATABLE;
|
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 {
|
try {
|
||||||
if (installMetadata.type === 'git') {
|
if (installMetadata.type === 'git') {
|
||||||
const git = simpleGit(extension.path);
|
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 () => {
|
it('should set state to UPDATED if enableExtensionReloading is true', async () => {
|
||||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
|
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
|
|||||||
@@ -55,6 +55,24 @@ export async function updateExtension(
|
|||||||
});
|
});
|
||||||
throw new Error(`Extension is linked so does not need to be updated`);
|
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 originalVersion = extension.version;
|
||||||
|
|
||||||
const tempDir = await ExtensionStorage.createTmpDir();
|
const tempDir = await ExtensionStorage.createTmpDir();
|
||||||
|
|||||||
@@ -361,6 +361,10 @@ export interface GeminiCLIExtension {
|
|||||||
*/
|
*/
|
||||||
directory?: string;
|
directory?: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Used to migrate an extension to a new repository source.
|
||||||
|
*/
|
||||||
|
migratedTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionInstallMetadata {
|
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