diff --git a/packages/cli/src/commands/extensions/disable.ts b/packages/cli/src/commands/extensions/disable.ts index 139e7da8c4..19727233c4 100644 --- a/packages/cli/src/commands/extensions/disable.ts +++ b/packages/cli/src/commands/extensions/disable.ts @@ -14,7 +14,7 @@ interface DisableArgs { scope: SettingScope; } -export async function handleDisable(args: DisableArgs) { +export function handleDisable(args: DisableArgs) { try { disableExtension(args.name, args.scope); console.log( @@ -42,8 +42,8 @@ export const disableCommand: CommandModule = { choices: [SettingScope.User, SettingScope.Workspace], }) .check((_argv) => true), - handler: async (argv) => { - await handleDisable({ + handler: (argv) => { + handleDisable({ name: argv['name'] as string, scope: argv['scope'] as SettingScope, }); diff --git a/packages/cli/src/commands/extensions/enable.ts b/packages/cli/src/commands/extensions/enable.ts index 0d4474208a..6bf3b71ff4 100644 --- a/packages/cli/src/commands/extensions/enable.ts +++ b/packages/cli/src/commands/extensions/enable.ts @@ -14,12 +14,10 @@ interface EnableArgs { scope?: SettingScope; } -export async function handleEnable(args: EnableArgs) { +export function handleEnable(args: EnableArgs) { try { - const scopes = args.scope - ? [args.scope] - : [SettingScope.User, SettingScope.Workspace]; - enableExtension(args.name, scopes); + const scope = args.scope ? args.scope : SettingScope.User; + enableExtension(args.name, scope); if (args.scope) { console.log( `Extension "${args.name}" successfully enabled for scope "${args.scope}".`, @@ -50,8 +48,8 @@ export const enableCommand: CommandModule = { choices: [SettingScope.User, SettingScope.Workspace], }) .check((_argv) => true), - handler: async (argv) => { - await handleEnable({ + handler: (argv) => { + handleEnable({ name: argv['name'] as string, scope: argv['scope'] as SettingScope, }); diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 7464f04d09..a94c410fd9 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -34,9 +34,10 @@ import { ExtensionUninstallEvent, } from '@google/gemini-cli-core'; import { execSync } from 'node:child_process'; -import { SettingScope, loadSettings } from './settings.js'; +import { SettingScope } from './settings.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { ExtensionUpdateState } from '../ui/state/extensions.js'; +import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; const mockGit = { clone: vi.fn(), @@ -111,177 +112,233 @@ vi.mock('node:readline', () => ({ const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); -describe('loadExtensions', () => { +describe('extension tests', () => { let tempHomeDir: string; + let tempWorkspaceDir: string; let userExtensionsDir: string; beforeEach(() => { tempHomeDir = fs.mkdtempSync( path.join(os.tmpdir(), 'gemini-cli-test-home-'), ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - vi.mocked(isWorkspaceTrusted).mockReturnValue(true); - + tempWorkspaceDir = fs.mkdtempSync( + path.join(tempHomeDir, 'gemini-cli-test-workspace-'), + ); userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); fs.mkdirSync(userExtensionsDir, { recursive: true }); + + vi.mocked(os.homedir).mockReturnValue(tempHomeDir); + vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + mockQuestion.mockImplementation((_query, callback) => callback('y')); + vi.mocked(execSync).mockClear(); + Object.values(mockGit).forEach((fn) => fn.mockReset()); }); afterEach(() => { fs.rmSync(tempHomeDir, { recursive: true, force: true }); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); vi.restoreAllMocks(); + mockQuestion.mockClear(); + mockClose.mockClear(); }); - it('should include extension path in loaded extension', () => { - const extensionDir = path.join(userExtensionsDir, 'test-extension'); - fs.mkdirSync(extensionDir, { recursive: true }); + describe('loadExtensions', () => { + it('should include extension path in loaded extension', () => { + const extensionDir = path.join(userExtensionsDir, 'test-extension'); + fs.mkdirSync(extensionDir, { recursive: true }); - createExtension({ - extensionsDir: userExtensionsDir, - name: 'test-extension', - version: '1.0.0', + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + }); + + const extensions = loadExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0].path).toBe(extensionDir); + expect(extensions[0].config.name).toBe('test-extension'); }); - const extensions = loadExtensions(); - expect(extensions).toHaveLength(1); - expect(extensions[0].path).toBe(extensionDir); - expect(extensions[0].config.name).toBe('test-extension'); - }); + it('should load context file path when GEMINI.md is present', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + addContextFile: true, + }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext2', + version: '2.0.0', + }); - it('should load context file path when GEMINI.md is present', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'ext1', - version: '1.0.0', - addContextFile: true, - }); - createExtension({ - extensionsDir: userExtensionsDir, - name: 'ext2', - version: '2.0.0', + const extensions = loadExtensions(); + + expect(extensions).toHaveLength(2); + const ext1 = extensions.find((e) => e.config.name === 'ext1'); + const ext2 = extensions.find((e) => e.config.name === 'ext2'); + expect(ext1?.contextFiles).toEqual([ + path.join(userExtensionsDir, 'ext1', 'GEMINI.md'), + ]); + expect(ext2?.contextFiles).toEqual([]); }); - const extensions = loadExtensions(); + it('should load context file path from the extension config', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + addContextFile: false, + contextFileName: 'my-context-file.md', + }); - expect(extensions).toHaveLength(2); - const ext1 = extensions.find((e) => e.config.name === 'ext1'); - const ext2 = extensions.find((e) => e.config.name === 'ext2'); - expect(ext1?.contextFiles).toEqual([ - path.join(userExtensionsDir, 'ext1', 'GEMINI.md'), - ]); - expect(ext2?.contextFiles).toEqual([]); - }); + const extensions = loadExtensions(); - it('should load context file path from the extension config', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'ext1', - version: '1.0.0', - addContextFile: false, - contextFileName: 'my-context-file.md', + expect(extensions).toHaveLength(1); + const ext1 = extensions.find((e) => e.config.name === 'ext1'); + expect(ext1?.contextFiles).toEqual([ + path.join(userExtensionsDir, 'ext1', 'my-context-file.md'), + ]); }); - const extensions = loadExtensions(); - - expect(extensions).toHaveLength(1); - const ext1 = extensions.find((e) => e.config.name === 'ext1'); - expect(ext1?.contextFiles).toEqual([ - path.join(userExtensionsDir, 'ext1', 'my-context-file.md'), - ]); - }); - - it('should filter out disabled extensions', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - createExtension({ - extensionsDir: userExtensionsDir, - name: 'ext2', - version: '2.0.0', + it('should filter out disabled extensions', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'disabled-extension', + version: '1.0.0', + }); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'enabled-extension', + version: '2.0.0', + }); + disableExtension( + 'disabled-extension', + SettingScope.User, + tempWorkspaceDir, + ); + const extensions = loadExtensions(); + const activeExtensions = annotateActiveExtensions( + extensions, + [], + tempWorkspaceDir, + ).filter((e) => e.isActive); + expect(activeExtensions).toHaveLength(1); + expect(activeExtensions[0].name).toBe('enabled-extension'); }); - const settingsDir = path.join(tempHomeDir, GEMINI_DIR); - fs.mkdirSync(settingsDir, { recursive: true }); - fs.writeFileSync( - path.join(settingsDir, 'settings.json'), - JSON.stringify({ extensions: { disabled: ['ext1'] } }), - ); - - const extensions = loadExtensions(); - const activeExtensions = annotateActiveExtensions( - extensions, - [], - tempHomeDir, - ).filter((e) => e.isActive); - expect(activeExtensions).toHaveLength(1); - expect(activeExtensions[0].name).toBe('ext2'); - }); - - it('should hydrate variables', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'test-extension', - version: '1.0.0', - addContextFile: false, - contextFileName: undefined, - mcpServers: { - 'test-server': { - cwd: '${extensionPath}${/}server', + it('should hydrate variables', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + addContextFile: false, + contextFileName: undefined, + mcpServers: { + 'test-server': { + cwd: '${extensionPath}${/}server', + }, }, - }, + }); + + const extensions = loadExtensions(); + expect(extensions).toHaveLength(1); + const loadedConfig = extensions[0].config; + const expectedCwd = path.join( + userExtensionsDir, + 'test-extension', + 'server', + ); + expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd); }); - const extensions = loadExtensions(); - expect(extensions).toHaveLength(1); - const loadedConfig = extensions[0].config; - const expectedCwd = path.join( - userExtensionsDir, - 'test-extension', - 'server', - ); - expect(loadedConfig.mcpServers?.['test-server'].cwd).toBe(expectedCwd); - }); + it('should load a linked extension correctly', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempWorkspaceDir, + name: 'my-linked-extension', + version: '1.0.0', + contextFileName: 'context.md', + }); + fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context'); - it('should load a linked extension correctly', async () => { - const tempWorkspaceDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), - ); - const sourceExtDir = createExtension({ - extensionsDir: tempWorkspaceDir, - name: 'my-linked-extension', - version: '1.0.0', - contextFileName: 'context.md', - }); - fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context'); + const extensionName = await installExtension({ + source: sourceExtDir, + type: 'link', + }); - const extensionName = await installExtension({ - source: sourceExtDir, - type: 'link', + expect(extensionName).toEqual('my-linked-extension'); + const extensions = loadExtensions(); + expect(extensions).toHaveLength(1); + + const linkedExt = extensions[0]; + expect(linkedExt.config.name).toBe('my-linked-extension'); + + expect(linkedExt.path).toBe(sourceExtDir); + expect(linkedExt.installMetadata).toEqual({ + source: sourceExtDir, + type: 'link', + }); + expect(linkedExt.contextFiles).toEqual([ + path.join(sourceExtDir, 'context.md'), + ]); }); - expect(extensionName).toEqual('my-linked-extension'); - const extensions = loadExtensions(); - expect(extensions).toHaveLength(1); + it('should resolve environment variables in extension configuration', () => { + process.env.TEST_API_KEY = 'test-api-key-123'; + process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb'; - const linkedExt = extensions[0]; - expect(linkedExt.config.name).toBe('my-linked-extension'); + try { + const userExtensionsDir = path.join( + tempHomeDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(userExtensionsDir, { recursive: true }); - expect(linkedExt.path).toBe(sourceExtDir); - expect(linkedExt.installMetadata).toEqual({ - source: sourceExtDir, - type: 'link', + const extDir = path.join(userExtensionsDir, 'test-extension'); + fs.mkdirSync(extDir); + + // Write config to a separate file for clarity and good practices + const configPath = path.join(extDir, EXTENSIONS_CONFIG_FILENAME); + const extensionConfig = { + name: 'test-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + env: { + API_KEY: '$TEST_API_KEY', + DATABASE_URL: '${TEST_DB_URL}', + STATIC_VALUE: 'no-substitution', + }, + }, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(extensionConfig)); + + const extensions = loadExtensions(); + + expect(extensions).toHaveLength(1); + const extension = extensions[0]; + expect(extension.config.name).toBe('test-extension'); + expect(extension.config.mcpServers).toBeDefined(); + + const serverConfig = extension.config.mcpServers?.['test-server']; + expect(serverConfig).toBeDefined(); + expect(serverConfig?.env).toBeDefined(); + expect(serverConfig?.env?.API_KEY).toBe('test-api-key-123'); + expect(serverConfig?.env?.DATABASE_URL).toBe( + 'postgresql://localhost:5432/testdb', + ); + expect(serverConfig?.env?.STATIC_VALUE).toBe('no-substitution'); + } finally { + delete process.env.TEST_API_KEY; + delete process.env.TEST_DB_URL; + } }); - expect(linkedExt.contextFiles).toEqual([ - path.join(sourceExtDir, 'context.md'), - ]); - }); - it('should resolve environment variables in extension configuration', () => { - process.env.TEST_API_KEY = 'test-api-key-123'; - process.env.TEST_DB_URL = 'postgresql://localhost:5432/testdb'; - - try { + it('should handle missing environment variables gracefully', () => { const userExtensionsDir = path.join( tempHomeDir, EXTENSIONS_DIRECTORY_NAME, @@ -291,8 +348,6 @@ describe('loadExtensions', () => { const extDir = path.join(userExtensionsDir, 'test-extension'); fs.mkdirSync(extDir); - // Write config to a separate file for clarity and good practices - const configPath = path.join(extDir, EXTENSIONS_CONFIG_FILENAME); const extensionConfig = { name: 'test-extension', version: '1.0.0', @@ -301,584 +356,906 @@ describe('loadExtensions', () => { command: 'node', args: ['server.js'], env: { - API_KEY: '$TEST_API_KEY', - DATABASE_URL: '${TEST_DB_URL}', - STATIC_VALUE: 'no-substitution', + MISSING_VAR: '$UNDEFINED_ENV_VAR', + MISSING_VAR_BRACES: '${ALSO_UNDEFINED}', }, }, }, }; - fs.writeFileSync(configPath, JSON.stringify(extensionConfig)); + + fs.writeFileSync( + path.join(extDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(extensionConfig), + ); const extensions = loadExtensions(); expect(extensions).toHaveLength(1); const extension = extensions[0]; - expect(extension.config.name).toBe('test-extension'); - expect(extension.config.mcpServers).toBeDefined(); - - const serverConfig = extension.config.mcpServers?.['test-server']; - expect(serverConfig).toBeDefined(); - expect(serverConfig?.env).toBeDefined(); - expect(serverConfig?.env?.API_KEY).toBe('test-api-key-123'); - expect(serverConfig?.env?.DATABASE_URL).toBe( - 'postgresql://localhost:5432/testdb', - ); - expect(serverConfig?.env?.STATIC_VALUE).toBe('no-substitution'); - } finally { - delete process.env.TEST_API_KEY; - delete process.env.TEST_DB_URL; - } + const serverConfig = extension.config.mcpServers!['test-server']; + expect(serverConfig.env).toBeDefined(); + expect(serverConfig.env!.MISSING_VAR).toBe('$UNDEFINED_ENV_VAR'); + expect(serverConfig.env!.MISSING_VAR_BRACES).toBe('${ALSO_UNDEFINED}'); + }); }); - it('should handle missing environment variables gracefully', () => { - const userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); - fs.mkdirSync(userExtensionsDir, { recursive: true }); + describe('annotateActiveExtensions', () => { + const extensions: Extension[] = [ + { + path: '/path/to/ext1', + config: { name: 'ext1', version: '1.0.0' }, + contextFiles: [], + }, + { + path: '/path/to/ext2', + config: { name: 'ext2', version: '1.0.0' }, + contextFiles: [], + }, + { + path: '/path/to/ext3', + config: { name: 'ext3', version: '1.0.0' }, + contextFiles: [], + }, + ]; - const extDir = path.join(userExtensionsDir, 'test-extension'); - fs.mkdirSync(extDir); + it('should mark all extensions as active if no enabled extensions are provided', () => { + const activeExtensions = annotateActiveExtensions( + extensions, + [], + '/path/to/workspace', + ); + expect(activeExtensions).toHaveLength(3); + expect(activeExtensions.every((e) => e.isActive)).toBe(true); + }); - const extensionConfig = { - name: 'test-extension', - version: '1.0.0', - mcpServers: { - 'test-server': { - command: 'node', - args: ['server.js'], - env: { - MISSING_VAR: '$UNDEFINED_ENV_VAR', - MISSING_VAR_BRACES: '${ALSO_UNDEFINED}', + it('should mark only the enabled extensions as active', () => { + const activeExtensions = annotateActiveExtensions( + extensions, + ['ext1', 'ext3'], + '/path/to/workspace', + ); + expect(activeExtensions).toHaveLength(3); + expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( + true, + ); + expect(activeExtensions.find((e) => e.name === 'ext2')?.isActive).toBe( + false, + ); + expect(activeExtensions.find((e) => e.name === 'ext3')?.isActive).toBe( + true, + ); + }); + + it('should mark all extensions as inactive when "none" is provided', () => { + const activeExtensions = annotateActiveExtensions( + extensions, + ['none'], + '/path/to/workspace', + ); + expect(activeExtensions).toHaveLength(3); + expect(activeExtensions.every((e) => !e.isActive)).toBe(true); + }); + + it('should handle case-insensitivity', () => { + const activeExtensions = annotateActiveExtensions( + extensions, + ['EXT1'], + '/path/to/workspace', + ); + expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( + true, + ); + }); + + it('should log an error for unknown extensions', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + annotateActiveExtensions(extensions, ['ext4'], '/path/to/workspace'); + expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4'); + consoleSpy.mockRestore(); + }); + }); + + describe('installExtension', () => { + it('should install an extension from a local path', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + + await installExtension({ source: sourceExtDir, type: 'local' }); + + expect(fs.existsSync(targetExtDir)).toBe(true); + expect(fs.existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: sourceExtDir, + type: 'local', + }); + fs.rmSync(targetExtDir, { recursive: true, force: true }); + }); + + it('should throw an error if the extension already exists', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + await installExtension({ source: sourceExtDir, type: 'local' }); + await expect( + installExtension({ source: sourceExtDir, type: 'local' }), + ).rejects.toThrow( + 'Extension "my-local-extension" is already installed. Please uninstall it first.', + ); + }); + + it('should throw an error and cleanup if gemini-extension.json is missing', async () => { + const sourceExtDir = path.join(tempHomeDir, 'bad-extension'); + fs.mkdirSync(sourceExtDir, { recursive: true }); + + await expect( + installExtension({ source: sourceExtDir, type: 'local' }), + ).rejects.toThrow( + `Invalid extension at ${sourceExtDir}. Please make sure it has a valid gemini-extension.json file.`, + ); + + const targetExtDir = path.join(userExtensionsDir, 'bad-extension'); + expect(fs.existsSync(targetExtDir)).toBe(false); + }); + + it('should install an extension from a git URL', async () => { + const gitUrl = 'https://github.com/google/gemini-extensions.git'; + const extensionName = 'gemini-extensions'; + const targetExtDir = path.join(userExtensionsDir, extensionName); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + + mockGit.clone.mockImplementation(async (_, destination) => { + fs.mkdirSync(path.join(mockGit.path(), destination), { + recursive: true, + }); + fs.writeFileSync( + path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.0.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + + await installExtension({ source: gitUrl, type: 'git' }); + + expect(fs.existsSync(targetExtDir)).toBe(true); + expect(fs.existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: gitUrl, + type: 'git', + }); + fs.rmSync(targetExtDir, { recursive: true, force: true }); + }); + + it('should install a linked extension', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-linked-extension', + version: '1.0.0', + }); + const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension'); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME); + + await installExtension({ source: sourceExtDir, type: 'link' }); + + expect(fs.existsSync(targetExtDir)).toBe(true); + expect(fs.existsSync(metadataPath)).toBe(true); + + expect(fs.existsSync(configPath)).toBe(false); + + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: sourceExtDir, + type: 'link', + }); + fs.rmSync(targetExtDir, { recursive: true, force: true }); + }); + + it('should log to clearcut on successful install', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + + await installExtension({ source: sourceExtDir, type: 'local' }); + + const logger = ClearcutLogger.getInstance({} as Config); + expect(logger?.logExtensionInstallEvent).toHaveBeenCalled(); + }); + + it('should show users information on their mcp server when installing', async () => { + const consoleInfoSpy = vi.spyOn(console, 'info'); + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + description: 'a local mcp server', + }, + 'test-server-2': { + description: 'a remote mcp server', + httpUrl: 'https://google.com', }, }, - }, - }; + }); - fs.writeFileSync( - path.join(extDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify(extensionConfig), - ); + mockQuestion.mockImplementation((_query, callback) => callback('y')); - const extensions = loadExtensions(); + await expect( + installExtension({ source: sourceExtDir, type: 'local' }), + ).resolves.toBe('my-local-extension'); - expect(extensions).toHaveLength(1); - const extension = extensions[0]; - const serverConfig = extension.config.mcpServers!['test-server']; - expect(serverConfig.env).toBeDefined(); - expect(serverConfig.env!.MISSING_VAR).toBe('$UNDEFINED_ENV_VAR'); - expect(serverConfig.env!.MISSING_VAR_BRACES).toBe('${ALSO_UNDEFINED}'); - }); -}); - -describe('annotateActiveExtensions', () => { - const extensions: Extension[] = [ - { - path: '/path/to/ext1', - config: { name: 'ext1', version: '1.0.0' }, - contextFiles: [], - }, - { - path: '/path/to/ext2', - config: { name: 'ext2', version: '1.0.0' }, - contextFiles: [], - }, - { - path: '/path/to/ext3', - config: { name: 'ext3', version: '1.0.0' }, - contextFiles: [], - }, - ]; - - it('should mark all extensions as active if no enabled extensions are provided', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - [], - '/path/to/workspace', - ); - expect(activeExtensions).toHaveLength(3); - expect(activeExtensions.every((e) => e.isActive)).toBe(true); - }); - - it('should mark only the enabled extensions as active', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - ['ext1', 'ext3'], - '/path/to/workspace', - ); - expect(activeExtensions).toHaveLength(3); - expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( - true, - ); - expect(activeExtensions.find((e) => e.name === 'ext2')?.isActive).toBe( - false, - ); - expect(activeExtensions.find((e) => e.name === 'ext3')?.isActive).toBe( - true, - ); - }); - - it('should mark all extensions as inactive when "none" is provided', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - ['none'], - '/path/to/workspace', - ); - expect(activeExtensions).toHaveLength(3); - expect(activeExtensions.every((e) => !e.isActive)).toBe(true); - }); - - it('should handle case-insensitivity', () => { - const activeExtensions = annotateActiveExtensions( - extensions, - ['EXT1'], - '/path/to/workspace', - ); - expect(activeExtensions.find((e) => e.name === 'ext1')?.isActive).toBe( - true, - ); - }); - - it('should log an error for unknown extensions', () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - annotateActiveExtensions(extensions, ['ext4'], '/path/to/workspace'); - expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4'); - consoleSpy.mockRestore(); - }); -}); - -describe('installExtension', () => { - let tempHomeDir: string; - let userExtensionsDir: string; - - beforeEach(() => { - mockQuestion.mockImplementation((_query, callback) => callback('y')); - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-home-'), - ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); - // Clean up before each test - fs.rmSync(userExtensionsDir, { recursive: true, force: true }); - fs.mkdirSync(userExtensionsDir, { recursive: true }); - vi.mocked(isWorkspaceTrusted).mockReturnValue(true); - vi.mocked(execSync).mockClear(); - Object.values(mockGit).forEach((fn) => fn.mockReset()); - }); - - afterEach(() => { - mockQuestion.mockClear(); - mockClose.mockClear(); - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - fs.rmSync(userExtensionsDir, { recursive: true, force: true }); - }); - - it('should install an extension from a local path', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - }); - const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); - const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - - await installExtension({ source: sourceExtDir, type: 'local' }); - - expect(fs.existsSync(targetExtDir)).toBe(true); - expect(fs.existsSync(metadataPath)).toBe(true); - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - expect(metadata).toEqual({ - source: sourceExtDir, - type: 'local', - }); - fs.rmSync(targetExtDir, { recursive: true, force: true }); - }); - - it('should throw an error if the extension already exists', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - }); - await installExtension({ source: sourceExtDir, type: 'local' }); - await expect( - installExtension({ source: sourceExtDir, type: 'local' }), - ).rejects.toThrow( - 'Extension "my-local-extension" is already installed. Please uninstall it first.', - ); - }); - - it('should throw an error and cleanup if gemini-extension.json is missing', async () => { - const sourceExtDir = path.join(tempHomeDir, 'bad-extension'); - fs.mkdirSync(sourceExtDir, { recursive: true }); - - await expect( - installExtension({ source: sourceExtDir, type: 'local' }), - ).rejects.toThrow( - `Invalid extension at ${sourceExtDir}. Please make sure it has a valid gemini-extension.json file.`, - ); - - const targetExtDir = path.join(userExtensionsDir, 'bad-extension'); - expect(fs.existsSync(targetExtDir)).toBe(false); - }); - - it('should install an extension from a git URL', async () => { - const gitUrl = 'https://github.com/google/gemini-extensions.git'; - const extensionName = 'gemini-extensions'; - const targetExtDir = path.join(userExtensionsDir, extensionName); - const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - - mockGit.clone.mockImplementation(async (_, destination) => { - fs.mkdirSync(path.join(mockGit.path(), destination), { recursive: true }); - fs.writeFileSync( - path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name: extensionName, version: '1.0.0' }), + expect(consoleInfoSpy).toHaveBeenCalledWith( + 'This extension will run the following MCP servers: ', + ); + expect(consoleInfoSpy).toHaveBeenCalledWith( + ' * test-server (local): a local mcp server', + ); + expect(consoleInfoSpy).toHaveBeenCalledWith( + ' * test-server-2 (remote): a remote mcp server', + ); + expect(consoleInfoSpy).toHaveBeenCalledWith( + 'The extension will append info to your gemini.md context', ); }); - mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); - await installExtension({ source: gitUrl, type: 'git' }); - - expect(fs.existsSync(targetExtDir)).toBe(true); - expect(fs.existsSync(metadataPath)).toBe(true); - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - expect(metadata).toEqual({ - source: gitUrl, - type: 'git', - }); - fs.rmSync(targetExtDir, { recursive: true, force: true }); - }); - - it('should install a linked extension', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-linked-extension', - version: '1.0.0', - }); - const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension'); - const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME); - - await installExtension({ source: sourceExtDir, type: 'link' }); - - expect(fs.existsSync(targetExtDir)).toBe(true); - expect(fs.existsSync(metadataPath)).toBe(true); - - expect(fs.existsSync(configPath)).toBe(false); - - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - expect(metadata).toEqual({ - source: sourceExtDir, - type: 'link', - }); - fs.rmSync(targetExtDir, { recursive: true, force: true }); - }); - - it('should log to clearcut on successful install', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - }); - - await installExtension({ source: sourceExtDir, type: 'local' }); - - const logger = ClearcutLogger.getInstance({} as Config); - expect(logger?.logExtensionInstallEvent).toHaveBeenCalled(); - }); - - it('should show users information on their mcp server when installing', async () => { - const consoleInfoSpy = vi.spyOn(console, 'info'); - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - mcpServers: { - 'test-server': { - command: 'node', - args: ['server.js'], - description: 'a local mcp server', + it('should continue installation if user accepts prompt for local extension with mcp servers', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + }, }, - 'test-server-2': { - description: 'a remote mcp server', - httpUrl: 'https://google.com', + }); + + mockQuestion.mockImplementation((_query, callback) => callback('y')); + + await expect( + installExtension({ source: sourceExtDir, type: 'local' }), + ).resolves.toBe('my-local-extension'); + + expect(mockQuestion).toHaveBeenCalledWith( + expect.stringContaining('Do you want to continue? (y/n)'), + expect.any(Function), + ); + }); + + it('should cancel installation if user declines prompt for local extension with mcp servers', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + mcpServers: { + 'test-server': { + command: 'node', + args: ['server.js'], + }, }, - }, + }); + + mockQuestion.mockImplementation((_query, callback) => callback('n')); + + await expect( + installExtension({ source: sourceExtDir, type: 'local' }), + ).rejects.toThrow('Installation cancelled by user.'); + + expect(mockQuestion).toHaveBeenCalledWith( + expect.stringContaining('Do you want to continue? (y/n)'), + expect.any(Function), + ); + }); + }); + + describe('uninstallExtension', () => { + it('should uninstall an extension by name', async () => { + const sourceExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-local-extension', + version: '1.0.0', + }); + + await uninstallExtension('my-local-extension'); + + expect(fs.existsSync(sourceExtDir)).toBe(false); }); - mockQuestion.mockImplementation((_query, callback) => callback('y')); + it('should uninstall an extension by name and retain existing extensions', async () => { + const sourceExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-local-extension', + version: '1.0.0', + }); + const otherExtDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'other-extension', + version: '1.0.0', + }); - await expect( - installExtension({ source: sourceExtDir, type: 'local' }), - ).resolves.toBe('my-local-extension'); + await uninstallExtension('my-local-extension'); - expect(consoleInfoSpy).toHaveBeenCalledWith( - 'This extension will run the following MCP servers: ', - ); - expect(consoleInfoSpy).toHaveBeenCalledWith( - ' * test-server (local): a local mcp server', - ); - expect(consoleInfoSpy).toHaveBeenCalledWith( - ' * test-server-2 (remote): a remote mcp server', - ); - expect(consoleInfoSpy).toHaveBeenCalledWith( - 'The extension will append info to your gemini.md context', - ); - }); - - it('should continue installation if user accepts prompt for local extension with mcp servers', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - mcpServers: { - 'test-server': { - command: 'node', - args: ['server.js'], - }, - }, + expect(fs.existsSync(sourceExtDir)).toBe(false); + expect(loadExtensions()).toHaveLength(1); + expect(fs.existsSync(otherExtDir)).toBe(true); }); - mockQuestion.mockImplementation((_query, callback) => callback('y')); - - await expect( - installExtension({ source: sourceExtDir, type: 'local' }), - ).resolves.toBe('my-local-extension'); - - expect(mockQuestion).toHaveBeenCalledWith( - expect.stringContaining('Do you want to continue? (y/n)'), - expect.any(Function), - ); - }); - - it('should cancel installation if user declines prompt for local extension with mcp servers', async () => { - const sourceExtDir = createExtension({ - extensionsDir: tempHomeDir, - name: 'my-local-extension', - version: '1.0.0', - mcpServers: { - 'test-server': { - command: 'node', - args: ['server.js'], - }, - }, + it('should throw an error if the extension does not exist', async () => { + await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow( + 'Extension "nonexistent-extension" not found.', + ); }); - mockQuestion.mockImplementation((_query, callback) => callback('n')); + it('should log uninstall event', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-local-extension', + version: '1.0.0', + }); - await expect( - installExtension({ source: sourceExtDir, type: 'local' }), - ).rejects.toThrow('Installation cancelled by user.'); + await uninstallExtension('my-local-extension'); - expect(mockQuestion).toHaveBeenCalledWith( - expect.stringContaining('Do you want to continue? (y/n)'), - expect.any(Function), - ); - }); -}); - -describe('uninstallExtension', () => { - let tempHomeDir: string; - let userExtensionsDir: string; - - beforeEach(() => { - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-home-'), - ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); - // Clean up before each test - fs.rmSync(userExtensionsDir, { recursive: true, force: true }); - fs.mkdirSync(userExtensionsDir, { recursive: true }); - - vi.mocked(execSync).mockClear(); + const logger = ClearcutLogger.getInstance({} as Config); + expect(logger?.logExtensionUninstallEvent).toHaveBeenCalledWith( + new ExtensionUninstallEvent('my-local-extension', 'success'), + ); + }); }); - afterEach(() => { - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - }); + describe('performWorkspaceExtensionMigration', () => { + let workspaceExtensionsDir: string; - it('should uninstall an extension by name', async () => { - const sourceExtDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'my-local-extension', - version: '1.0.0', + beforeEach(() => { + workspaceExtensionsDir = path.join( + tempWorkspaceDir, + EXTENSIONS_DIRECTORY_NAME, + ); + fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); }); - await uninstallExtension('my-local-extension'); - - expect(fs.existsSync(sourceExtDir)).toBe(false); - }); - - it('should uninstall an extension by name and retain existing extensions', async () => { - const sourceExtDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'my-local-extension', - version: '1.0.0', - }); - const otherExtDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'other-extension', - version: '1.0.0', + afterEach(() => { + fs.rmSync(workspaceExtensionsDir, { recursive: true, force: true }); }); - await uninstallExtension('my-local-extension'); + describe('folder trust', () => { + it('refuses to install extensions from untrusted folders', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); - expect(fs.existsSync(sourceExtDir)).toBe(false); - expect(loadExtensions()).toHaveLength(1); - expect(fs.existsSync(otherExtDir)).toBe(true); - }); + const failed = await performWorkspaceExtensionMigration([ + loadExtension(ext1Path)!, + ]); - it('should throw an error if the extension does not exist', async () => { - await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow( - 'Extension "nonexistent-extension" not found.', - ); - }); + expect(failed).toEqual(['ext1']); + }); - it('should log uninstall event', async () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'my-local-extension', - version: '1.0.0', + it('does not copy extensions to the user dir', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]); + + const userExtensionsDir = path.join( + tempHomeDir, + GEMINI_DIR, + 'extensions', + ); + expect(fs.readdirSync(userExtensionsDir).length).toBe(0); + }); + + it('does not load any extensions in the workspace config', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + const ext1Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + + await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]); + const extensions = loadExtensions(); + + expect(extensions).toEqual([]); + }); }); - await uninstallExtension('my-local-extension'); - - const logger = ClearcutLogger.getInstance({} as Config); - expect(logger?.logExtensionUninstallEvent).toHaveBeenCalledWith( - new ExtensionUninstallEvent('my-local-extension', 'success'), - ); - }); -}); - -describe('performWorkspaceExtensionMigration', () => { - let tempWorkspaceDir: string; - let tempHomeDir: string; - let workspaceExtensionsDir: string; - - beforeEach(() => { - tempWorkspaceDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), - ); - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-home-'), - ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - vi.mocked(isWorkspaceTrusted).mockReturnValue(true); - - workspaceExtensionsDir = path.join( - tempWorkspaceDir, - EXTENSIONS_DIRECTORY_NAME, - ); - fs.mkdirSync(workspaceExtensionsDir, { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - vi.restoreAllMocks(); - }); - - describe('folder trust', () => { - it('refuses to install extensions from untrusted folders', async () => { - vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + it('should install the extensions in the user directory', async () => { const ext1Path = createExtension({ extensionsDir: workspaceExtensionsDir, name: 'ext1', version: '1.0.0', }); - - const failed = await performWorkspaceExtensionMigration([ + const ext2Path = createExtension({ + extensionsDir: workspaceExtensionsDir, + name: 'ext2', + version: '1.0.0', + }); + const extensionsToMigrate: Extension[] = [ loadExtension(ext1Path)!, - ]); + loadExtension(ext2Path)!, + ]; + const failed = + await performWorkspaceExtensionMigration(extensionsToMigrate); - expect(failed).toEqual(['ext1']); - }); - - it('does not copy extensions to the user dir', async () => { - vi.mocked(isWorkspaceTrusted).mockReturnValue(false); - const ext1Path = createExtension({ - extensionsDir: workspaceExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - - await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]); + expect(failed).toEqual([]); const userExtensionsDir = path.join( tempHomeDir, GEMINI_DIR, 'extensions', ); + const userExt1Path = path.join(userExtensionsDir, 'ext1'); + const extensions = loadExtensions(); - expect(() => fs.readdirSync(userExtensionsDir)).toThrow(); + expect(extensions).toHaveLength(2); + const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME); + expect(fs.existsSync(metadataPath)).toBe(true); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + expect(metadata).toEqual({ + source: ext1Path, + type: 'local', + }); }); - it('does not load any extensions in the workspace config', async () => { - vi.mocked(isWorkspaceTrusted).mockReturnValue(false); + it('should return the names of failed installations', async () => { const ext1Path = createExtension({ extensionsDir: workspaceExtensionsDir, name: 'ext1', version: '1.0.0', }); - await performWorkspaceExtensionMigration([loadExtension(ext1Path)!]); + const extensions: Extension[] = [ + loadExtension(ext1Path)!, + { + path: '/ext/path/1', + config: { name: 'ext2', version: '1.0.0' }, + contextFiles: [], + }, + ]; + + const failed = await performWorkspaceExtensionMigration(extensions); + expect(failed).toEqual(['ext2']); + }); + }); + + describe('updateExtension', () => { + it('should update a git-installed extension', async () => { + const gitUrl = 'https://github.com/google/gemini-extensions.git'; + const extensionName = 'gemini-extensions'; + const targetExtDir = path.join(userExtensionsDir, extensionName); + const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); + + fs.mkdirSync(targetExtDir, { recursive: true }); + fs.writeFileSync( + path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.0.0' }), + ); + fs.writeFileSync( + metadataPath, + JSON.stringify({ source: gitUrl, type: 'git' }), + ); + + mockGit.clone.mockImplementation(async (_, destination) => { + fs.mkdirSync(path.join(mockGit.path(), destination), { + recursive: true, + }); + fs.writeFileSync( + path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.1.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + const extension = annotateActiveExtensions( + [loadExtension(targetExtDir)!], + [], + process.cwd(), + )[0]; + const updateInfo = await updateExtension( + extension, + tempHomeDir, + () => {}, + ); + + expect(updateInfo).toEqual({ + name: 'gemini-extensions', + originalVersion: '1.0.0', + updatedVersion: '1.1.0', + }); + + const updatedConfig = JSON.parse( + fs.readFileSync( + path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME), + 'utf-8', + ), + ); + expect(updatedConfig.version).toBe('1.1.0'); + }); + + it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => { + const extensionName = 'test-extension'; + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: extensionName, + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + + mockGit.clone.mockImplementation(async (_, destination) => { + fs.mkdirSync(path.join(mockGit.path(), destination), { + recursive: true, + }); + fs.writeFileSync( + path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: extensionName, version: '1.1.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + + const setExtensionUpdateState = vi.fn(); + + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + await updateExtension(extension, tempHomeDir, setExtensionUpdateState); + + expect(setExtensionUpdateState).toHaveBeenCalledWith( + ExtensionUpdateState.UPDATING, + ); + expect(setExtensionUpdateState).toHaveBeenCalledWith( + ExtensionUpdateState.UPDATED_NEEDS_RESTART, + ); + }); + + it('should call setExtensionUpdateState with ERROR on failure', async () => { + const extensionName = 'test-extension'; + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: extensionName, + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + + mockGit.clone.mockRejectedValue(new Error('Git clone failed')); + mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); + + const setExtensionUpdateState = vi.fn(); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + await expect( + updateExtension(extension, tempHomeDir, setExtensionUpdateState), + ).rejects.toThrow(); + + expect(setExtensionUpdateState).toHaveBeenCalledWith( + ExtensionUpdateState.UPDATING, + ); + expect(setExtensionUpdateState).toHaveBeenCalledWith( + ExtensionUpdateState.ERROR, + ); + }); + }); + + describe('checkForAllExtensionUpdates', () => { + it('should return UpdateAvailable for a git extension with updates', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, + ]); + mockGit.listRemote.mockResolvedValue('remoteHash HEAD'); + mockGit.revparse.mockResolvedValue('localHash'); + + const results = await checkForAllExtensionUpdates([extension], () => {}); + const result = results.get('test-extension'); + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + }); + + it('should return UpToDate for a git extension with no updates', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, + ]); + mockGit.listRemote.mockResolvedValue('sameHash HEAD'); + mockGit.revparse.mockResolvedValue('sameHash'); + + const results = await checkForAllExtensionUpdates([extension], () => {}); + const result = results.get('test-extension'); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + }); + + it('should return NotUpdatable for a non-git extension', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'local-extension', + version: '1.0.0', + installMetadata: { source: '/local/path', type: 'local' }, + }); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + + const results = await checkForAllExtensionUpdates([extension], () => {}); + const result = results.get('local-extension'); + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + }); + + it('should return Error when git check fails', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'error-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + + mockGit.getRemotes.mockRejectedValue(new Error('Git error')); + + const results = await checkForAllExtensionUpdates([extension], () => {}); + const result = results.get('error-extension'); + expect(result).toBe(ExtensionUpdateState.ERROR); + }); + }); + + describe('checkForExtensionUpdate', () => { + it('should return UpdateAvailable for a git extension with updates', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, + ]); + mockGit.listRemote.mockResolvedValue('remoteHash HEAD'); + mockGit.revparse.mockResolvedValue('localHash'); + + const result = await checkForExtensionUpdate(extension); + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + }); + + it('should return UpToDate for a git extension with no updates', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, + ]); + mockGit.listRemote.mockResolvedValue('sameHash HEAD'); + mockGit.revparse.mockResolvedValue('sameHash'); + + const result = await checkForExtensionUpdate(extension); + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + }); + + it('should return NotUpdatable for a non-git extension', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'local-extension', + version: '1.0.0', + }); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + + const result = await checkForExtensionUpdate(extension); + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + }); + + it('should return Error when git check fails', async () => { + const extensionDir = createExtension({ + extensionsDir: userExtensionsDir, + name: 'error-extension', + version: '1.0.0', + installMetadata: { + source: 'https://some.git/repo', + type: 'git', + }, + }); + const extension = annotateActiveExtensions( + [loadExtension(extensionDir)!], + [], + process.cwd(), + )[0]; + + mockGit.getRemotes.mockRejectedValue(new Error('Git error')); + + const result = await checkForExtensionUpdate(extension); + expect(result).toBe(ExtensionUpdateState.ERROR); + }); + }); + + describe('disableExtension', () => { + it('should disable an extension at the user scope', () => { + disableExtension('my-extension', SettingScope.User); + expect( + isEnabled({ + name: 'my-extension', + configDir: userExtensionsDir, + enabledForPath: tempWorkspaceDir, + }), + ).toBe(false); + }); + + it('should disable an extension at the workspace scope', () => { + disableExtension( + 'my-extension', + SettingScope.Workspace, + tempWorkspaceDir, + ); + expect( + isEnabled({ + name: 'my-extension', + configDir: userExtensionsDir, + enabledForPath: tempHomeDir, + }), + ).toBe(true); + expect( + isEnabled({ + name: 'my-extension', + configDir: userExtensionsDir, + enabledForPath: tempWorkspaceDir, + }), + ).toBe(false); + }); + + it('should handle disabling the same extension twice', () => { + disableExtension('my-extension', SettingScope.User); + disableExtension('my-extension', SettingScope.User); + expect( + isEnabled({ + name: 'my-extension', + configDir: userExtensionsDir, + enabledForPath: tempWorkspaceDir, + }), + ).toBe(false); + }); + + it('should throw an error if you request system scope', () => { + expect(() => + disableExtension('my-extension', SettingScope.System), + ).toThrow('System and SystemDefaults scopes are not supported.'); + }); + }); + + describe('enableExtension', () => { + afterAll(() => { + vi.restoreAllMocks(); + }); + + const getActiveExtensions = (): GeminiCLIExtension[] => { const extensions = loadExtensions(); + const activeExtensions = annotateActiveExtensions( + extensions, + [], + tempWorkspaceDir, + ); + return activeExtensions.filter((e) => e.isActive); + }; - expect(extensions).toEqual([]); - }); - }); + it('should enable an extension at the user scope', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + disableExtension('ext1', SettingScope.User); + let activeExtensions = getActiveExtensions(); + expect(activeExtensions).toHaveLength(0); - it('should install the extensions in the user directory', async () => { - const ext1Path = createExtension({ - extensionsDir: workspaceExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - const ext2Path = createExtension({ - extensionsDir: workspaceExtensionsDir, - name: 'ext2', - version: '1.0.0', - }); - const extensionsToMigrate: Extension[] = [ - loadExtension(ext1Path)!, - loadExtension(ext2Path)!, - ]; - const failed = - await performWorkspaceExtensionMigration(extensionsToMigrate); - - expect(failed).toEqual([]); - - const userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); - const userExt1Path = path.join(userExtensionsDir, 'ext1'); - const extensions = loadExtensions(); - - expect(extensions).toHaveLength(2); - const metadataPath = path.join(userExt1Path, INSTALL_METADATA_FILENAME); - expect(fs.existsSync(metadataPath)).toBe(true); - const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); - expect(metadata).toEqual({ - source: ext1Path, - type: 'local', - }); - }); - - it('should return the names of failed installations', async () => { - const ext1Path = createExtension({ - extensionsDir: workspaceExtensionsDir, - name: 'ext1', - version: '1.0.0', + enableExtension('ext1', SettingScope.User); + activeExtensions = getActiveExtensions(); + expect(activeExtensions).toHaveLength(1); + expect(activeExtensions[0].name).toBe('ext1'); }); - const extensions: Extension[] = [ - loadExtension(ext1Path)!, - { - path: '/ext/path/1', - config: { name: 'ext2', version: '1.0.0' }, - contextFiles: [], - }, - ]; + it('should enable an extension at the workspace scope', () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'ext1', + version: '1.0.0', + }); + disableExtension('ext1', SettingScope.Workspace); + let activeExtensions = getActiveExtensions(); + expect(activeExtensions).toHaveLength(0); - const failed = await performWorkspaceExtensionMigration(extensions); - expect(failed).toEqual(['ext2']); + enableExtension('ext1', SettingScope.Workspace); + activeExtensions = getActiveExtensions(); + expect(activeExtensions).toHaveLength(1); + expect(activeExtensions[0].name).toBe('ext1'); + }); }); }); @@ -915,489 +1292,11 @@ function createExtension({ return extDir; } -describe('updateExtension', () => { - let tempHomeDir: string; - let userExtensionsDir: string; - - beforeEach(() => { - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-home-'), - ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); - // Clean up before each test - fs.rmSync(userExtensionsDir, { recursive: true, force: true }); - fs.mkdirSync(userExtensionsDir, { recursive: true }); - vi.mocked(isWorkspaceTrusted).mockReturnValue(true); - - vi.mocked(execSync).mockClear(); - Object.values(mockGit).forEach((fn) => fn.mockReset()); - }); - - afterEach(() => { - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - mockClose.mockClear(); - }); - - it('should update a git-installed extension', async () => { - const gitUrl = 'https://github.com/google/gemini-extensions.git'; - const extensionName = 'gemini-extensions'; - const targetExtDir = path.join(userExtensionsDir, extensionName); - const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - - fs.mkdirSync(targetExtDir, { recursive: true }); - fs.writeFileSync( - path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name: extensionName, version: '1.0.0' }), - ); - fs.writeFileSync( - metadataPath, - JSON.stringify({ source: gitUrl, type: 'git' }), - ); - - mockGit.clone.mockImplementation(async (_, destination) => { - fs.mkdirSync(path.join(mockGit.path(), destination), { recursive: true }); - fs.writeFileSync( - path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name: extensionName, version: '1.1.0' }), - ); - }); - mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); - const extension = annotateActiveExtensions( - [loadExtension(targetExtDir)!], - [], - process.cwd(), - )[0]; - const updateInfo = await updateExtension(extension, tempHomeDir, () => {}); - - expect(updateInfo).toEqual({ - name: 'gemini-extensions', - originalVersion: '1.0.0', - updatedVersion: '1.1.0', - }); - - const updatedConfig = JSON.parse( - fs.readFileSync( - path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME), - 'utf-8', - ), - ); - expect(updatedConfig.version).toBe('1.1.0'); - }); - - it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => { - const extensionName = 'test-extension'; - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: extensionName, - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - - mockGit.clone.mockImplementation(async (_, destination) => { - fs.mkdirSync(path.join(mockGit.path(), destination), { recursive: true }); - fs.writeFileSync( - path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name: extensionName, version: '1.1.0' }), - ); - }); - mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); - - const setExtensionUpdateState = vi.fn(); - - const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], - [], - process.cwd(), - )[0]; - await updateExtension(extension, tempHomeDir, setExtensionUpdateState); - - expect(setExtensionUpdateState).toHaveBeenCalledWith( - ExtensionUpdateState.UPDATING, - ); - expect(setExtensionUpdateState).toHaveBeenCalledWith( - ExtensionUpdateState.UPDATED_NEEDS_RESTART, - ); - }); - - it('should call setExtensionUpdateState with ERROR on failure', async () => { - const extensionName = 'test-extension'; - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: extensionName, - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - - mockGit.clone.mockRejectedValue(new Error('Git clone failed')); - mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); - - const setExtensionUpdateState = vi.fn(); - const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], - [], - process.cwd(), - )[0]; - await expect( - updateExtension(extension, tempHomeDir, setExtensionUpdateState), - ).rejects.toThrow(); - - expect(setExtensionUpdateState).toHaveBeenCalledWith( - ExtensionUpdateState.UPDATING, - ); - expect(setExtensionUpdateState).toHaveBeenCalledWith( - ExtensionUpdateState.ERROR, - ); - }); -}); - -describe('checkForAllExtensionUpdates', () => { - let tempHomeDir: string; - let userExtensionsDir: string; - - beforeEach(() => { - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-home-'), - ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); - fs.mkdirSync(userExtensionsDir, { recursive: true }); - Object.values(mockGit).forEach((fn) => fn.mockReset()); - }); - - afterEach(() => { - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - }); - - it('should return UpdateAvailable for a git extension with updates', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'test-extension', - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], - [], - process.cwd(), - )[0]; - - mockGit.getRemotes.mockResolvedValue([ - { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, - ]); - mockGit.listRemote.mockResolvedValue('remoteHash HEAD'); - mockGit.revparse.mockResolvedValue('localHash'); - - const results = await checkForAllExtensionUpdates([extension], () => {}); - const result = results.get('test-extension'); - expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); - }); - - it('should return UpToDate for a git extension with no updates', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'test-extension', - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], - [], - process.cwd(), - )[0]; - - mockGit.getRemotes.mockResolvedValue([ - { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, - ]); - mockGit.listRemote.mockResolvedValue('sameHash HEAD'); - mockGit.revparse.mockResolvedValue('sameHash'); - - const results = await checkForAllExtensionUpdates([extension], () => {}); - const result = results.get('test-extension'); - expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); - }); - - it('should return NotUpdatable for a non-git extension', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'local-extension', - version: '1.0.0', - installMetadata: { source: '/local/path', type: 'local' }, - }); - const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], - [], - process.cwd(), - )[0]; - - const results = await checkForAllExtensionUpdates([extension], () => {}); - const result = results.get('local-extension'); - expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); - }); - - it('should return Error when git check fails', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'error-extension', - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], - [], - process.cwd(), - )[0]; - - mockGit.getRemotes.mockRejectedValue(new Error('Git error')); - - const results = await checkForAllExtensionUpdates([extension], () => {}); - const result = results.get('error-extension'); - expect(result).toBe(ExtensionUpdateState.ERROR); - }); -}); - -describe('checkForExtensionUpdate', () => { - let tempHomeDir: string; - let userExtensionsDir: string; - - beforeEach(() => { - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-home-'), - ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); - fs.mkdirSync(userExtensionsDir, { recursive: true }); - Object.values(mockGit).forEach((fn) => fn.mockReset()); - }); - - afterEach(() => { - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - }); - - it('should return UpdateAvailable for a git extension with updates', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'test-extension', - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], - [], - process.cwd(), - )[0]; - - mockGit.getRemotes.mockResolvedValue([ - { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, - ]); - mockGit.listRemote.mockResolvedValue('remoteHash HEAD'); - mockGit.revparse.mockResolvedValue('localHash'); - - const result = await checkForExtensionUpdate(extension); - expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); - }); - - it('should return UpToDate for a git extension with no updates', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'test-extension', - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], - [], - process.cwd(), - )[0]; - - mockGit.getRemotes.mockResolvedValue([ - { name: 'origin', refs: { fetch: 'https://some.git/repo' } }, - ]); - mockGit.listRemote.mockResolvedValue('sameHash HEAD'); - mockGit.revparse.mockResolvedValue('sameHash'); - - const result = await checkForExtensionUpdate(extension); - expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); - }); - - it('should return NotUpdatable for a non-git extension', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'local-extension', - version: '1.0.0', - }); - const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], - [], - process.cwd(), - )[0]; - - const result = await checkForExtensionUpdate(extension); - expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); - }); - - it('should return Error when git check fails', async () => { - const extensionDir = createExtension({ - extensionsDir: userExtensionsDir, - name: 'error-extension', - version: '1.0.0', - installMetadata: { - source: 'https://some.git/repo', - type: 'git', - }, - }); - const extension = annotateActiveExtensions( - [loadExtension(extensionDir)!], - [], - process.cwd(), - )[0]; - - mockGit.getRemotes.mockRejectedValue(new Error('Git error')); - - const result = await checkForExtensionUpdate(extension); - expect(result).toBe(ExtensionUpdateState.ERROR); - }); -}); - -describe('disableExtension', () => { - let tempWorkspaceDir: string; - let tempHomeDir: string; - - beforeEach(() => { - tempWorkspaceDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), - ); - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-home-'), - ); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); - }); - - afterEach(() => { - fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - }); - - it('should disable an extension at the user scope', () => { - disableExtension('my-extension', SettingScope.User); - const settings = loadSettings(tempWorkspaceDir); - expect( - settings.forScope(SettingScope.User).settings.extensions?.disabled, - ).toEqual(['my-extension']); - }); - - it('should disable an extension at the workspace scope', () => { - disableExtension('my-extension', SettingScope.Workspace); - const settings = loadSettings(tempWorkspaceDir); - expect( - settings.forScope(SettingScope.Workspace).settings.extensions?.disabled, - ).toEqual(['my-extension']); - }); - - it('should handle disabling the same extension twice', () => { - disableExtension('my-extension', SettingScope.User); - disableExtension('my-extension', SettingScope.User); - const settings = loadSettings(tempWorkspaceDir); - expect( - settings.forScope(SettingScope.User).settings.extensions?.disabled, - ).toEqual(['my-extension']); - }); - - it('should throw an error if you request system scope', () => { - expect(() => disableExtension('my-extension', SettingScope.System)).toThrow( - 'System and SystemDefaults scopes are not supported.', - ); - }); -}); - -describe('enableExtension', () => { - let tempWorkspaceDir: string; - let tempHomeDir: string; - let userExtensionsDir: string; - - beforeEach(() => { - tempWorkspaceDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-workspace-'), - ); - tempHomeDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'gemini-cli-test-home-'), - ); - userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); - vi.mocked(os.homedir).mockReturnValue(tempHomeDir); - vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); - }); - - afterEach(() => { - fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); - fs.rmSync(tempHomeDir, { recursive: true, force: true }); - fs.rmSync(userExtensionsDir, { recursive: true, force: true }); - }); - - afterAll(() => { - vi.restoreAllMocks(); - }); - - const getActiveExtensions = (): GeminiCLIExtension[] => { - const extensions = loadExtensions(); - const activeExtensions = annotateActiveExtensions( - extensions, - [], - tempWorkspaceDir, - ); - return activeExtensions.filter((e) => e.isActive); - }; - - it('should enable an extension at the user scope', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - disableExtension('ext1', SettingScope.User); - let activeExtensions = getActiveExtensions(); - expect(activeExtensions).toHaveLength(0); - - enableExtension('ext1', [SettingScope.User]); - activeExtensions = getActiveExtensions(); - expect(activeExtensions).toHaveLength(1); - expect(activeExtensions[0].name).toBe('ext1'); - }); - - it('should enable an extension at the workspace scope', () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'ext1', - version: '1.0.0', - }); - disableExtension('ext1', SettingScope.Workspace); - let activeExtensions = getActiveExtensions(); - expect(activeExtensions).toHaveLength(0); - - enableExtension('ext1', [SettingScope.Workspace]); - activeExtensions = getActiveExtensions(); - expect(activeExtensions).toHaveLength(1); - expect(activeExtensions[0].name).toBe('ext1'); - }); -}); +function isEnabled(options: { + name: string; + configDir: string; + enabledForPath: string; +}) { + const manager = new ExtensionEnablementManager(options.configDir); + return manager.isEnabled(options.name, options.enabledForPath); +} diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index db3fa3698b..17974ac840 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -27,6 +27,7 @@ import { isWorkspaceTrusted } from './trustedFolders.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { randomUUID } from 'node:crypto'; import { ExtensionUpdateState } from '../ui/state/extensions.js'; +import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); @@ -140,7 +141,6 @@ export function loadExtensions( workspaceDir: string = process.cwd(), ): Extension[] { const settings = loadSettings(workspaceDir).merged; - const disabledExtensions = settings.extensions?.disabled ?? []; const allExtensions = [...loadUserExtensions()]; if ( @@ -152,10 +152,14 @@ export function loadExtensions( } const uniqueExtensions = new Map(); + const manager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ); + for (const extension of allExtensions) { if ( !uniqueExtensions.has(extension.config.name) && - !disabledExtensions.includes(extension.config.name) + manager.isEnabled(extension.config.name, workspaceDir) ) { uniqueExtensions.set(extension.config.name, extension); } @@ -198,9 +202,6 @@ export function loadExtensionsFromDir(dir: string): Extension[] { export function loadExtension(extensionDir: string): Extension | null { if (!fs.statSync(extensionDir).isDirectory()) { - console.error( - `Warning: unexpected file ${extensionDir} in extensions directory.`, - ); return null; } @@ -284,7 +285,7 @@ function getContextFileNames(config: ExtensionConfig): string[] { /** * Returns an annotated list of extensions. If an extension is listed in enabledExtensionNames, it will be active. - * If enabledExtensionNames is empty, an extension is active unless it is in list of disabled extensions in settings. + * If enabledExtensionNames is empty, an extension is active unless it is disabled. * @param extensions The base list of extensions. * @param enabledExtensionNames The names of explicitly enabled extensions. * @param workspaceDir The current workspace directory. @@ -294,16 +295,16 @@ export function annotateActiveExtensions( enabledExtensionNames: string[], workspaceDir: string, ): GeminiCLIExtension[] { - const settings = loadSettings(workspaceDir).merged; - const disabledExtensions = settings.extensions?.disabled ?? []; + const manager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ); const annotatedExtensions: GeminiCLIExtension[] = []; - if (enabledExtensionNames.length === 0) { return extensions.map((extension) => ({ name: extension.config.name, version: extension.config.version, - isActive: !disabledExtensions.includes(extension.config.name), + isActive: manager.isEnabled(extension.config.name, workspaceDir), path: extension.path, source: extension.installMetadata?.source, type: extension.installMetadata?.type, @@ -526,6 +527,7 @@ export async function installExtension( ), ); + enableExtension(newExtensionConfig!.name, SettingScope.User); return newExtensionConfig!.name; } catch (error) { // Attempt to load config from the source path even if installation fails @@ -581,11 +583,10 @@ export async function uninstallExtension( ) { throw new Error(`Extension "${extensionName}" not found.`); } - removeFromDisabledExtensions( - extensionName, - [SettingScope.User, SettingScope.Workspace], - cwd, + const manager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), ); + manager.remove(extensionName); const storage = new ExtensionStorage(extensionName); await fs.promises.rm(storage.getExtensionDir(), { @@ -710,45 +711,27 @@ export function disableExtension( if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) { throw new Error('System and SystemDefaults scopes are not supported.'); } - const settings = loadSettings(cwd); - const settingsFile = settings.forScope(scope); - const extensionSettings = settingsFile.settings.extensions || { - disabled: [], - }; - const disabledExtensions = extensionSettings.disabled || []; - if (!disabledExtensions.includes(name)) { - disabledExtensions.push(name); - extensionSettings.disabled = disabledExtensions; - settings.setValue(scope, 'extensions', extensionSettings); - } + + const manager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ); + const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir(); + manager.disable(name, true, scopePath); } -export function enableExtension(name: string, scopes: SettingScope[]) { - removeFromDisabledExtensions(name, scopes); -} - -/** - * Removes an extension from the list of disabled extensions. - * @param name The name of the extension to remove. - * @param scope The scopes to remove the name from. - */ -function removeFromDisabledExtensions( +export function enableExtension( name: string, - scopes: SettingScope[], + scope: SettingScope, cwd: string = process.cwd(), ) { - const settings = loadSettings(cwd); - for (const scope of scopes) { - const settingsFile = settings.forScope(scope); - const extensionSettings = settingsFile.settings.extensions || { - disabled: [], - }; - const disabledExtensions = extensionSettings.disabled || []; - extensionSettings.disabled = disabledExtensions.filter( - (extension) => extension !== name, - ); - settings.setValue(scope, 'extensions', extensionSettings); + if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) { + throw new Error('System and SystemDefaults scopes are not supported.'); } + const manager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ); + const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir(); + manager.enable(name, true, scopePath); } export async function updateAllUpdatableExtensions( diff --git a/packages/cli/src/config/extensions/extensionEnablement.test.ts b/packages/cli/src/config/extensions/extensionEnablement.test.ts new file mode 100644 index 0000000000..874b68b8c9 --- /dev/null +++ b/packages/cli/src/config/extensions/extensionEnablement.test.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ExtensionEnablementManager } from './extensionEnablement.js'; + +// Helper to create a temporary directory for testing +function createTestDir() { + const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-')); + return { + path: dirPath, + cleanup: () => fs.rmSync(dirPath, { recursive: true, force: true }), + }; +} + +let testDir: { path: string; cleanup: () => void }; +let configDir: string; +let manager: ExtensionEnablementManager; + +describe('ExtensionEnablementManager', () => { + beforeEach(() => { + testDir = createTestDir(); + configDir = path.join(testDir.path, '.gemini'); + manager = new ExtensionEnablementManager(configDir); + }); + + afterEach(() => { + testDir.cleanup(); + // Reset the singleton instance for test isolation + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ExtensionEnablementManager as any).instance = undefined; + }); + + describe('isEnabled', () => { + it('should return true if extension is not configured', () => { + expect(manager.isEnabled('ext-test', '/any/path')).toBe(true); + }); + + it('should return true if no overrides match', () => { + manager.disable('ext-test', false, '/another/path'); + expect(manager.isEnabled('ext-test', '/any/path')).toBe(true); + }); + + it('should enable a path based on an override rule', () => { + manager.disable('ext-test', true, '*'); // Disable globally + manager.enable('ext-test', true, '/home/user/projects/'); + expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( + true, + ); + }); + + it('should disable a path based on a disable override rule', () => { + manager.enable('ext-test', true, '*'); // Enable globally + manager.disable('ext-test', true, '/home/user/projects/'); + expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( + false, + ); + }); + + it('should respect the last matching rule (enable wins)', () => { + manager.disable('ext-test', true, '/home/user/projects/'); + manager.enable('ext-test', false, '/home/user/projects/my-app'); + expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( + true, + ); + }); + + it('should respect the last matching rule (disable wins)', () => { + manager.enable('ext-test', true, '/home/user/projects/'); + manager.disable('ext-test', false, '/home/user/projects/my-app'); + expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe( + false, + ); + }); + }); + + describe('includeSubdirs', () => { + it('should add a glob when enabling with includeSubdirs', () => { + manager.enable('ext-test', true, '/path/to/dir'); + const config = manager.readConfig(); + expect(config['ext-test'].overrides).toContain('/path/to/dir*'); + }); + + it('should not add a glob when enabling without includeSubdirs', () => { + manager.enable('ext-test', false, '/path/to/dir'); + const config = manager.readConfig(); + expect(config['ext-test'].overrides).toContain('/path/to/dir'); + expect(config['ext-test'].overrides).not.toContain('/path/to/dir*'); + }); + + it('should add a glob when disabling with includeSubdirs', () => { + manager.disable('ext-test', true, '/path/to/dir'); + const config = manager.readConfig(); + expect(config['ext-test'].overrides).toContain('!/path/to/dir*'); + }); + + it('should remove conflicting glob rule when enabling without subdirs', () => { + manager.enable('ext-test', true, '/path/to/dir'); // Adds /path/to/dir* + manager.enable('ext-test', false, '/path/to/dir'); // Should remove the glob + const config = manager.readConfig(); + expect(config['ext-test'].overrides).toContain('/path/to/dir'); + expect(config['ext-test'].overrides).not.toContain('/path/to/dir*'); + }); + + it('should remove conflicting non-glob rule when enabling with subdirs', () => { + manager.enable('ext-test', false, '/path/to/dir'); // Adds /path/to/dir + manager.enable('ext-test', true, '/path/to/dir'); // Should remove the non-glob + const config = manager.readConfig(); + expect(config['ext-test'].overrides).toContain('/path/to/dir*'); + expect(config['ext-test'].overrides).not.toContain('/path/to/dir'); + }); + + it('should remove conflicting rules when disabling', () => { + manager.enable('ext-test', true, '/path/to/dir'); // enabled with glob + manager.disable('ext-test', false, '/path/to/dir'); // disabled without + const config = manager.readConfig(); + expect(config['ext-test'].overrides).toContain('!/path/to/dir'); + expect(config['ext-test'].overrides).not.toContain('/path/to/dir*'); + }); + + it('should correctly evaluate isEnabled with subdirs', () => { + manager.disable('ext-test', true, '*'); + manager.enable('ext-test', true, '/path/to/dir'); + expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(true); + expect(manager.isEnabled('ext-test', '/path/to/dir/sub')).toBe(true); + expect(manager.isEnabled('ext-test', '/path/to/another')).toBe(false); + }); + + it('should correctly evaluate isEnabled without subdirs', () => { + manager.disable('ext-test', true, '*'); + manager.enable('ext-test', false, '/path/to/dir'); + expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(true); + expect(manager.isEnabled('ext-test', '/path/to/dir/sub')).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/extensionEnablement.ts b/packages/cli/src/config/extensions/extensionEnablement.ts new file mode 100644 index 0000000000..b32fece9c1 --- /dev/null +++ b/packages/cli/src/config/extensions/extensionEnablement.ts @@ -0,0 +1,158 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +export interface ExtensionEnablementConfig { + overrides: string[]; +} + +export interface AllExtensionsEnablementConfig { + [extensionName: string]: ExtensionEnablementConfig; +} + +/** + * Converts a glob pattern to a RegExp object. + * This is a simplified implementation that supports `*`. + * + * @param glob The glob pattern to convert. + * @returns A RegExp object. + */ +function globToRegex(glob: string): RegExp { + const regexString = glob + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex characters + .replace(/\*/g, '.*'); // Convert * to .* + + return new RegExp(`^${regexString}$`); +} + +/** + * Determines if an extension is enabled based on the configuration and current path. + * The last matching rule in the overrides list wins. + * + * @param config The enablement configuration for a single extension. + * @param currentPath The absolute path of the current working directory. + * @returns True if the extension is enabled, false otherwise. + */ +export class ExtensionEnablementManager { + private configFilePath: string; + private configDir: string; + + constructor(configDir: string) { + this.configDir = configDir; + this.configFilePath = path.join(configDir, 'extension-enablement.json'); + } + + isEnabled(extensionName: string, currentPath: string): boolean { + const config = this.readConfig(); + const extensionConfig = config[extensionName]; + // Extensions are enabled by default. + let enabled = true; + + for (const rule of extensionConfig?.overrides ?? []) { + const isDisableRule = rule.startsWith('!'); + const globPattern = isDisableRule ? rule.substring(1) : rule; + const regex = globToRegex(globPattern); + if (regex.test(currentPath)) { + enabled = !isDisableRule; + } + } + + return enabled; + } + + readConfig(): AllExtensionsEnablementConfig { + try { + const content = fs.readFileSync(this.configFilePath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + return {}; + } + console.error('Error reading extension enablement config:', error); + return {}; + } + } + + writeConfig(config: AllExtensionsEnablementConfig): void { + fs.mkdirSync(this.configDir, { recursive: true }); + fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2)); + } + + enable( + extensionName: string, + includeSubdirs: boolean, + scopePath: string, + ): void { + const config = this.readConfig(); + if (!config[extensionName]) { + config[extensionName] = { overrides: [] }; + } + + const pathWithGlob = `${scopePath}*`; + const pathWithoutGlob = scopePath; + + const newPath = includeSubdirs ? pathWithGlob : pathWithoutGlob; + const conflictingPath = includeSubdirs ? pathWithoutGlob : pathWithGlob; + + config[extensionName].overrides = config[extensionName].overrides.filter( + (rule) => + rule !== conflictingPath && + rule !== `!${conflictingPath}` && + rule !== `!${newPath}`, + ); + + if (!config[extensionName].overrides.includes(newPath)) { + config[extensionName].overrides.push(newPath); + } + + this.writeConfig(config); + } + + disable( + extensionName: string, + includeSubdirs: boolean, + scopePath: string, + ): void { + const config = this.readConfig(); + if (!config[extensionName]) { + config[extensionName] = { overrides: [] }; + } + + const pathWithGlob = `${scopePath}*`; + const pathWithoutGlob = scopePath; + + const targetPath = includeSubdirs ? pathWithGlob : pathWithoutGlob; + const newRule = `!${targetPath}`; + const conflictingPath = includeSubdirs ? pathWithoutGlob : pathWithGlob; + + config[extensionName].overrides = config[extensionName].overrides.filter( + (rule) => + rule !== conflictingPath && + rule !== `!${conflictingPath}` && + rule !== targetPath, + ); + + if (!config[extensionName].overrides.includes(newRule)) { + config[extensionName].overrides.push(newRule); + } + + this.writeConfig(config); + } + + remove(extensionName: string): void { + const config = this.readConfig(); + if (config[extensionName]) { + delete config[extensionName]; + this.writeConfig(config); + } + } +} diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index abce521c2a..462a67d0b0 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -49,6 +49,7 @@ import { import * as fs from 'node:fs'; // fs will be mocked separately import stripJsonComments from 'strip-json-comments'; // Will be mocked separately import { isWorkspaceTrusted } from './trustedFolders.js'; +import { disableExtension } from './extension.js'; // These imports will get the versions from the vi.mock('./settings.js', ...) factory. import { @@ -61,6 +62,8 @@ import { needsMigration, type Settings, loadEnvironment, + migrateDeprecatedSettings, + SettingScope, } from './settings.js'; import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core'; @@ -90,6 +93,10 @@ vi.mock('fs', async (importOriginal) => { }; }); +vi.mock('./extension.js', () => ({ + disableExtension: vi.fn(), +})); + vi.mock('strip-json-comments', () => ({ default: vi.fn((content) => content), })); @@ -2311,4 +2318,122 @@ describe('Settings Loading and Merging', () => { expect(needsMigration(settings)).toBe(false); }); }); + + describe('migrateDeprecatedSettings', () => { + let mockFsExistsSync: Mocked; + let mockFsReadFileSync: Mocked; + let mockDisableExtension: Mocked; + + beforeEach(() => { + vi.resetAllMocks(); + + mockFsExistsSync = vi.mocked(fs.existsSync); + mockFsReadFileSync = vi.mocked(fs.readFileSync); + mockDisableExtension = vi.mocked(disableExtension); + + (mockFsExistsSync as Mock).mockReturnValue(true); + vi.mocked(isWorkspaceTrusted).mockReturnValue(true); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should migrate disabled extensions from user and workspace settings', () => { + const userSettingsContent = { + extensions: { + disabled: ['user-ext-1', 'shared-ext'], + }, + }; + const workspaceSettingsContent = { + extensions: { + disabled: ['workspace-ext-1', 'shared-ext'], + }, + }; + + (mockFsReadFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); + + migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR); + + // Check user settings migration + expect(mockDisableExtension).toHaveBeenCalledWith( + 'user-ext-1', + SettingScope.User, + MOCK_WORKSPACE_DIR, + ); + expect(mockDisableExtension).toHaveBeenCalledWith( + 'shared-ext', + SettingScope.User, + MOCK_WORKSPACE_DIR, + ); + + // Check workspace settings migration + expect(mockDisableExtension).toHaveBeenCalledWith( + 'workspace-ext-1', + SettingScope.Workspace, + MOCK_WORKSPACE_DIR, + ); + expect(mockDisableExtension).toHaveBeenCalledWith( + 'shared-ext', + SettingScope.Workspace, + MOCK_WORKSPACE_DIR, + ); + + // Check that setValue was called to remove the deprecated setting + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.User, + 'extensions', + { + disabled: undefined, + }, + ); + expect(setValueSpy).toHaveBeenCalledWith( + SettingScope.Workspace, + 'extensions', + { + disabled: undefined, + }, + ); + }); + + it('should not do anything if there are no deprecated settings', () => { + const userSettingsContent = { + extensions: { + enabled: ['user-ext-1'], + }, + }; + const workspaceSettingsContent = { + someOtherSetting: 'value', + }; + + (mockFsReadFileSync as Mock).mockImplementation( + (p: fs.PathOrFileDescriptor) => { + if (p === USER_SETTINGS_PATH) + return JSON.stringify(userSettingsContent); + if (p === MOCK_WORKSPACE_SETTINGS_PATH) + return JSON.stringify(workspaceSettingsContent); + return '{}'; + }, + ); + + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + const setValueSpy = vi.spyOn(loadedSettings, 'setValue'); + + migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR); + + expect(mockDisableExtension).not.toHaveBeenCalled(); + expect(setValueSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 2114254159..beaf1f4f29 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -30,6 +30,7 @@ import { import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { customDeepMerge } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; +import { disableExtension } from './extension.js'; function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { let current: SettingDefinition | undefined = undefined; @@ -707,6 +708,31 @@ export function loadSettings( ); } +export function migrateDeprecatedSettings( + loadedSettings: LoadedSettings, + workspaceDir: string = process.cwd(), +): void { + const processScope = (scope: SettingScope) => { + const settings = loadedSettings.forScope(scope).settings; + if (settings.extensions?.disabled) { + console.log( + `Migrating deprecated extensions.disabled settings from ${scope} settings...`, + ); + for (const extension of settings.extensions.disabled ?? []) { + disableExtension(extension, scope, workspaceDir); + } + + const newExtensionsValue = { ...settings.extensions }; + newExtensionsValue.disabled = undefined; + + loadedSettings.setValue(scope, 'extensions', newExtensionsValue); + } + }; + + processScope(SettingScope.User); + processScope(SettingScope.Workspace); +} + export function saveSettings(settingsFile: SettingsFile): void { try { // Ensure the directory exists diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 131b771dc1..2fd0c84e64 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -214,6 +214,7 @@ describe('gemini.tsx main function kitty protocol', () => { ui: {}, }, setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), } as never); vi.mocked(parseArguments).mockResolvedValue({ model: undefined, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 3fea2befeb..8deafa8600 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -15,7 +15,11 @@ import dns from 'node:dns'; import { spawn } from 'node:child_process'; import { start_sandbox } from './utils/sandbox.js'; import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; -import { loadSettings, SettingScope } from './config/settings.js'; +import { + loadSettings, + migrateDeprecatedSettings, + SettingScope, +} from './config/settings.js'; import { themeManager } from './ui/themes/theme-manager.js'; import { getStartupWarnings } from './utils/startupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; @@ -196,7 +200,7 @@ export async function startInteractiveUI( export async function main() { setupUnhandledRejectionHandler(); const settings = loadSettings(); - + migrateDeprecatedSettings(settings); await cleanupCheckpoints(); const argv = await parseArguments(settings.merged);