diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index df3ba4b34..2654ac98c 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -51,7 +51,7 @@ export async function handleInstall(args: InstallArgs) { }); await extensionManager.refreshCache(); - const name = await extensionManager.installExtension( + const extension = await extensionManager.installExtension( { ...installMetadata, ref: args.ref, @@ -60,7 +60,9 @@ export async function handleInstall(args: InstallArgs) { }, requestConsent, ); - console.log(`Extension "${name}" installed successfully and enabled.`); + console.log( + `Extension "${extension.name}" installed successfully and enabled.`, + ); } catch (error) { console.error(getErrorMessage(error)); process.exit(1); diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index 1bebae39d..72197cda7 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -17,6 +17,13 @@ import { ExtensionManager, SettingScope, type ExtensionManagerOptions, + validateName, + getExtensionId, + hashValue, + extensionConsentString, + maybeRequestConsentOrFail, + parseInstallSource, + type ExtensionConfig, } from './extensionManager.js'; import type { MCPServerConfig, ExtensionInstallMetadata } from '../index.js'; @@ -601,3 +608,225 @@ describe('extension tests', () => { }); }); }); + +describe('extensionManager utility functions', () => { + describe('validateName', () => { + it('should accept valid extension names', () => { + expect(() => validateName('my-extension')).not.toThrow(); + expect(() => validateName('Extension123')).not.toThrow(); + expect(() => validateName('test-ext-1')).not.toThrow(); + expect(() => validateName('UPPERCASE')).not.toThrow(); + }); + + it('should reject names with invalid characters', () => { + expect(() => validateName('my_extension')).toThrow( + 'Invalid extension name', + ); + expect(() => validateName('my.extension')).toThrow( + 'Invalid extension name', + ); + expect(() => validateName('my extension')).toThrow( + 'Invalid extension name', + ); + expect(() => validateName('my@ext')).toThrow('Invalid extension name'); + }); + + it('should reject empty names', () => { + expect(() => validateName('')).toThrow('Invalid extension name'); + }); + }); + + describe('hashValue', () => { + it('should generate consistent hash for same input', () => { + const hash1 = hashValue('test-input'); + const hash2 = hashValue('test-input'); + expect(hash1).toBe(hash2); + }); + + it('should generate different hashes for different inputs', () => { + const hash1 = hashValue('input-1'); + const hash2 = hashValue('input-2'); + expect(hash1).not.toBe(hash2); + }); + + it('should generate a valid SHA256 hash', () => { + const hash = hashValue('test'); + expect(hash).toMatch(/^[a-f0-9]{64}$/); + }); + }); + + describe('getExtensionId', () => { + it('should use hashed name when no install metadata', () => { + const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; + const id = getExtensionId(config); + expect(id).toBe(hashValue('test-ext')); + }); + + it('should use hashed source for local install', () => { + const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; + const metadata = { type: 'local' as const, source: '/path/to/ext' }; + const id = getExtensionId(config, metadata); + expect(id).toBe(hashValue('/path/to/ext')); + }); + + it('should use GitHub URL for git install', () => { + const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; + const metadata = { + type: 'git' as const, + source: 'https://github.com/owner/repo', + }; + const id = getExtensionId(config, metadata); + expect(id).toBe(hashValue('https://github.com/owner/repo')); + }); + }); + + describe('extensionConsentString', () => { + it('should generate basic consent string', () => { + const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; + const consent = extensionConsentString(config); + expect(consent).toContain('Installing extension "test-ext"'); + expect(consent).toContain('Extensions may introduce unexpected behavior'); + }); + + it('should include MCP servers in consent string', () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + mcpServers: { + 'my-server': { command: 'node', args: ['server.js'] }, + }, + }; + const consent = extensionConsentString(config); + expect(consent).toContain( + 'This extension will run the following MCP servers', + ); + expect(consent).toContain('my-server'); + }); + + it('should include commands in consent string', () => { + const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; + const consent = extensionConsentString(config, ['cmd1', 'cmd2']); + expect(consent).toContain( + 'This extension will add the following commands', + ); + expect(consent).toContain('cmd1, cmd2'); + }); + + it('should include context file info', () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + contextFileName: 'CONTEXT.md', + }; + const consent = extensionConsentString(config); + expect(consent).toContain('CONTEXT.md'); + }); + + it('should include excluded tools', () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + excludeTools: ['tool1', 'tool2'], + }; + const consent = extensionConsentString(config); + expect(consent).toContain('exclude the following core tools'); + }); + }); + + describe('maybeRequestConsentOrFail', () => { + it('should request consent for new installation', async () => { + const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; + const requestConsent = vi.fn().mockResolvedValue(true); + + await maybeRequestConsentOrFail(config, requestConsent, []); + + expect(requestConsent).toHaveBeenCalledTimes(1); + }); + + it('should throw if user declines consent', async () => { + const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; + const requestConsent = vi.fn().mockResolvedValue(false); + + await expect( + maybeRequestConsentOrFail(config, requestConsent, []), + ).rejects.toThrow('Installation cancelled'); + }); + + it('should skip consent if config unchanged during update', async () => { + const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' }; + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '0.9.0', + }; + const requestConsent = vi.fn().mockResolvedValue(true); + + await maybeRequestConsentOrFail( + config, + requestConsent, + [], + previousConfig, + ); + + expect(requestConsent).not.toHaveBeenCalled(); + }); + + it('should request consent if config changed during update', async () => { + const config: ExtensionConfig = { + name: 'test-ext', + version: '1.0.0', + mcpServers: { server: { command: 'node' } }, + }; + const previousConfig: ExtensionConfig = { + name: 'test-ext', + version: '0.9.0', + }; + const requestConsent = vi.fn().mockResolvedValue(true); + + await maybeRequestConsentOrFail( + config, + requestConsent, + [], + previousConfig, + ); + + expect(requestConsent).toHaveBeenCalledTimes(1); + }); + }); + + describe('parseInstallSource', () => { + it('should parse HTTPS URL as git type', async () => { + const result = await parseInstallSource('https://github.com/owner/repo'); + expect(result.type).toBe('git'); + expect(result.source).toBe('https://github.com/owner/repo'); + }); + + it('should parse HTTP URL as git type', async () => { + const result = await parseInstallSource('http://example.com/repo'); + expect(result.type).toBe('git'); + }); + + it('should parse git@ URL as git type', async () => { + const result = await parseInstallSource('git@github.com:owner/repo.git'); + expect(result.type).toBe('git'); + }); + + it('should parse sso:// URL as git type', async () => { + const result = await parseInstallSource('sso://some/path'); + expect(result.type).toBe('git'); + }); + + it('should parse marketplace URL correctly', async () => { + const result = await parseInstallSource( + 'https://example.com/marketplace:plugin-name', + ); + expect(result.type).toBe('marketplace'); + expect(result.marketplace?.pluginName).toBe('plugin-name'); + }); + + it('should throw for non-existent local path', async () => { + await expect( + parseInstallSource('/nonexistent/path/to/extension'), + ).rejects.toThrow('Install source not found'); + }); + }); +}); diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index d135f3e28..e98e6498a 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -231,6 +231,65 @@ describe('git extension helpers', () => { ); expect(result).toBe(ExtensionUpdateState.ERROR); }); + + it('should return UPDATE_AVAILABLE for local extension with different version', async () => { + const extension = createExtension({ + version: '1.0.0', + installMetadata: { + type: 'local', + source: '/path/to/source', + }, + }); + + const mockManager = { + loadExtensionConfig: vi.fn().mockReturnValue({ + name: 'test', + version: '2.0.0', + }), + } as unknown as ExtensionManager; + + const result = await checkForExtensionUpdate(extension, mockManager); + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + }); + + it('should return UP_TO_DATE for local extension with same version', async () => { + const extension = createExtension({ + version: '1.0.0', + installMetadata: { + type: 'local', + source: '/path/to/source', + }, + }); + + const mockManager = { + loadExtensionConfig: vi.fn().mockReturnValue({ + name: 'test', + version: '1.0.0', + }), + } as unknown as ExtensionManager; + + const result = await checkForExtensionUpdate(extension, mockManager); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + }); + + it('should return NOT_UPDATABLE for local extension when source cannot be loaded', async () => { + const extension = createExtension({ + version: '1.0.0', + installMetadata: { + type: 'local', + source: '/path/to/source', + }, + }); + + const mockManager = { + loadExtensionConfig: vi.fn().mockImplementation(() => { + throw new Error('Cannot load config'); + }), + } as unknown as ExtensionManager; + + const result = await checkForExtensionUpdate(extension, mockManager); + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + }); }); describe('findReleaseAsset', () => { diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index 22d7d6f61..7cbf796c8 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -288,28 +288,32 @@ export async function downloadFromGitHubRelease( // For regular github releases, the repository is put inside of a top level // directory. In this case we should see exactly two file in the destination // dir, the archive and the directory. If we see that, validate that the - // dir has a qwen extension configuration file and then move all files - // from the directory up one level into the destination directory. + // dir has a qwen extension configuration file (or gemini-extension.json + // which will be converted later) and then move all files from the directory + // up one level into the destination directory. const entries = await fs.promises.readdir(destination, { withFileTypes: true, }); if (entries.length === 2) { const lonelyDir = entries.find((entry) => entry.isDirectory()); - if ( - lonelyDir && - fs.existsSync( + if (lonelyDir) { + const hasQwenConfig = fs.existsSync( path.join(destination, lonelyDir.name, EXTENSIONS_CONFIG_FILENAME), - ) - ) { - const dirPathToExtract = path.join(destination, lonelyDir.name); - const extractedDirFiles = await fs.promises.readdir(dirPathToExtract); - for (const file of extractedDirFiles) { - await fs.promises.rename( - path.join(dirPathToExtract, file), - path.join(destination, file), - ); + ); + const hasGeminiConfig = fs.existsSync( + path.join(destination, lonelyDir.name, 'gemini-extension.json'), + ); + if (hasQwenConfig || hasGeminiConfig) { + const dirPathToExtract = path.join(destination, lonelyDir.name); + const extractedDirFiles = await fs.promises.readdir(dirPathToExtract); + for (const file of extractedDirFiles) { + await fs.promises.rename( + path.join(dirPathToExtract, file), + path.join(destination, file), + ); + } + await fs.promises.rmdir(dirPathToExtract); } - await fs.promises.rmdir(dirPathToExtract); } }