Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/extensions/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions docs/extensions/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
140 changes: 140 additions & 0 deletions packages/cli/src/config/extension-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
});
70 changes: 65 additions & 5 deletions packages/cli/src/config/extension-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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 });
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/config/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export interface ExtensionConfig {
*/
directory?: string;
};
/**
* Used to migrate an extension to a new repository source.
*/
migratedTo?: string;
}

export interface ExtensionUpdateInfo {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/config/extensions/consent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading