@google/gemini-cli 0.11.0-preview.0 → 0.12.0-nightly.20251023.c4c0c0d1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/dist/google-gemini-cli-0.12.0-nightly.20251022.0542de95.tgz +0 -0
  2. package/dist/package.json +4 -3
  3. package/dist/src/commands/extensions/disable.js +13 -6
  4. package/dist/src/commands/extensions/disable.js.map +1 -1
  5. package/dist/src/commands/extensions/enable.js +13 -6
  6. package/dist/src/commands/extensions/enable.js.map +1 -1
  7. package/dist/src/commands/extensions/install.js +12 -2
  8. package/dist/src/commands/extensions/install.js.map +1 -1
  9. package/dist/src/commands/extensions/install.test.js +11 -3
  10. package/dist/src/commands/extensions/install.test.js.map +1 -1
  11. package/dist/src/commands/extensions/link.js +12 -2
  12. package/dist/src/commands/extensions/link.js.map +1 -1
  13. package/dist/src/commands/extensions/list.js +13 -4
  14. package/dist/src/commands/extensions/list.js.map +1 -1
  15. package/dist/src/commands/extensions/uninstall.js +12 -2
  16. package/dist/src/commands/extensions/uninstall.js.map +1 -1
  17. package/dist/src/commands/extensions/update.js +17 -13
  18. package/dist/src/commands/extensions/update.js.map +1 -1
  19. package/dist/src/commands/extensions.js +1 -0
  20. package/dist/src/commands/extensions.js.map +1 -1
  21. package/dist/src/commands/mcp/list.js +10 -3
  22. package/dist/src/commands/mcp/list.js.map +1 -1
  23. package/dist/src/commands/mcp/list.test.js +12 -6
  24. package/dist/src/commands/mcp/list.test.js.map +1 -1
  25. package/dist/src/config/config.js +12 -0
  26. package/dist/src/config/config.js.map +1 -1
  27. package/dist/src/config/config.test.js +11 -0
  28. package/dist/src/config/config.test.js.map +1 -1
  29. package/dist/src/config/extension-manager.d.ts +38 -0
  30. package/dist/src/config/extension-manager.js +412 -0
  31. package/dist/src/config/extension-manager.js.map +1 -0
  32. package/dist/src/config/extension.d.ts +4 -51
  33. package/dist/src/config/extension.js +1 -535
  34. package/dist/src/config/extension.js.map +1 -1
  35. package/dist/src/config/extension.test.js +316 -159
  36. package/dist/src/config/extension.test.js.map +1 -1
  37. package/dist/src/config/extensions/consent.d.ts +38 -0
  38. package/dist/src/config/extensions/consent.js +123 -0
  39. package/dist/src/config/extensions/consent.js.map +1 -0
  40. package/dist/src/config/extensions/extensionEnablement.js +1 -1
  41. package/dist/src/config/extensions/extensionEnablement.js.map +1 -1
  42. package/dist/src/config/extensions/extensionSettings.d.ts +15 -0
  43. package/dist/src/config/extensions/extensionSettings.js +63 -0
  44. package/dist/src/config/extensions/extensionSettings.js.map +1 -0
  45. package/dist/src/config/extensions/extensionSettings.test.d.ts +6 -0
  46. package/dist/src/config/extensions/extensionSettings.test.js +137 -0
  47. package/dist/src/config/extensions/extensionSettings.test.js.map +1 -0
  48. package/dist/src/config/extensions/github.d.ts +2 -2
  49. package/dist/src/config/extensions/github.js +3 -8
  50. package/dist/src/config/extensions/github.js.map +1 -1
  51. package/dist/src/config/extensions/github.test.js +25 -7
  52. package/dist/src/config/extensions/github.test.js.map +1 -1
  53. package/dist/src/config/extensions/storage.d.ts +14 -0
  54. package/dist/src/config/extensions/storage.js +32 -0
  55. package/dist/src/config/extensions/storage.js.map +1 -0
  56. package/dist/src/config/extensions/update.d.ts +4 -4
  57. package/dist/src/config/extensions/update.js +11 -18
  58. package/dist/src/config/extensions/update.js.map +1 -1
  59. package/dist/src/config/extensions/update.test.js +32 -58
  60. package/dist/src/config/extensions/update.test.js.map +1 -1
  61. package/dist/src/config/extensions/variableSchema.d.ts +0 -6
  62. package/dist/src/config/extensions/variableSchema.js.map +1 -1
  63. package/dist/src/config/extensions/variables.d.ts +4 -0
  64. package/dist/src/config/extensions/variables.js +6 -0
  65. package/dist/src/config/extensions/variables.js.map +1 -1
  66. package/dist/src/config/settings.d.ts +2 -1
  67. package/dist/src/config/settings.js +4 -7
  68. package/dist/src/config/settings.js.map +1 -1
  69. package/dist/src/config/settings.test.js +113 -14
  70. package/dist/src/config/settings.test.js.map +1 -1
  71. package/dist/src/config/settingsSchema.d.ts +9 -0
  72. package/dist/src/config/settingsSchema.js +9 -0
  73. package/dist/src/config/settingsSchema.js.map +1 -1
  74. package/dist/src/gemini.js +22 -5
  75. package/dist/src/gemini.js.map +1 -1
  76. package/dist/src/generated/git-commit.d.ts +2 -2
  77. package/dist/src/generated/git-commit.js +2 -2
  78. package/dist/src/generated/git-commit.js.map +1 -1
  79. package/dist/src/nonInteractiveCli.js +14 -1
  80. package/dist/src/nonInteractiveCli.js.map +1 -1
  81. package/dist/src/nonInteractiveCli.test.js +84 -2
  82. package/dist/src/nonInteractiveCli.test.js.map +1 -1
  83. package/dist/src/test-utils/createExtension.d.ts +3 -1
  84. package/dist/src/test-utils/createExtension.js +3 -3
  85. package/dist/src/test-utils/createExtension.js.map +1 -1
  86. package/dist/src/ui/AppContainer.js +101 -47
  87. package/dist/src/ui/AppContainer.js.map +1 -1
  88. package/dist/src/ui/AppContainer.test.js +138 -79
  89. package/dist/src/ui/AppContainer.test.js.map +1 -1
  90. package/dist/src/ui/commands/extensionsCommand.js +19 -10
  91. package/dist/src/ui/commands/extensionsCommand.js.map +1 -1
  92. package/dist/src/ui/commands/extensionsCommand.test.js +8 -0
  93. package/dist/src/ui/commands/extensionsCommand.test.js.map +1 -1
  94. package/dist/src/ui/components/HistoryItemDisplay.js +1 -1
  95. package/dist/src/ui/components/HistoryItemDisplay.js.map +1 -1
  96. package/dist/src/ui/components/InputPrompt.js +5 -6
  97. package/dist/src/ui/components/InputPrompt.js.map +1 -1
  98. package/dist/src/ui/components/InputPrompt.test.js +249 -393
  99. package/dist/src/ui/components/InputPrompt.test.js.map +1 -1
  100. package/dist/src/ui/components/SettingsDialog.test.js +0 -29
  101. package/dist/src/ui/components/SettingsDialog.test.js.map +1 -1
  102. package/dist/src/ui/components/views/ExtensionsList.d.ts +7 -1
  103. package/dist/src/ui/components/views/ExtensionsList.js +4 -10
  104. package/dist/src/ui/components/views/ExtensionsList.js.map +1 -1
  105. package/dist/src/ui/components/views/ExtensionsList.test.js +34 -21
  106. package/dist/src/ui/components/views/ExtensionsList.test.js.map +1 -1
  107. package/dist/src/ui/contexts/KeypressContext.js +328 -335
  108. package/dist/src/ui/contexts/KeypressContext.js.map +1 -1
  109. package/dist/src/ui/hooks/useAutoAcceptIndicator.js +10 -0
  110. package/dist/src/ui/hooks/useAutoAcceptIndicator.js.map +1 -1
  111. package/dist/src/ui/hooks/useAutoAcceptIndicator.test.js +30 -0
  112. package/dist/src/ui/hooks/useAutoAcceptIndicator.test.js.map +1 -1
  113. package/dist/src/ui/hooks/useExtensionUpdates.d.ts +14 -4
  114. package/dist/src/ui/hooks/useExtensionUpdates.js +14 -17
  115. package/dist/src/ui/hooks/useExtensionUpdates.js.map +1 -1
  116. package/dist/src/ui/hooks/useExtensionUpdates.test.js +23 -30
  117. package/dist/src/ui/hooks/useExtensionUpdates.test.js.map +1 -1
  118. package/dist/src/ui/hooks/useGitBranchName.js +4 -0
  119. package/dist/src/ui/hooks/useGitBranchName.js.map +1 -1
  120. package/dist/src/ui/hooks/useGitBranchName.test.js +19 -21
  121. package/dist/src/ui/hooks/useGitBranchName.test.js.map +1 -1
  122. package/dist/src/ui/hooks/useReactToolScheduler.js +22 -8
  123. package/dist/src/ui/hooks/useReactToolScheduler.js.map +1 -1
  124. package/dist/src/ui/hooks/useReactToolScheduler.test.d.ts +6 -0
  125. package/dist/src/ui/hooks/useReactToolScheduler.test.js +65 -0
  126. package/dist/src/ui/hooks/useReactToolScheduler.test.js.map +1 -0
  127. package/dist/src/ui/hooks/useToolScheduler.test.js +30 -48
  128. package/dist/src/ui/hooks/useToolScheduler.test.js.map +1 -1
  129. package/dist/src/ui/types.d.ts +2 -1
  130. package/dist/src/ui/types.js.map +1 -1
  131. package/dist/src/ui/utils/CodeColorizer.js +2 -1
  132. package/dist/src/ui/utils/CodeColorizer.js.map +1 -1
  133. package/dist/src/utils/envVarResolver.d.ts +2 -2
  134. package/dist/src/utils/envVarResolver.js +10 -7
  135. package/dist/src/utils/envVarResolver.js.map +1 -1
  136. package/dist/src/zed-integration/schema.d.ts +4 -4
  137. package/dist/src/zed-integration/zedIntegration.js +3 -3
  138. package/dist/src/zed-integration/zedIntegration.js.map +1 -1
  139. package/dist/tsconfig.tsbuildinfo +1 -1
  140. package/package.json +4 -3
@@ -7,14 +7,16 @@ import { vi } from 'vitest';
7
7
  import * as fs from 'node:fs';
8
8
  import * as os from 'node:os';
9
9
  import * as path from 'node:path';
10
- import { createHash } from 'node:crypto';
11
- import { EXTENSIONS_CONFIG_FILENAME, ExtensionStorage, INSTALL_METADATA_FILENAME, INSTALL_WARNING_MESSAGE, disableExtension, enableExtension, installOrUpdateExtension, loadExtension, loadExtensionConfig, loadExtensions, uninstallExtension, hashValue, } from './extension.js';
12
- import { GEMINI_DIR, ExtensionUninstallEvent, ExtensionDisableEvent, ExtensionEnableEvent, } from '@google/gemini-cli-core';
13
- import { SettingScope } from './settings.js';
10
+ import { ExtensionUninstallEvent, ExtensionDisableEvent, ExtensionEnableEvent, } from '@google/gemini-cli-core';
11
+ import { loadSettings, SettingScope } from './settings.js';
14
12
  import { isWorkspaceTrusted } from './trustedFolders.js';
15
13
  import { createExtension } from '../test-utils/createExtension.js';
16
14
  import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
17
15
  import { join } from 'node:path';
16
+ import { EXTENSIONS_CONFIG_FILENAME, EXTENSIONS_DIRECTORY_NAME, INSTALL_METADATA_FILENAME, } from './extensions/variables.js';
17
+ import { hashValue, ExtensionManager } from './extension-manager.js';
18
+ import { ExtensionStorage } from './extensions/storage.js';
19
+ import { INSTALL_WARNING_MESSAGE } from './extensions/consent.js';
18
20
  const mockGit = {
19
21
  clone: vi.fn(),
20
22
  getRemotes: vi.fn(),
@@ -81,15 +83,21 @@ vi.mock('child_process', async (importOriginal) => {
81
83
  execSync: vi.fn(),
82
84
  };
83
85
  });
84
- const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
85
86
  describe('extension tests', () => {
86
87
  let tempHomeDir;
87
88
  let tempWorkspaceDir;
88
89
  let userExtensionsDir;
90
+ let extensionManager;
91
+ let mockRequestConsent;
92
+ let mockPromptForSettings;
89
93
  beforeEach(() => {
90
94
  tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-home-'));
91
95
  tempWorkspaceDir = fs.mkdtempSync(path.join(tempHomeDir, 'gemini-cli-test-workspace-'));
92
96
  userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
97
+ mockRequestConsent = vi.fn();
98
+ mockRequestConsent.mockResolvedValue(true);
99
+ mockPromptForSettings = vi.fn();
100
+ mockPromptForSettings.mockResolvedValue('');
93
101
  fs.mkdirSync(userExtensionsDir, { recursive: true });
94
102
  vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
95
103
  vi.mocked(isWorkspaceTrusted).mockReturnValue({
@@ -97,6 +105,12 @@ describe('extension tests', () => {
97
105
  source: undefined,
98
106
  });
99
107
  vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
108
+ extensionManager = new ExtensionManager({
109
+ workspaceDir: tempWorkspaceDir,
110
+ requestConsent: mockRequestConsent,
111
+ requestSetting: mockPromptForSettings,
112
+ loadedSettings: loadSettings(tempWorkspaceDir),
113
+ });
100
114
  });
101
115
  afterEach(() => {
102
116
  fs.rmSync(tempHomeDir, { recursive: true, force: true });
@@ -112,7 +126,7 @@ describe('extension tests', () => {
112
126
  name: 'test-extension',
113
127
  version: '1.0.0',
114
128
  });
115
- const extensions = loadExtensions(new ExtensionEnablementManager());
129
+ const extensions = extensionManager.loadExtensions();
116
130
  expect(extensions).toHaveLength(1);
117
131
  expect(extensions[0].path).toBe(extensionDir);
118
132
  expect(extensions[0].name).toBe('test-extension');
@@ -129,7 +143,7 @@ describe('extension tests', () => {
129
143
  name: 'ext2',
130
144
  version: '2.0.0',
131
145
  });
132
- const extensions = loadExtensions(new ExtensionEnablementManager());
146
+ const extensions = extensionManager.loadExtensions();
133
147
  expect(extensions).toHaveLength(2);
134
148
  const ext1 = extensions.find((e) => e.name === 'ext1');
135
149
  const ext2 = extensions.find((e) => e.name === 'ext2');
@@ -146,7 +160,7 @@ describe('extension tests', () => {
146
160
  addContextFile: false,
147
161
  contextFileName: 'my-context-file.md',
148
162
  });
149
- const extensions = loadExtensions(new ExtensionEnablementManager());
163
+ const extensions = extensionManager.loadExtensions();
150
164
  expect(extensions).toHaveLength(1);
151
165
  const ext1 = extensions.find((e) => e.name === 'ext1');
152
166
  expect(ext1?.contextFiles).toEqual([
@@ -164,9 +178,8 @@ describe('extension tests', () => {
164
178
  name: 'enabled-extension',
165
179
  version: '2.0.0',
166
180
  });
167
- const manager = new ExtensionEnablementManager();
168
- disableExtension('disabled-extension', SettingScope.User, manager, tempWorkspaceDir);
169
- const extensions = loadExtensions(manager);
181
+ extensionManager.disableExtension('disabled-extension', SettingScope.User);
182
+ const extensions = extensionManager.loadExtensions();
170
183
  expect(extensions).toHaveLength(2);
171
184
  expect(extensions[0].name).toBe('disabled-extension');
172
185
  expect(extensions[0].isActive).toBe(false);
@@ -186,7 +199,7 @@ describe('extension tests', () => {
186
199
  },
187
200
  },
188
201
  });
189
- const extensions = loadExtensions(new ExtensionEnablementManager());
202
+ const extensions = extensionManager.loadExtensions();
190
203
  expect(extensions).toHaveLength(1);
191
204
  const expectedCwd = path.join(userExtensionsDir, 'test-extension', 'server');
192
205
  expect(extensions[0].mcpServers?.['test-server'].cwd).toBe(expectedCwd);
@@ -199,12 +212,12 @@ describe('extension tests', () => {
199
212
  contextFileName: 'context.md',
200
213
  });
201
214
  fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
202
- const extensionName = await installOrUpdateExtension({
215
+ const extensionName = await extensionManager.installOrUpdateExtension({
203
216
  source: sourceExtDir,
204
217
  type: 'link',
205
- }, async (_) => true);
218
+ });
206
219
  expect(extensionName).toEqual('my-linked-extension');
207
- const extensions = loadExtensions(new ExtensionEnablementManager());
220
+ const extensions = extensionManager.loadExtensions();
208
221
  expect(extensions).toHaveLength(1);
209
222
  const linkedExt = extensions[0];
210
223
  expect(linkedExt.name).toBe('my-linked-extension');
@@ -243,7 +256,7 @@ describe('extension tests', () => {
243
256
  },
244
257
  };
245
258
  fs.writeFileSync(configPath, JSON.stringify(extensionConfig));
246
- const extensions = loadExtensions(new ExtensionEnablementManager());
259
+ const extensions = extensionManager.loadExtensions();
247
260
  expect(extensions).toHaveLength(1);
248
261
  const extension = extensions[0];
249
262
  expect(extension.name).toBe('test-extension');
@@ -260,6 +273,32 @@ describe('extension tests', () => {
260
273
  delete process.env['TEST_DB_URL'];
261
274
  }
262
275
  });
276
+ it('should resolve environment variables from an extension .env file', () => {
277
+ const extDir = createExtension({
278
+ extensionsDir: userExtensionsDir,
279
+ name: 'test-extension',
280
+ version: '1.0.0',
281
+ mcpServers: {
282
+ 'test-server': {
283
+ command: 'node',
284
+ args: ['server.js'],
285
+ env: {
286
+ API_KEY: '$MY_API_KEY',
287
+ STATIC_VALUE: 'no-substitution',
288
+ },
289
+ },
290
+ },
291
+ });
292
+ const envFilePath = path.join(extDir, '.env');
293
+ fs.writeFileSync(envFilePath, 'MY_API_KEY=test-key-from-file\n');
294
+ const extensions = extensionManager.loadExtensions();
295
+ expect(extensions).toHaveLength(1);
296
+ const extension = extensions[0];
297
+ const serverConfig = extension.mcpServers['test-server'];
298
+ expect(serverConfig.env).toBeDefined();
299
+ expect(serverConfig.env['API_KEY']).toBe('test-key-from-file');
300
+ expect(serverConfig.env['STATIC_VALUE']).toBe('no-substitution');
301
+ });
263
302
  it('should handle missing environment variables gracefully', () => {
264
303
  const userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
265
304
  fs.mkdirSync(userExtensionsDir, { recursive: true });
@@ -280,7 +319,7 @@ describe('extension tests', () => {
280
319
  },
281
320
  };
282
321
  fs.writeFileSync(path.join(extDir, EXTENSIONS_CONFIG_FILENAME), JSON.stringify(extensionConfig));
283
- const extensions = loadExtensions(new ExtensionEnablementManager());
322
+ const extensions = extensionManager.loadExtensions();
284
323
  expect(extensions).toHaveLength(1);
285
324
  const extension = extensions[0];
286
325
  const serverConfig = extension.mcpServers['test-server'];
@@ -303,7 +342,7 @@ describe('extension tests', () => {
303
342
  fs.mkdirSync(badExtDir);
304
343
  const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
305
344
  fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed
306
- const extensions = loadExtensions(new ExtensionEnablementManager());
345
+ const extensions = extensionManager.loadExtensions();
307
346
  expect(extensions).toHaveLength(1);
308
347
  expect(extensions[0].name).toBe('good-ext');
309
348
  expect(consoleSpy).toHaveBeenCalledOnce();
@@ -325,7 +364,7 @@ describe('extension tests', () => {
325
364
  fs.mkdirSync(badExtDir);
326
365
  const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
327
366
  fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' }));
328
- const extensions = loadExtensions(new ExtensionEnablementManager());
367
+ const extensions = extensionManager.loadExtensions();
329
368
  expect(extensions).toHaveLength(1);
330
369
  expect(extensions[0].name).toBe('good-ext');
331
370
  expect(consoleSpy).toHaveBeenCalledOnce();
@@ -345,7 +384,7 @@ describe('extension tests', () => {
345
384
  },
346
385
  },
347
386
  });
348
- const extensions = loadExtensions(new ExtensionEnablementManager());
387
+ const extensions = extensionManager.loadExtensions();
349
388
  expect(extensions).toHaveLength(1);
350
389
  expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
351
390
  });
@@ -358,11 +397,7 @@ describe('extension tests', () => {
358
397
  name: 'bad_name',
359
398
  version: '1.0.0',
360
399
  });
361
- const extension = loadExtension({
362
- extensionDir: badExtDir,
363
- workspaceDir: tempWorkspaceDir,
364
- extensionEnablementManager: new ExtensionEnablementManager(),
365
- });
400
+ const extension = extensionManager.loadExtension(badExtDir);
366
401
  expect(extension).toBeNull();
367
402
  expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid extension name: "bad_name"'));
368
403
  consoleSpy.mockRestore();
@@ -378,15 +413,8 @@ describe('extension tests', () => {
378
413
  source: 'http://somehost.com/foo/bar',
379
414
  },
380
415
  });
381
- const extension = loadExtension({
382
- extensionDir,
383
- workspaceDir: tempWorkspaceDir,
384
- extensionEnablementManager: new ExtensionEnablementManager(),
385
- });
386
- const expectedHash = createHash('sha256')
387
- .update('http://somehost.com/foo/bar')
388
- .digest('hex');
389
- expect(extension?.id).toBe(expectedHash);
416
+ const extension = extensionManager.loadExtension(extensionDir);
417
+ expect(extension?.id).toBe(hashValue('http://somehost.com/foo/bar'));
390
418
  });
391
419
  it('should generate id from owner/repo for github http urls', () => {
392
420
  const extensionDir = createExtension({
@@ -398,15 +426,8 @@ describe('extension tests', () => {
398
426
  source: 'http://github.com/foo/bar',
399
427
  },
400
428
  });
401
- const extension = loadExtension({
402
- extensionDir,
403
- workspaceDir: tempWorkspaceDir,
404
- extensionEnablementManager: new ExtensionEnablementManager(),
405
- });
406
- const expectedHash = createHash('sha256')
407
- .update('https://github.com/foo/bar')
408
- .digest('hex');
409
- expect(extension?.id).toBe(expectedHash);
429
+ const extension = extensionManager.loadExtension(extensionDir);
430
+ expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
410
431
  });
411
432
  it('should generate id from owner/repo for github ssh urls', () => {
412
433
  const extensionDir = createExtension({
@@ -418,15 +439,8 @@ describe('extension tests', () => {
418
439
  source: 'git@github.com:foo/bar',
419
440
  },
420
441
  });
421
- const extension = loadExtension({
422
- extensionDir,
423
- workspaceDir: tempWorkspaceDir,
424
- extensionEnablementManager: new ExtensionEnablementManager(),
425
- });
426
- const expectedHash = createHash('sha256')
427
- .update('https://github.com/foo/bar')
428
- .digest('hex');
429
- expect(extension?.id).toBe(expectedHash);
442
+ const extension = extensionManager.loadExtension(extensionDir);
443
+ expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
430
444
  });
431
445
  it('should generate id from source for github-release extension', () => {
432
446
  const extensionDir = createExtension({
@@ -438,15 +452,8 @@ describe('extension tests', () => {
438
452
  source: 'https://github.com/foo/bar',
439
453
  },
440
454
  });
441
- const extension = loadExtension({
442
- extensionDir,
443
- workspaceDir: tempWorkspaceDir,
444
- extensionEnablementManager: new ExtensionEnablementManager(),
445
- });
446
- const expectedHash = createHash('sha256')
447
- .update('https://github.com/foo/bar')
448
- .digest('hex');
449
- expect(extension?.id).toBe(expectedHash);
455
+ const extension = extensionManager.loadExtension(extensionDir);
456
+ expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
450
457
  });
451
458
  it('should generate id from the original source for local extension', () => {
452
459
  const extensionDir = createExtension({
@@ -458,15 +465,8 @@ describe('extension tests', () => {
458
465
  source: '/some/path',
459
466
  },
460
467
  });
461
- const extension = loadExtension({
462
- extensionDir,
463
- workspaceDir: tempWorkspaceDir,
464
- extensionEnablementManager: new ExtensionEnablementManager(),
465
- });
466
- const expectedHash = createHash('sha256')
467
- .update('/some/path')
468
- .digest('hex');
469
- expect(extension?.id).toBe(expectedHash);
468
+ const extension = extensionManager.loadExtension(extensionDir);
469
+ expect(extension?.id).toBe(hashValue('/some/path'));
470
470
  });
471
471
  it('should generate id from the original source for linked extensions', async () => {
472
472
  const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
@@ -475,19 +475,12 @@ describe('extension tests', () => {
475
475
  name: 'link-ext-name',
476
476
  version: '1.0.0',
477
477
  });
478
- const extensionName = await installOrUpdateExtension({
478
+ const extensionName = await extensionManager.installOrUpdateExtension({
479
479
  type: 'link',
480
480
  source: actualExtensionDir,
481
- }, async () => true, tempWorkspaceDir);
482
- const extension = loadExtension({
483
- extensionDir: new ExtensionStorage(extensionName).getExtensionDir(),
484
- workspaceDir: tempWorkspaceDir,
485
- extensionEnablementManager: new ExtensionEnablementManager(),
486
481
  });
487
- const expectedHash = createHash('sha256')
488
- .update(actualExtensionDir)
489
- .digest('hex');
490
- expect(extension?.id).toBe(expectedHash);
482
+ const extension = extensionManager.loadExtension(new ExtensionStorage(extensionName).getExtensionDir());
483
+ expect(extension?.id).toBe(hashValue(actualExtensionDir));
491
484
  });
492
485
  it('should generate id from name for extension with no install metadata', () => {
493
486
  const extensionDir = createExtension({
@@ -495,15 +488,8 @@ describe('extension tests', () => {
495
488
  name: 'no-meta-name',
496
489
  version: '1.0.0',
497
490
  });
498
- const extension = loadExtension({
499
- extensionDir,
500
- workspaceDir: tempWorkspaceDir,
501
- extensionEnablementManager: new ExtensionEnablementManager(),
502
- });
503
- const expectedHash = createHash('sha256')
504
- .update('no-meta-name')
505
- .digest('hex');
506
- expect(extension?.id).toBe(expectedHash);
491
+ const extension = extensionManager.loadExtension(extensionDir);
492
+ expect(extension?.id).toBe(hashValue('no-meta-name'));
507
493
  });
508
494
  });
509
495
  });
@@ -516,7 +502,10 @@ describe('extension tests', () => {
516
502
  });
517
503
  const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
518
504
  const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
519
- await installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async (_) => true);
505
+ await extensionManager.installOrUpdateExtension({
506
+ source: sourceExtDir,
507
+ type: 'local',
508
+ });
520
509
  expect(fs.existsSync(targetExtDir)).toBe(true);
521
510
  expect(fs.existsSync(metadataPath)).toBe(true);
522
511
  const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
@@ -532,14 +521,23 @@ describe('extension tests', () => {
532
521
  name: 'my-local-extension',
533
522
  version: '1.0.0',
534
523
  });
535
- await installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async (_) => true);
536
- await expect(installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async (_) => true)).rejects.toThrow('Extension "my-local-extension" is already installed. Please uninstall it first.');
524
+ await extensionManager.installOrUpdateExtension({
525
+ source: sourceExtDir,
526
+ type: 'local',
527
+ });
528
+ await expect(extensionManager.installOrUpdateExtension({
529
+ source: sourceExtDir,
530
+ type: 'local',
531
+ })).rejects.toThrow('Extension "my-local-extension" is already installed. Please uninstall it first.');
537
532
  });
538
533
  it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
539
534
  const sourceExtDir = path.join(tempHomeDir, 'bad-extension');
540
535
  fs.mkdirSync(sourceExtDir, { recursive: true });
541
536
  const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
542
- await expect(installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async (_) => true)).rejects.toThrow(`Configuration file not found at ${configPath}`);
537
+ await expect(extensionManager.installOrUpdateExtension({
538
+ source: sourceExtDir,
539
+ type: 'local',
540
+ })).rejects.toThrow(`Configuration file not found at ${configPath}`);
543
541
  const targetExtDir = path.join(userExtensionsDir, 'bad-extension');
544
542
  expect(fs.existsSync(targetExtDir)).toBe(false);
545
543
  });
@@ -548,7 +546,10 @@ describe('extension tests', () => {
548
546
  fs.mkdirSync(sourceExtDir, { recursive: true });
549
547
  const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
550
548
  fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON
551
- await expect(installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async (_) => true)).rejects.toThrow(new RegExp(`^Failed to load extension config from ${configPath.replace(/\\/g, '\\\\')}`));
549
+ await expect(extensionManager.installOrUpdateExtension({
550
+ source: sourceExtDir,
551
+ type: 'local',
552
+ })).rejects.toThrow(new RegExp(`^Failed to load extension config from ${configPath.replace(/\\/g, '\\\\')}`));
552
553
  });
553
554
  it('should throw an error for missing name in gemini-extension.json', async () => {
554
555
  const sourceExtDir = createExtension({
@@ -559,7 +560,10 @@ describe('extension tests', () => {
559
560
  const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
560
561
  // Overwrite with invalid config
561
562
  fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' }));
562
- await expect(installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async (_) => true)).rejects.toThrow(`Invalid configuration in ${configPath}: missing "name"`);
563
+ await expect(extensionManager.installOrUpdateExtension({
564
+ source: sourceExtDir,
565
+ type: 'local',
566
+ })).rejects.toThrow(`Invalid configuration in ${configPath}: missing "name"`);
563
567
  });
564
568
  it('should install an extension from a git URL', async () => {
565
569
  const gitUrl = 'https://somehost.com/somerepo.git';
@@ -578,7 +582,10 @@ describe('extension tests', () => {
578
582
  failureReason: 'no release data',
579
583
  type: 'github-release',
580
584
  });
581
- await installOrUpdateExtension({ source: gitUrl, type: 'git' }, async (_) => true);
585
+ await extensionManager.installOrUpdateExtension({
586
+ source: gitUrl,
587
+ type: 'git',
588
+ });
582
589
  expect(fs.existsSync(targetExtDir)).toBe(true);
583
590
  expect(fs.existsSync(metadataPath)).toBe(true);
584
591
  const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
@@ -596,7 +603,10 @@ describe('extension tests', () => {
596
603
  const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
597
604
  const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
598
605
  const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
599
- await installOrUpdateExtension({ source: sourceExtDir, type: 'link' }, async (_) => true);
606
+ await extensionManager.installOrUpdateExtension({
607
+ source: sourceExtDir,
608
+ type: 'link',
609
+ });
600
610
  expect(fs.existsSync(targetExtDir)).toBe(true);
601
611
  expect(fs.existsSync(metadataPath)).toBe(true);
602
612
  expect(fs.existsSync(configPath)).toBe(false);
@@ -616,13 +626,16 @@ describe('extension tests', () => {
616
626
  version: '1.1.0',
617
627
  });
618
628
  if (isUpdate) {
619
- await installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async (_) => true);
629
+ await extensionManager.installOrUpdateExtension({
630
+ source: sourceExtDir,
631
+ type: 'local',
632
+ });
620
633
  }
621
634
  // Clears out any calls to mocks from the above function calls.
622
635
  vi.clearAllMocks();
623
636
  });
624
637
  it(`should log an ${isUpdate ? 'update' : 'install'} event to clearcut on success`, async () => {
625
- await installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async (_) => true, undefined, isUpdate
638
+ await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, isUpdate
626
639
  ? {
627
640
  name: 'my-local-extension',
628
641
  version: '1.0.0',
@@ -640,7 +653,7 @@ describe('extension tests', () => {
640
653
  it(`should ${isUpdate ? 'not ' : ''} alter the extension enablement configuration`, async () => {
641
654
  const enablementManager = new ExtensionEnablementManager();
642
655
  enablementManager.enable('my-local-extension', true, '/some/scope');
643
- await installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async (_) => true, undefined, isUpdate
656
+ await extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, isUpdate
644
657
  ? {
645
658
  name: 'my-local-extension',
646
659
  version: '1.0.0',
@@ -673,9 +686,10 @@ describe('extension tests', () => {
673
686
  },
674
687
  },
675
688
  });
676
- const mockRequestConsent = vi.fn();
677
- mockRequestConsent.mockResolvedValue(true);
678
- await expect(installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, mockRequestConsent)).resolves.toBe('my-local-extension');
689
+ await expect(extensionManager.installOrUpdateExtension({
690
+ source: sourceExtDir,
691
+ type: 'local',
692
+ })).resolves.toBe('my-local-extension');
679
693
  expect(mockRequestConsent).toHaveBeenCalledWith(`Installing extension "my-local-extension".
680
694
  ${INSTALL_WARNING_MESSAGE}
681
695
  This extension will run the following MCP servers:
@@ -694,7 +708,10 @@ This extension will run the following MCP servers:
694
708
  },
695
709
  },
696
710
  });
697
- await expect(installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async () => true)).resolves.toBe('my-local-extension');
711
+ await expect(extensionManager.installOrUpdateExtension({
712
+ source: sourceExtDir,
713
+ type: 'local',
714
+ })).resolves.toBe('my-local-extension');
698
715
  });
699
716
  it('should cancel installation if user declines prompt for local extension with mcp servers', async () => {
700
717
  const sourceExtDir = createExtension({
@@ -708,7 +725,11 @@ This extension will run the following MCP servers:
708
725
  },
709
726
  },
710
727
  });
711
- await expect(installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async () => false)).rejects.toThrow('Installation cancelled for "my-local-extension".');
728
+ mockRequestConsent.mockResolvedValue(false);
729
+ await expect(extensionManager.installOrUpdateExtension({
730
+ source: sourceExtDir,
731
+ type: 'local',
732
+ })).rejects.toThrow('Installation cancelled for "my-local-extension".');
712
733
  });
713
734
  it('should save the autoUpdate flag to the install metadata', async () => {
714
735
  const sourceExtDir = createExtension({
@@ -718,11 +739,11 @@ This extension will run the following MCP servers:
718
739
  });
719
740
  const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
720
741
  const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
721
- await installOrUpdateExtension({
742
+ await extensionManager.installOrUpdateExtension({
722
743
  source: sourceExtDir,
723
744
  type: 'local',
724
745
  autoUpdate: true,
725
- }, async (_) => true);
746
+ });
726
747
  expect(fs.existsSync(targetExtDir)).toBe(true);
727
748
  expect(fs.existsSync(metadataPath)).toBe(true);
728
749
  const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
@@ -745,18 +766,151 @@ This extension will run the following MCP servers:
745
766
  },
746
767
  },
747
768
  });
748
- const mockRequestConsent = vi.fn();
749
- // Install it and force consent first.
750
- await installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async () => true);
769
+ // Install it with hard coded consent first.
770
+ await extensionManager.installOrUpdateExtension({
771
+ source: sourceExtDir,
772
+ type: 'local',
773
+ });
774
+ expect(mockRequestConsent).toHaveBeenCalledOnce();
751
775
  // Now update it without changing anything.
752
- await expect(installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, mockRequestConsent, process.cwd(),
776
+ await expect(extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local' },
753
777
  // Provide its own existing config as the previous config.
754
- await loadExtensionConfig({
755
- extensionDir: sourceExtDir,
756
- workspaceDir: process.cwd(),
757
- extensionEnablementManager: new ExtensionEnablementManager(),
758
- }))).resolves.toBe('my-local-extension');
759
- expect(mockRequestConsent).not.toHaveBeenCalled();
778
+ await extensionManager.loadExtensionConfig(sourceExtDir))).resolves.toBe('my-local-extension');
779
+ // Still only called once
780
+ expect(mockRequestConsent).toHaveBeenCalledOnce();
781
+ });
782
+ it('should prompt for settings if promptForSettings', async () => {
783
+ const sourceExtDir = createExtension({
784
+ extensionsDir: tempHomeDir,
785
+ name: 'my-local-extension',
786
+ version: '1.0.0',
787
+ settings: [
788
+ {
789
+ name: 'API Key',
790
+ description: 'Your API key for the service.',
791
+ envVar: 'MY_API_KEY',
792
+ },
793
+ ],
794
+ });
795
+ await extensionManager.installOrUpdateExtension({
796
+ source: sourceExtDir,
797
+ type: 'local',
798
+ });
799
+ expect(mockPromptForSettings).toHaveBeenCalled();
800
+ });
801
+ it('should not prompt for settings if promptForSettings is false', async () => {
802
+ const sourceExtDir = createExtension({
803
+ extensionsDir: tempHomeDir,
804
+ name: 'my-local-extension',
805
+ version: '1.0.0',
806
+ settings: [
807
+ {
808
+ name: 'API Key',
809
+ description: 'Your API key for the service.',
810
+ envVar: 'MY_API_KEY',
811
+ },
812
+ ],
813
+ });
814
+ extensionManager = new ExtensionManager({
815
+ workspaceDir: tempWorkspaceDir,
816
+ requestConsent: mockRequestConsent,
817
+ requestSetting: null,
818
+ loadedSettings: loadSettings(tempWorkspaceDir),
819
+ });
820
+ await extensionManager.installOrUpdateExtension({
821
+ source: sourceExtDir,
822
+ type: 'local',
823
+ });
824
+ });
825
+ it('should only prompt for new settings on update, and preserve old settings', async () => {
826
+ // 1. Create and install the "old" version of the extension.
827
+ const oldSourceExtDir = createExtension({
828
+ extensionsDir: tempHomeDir, // Create it in a temp location first
829
+ name: 'my-local-extension',
830
+ version: '1.0.0',
831
+ settings: [
832
+ {
833
+ name: 'API Key',
834
+ description: 'Your API key for the service.',
835
+ envVar: 'MY_API_KEY',
836
+ },
837
+ ],
838
+ });
839
+ mockPromptForSettings.mockResolvedValueOnce('old-api-key');
840
+ // Install it so it exists in the userExtensionsDir
841
+ await extensionManager.installOrUpdateExtension({
842
+ source: oldSourceExtDir,
843
+ type: 'local',
844
+ });
845
+ const envPath = new ExtensionStorage('my-local-extension').getEnvFilePath();
846
+ expect(fs.existsSync(envPath)).toBe(true);
847
+ let envContent = fs.readFileSync(envPath, 'utf-8');
848
+ expect(envContent).toContain('MY_API_KEY=old-api-key');
849
+ expect(mockPromptForSettings).toHaveBeenCalledTimes(1);
850
+ // 2. Create the "new" version of the extension in a new source directory.
851
+ const newSourceExtDir = createExtension({
852
+ extensionsDir: path.join(tempHomeDir, 'new-source'), // Another temp location
853
+ name: 'my-local-extension', // Same name
854
+ version: '1.1.0', // New version
855
+ settings: [
856
+ {
857
+ name: 'API Key',
858
+ description: 'Your API key for the service.',
859
+ envVar: 'MY_API_KEY',
860
+ },
861
+ {
862
+ name: 'New Setting',
863
+ description: 'A new setting.',
864
+ envVar: 'NEW_SETTING',
865
+ },
866
+ ],
867
+ });
868
+ const previousExtensionConfig = extensionManager.loadExtensionConfig(path.join(userExtensionsDir, 'my-local-extension'));
869
+ mockPromptForSettings.mockResolvedValueOnce('new-setting-value');
870
+ // 3. Call installOrUpdateExtension to perform the update.
871
+ await extensionManager.installOrUpdateExtension({ source: newSourceExtDir, type: 'local' }, previousExtensionConfig);
872
+ expect(mockPromptForSettings).toHaveBeenCalledTimes(2);
873
+ expect(mockPromptForSettings).toHaveBeenCalledWith(expect.objectContaining({ name: 'New Setting' }));
874
+ expect(fs.existsSync(envPath)).toBe(true);
875
+ envContent = fs.readFileSync(envPath, 'utf-8');
876
+ expect(envContent).toContain('MY_API_KEY=old-api-key');
877
+ expect(envContent).toContain('NEW_SETTING=new-setting-value');
878
+ });
879
+ it('should fail auto-update if settings have changed', async () => {
880
+ // 1. Install initial version with autoUpdate: true
881
+ const oldSourceExtDir = createExtension({
882
+ extensionsDir: tempHomeDir,
883
+ name: 'my-auto-update-ext',
884
+ version: '1.0.0',
885
+ settings: [
886
+ {
887
+ name: 'OLD_SETTING',
888
+ envVar: 'OLD_SETTING',
889
+ description: 'An old setting',
890
+ },
891
+ ],
892
+ });
893
+ await extensionManager.installOrUpdateExtension({
894
+ source: oldSourceExtDir,
895
+ type: 'local',
896
+ autoUpdate: true,
897
+ });
898
+ // 2. Create new version with different settings
899
+ const newSourceExtDir = createExtension({
900
+ extensionsDir: tempHomeDir,
901
+ name: 'my-auto-update-ext',
902
+ version: '1.1.0',
903
+ settings: [
904
+ {
905
+ name: 'NEW_SETTING',
906
+ envVar: 'NEW_SETTING',
907
+ description: 'A new setting',
908
+ },
909
+ ],
910
+ });
911
+ const previousExtensionConfig = extensionManager.loadExtensionConfig(path.join(userExtensionsDir, 'my-auto-update-ext'));
912
+ // 3. Attempt to update and assert it fails
913
+ await expect(extensionManager.installOrUpdateExtension({ source: newSourceExtDir, type: 'local', autoUpdate: true }, previousExtensionConfig)).rejects.toThrow('Extension "my-auto-update-ext" has settings changes and cannot be auto-updated. Please update manually.');
760
914
  });
761
915
  it('should throw an error for invalid extension names', async () => {
762
916
  const sourceExtDir = createExtension({
@@ -764,7 +918,10 @@ This extension will run the following MCP servers:
764
918
  name: 'bad_name',
765
919
  version: '1.0.0',
766
920
  });
767
- await expect(installOrUpdateExtension({ source: sourceExtDir, type: 'local' }, async (_) => true)).rejects.toThrow('Invalid extension name: "bad_name"');
921
+ await expect(extensionManager.installOrUpdateExtension({
922
+ source: sourceExtDir,
923
+ type: 'local',
924
+ })).rejects.toThrow('Invalid extension name: "bad_name"');
768
925
  });
769
926
  describe('installing from github', () => {
770
927
  const gitUrl = 'https://github.com/google/gemini-test-extension.git';
@@ -797,7 +954,10 @@ This extension will run the following MCP servers:
797
954
  version: '1.0.0',
798
955
  });
799
956
  vi.spyOn(ExtensionStorage, 'createTmpDir').mockResolvedValue(join(tempDir, extensionName));
800
- await installOrUpdateExtension({ source: gitUrl, type: 'github-release' }, async () => true);
957
+ await extensionManager.installOrUpdateExtension({
958
+ source: gitUrl,
959
+ type: 'github-release',
960
+ });
801
961
  expect(fs.existsSync(targetExtDir)).toBe(true);
802
962
  const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
803
963
  expect(fs.existsSync(metadataPath)).toBe(true);
@@ -815,13 +975,11 @@ This extension will run the following MCP servers:
815
975
  errorMessage: 'download failed',
816
976
  type: 'github-release',
817
977
  });
818
- const requestConsent = vi.fn().mockResolvedValue(true);
819
- await installOrUpdateExtension({ source: gitUrl, type: 'github-release' }, // Use github-release to force consent
820
- requestConsent);
978
+ await extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'github-release' });
821
979
  // It gets called once to ask for a git clone, and once to consent to
822
980
  // the actual extension features.
823
- expect(requestConsent).toHaveBeenCalledTimes(2);
824
- expect(requestConsent).toHaveBeenCalledWith(expect.stringContaining('Would you like to attempt to install via "git clone" instead?'));
981
+ expect(mockRequestConsent).toHaveBeenCalledTimes(2);
982
+ expect(mockRequestConsent).toHaveBeenCalledWith(expect.stringContaining('Would you like to attempt to install via "git clone" instead?'));
825
983
  expect(mockGit.clone).toHaveBeenCalled();
826
984
  const metadataPath = path.join(userExtensionsDir, extensionName, INSTALL_METADATA_FILENAME);
827
985
  const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
@@ -833,9 +991,12 @@ This extension will run the following MCP servers:
833
991
  errorMessage: 'download failed',
834
992
  type: 'github-release',
835
993
  });
836
- const requestConsent = vi.fn().mockResolvedValue(false);
837
- await expect(installOrUpdateExtension({ source: gitUrl, type: 'github-release' }, requestConsent)).rejects.toThrow(`Failed to install extension ${gitUrl}: download failed`);
838
- expect(requestConsent).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('Would you like to attempt to install via "git clone" instead?'));
994
+ mockRequestConsent.mockResolvedValue(false);
995
+ await expect(extensionManager.installOrUpdateExtension({
996
+ source: gitUrl,
997
+ type: 'github-release',
998
+ })).rejects.toThrow(`Failed to install extension ${gitUrl}: download failed`);
999
+ expect(mockRequestConsent).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('Would you like to attempt to install via "git clone" instead?'));
839
1000
  expect(mockGit.clone).not.toHaveBeenCalled();
840
1001
  });
841
1002
  it('should fallback to git clone without consent if no release data is found on first install', async () => {
@@ -844,11 +1005,13 @@ This extension will run the following MCP servers:
844
1005
  failureReason: 'no release data',
845
1006
  type: 'github-release',
846
1007
  });
847
- const requestConsent = vi.fn().mockResolvedValue(true);
848
- await installOrUpdateExtension({ source: gitUrl, type: 'git' }, requestConsent);
1008
+ await extensionManager.installOrUpdateExtension({
1009
+ source: gitUrl,
1010
+ type: 'git',
1011
+ });
849
1012
  // We should not see the request to use git clone, this is a repo that
850
1013
  // has no github releases so it is the only install method.
851
- expect(requestConsent).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('Installing extension "gemini-test-extension"'));
1014
+ expect(mockRequestConsent).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('Installing extension "gemini-test-extension"'));
852
1015
  expect(mockGit.clone).toHaveBeenCalled();
853
1016
  const metadataPath = path.join(userExtensionsDir, extensionName, INSTALL_METADATA_FILENAME);
854
1017
  const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
@@ -861,10 +1024,8 @@ This extension will run the following MCP servers:
861
1024
  errorMessage: 'No release data found',
862
1025
  type: 'github-release',
863
1026
  });
864
- const requestConsent = vi.fn().mockResolvedValue(true);
865
- await installOrUpdateExtension({ source: gitUrl, type: 'github-release' }, // Note the type
866
- requestConsent);
867
- expect(requestConsent).toHaveBeenCalledWith(expect.stringContaining('Would you like to attempt to install via "git clone" instead?'));
1027
+ await extensionManager.installOrUpdateExtension({ source: gitUrl, type: 'github-release' });
1028
+ expect(mockRequestConsent).toHaveBeenCalledWith(expect.stringContaining('Would you like to attempt to install via "git clone" instead?'));
868
1029
  expect(mockGit.clone).toHaveBeenCalled();
869
1030
  });
870
1031
  });
@@ -876,7 +1037,7 @@ This extension will run the following MCP servers:
876
1037
  name: 'my-local-extension',
877
1038
  version: '1.0.0',
878
1039
  });
879
- await uninstallExtension('my-local-extension', false);
1040
+ await extensionManager.uninstallExtension('my-local-extension', false);
880
1041
  expect(fs.existsSync(sourceExtDir)).toBe(false);
881
1042
  });
882
1043
  it('should uninstall an extension by name and retain existing extensions', async () => {
@@ -890,13 +1051,13 @@ This extension will run the following MCP servers:
890
1051
  name: 'other-extension',
891
1052
  version: '1.0.0',
892
1053
  });
893
- await uninstallExtension('my-local-extension', false);
1054
+ await extensionManager.uninstallExtension('my-local-extension', false);
894
1055
  expect(fs.existsSync(sourceExtDir)).toBe(false);
895
- expect(loadExtensions(new ExtensionEnablementManager())).toHaveLength(1);
1056
+ expect(extensionManager.loadExtensions()).toHaveLength(1);
896
1057
  expect(fs.existsSync(otherExtDir)).toBe(true);
897
1058
  });
898
1059
  it('should throw an error if the extension does not exist', async () => {
899
- await expect(uninstallExtension('nonexistent-extension', false)).rejects.toThrow('Extension not found.');
1060
+ await expect(extensionManager.uninstallExtension('nonexistent-extension', false)).rejects.toThrow('Extension not found.');
900
1061
  });
901
1062
  describe.each([true, false])('with isUpdate: %s', (isUpdate) => {
902
1063
  it(`should ${isUpdate ? 'not ' : ''}log uninstall event`, async () => {
@@ -909,7 +1070,7 @@ This extension will run the following MCP servers:
909
1070
  type: 'local',
910
1071
  },
911
1072
  });
912
- await uninstallExtension('my-local-extension', isUpdate);
1073
+ await extensionManager.uninstallExtension('my-local-extension', isUpdate);
913
1074
  if (isUpdate) {
914
1075
  expect(mockLogExtensionUninstall).not.toHaveBeenCalled();
915
1076
  expect(ExtensionUninstallEvent).not.toHaveBeenCalled();
@@ -927,7 +1088,7 @@ This extension will run the following MCP servers:
927
1088
  });
928
1089
  const enablementManager = new ExtensionEnablementManager();
929
1090
  enablementManager.enable('test-extension', true, '/some/scope');
930
- await uninstallExtension('test-extension', isUpdate);
1091
+ await extensionManager.uninstallExtension('test-extension', isUpdate);
931
1092
  const config = enablementManager.readConfig()['test-extension'];
932
1093
  if (isUpdate) {
933
1094
  expect(config).not.toBeUndefined();
@@ -949,7 +1110,7 @@ This extension will run the following MCP servers:
949
1110
  type: 'git',
950
1111
  },
951
1112
  });
952
- await uninstallExtension(gitUrl, false);
1113
+ await extensionManager.uninstallExtension(gitUrl, false);
953
1114
  expect(fs.existsSync(sourceExtDir)).toBe(false);
954
1115
  expect(mockLogExtensionUninstall).toHaveBeenCalled();
955
1116
  expect(ExtensionUninstallEvent).toHaveBeenCalledWith(hashValue('gemini-sql-extension'), hashValue('https://github.com/google/gemini-sql-extension'), 'success');
@@ -961,7 +1122,7 @@ This extension will run the following MCP servers:
961
1122
  version: '1.0.0',
962
1123
  // No installMetadata provided
963
1124
  });
964
- await expect(uninstallExtension('https://github.com/google/no-metadata-extension', false)).rejects.toThrow('Extension not found.');
1125
+ await expect(extensionManager.uninstallExtension('https://github.com/google/no-metadata-extension', false)).rejects.toThrow('Extension not found.');
965
1126
  });
966
1127
  });
967
1128
  describe('disableExtension', () => {
@@ -971,7 +1132,7 @@ This extension will run the following MCP servers:
971
1132
  name: 'my-extension',
972
1133
  version: '1.0.0',
973
1134
  });
974
- disableExtension('my-extension', SettingScope.User, new ExtensionEnablementManager());
1135
+ extensionManager.disableExtension('my-extension', SettingScope.User);
975
1136
  expect(isEnabled({
976
1137
  name: 'my-extension',
977
1138
  enabledForPath: tempWorkspaceDir,
@@ -983,7 +1144,7 @@ This extension will run the following MCP servers:
983
1144
  name: 'my-extension',
984
1145
  version: '1.0.0',
985
1146
  });
986
- disableExtension('my-extension', SettingScope.Workspace, new ExtensionEnablementManager(), tempWorkspaceDir);
1147
+ extensionManager.disableExtension('my-extension', SettingScope.Workspace);
987
1148
  expect(isEnabled({
988
1149
  name: 'my-extension',
989
1150
  enabledForPath: tempHomeDir,
@@ -999,15 +1160,15 @@ This extension will run the following MCP servers:
999
1160
  name: 'my-extension',
1000
1161
  version: '1.0.0',
1001
1162
  });
1002
- disableExtension('my-extension', SettingScope.User, new ExtensionEnablementManager());
1003
- disableExtension('my-extension', SettingScope.User, new ExtensionEnablementManager());
1163
+ extensionManager.disableExtension('my-extension', SettingScope.User);
1164
+ extensionManager.disableExtension('my-extension', SettingScope.User);
1004
1165
  expect(isEnabled({
1005
1166
  name: 'my-extension',
1006
1167
  enabledForPath: tempWorkspaceDir,
1007
1168
  })).toBe(false);
1008
1169
  });
1009
1170
  it('should throw an error if you request system scope', () => {
1010
- expect(() => disableExtension('my-extension', SettingScope.System, new ExtensionEnablementManager())).toThrow('System and SystemDefaults scopes are not supported.');
1171
+ expect(() => extensionManager.disableExtension('my-extension', SettingScope.System)).toThrow('System and SystemDefaults scopes are not supported.');
1011
1172
  });
1012
1173
  it('should log a disable event', () => {
1013
1174
  createExtension({
@@ -1019,7 +1180,7 @@ This extension will run the following MCP servers:
1019
1180
  type: 'local',
1020
1181
  },
1021
1182
  });
1022
- disableExtension('ext1', SettingScope.Workspace, new ExtensionEnablementManager());
1183
+ extensionManager.disableExtension('ext1', SettingScope.Workspace);
1023
1184
  expect(mockLogExtensionDisable).toHaveBeenCalled();
1024
1185
  expect(ExtensionDisableEvent).toHaveBeenCalledWith(hashValue('ext1'), hashValue(userExtensionsDir), SettingScope.Workspace);
1025
1186
  });
@@ -1029,8 +1190,7 @@ This extension will run the following MCP servers:
1029
1190
  vi.restoreAllMocks();
1030
1191
  });
1031
1192
  const getActiveExtensions = () => {
1032
- const manager = new ExtensionEnablementManager();
1033
- const extensions = loadExtensions(manager);
1193
+ const extensions = extensionManager.loadExtensions();
1034
1194
  return extensions.filter((e) => e.isActive);
1035
1195
  };
1036
1196
  it('should enable an extension at the user scope', () => {
@@ -1039,11 +1199,10 @@ This extension will run the following MCP servers:
1039
1199
  name: 'ext1',
1040
1200
  version: '1.0.0',
1041
1201
  });
1042
- const extensionEnablementManager = new ExtensionEnablementManager();
1043
- disableExtension('ext1', SettingScope.User, extensionEnablementManager);
1202
+ extensionManager.disableExtension('ext1', SettingScope.User);
1044
1203
  let activeExtensions = getActiveExtensions();
1045
1204
  expect(activeExtensions).toHaveLength(0);
1046
- enableExtension('ext1', SettingScope.User, extensionEnablementManager);
1205
+ extensionManager.enableExtension('ext1', SettingScope.User);
1047
1206
  activeExtensions = getActiveExtensions();
1048
1207
  expect(activeExtensions).toHaveLength(1);
1049
1208
  expect(activeExtensions[0].name).toBe('ext1');
@@ -1054,11 +1213,10 @@ This extension will run the following MCP servers:
1054
1213
  name: 'ext1',
1055
1214
  version: '1.0.0',
1056
1215
  });
1057
- const extensionEnablementManager = new ExtensionEnablementManager();
1058
- disableExtension('ext1', SettingScope.Workspace, extensionEnablementManager);
1216
+ extensionManager.disableExtension('ext1', SettingScope.Workspace);
1059
1217
  let activeExtensions = getActiveExtensions();
1060
1218
  expect(activeExtensions).toHaveLength(0);
1061
- enableExtension('ext1', SettingScope.Workspace, extensionEnablementManager);
1219
+ extensionManager.enableExtension('ext1', SettingScope.Workspace);
1062
1220
  activeExtensions = getActiveExtensions();
1063
1221
  expect(activeExtensions).toHaveLength(1);
1064
1222
  expect(activeExtensions[0].name).toBe('ext1');
@@ -1073,9 +1231,8 @@ This extension will run the following MCP servers:
1073
1231
  type: 'local',
1074
1232
  },
1075
1233
  });
1076
- const extensionEnablementManager = new ExtensionEnablementManager();
1077
- disableExtension('ext1', SettingScope.Workspace, extensionEnablementManager);
1078
- enableExtension('ext1', SettingScope.Workspace, extensionEnablementManager);
1234
+ extensionManager.disableExtension('ext1', SettingScope.Workspace);
1235
+ extensionManager.enableExtension('ext1', SettingScope.Workspace);
1079
1236
  expect(mockLogExtensionEnable).toHaveBeenCalled();
1080
1237
  expect(ExtensionEnableEvent).toHaveBeenCalledWith(hashValue('ext1'), hashValue(userExtensionsDir), SettingScope.Workspace);
1081
1238
  });