diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index a7add924be..6491dd9e69 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -438,6 +438,19 @@ Would you like to attempt to install via "git clone" instead?`, extensionIdentifier.toLowerCase(), ); if (!extension) { + const extensionDir = path.join( + ExtensionStorage.getUserExtensionsDir(), + extensionIdentifier, + ); + if (fs.existsSync(extensionDir)) { + await fs.promises.rm(extensionDir, { recursive: true, force: true }); + this.extensionEnablementManager.remove(extensionIdentifier); + coreEvents.emitFeedback( + 'info', + `Uninstalled broken extension '${extensionIdentifier}'.`, + ); + return; + } throw new Error(`Extension not found.`); } await this.unloadExtension(extension); @@ -515,25 +528,24 @@ Would you like to attempt to install via "git clone" instead?`, extensionDir: string, ): Promise { this.loadedExtensions ??= []; - if (!fs.statSync(extensionDir).isDirectory()) { - return null; - } - - const installMetadata = loadInstallMetadata(extensionDir); let effectiveExtensionPath = extensionDir; - if ( - (installMetadata?.type === 'git' || - installMetadata?.type === 'github-release') && - this.settings.security.blockGitExtensions - ) { - return null; - } - - if (installMetadata?.type === 'link') { - effectiveExtensionPath = installMetadata.source; - } - try { + if (!fs.statSync(extensionDir).isDirectory()) { + return null; + } + + const installMetadata = loadInstallMetadata(extensionDir); + if ( + (installMetadata?.type === 'git' || + installMetadata?.type === 'github-release') && + this.settings.security.blockGitExtensions + ) { + return null; + } + + if (installMetadata?.type === 'link') { + effectiveExtensionPath = installMetadata.source; + } let config = await this.loadExtensionConfig(effectiveExtensionPath); if ( this.getExtensions().find((extension) => extension.name === config.name) diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 7acaf2cc67..db133dcd4a 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -1769,6 +1769,39 @@ ${INSTALL_WARNING_MESSAGE}`, }); describe('uninstallExtension', () => { + it('should uninstall a broken extension without crashing', async () => { + const badExtDir = path.join(userExtensionsDir, 'broken-ext'); + fs.mkdirSync(badExtDir); + // Malformed JSON to simulate a broken extension + fs.writeFileSync( + path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME), + '{ "name": "broken-ext"', + ); + + // Attempt to load extensions. The broken one should be skipped. + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + await extensionManager.loadExtensions(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badExtDir}/gemini-extension.json`, + ), + ); + consoleErrorSpy.mockRestore(); + + // Ensure no extensions were loaded + expect(extensionManager.getExtensions()).toHaveLength(0); + + // Attempt to uninstall the broken extension by its name. + await expect( + extensionManager.uninstallExtension('broken-ext', false), + ).resolves.toBeUndefined(); // Should resolve, not throw an error + + // Verify the directory is removed + expect(fs.existsSync(badExtDir)).toBe(false); + }); + it('should uninstall an extension by name', async () => { const sourceExtDir = createExtension({ extensionsDir: userExtensionsDir,