@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.
- package/dist/google-gemini-cli-0.12.0-nightly.20251022.0542de95.tgz +0 -0
- package/dist/package.json +4 -3
- package/dist/src/commands/extensions/disable.js +13 -6
- package/dist/src/commands/extensions/disable.js.map +1 -1
- package/dist/src/commands/extensions/enable.js +13 -6
- package/dist/src/commands/extensions/enable.js.map +1 -1
- package/dist/src/commands/extensions/install.js +12 -2
- package/dist/src/commands/extensions/install.js.map +1 -1
- package/dist/src/commands/extensions/install.test.js +11 -3
- package/dist/src/commands/extensions/install.test.js.map +1 -1
- package/dist/src/commands/extensions/link.js +12 -2
- package/dist/src/commands/extensions/link.js.map +1 -1
- package/dist/src/commands/extensions/list.js +13 -4
- package/dist/src/commands/extensions/list.js.map +1 -1
- package/dist/src/commands/extensions/uninstall.js +12 -2
- package/dist/src/commands/extensions/uninstall.js.map +1 -1
- package/dist/src/commands/extensions/update.js +17 -13
- package/dist/src/commands/extensions/update.js.map +1 -1
- package/dist/src/commands/extensions.js +1 -0
- package/dist/src/commands/extensions.js.map +1 -1
- package/dist/src/commands/mcp/list.js +10 -3
- package/dist/src/commands/mcp/list.js.map +1 -1
- package/dist/src/commands/mcp/list.test.js +12 -6
- package/dist/src/commands/mcp/list.test.js.map +1 -1
- package/dist/src/config/config.js +12 -0
- package/dist/src/config/config.js.map +1 -1
- package/dist/src/config/config.test.js +11 -0
- package/dist/src/config/config.test.js.map +1 -1
- package/dist/src/config/extension-manager.d.ts +38 -0
- package/dist/src/config/extension-manager.js +412 -0
- package/dist/src/config/extension-manager.js.map +1 -0
- package/dist/src/config/extension.d.ts +4 -51
- package/dist/src/config/extension.js +1 -535
- package/dist/src/config/extension.js.map +1 -1
- package/dist/src/config/extension.test.js +316 -159
- package/dist/src/config/extension.test.js.map +1 -1
- package/dist/src/config/extensions/consent.d.ts +38 -0
- package/dist/src/config/extensions/consent.js +123 -0
- package/dist/src/config/extensions/consent.js.map +1 -0
- package/dist/src/config/extensions/extensionEnablement.js +1 -1
- package/dist/src/config/extensions/extensionEnablement.js.map +1 -1
- package/dist/src/config/extensions/extensionSettings.d.ts +15 -0
- package/dist/src/config/extensions/extensionSettings.js +63 -0
- package/dist/src/config/extensions/extensionSettings.js.map +1 -0
- package/dist/src/config/extensions/extensionSettings.test.d.ts +6 -0
- package/dist/src/config/extensions/extensionSettings.test.js +137 -0
- package/dist/src/config/extensions/extensionSettings.test.js.map +1 -0
- package/dist/src/config/extensions/github.d.ts +2 -2
- package/dist/src/config/extensions/github.js +3 -8
- package/dist/src/config/extensions/github.js.map +1 -1
- package/dist/src/config/extensions/github.test.js +25 -7
- package/dist/src/config/extensions/github.test.js.map +1 -1
- package/dist/src/config/extensions/storage.d.ts +14 -0
- package/dist/src/config/extensions/storage.js +32 -0
- package/dist/src/config/extensions/storage.js.map +1 -0
- package/dist/src/config/extensions/update.d.ts +4 -4
- package/dist/src/config/extensions/update.js +11 -18
- package/dist/src/config/extensions/update.js.map +1 -1
- package/dist/src/config/extensions/update.test.js +32 -58
- package/dist/src/config/extensions/update.test.js.map +1 -1
- package/dist/src/config/extensions/variableSchema.d.ts +0 -6
- package/dist/src/config/extensions/variableSchema.js.map +1 -1
- package/dist/src/config/extensions/variables.d.ts +4 -0
- package/dist/src/config/extensions/variables.js +6 -0
- package/dist/src/config/extensions/variables.js.map +1 -1
- package/dist/src/config/settings.d.ts +2 -1
- package/dist/src/config/settings.js +4 -7
- package/dist/src/config/settings.js.map +1 -1
- package/dist/src/config/settings.test.js +113 -14
- package/dist/src/config/settings.test.js.map +1 -1
- package/dist/src/config/settingsSchema.d.ts +9 -0
- package/dist/src/config/settingsSchema.js +9 -0
- package/dist/src/config/settingsSchema.js.map +1 -1
- package/dist/src/gemini.js +22 -5
- package/dist/src/gemini.js.map +1 -1
- package/dist/src/generated/git-commit.d.ts +2 -2
- package/dist/src/generated/git-commit.js +2 -2
- package/dist/src/generated/git-commit.js.map +1 -1
- package/dist/src/nonInteractiveCli.js +14 -1
- package/dist/src/nonInteractiveCli.js.map +1 -1
- package/dist/src/nonInteractiveCli.test.js +84 -2
- package/dist/src/nonInteractiveCli.test.js.map +1 -1
- package/dist/src/test-utils/createExtension.d.ts +3 -1
- package/dist/src/test-utils/createExtension.js +3 -3
- package/dist/src/test-utils/createExtension.js.map +1 -1
- package/dist/src/ui/AppContainer.js +101 -47
- package/dist/src/ui/AppContainer.js.map +1 -1
- package/dist/src/ui/AppContainer.test.js +138 -79
- package/dist/src/ui/AppContainer.test.js.map +1 -1
- package/dist/src/ui/commands/extensionsCommand.js +19 -10
- package/dist/src/ui/commands/extensionsCommand.js.map +1 -1
- package/dist/src/ui/commands/extensionsCommand.test.js +8 -0
- package/dist/src/ui/commands/extensionsCommand.test.js.map +1 -1
- package/dist/src/ui/components/HistoryItemDisplay.js +1 -1
- package/dist/src/ui/components/HistoryItemDisplay.js.map +1 -1
- package/dist/src/ui/components/InputPrompt.js +5 -6
- package/dist/src/ui/components/InputPrompt.js.map +1 -1
- package/dist/src/ui/components/InputPrompt.test.js +249 -393
- package/dist/src/ui/components/InputPrompt.test.js.map +1 -1
- package/dist/src/ui/components/SettingsDialog.test.js +0 -29
- package/dist/src/ui/components/SettingsDialog.test.js.map +1 -1
- package/dist/src/ui/components/views/ExtensionsList.d.ts +7 -1
- package/dist/src/ui/components/views/ExtensionsList.js +4 -10
- package/dist/src/ui/components/views/ExtensionsList.js.map +1 -1
- package/dist/src/ui/components/views/ExtensionsList.test.js +34 -21
- package/dist/src/ui/components/views/ExtensionsList.test.js.map +1 -1
- package/dist/src/ui/contexts/KeypressContext.js +328 -335
- package/dist/src/ui/contexts/KeypressContext.js.map +1 -1
- package/dist/src/ui/hooks/useAutoAcceptIndicator.js +10 -0
- package/dist/src/ui/hooks/useAutoAcceptIndicator.js.map +1 -1
- package/dist/src/ui/hooks/useAutoAcceptIndicator.test.js +30 -0
- package/dist/src/ui/hooks/useAutoAcceptIndicator.test.js.map +1 -1
- package/dist/src/ui/hooks/useExtensionUpdates.d.ts +14 -4
- package/dist/src/ui/hooks/useExtensionUpdates.js +14 -17
- package/dist/src/ui/hooks/useExtensionUpdates.js.map +1 -1
- package/dist/src/ui/hooks/useExtensionUpdates.test.js +23 -30
- package/dist/src/ui/hooks/useExtensionUpdates.test.js.map +1 -1
- package/dist/src/ui/hooks/useGitBranchName.js +4 -0
- package/dist/src/ui/hooks/useGitBranchName.js.map +1 -1
- package/dist/src/ui/hooks/useGitBranchName.test.js +19 -21
- package/dist/src/ui/hooks/useGitBranchName.test.js.map +1 -1
- package/dist/src/ui/hooks/useReactToolScheduler.js +22 -8
- package/dist/src/ui/hooks/useReactToolScheduler.js.map +1 -1
- package/dist/src/ui/hooks/useReactToolScheduler.test.d.ts +6 -0
- package/dist/src/ui/hooks/useReactToolScheduler.test.js +65 -0
- package/dist/src/ui/hooks/useReactToolScheduler.test.js.map +1 -0
- package/dist/src/ui/hooks/useToolScheduler.test.js +30 -48
- package/dist/src/ui/hooks/useToolScheduler.test.js.map +1 -1
- package/dist/src/ui/types.d.ts +2 -1
- package/dist/src/ui/types.js.map +1 -1
- package/dist/src/ui/utils/CodeColorizer.js +2 -1
- package/dist/src/ui/utils/CodeColorizer.js.map +1 -1
- package/dist/src/utils/envVarResolver.d.ts +2 -2
- package/dist/src/utils/envVarResolver.js +10 -7
- package/dist/src/utils/envVarResolver.js.map +1 -1
- package/dist/src/zed-integration/schema.d.ts +4 -4
- package/dist/src/zed-integration/zedIntegration.js +3 -3
- package/dist/src/zed-integration/zedIntegration.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- 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 {
|
|
11
|
-
import {
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
168
|
-
|
|
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(
|
|
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
|
-
}
|
|
218
|
+
});
|
|
206
219
|
expect(extensionName).toEqual('my-linked-extension');
|
|
207
|
-
const extensions = loadExtensions(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
488
|
-
|
|
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
|
-
|
|
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({
|
|
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({
|
|
536
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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' },
|
|
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' },
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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' },
|
|
776
|
+
await expect(extensionManager.installOrUpdateExtension({ source: sourceExtDir, type: 'local' },
|
|
753
777
|
// Provide its own existing config as the previous config.
|
|
754
|
-
await loadExtensionConfig(
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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(
|
|
824
|
-
expect(
|
|
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
|
-
|
|
837
|
-
await expect(installOrUpdateExtension({
|
|
838
|
-
|
|
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
|
-
|
|
848
|
-
|
|
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(
|
|
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
|
-
|
|
865
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
1003
|
-
disableExtension('my-extension', SettingScope.User
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1077
|
-
|
|
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
|
});
|