Update extension enablement logic (#8544)

This commit is contained in:
christine betts
2025-09-16 15:51:46 -04:00
committed by GitHub
parent 88272cba8b
commit 459de383b2
10 changed files with 1521 additions and 1185 deletions
@@ -14,7 +14,7 @@ interface DisableArgs {
scope: SettingScope; scope: SettingScope;
} }
export async function handleDisable(args: DisableArgs) { export function handleDisable(args: DisableArgs) {
try { try {
disableExtension(args.name, args.scope); disableExtension(args.name, args.scope);
console.log( console.log(
@@ -42,8 +42,8 @@ export const disableCommand: CommandModule = {
choices: [SettingScope.User, SettingScope.Workspace], choices: [SettingScope.User, SettingScope.Workspace],
}) })
.check((_argv) => true), .check((_argv) => true),
handler: async (argv) => { handler: (argv) => {
await handleDisable({ handleDisable({
name: argv['name'] as string, name: argv['name'] as string,
scope: argv['scope'] as SettingScope, scope: argv['scope'] as SettingScope,
}); });
@@ -14,12 +14,10 @@ interface EnableArgs {
scope?: SettingScope; scope?: SettingScope;
} }
export async function handleEnable(args: EnableArgs) { export function handleEnable(args: EnableArgs) {
try { try {
const scopes = args.scope const scope = args.scope ? args.scope : SettingScope.User;
? [args.scope] enableExtension(args.name, scope);
: [SettingScope.User, SettingScope.Workspace];
enableExtension(args.name, scopes);
if (args.scope) { if (args.scope) {
console.log( console.log(
`Extension "${args.name}" successfully enabled for scope "${args.scope}".`, `Extension "${args.name}" successfully enabled for scope "${args.scope}".`,
@@ -50,8 +48,8 @@ export const enableCommand: CommandModule = {
choices: [SettingScope.User, SettingScope.Workspace], choices: [SettingScope.User, SettingScope.Workspace],
}) })
.check((_argv) => true), .check((_argv) => true),
handler: async (argv) => { handler: (argv) => {
await handleEnable({ handleEnable({
name: argv['name'] as string, name: argv['name'] as string,
scope: argv['scope'] as SettingScope, scope: argv['scope'] as SettingScope,
}); });
+132 -233
View File
@@ -34,9 +34,10 @@ import {
ExtensionUninstallEvent, ExtensionUninstallEvent,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { SettingScope, loadSettings } from './settings.js'; import { SettingScope } from './settings.js';
import { isWorkspaceTrusted } from './trustedFolders.js'; import { isWorkspaceTrusted } from './trustedFolders.js';
import { ExtensionUpdateState } from '../ui/state/extensions.js'; import { ExtensionUpdateState } from '../ui/state/extensions.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
const mockGit = { const mockGit = {
clone: vi.fn(), clone: vi.fn(),
@@ -111,26 +112,38 @@ vi.mock('node:readline', () => ({
const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
describe('loadExtensions', () => { describe('extension tests', () => {
let tempHomeDir: string; let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string; let userExtensionsDir: string;
beforeEach(() => { beforeEach(() => {
tempHomeDir = fs.mkdtempSync( tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'), path.join(os.tmpdir(), 'gemini-cli-test-home-'),
); );
vi.mocked(os.homedir).mockReturnValue(tempHomeDir); tempWorkspaceDir = fs.mkdtempSync(
vi.mocked(isWorkspaceTrusted).mockReturnValue(true); path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
);
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
fs.mkdirSync(userExtensionsDir, { recursive: true }); fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
mockQuestion.mockImplementation((_query, callback) => callback('y'));
vi.mocked(execSync).mockClear();
Object.values(mockGit).forEach((fn) => fn.mockReset());
}); });
afterEach(() => { afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true }); fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks(); vi.restoreAllMocks();
mockQuestion.mockClear();
mockClose.mockClear();
}); });
describe('loadExtensions', () => {
it('should include extension path in loaded extension', () => { it('should include extension path in loaded extension', () => {
const extensionDir = path.join(userExtensionsDir, 'test-extension'); const extensionDir = path.join(userExtensionsDir, 'test-extension');
fs.mkdirSync(extensionDir, { recursive: true }); fs.mkdirSync(extensionDir, { recursive: true });
@@ -192,30 +205,27 @@ describe('loadExtensions', () => {
it('should filter out disabled extensions', () => { it('should filter out disabled extensions', () => {
createExtension({ createExtension({
extensionsDir: userExtensionsDir, extensionsDir: userExtensionsDir,
name: 'ext1', name: 'disabled-extension',
version: '1.0.0', version: '1.0.0',
}); });
createExtension({ createExtension({
extensionsDir: userExtensionsDir, extensionsDir: userExtensionsDir,
name: 'ext2', name: 'enabled-extension',
version: '2.0.0', version: '2.0.0',
}); });
disableExtension(
const settingsDir = path.join(tempHomeDir, GEMINI_DIR); 'disabled-extension',
fs.mkdirSync(settingsDir, { recursive: true }); SettingScope.User,
fs.writeFileSync( tempWorkspaceDir,
path.join(settingsDir, 'settings.json'),
JSON.stringify({ extensions: { disabled: ['ext1'] } }),
); );
const extensions = loadExtensions(); const extensions = loadExtensions();
const activeExtensions = annotateActiveExtensions( const activeExtensions = annotateActiveExtensions(
extensions, extensions,
[], [],
tempHomeDir, tempWorkspaceDir,
).filter((e) => e.isActive); ).filter((e) => e.isActive);
expect(activeExtensions).toHaveLength(1); expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext2'); expect(activeExtensions[0].name).toBe('enabled-extension');
}); });
it('should hydrate variables', () => { it('should hydrate variables', () => {
@@ -244,9 +254,6 @@ describe('loadExtensions', () => {
}); });
it('should load a linked extension correctly', async () => { it('should load a linked extension correctly', async () => {
const tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
const sourceExtDir = createExtension({ const sourceExtDir = createExtension({
extensionsDir: tempWorkspaceDir, extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension', name: 'my-linked-extension',
@@ -332,7 +339,10 @@ describe('loadExtensions', () => {
}); });
it('should handle missing environment variables gracefully', () => { it('should handle missing environment variables gracefully', () => {
const userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME); const userExtensionsDir = path.join(
tempHomeDir,
EXTENSIONS_DIRECTORY_NAME,
);
fs.mkdirSync(userExtensionsDir, { recursive: true }); fs.mkdirSync(userExtensionsDir, { recursive: true });
const extDir = path.join(userExtensionsDir, 'test-extension'); const extDir = path.join(userExtensionsDir, 'test-extension');
@@ -438,7 +448,9 @@ describe('annotateActiveExtensions', () => {
}); });
it('should log an error for unknown extensions', () => { it('should log an error for unknown extensions', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
annotateActiveExtensions(extensions, ['ext4'], '/path/to/workspace'); annotateActiveExtensions(extensions, ['ext4'], '/path/to/workspace');
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4'); expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
consoleSpy.mockRestore(); consoleSpy.mockRestore();
@@ -446,31 +458,6 @@ describe('annotateActiveExtensions', () => {
}); });
describe('installExtension', () => { describe('installExtension', () => {
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
mockQuestion.mockImplementation((_query, callback) => callback('y'));
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
// Clean up before each test
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
vi.mocked(execSync).mockClear();
Object.values(mockGit).forEach((fn) => fn.mockReset());
});
afterEach(() => {
mockQuestion.mockClear();
mockClose.mockClear();
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
});
it('should install an extension from a local path', async () => { it('should install an extension from a local path', async () => {
const sourceExtDir = createExtension({ const sourceExtDir = createExtension({
extensionsDir: tempHomeDir, extensionsDir: tempHomeDir,
@@ -527,7 +514,9 @@ describe('installExtension', () => {
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
mockGit.clone.mockImplementation(async (_, destination) => { mockGit.clone.mockImplementation(async (_, destination) => {
fs.mkdirSync(path.join(mockGit.path(), destination), { recursive: true }); fs.mkdirSync(path.join(mockGit.path(), destination), {
recursive: true,
});
fs.writeFileSync( fs.writeFileSync(
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.0.0' }), JSON.stringify({ name: extensionName, version: '1.0.0' }),
@@ -676,26 +665,6 @@ describe('installExtension', () => {
}); });
describe('uninstallExtension', () => { describe('uninstallExtension', () => {
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
// Clean up before each test
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(execSync).mockClear();
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should uninstall an extension by name', async () => { it('should uninstall an extension by name', async () => {
const sourceExtDir = createExtension({ const sourceExtDir = createExtension({
extensionsDir: userExtensionsDir, extensionsDir: userExtensionsDir,
@@ -750,20 +719,9 @@ describe('uninstallExtension', () => {
}); });
describe('performWorkspaceExtensionMigration', () => { describe('performWorkspaceExtensionMigration', () => {
let tempWorkspaceDir: string;
let tempHomeDir: string;
let workspaceExtensionsDir: string; let workspaceExtensionsDir: string;
beforeEach(() => { beforeEach(() => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
workspaceExtensionsDir = path.join( workspaceExtensionsDir = path.join(
tempWorkspaceDir, tempWorkspaceDir,
EXTENSIONS_DIRECTORY_NAME, EXTENSIONS_DIRECTORY_NAME,
@@ -772,9 +730,7 @@ describe('performWorkspaceExtensionMigration', () => {
}); });
afterEach(() => { afterEach(() => {
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); fs.rmSync(workspaceExtensionsDir, { recursive: true, force: true });
fs.rmSync(tempHomeDir, { recursive: true, force: true });
vi.restoreAllMocks();
}); });
describe('folder trust', () => { describe('folder trust', () => {
@@ -808,8 +764,7 @@ describe('performWorkspaceExtensionMigration', () => {
GEMINI_DIR, GEMINI_DIR,
'extensions', 'extensions',
); );
expect(fs.readdirSync(userExtensionsDir).length).toBe(0);
expect(() => fs.readdirSync(userExtensionsDir)).toThrow();
}); });
it('does not load any extensions in the workspace config', async () => { it('does not load any extensions in the workspace config', async () => {
@@ -847,7 +802,11 @@ describe('performWorkspaceExtensionMigration', () => {
expect(failed).toEqual([]); expect(failed).toEqual([]);
const userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions'); const userExtensionsDir = path.join(
tempHomeDir,
GEMINI_DIR,
'extensions',
);
const userExt1Path = path.join(userExtensionsDir, 'ext1'); const userExt1Path = path.join(userExtensionsDir, 'ext1');
const extensions = loadExtensions(); const extensions = loadExtensions();
@@ -882,63 +841,7 @@ describe('performWorkspaceExtensionMigration', () => {
}); });
}); });
function createExtension({
extensionsDir = 'extensions-dir',
name = 'my-extension',
version = '1.0.0',
addContextFile = false,
contextFileName = undefined as string | undefined,
mcpServers = {} as Record<string, MCPServerConfig>,
installMetadata = undefined as ExtensionInstallMetadata | undefined,
} = {}): string {
const extDir = path.join(extensionsDir, name);
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name, version, contextFileName, mcpServers }),
);
if (addContextFile) {
fs.writeFileSync(path.join(extDir, 'GEMINI.md'), 'context');
}
if (contextFileName) {
fs.writeFileSync(path.join(extDir, contextFileName), 'context');
}
if (installMetadata) {
fs.writeFileSync(
path.join(extDir, INSTALL_METADATA_FILENAME),
JSON.stringify(installMetadata),
);
}
return extDir;
}
describe('updateExtension', () => { describe('updateExtension', () => {
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
// Clean up before each test
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
vi.mocked(execSync).mockClear();
Object.values(mockGit).forEach((fn) => fn.mockReset());
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
mockClose.mockClear();
});
it('should update a git-installed extension', async () => { it('should update a git-installed extension', async () => {
const gitUrl = 'https://github.com/google/gemini-extensions.git'; const gitUrl = 'https://github.com/google/gemini-extensions.git';
const extensionName = 'gemini-extensions'; const extensionName = 'gemini-extensions';
@@ -956,7 +859,9 @@ describe('updateExtension', () => {
); );
mockGit.clone.mockImplementation(async (_, destination) => { mockGit.clone.mockImplementation(async (_, destination) => {
fs.mkdirSync(path.join(mockGit.path(), destination), { recursive: true }); fs.mkdirSync(path.join(mockGit.path(), destination), {
recursive: true,
});
fs.writeFileSync( fs.writeFileSync(
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.1.0' }), JSON.stringify({ name: extensionName, version: '1.1.0' }),
@@ -968,7 +873,11 @@ describe('updateExtension', () => {
[], [],
process.cwd(), process.cwd(),
)[0]; )[0];
const updateInfo = await updateExtension(extension, tempHomeDir, () => {}); const updateInfo = await updateExtension(
extension,
tempHomeDir,
() => {},
);
expect(updateInfo).toEqual({ expect(updateInfo).toEqual({
name: 'gemini-extensions', name: 'gemini-extensions',
@@ -998,7 +907,9 @@ describe('updateExtension', () => {
}); });
mockGit.clone.mockImplementation(async (_, destination) => { mockGit.clone.mockImplementation(async (_, destination) => {
fs.mkdirSync(path.join(mockGit.path(), destination), { recursive: true }); fs.mkdirSync(path.join(mockGit.path(), destination), {
recursive: true,
});
fs.writeFileSync( fs.writeFileSync(
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME), path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.1.0' }), JSON.stringify({ name: extensionName, version: '1.1.0' }),
@@ -1058,23 +969,6 @@ describe('updateExtension', () => {
}); });
describe('checkForAllExtensionUpdates', () => { describe('checkForAllExtensionUpdates', () => {
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
fs.mkdirSync(userExtensionsDir, { recursive: true });
Object.values(mockGit).forEach((fn) => fn.mockReset());
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should return UpdateAvailable for a git extension with updates', async () => { it('should return UpdateAvailable for a git extension with updates', async () => {
const extensionDir = createExtension({ const extensionDir = createExtension({
extensionsDir: userExtensionsDir, extensionsDir: userExtensionsDir,
@@ -1172,23 +1066,6 @@ describe('checkForAllExtensionUpdates', () => {
}); });
describe('checkForExtensionUpdate', () => { describe('checkForExtensionUpdate', () => {
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
fs.mkdirSync(userExtensionsDir, { recursive: true });
Object.values(mockGit).forEach((fn) => fn.mockReset());
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should return UpdateAvailable for a git extension with updates', async () => { it('should return UpdateAvailable for a git extension with updates', async () => {
const extensionDir = createExtension({ const extensionDir = createExtension({
extensionsDir: userExtensionsDir, extensionsDir: userExtensionsDir,
@@ -1281,80 +1158,59 @@ describe('checkForExtensionUpdate', () => {
}); });
describe('disableExtension', () => { describe('disableExtension', () => {
let tempWorkspaceDir: string;
let tempHomeDir: string;
beforeEach(() => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
});
afterEach(() => {
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should disable an extension at the user scope', () => { it('should disable an extension at the user scope', () => {
disableExtension('my-extension', SettingScope.User); disableExtension('my-extension', SettingScope.User);
const settings = loadSettings(tempWorkspaceDir);
expect( expect(
settings.forScope(SettingScope.User).settings.extensions?.disabled, isEnabled({
).toEqual(['my-extension']); name: 'my-extension',
configDir: userExtensionsDir,
enabledForPath: tempWorkspaceDir,
}),
).toBe(false);
}); });
it('should disable an extension at the workspace scope', () => { it('should disable an extension at the workspace scope', () => {
disableExtension('my-extension', SettingScope.Workspace); disableExtension(
const settings = loadSettings(tempWorkspaceDir); 'my-extension',
SettingScope.Workspace,
tempWorkspaceDir,
);
expect( expect(
settings.forScope(SettingScope.Workspace).settings.extensions?.disabled, isEnabled({
).toEqual(['my-extension']); name: 'my-extension',
configDir: userExtensionsDir,
enabledForPath: tempHomeDir,
}),
).toBe(true);
expect(
isEnabled({
name: 'my-extension',
configDir: userExtensionsDir,
enabledForPath: tempWorkspaceDir,
}),
).toBe(false);
}); });
it('should handle disabling the same extension twice', () => { it('should handle disabling the same extension twice', () => {
disableExtension('my-extension', SettingScope.User); disableExtension('my-extension', SettingScope.User);
disableExtension('my-extension', SettingScope.User); disableExtension('my-extension', SettingScope.User);
const settings = loadSettings(tempWorkspaceDir);
expect( expect(
settings.forScope(SettingScope.User).settings.extensions?.disabled, isEnabled({
).toEqual(['my-extension']); name: 'my-extension',
configDir: userExtensionsDir,
enabledForPath: tempWorkspaceDir,
}),
).toBe(false);
}); });
it('should throw an error if you request system scope', () => { it('should throw an error if you request system scope', () => {
expect(() => disableExtension('my-extension', SettingScope.System)).toThrow( expect(() =>
'System and SystemDefaults scopes are not supported.', disableExtension('my-extension', SettingScope.System),
); ).toThrow('System and SystemDefaults scopes are not supported.');
}); });
}); });
describe('enableExtension', () => { describe('enableExtension', () => {
let tempWorkspaceDir: string;
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempWorkspaceDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-workspace-'),
);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
});
afterEach(() => {
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
});
afterAll(() => { afterAll(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -1379,7 +1235,7 @@ describe('enableExtension', () => {
let activeExtensions = getActiveExtensions(); let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0); expect(activeExtensions).toHaveLength(0);
enableExtension('ext1', [SettingScope.User]); enableExtension('ext1', SettingScope.User);
activeExtensions = getActiveExtensions(); activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(1); expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext1'); expect(activeExtensions[0].name).toBe('ext1');
@@ -1395,9 +1251,52 @@ describe('enableExtension', () => {
let activeExtensions = getActiveExtensions(); let activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(0); expect(activeExtensions).toHaveLength(0);
enableExtension('ext1', [SettingScope.Workspace]); enableExtension('ext1', SettingScope.Workspace);
activeExtensions = getActiveExtensions(); activeExtensions = getActiveExtensions();
expect(activeExtensions).toHaveLength(1); expect(activeExtensions).toHaveLength(1);
expect(activeExtensions[0].name).toBe('ext1'); expect(activeExtensions[0].name).toBe('ext1');
}); });
}); });
});
function createExtension({
extensionsDir = 'extensions-dir',
name = 'my-extension',
version = '1.0.0',
addContextFile = false,
contextFileName = undefined as string | undefined,
mcpServers = {} as Record<string, MCPServerConfig>,
installMetadata = undefined as ExtensionInstallMetadata | undefined,
} = {}): string {
const extDir = path.join(extensionsDir, name);
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name, version, contextFileName, mcpServers }),
);
if (addContextFile) {
fs.writeFileSync(path.join(extDir, 'GEMINI.md'), 'context');
}
if (contextFileName) {
fs.writeFileSync(path.join(extDir, contextFileName), 'context');
}
if (installMetadata) {
fs.writeFileSync(
path.join(extDir, INSTALL_METADATA_FILENAME),
JSON.stringify(installMetadata),
);
}
return extDir;
}
function isEnabled(options: {
name: string;
configDir: string;
enabledForPath: string;
}) {
const manager = new ExtensionEnablementManager(options.configDir);
return manager.isEnabled(options.name, options.enabledForPath);
}
+30 -47
View File
@@ -27,6 +27,7 @@ import { isWorkspaceTrusted } from './trustedFolders.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { ExtensionUpdateState } from '../ui/state/extensions.js'; import { ExtensionUpdateState } from '../ui/state/extensions.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
@@ -140,7 +141,6 @@ export function loadExtensions(
workspaceDir: string = process.cwd(), workspaceDir: string = process.cwd(),
): Extension[] { ): Extension[] {
const settings = loadSettings(workspaceDir).merged; const settings = loadSettings(workspaceDir).merged;
const disabledExtensions = settings.extensions?.disabled ?? [];
const allExtensions = [...loadUserExtensions()]; const allExtensions = [...loadUserExtensions()];
if ( if (
@@ -152,10 +152,14 @@ export function loadExtensions(
} }
const uniqueExtensions = new Map<string, Extension>(); const uniqueExtensions = new Map<string, Extension>();
const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
);
for (const extension of allExtensions) { for (const extension of allExtensions) {
if ( if (
!uniqueExtensions.has(extension.config.name) && !uniqueExtensions.has(extension.config.name) &&
!disabledExtensions.includes(extension.config.name) manager.isEnabled(extension.config.name, workspaceDir)
) { ) {
uniqueExtensions.set(extension.config.name, extension); uniqueExtensions.set(extension.config.name, extension);
} }
@@ -198,9 +202,6 @@ export function loadExtensionsFromDir(dir: string): Extension[] {
export function loadExtension(extensionDir: string): Extension | null { export function loadExtension(extensionDir: string): Extension | null {
if (!fs.statSync(extensionDir).isDirectory()) { if (!fs.statSync(extensionDir).isDirectory()) {
console.error(
`Warning: unexpected file ${extensionDir} in extensions directory.`,
);
return null; return null;
} }
@@ -284,7 +285,7 @@ function getContextFileNames(config: ExtensionConfig): string[] {
/** /**
* Returns an annotated list of extensions. If an extension is listed in enabledExtensionNames, it will be active. * Returns an annotated list of extensions. If an extension is listed in enabledExtensionNames, it will be active.
* If enabledExtensionNames is empty, an extension is active unless it is in list of disabled extensions in settings. * If enabledExtensionNames is empty, an extension is active unless it is disabled.
* @param extensions The base list of extensions. * @param extensions The base list of extensions.
* @param enabledExtensionNames The names of explicitly enabled extensions. * @param enabledExtensionNames The names of explicitly enabled extensions.
* @param workspaceDir The current workspace directory. * @param workspaceDir The current workspace directory.
@@ -294,16 +295,16 @@ export function annotateActiveExtensions(
enabledExtensionNames: string[], enabledExtensionNames: string[],
workspaceDir: string, workspaceDir: string,
): GeminiCLIExtension[] { ): GeminiCLIExtension[] {
const settings = loadSettings(workspaceDir).merged; const manager = new ExtensionEnablementManager(
const disabledExtensions = settings.extensions?.disabled ?? []; ExtensionStorage.getUserExtensionsDir(),
);
const annotatedExtensions: GeminiCLIExtension[] = []; const annotatedExtensions: GeminiCLIExtension[] = [];
if (enabledExtensionNames.length === 0) { if (enabledExtensionNames.length === 0) {
return extensions.map((extension) => ({ return extensions.map((extension) => ({
name: extension.config.name, name: extension.config.name,
version: extension.config.version, version: extension.config.version,
isActive: !disabledExtensions.includes(extension.config.name), isActive: manager.isEnabled(extension.config.name, workspaceDir),
path: extension.path, path: extension.path,
source: extension.installMetadata?.source, source: extension.installMetadata?.source,
type: extension.installMetadata?.type, type: extension.installMetadata?.type,
@@ -526,6 +527,7 @@ export async function installExtension(
), ),
); );
enableExtension(newExtensionConfig!.name, SettingScope.User);
return newExtensionConfig!.name; return newExtensionConfig!.name;
} catch (error) { } catch (error) {
// Attempt to load config from the source path even if installation fails // Attempt to load config from the source path even if installation fails
@@ -581,11 +583,10 @@ export async function uninstallExtension(
) { ) {
throw new Error(`Extension "${extensionName}" not found.`); throw new Error(`Extension "${extensionName}" not found.`);
} }
removeFromDisabledExtensions( const manager = new ExtensionEnablementManager(
extensionName, ExtensionStorage.getUserExtensionsDir(),
[SettingScope.User, SettingScope.Workspace],
cwd,
); );
manager.remove(extensionName);
const storage = new ExtensionStorage(extensionName); const storage = new ExtensionStorage(extensionName);
await fs.promises.rm(storage.getExtensionDir(), { await fs.promises.rm(storage.getExtensionDir(), {
@@ -710,45 +711,27 @@ export function disableExtension(
if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) { if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
throw new Error('System and SystemDefaults scopes are not supported.'); throw new Error('System and SystemDefaults scopes are not supported.');
} }
const settings = loadSettings(cwd);
const settingsFile = settings.forScope(scope); const manager = new ExtensionEnablementManager(
const extensionSettings = settingsFile.settings.extensions || { ExtensionStorage.getUserExtensionsDir(),
disabled: [], );
}; const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
const disabledExtensions = extensionSettings.disabled || []; manager.disable(name, true, scopePath);
if (!disabledExtensions.includes(name)) {
disabledExtensions.push(name);
extensionSettings.disabled = disabledExtensions;
settings.setValue(scope, 'extensions', extensionSettings);
}
} }
export function enableExtension(name: string, scopes: SettingScope[]) { export function enableExtension(
removeFromDisabledExtensions(name, scopes);
}
/**
* Removes an extension from the list of disabled extensions.
* @param name The name of the extension to remove.
* @param scope The scopes to remove the name from.
*/
function removeFromDisabledExtensions(
name: string, name: string,
scopes: SettingScope[], scope: SettingScope,
cwd: string = process.cwd(), cwd: string = process.cwd(),
) { ) {
const settings = loadSettings(cwd); if (scope === SettingScope.System || scope === SettingScope.SystemDefaults) {
for (const scope of scopes) { throw new Error('System and SystemDefaults scopes are not supported.');
const settingsFile = settings.forScope(scope);
const extensionSettings = settingsFile.settings.extensions || {
disabled: [],
};
const disabledExtensions = extensionSettings.disabled || [];
extensionSettings.disabled = disabledExtensions.filter(
(extension) => extension !== name,
);
settings.setValue(scope, 'extensions', extensionSettings);
} }
const manager = new ExtensionEnablementManager(
ExtensionStorage.getUserExtensionsDir(),
);
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
manager.enable(name, true, scopePath);
} }
export async function updateAllUpdatableExtensions( export async function updateAllUpdatableExtensions(
@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ExtensionEnablementManager } from './extensionEnablement.js';
// Helper to create a temporary directory for testing
function createTestDir() {
const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
return {
path: dirPath,
cleanup: () => fs.rmSync(dirPath, { recursive: true, force: true }),
};
}
let testDir: { path: string; cleanup: () => void };
let configDir: string;
let manager: ExtensionEnablementManager;
describe('ExtensionEnablementManager', () => {
beforeEach(() => {
testDir = createTestDir();
configDir = path.join(testDir.path, '.gemini');
manager = new ExtensionEnablementManager(configDir);
});
afterEach(() => {
testDir.cleanup();
// Reset the singleton instance for test isolation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ExtensionEnablementManager as any).instance = undefined;
});
describe('isEnabled', () => {
it('should return true if extension is not configured', () => {
expect(manager.isEnabled('ext-test', '/any/path')).toBe(true);
});
it('should return true if no overrides match', () => {
manager.disable('ext-test', false, '/another/path');
expect(manager.isEnabled('ext-test', '/any/path')).toBe(true);
});
it('should enable a path based on an override rule', () => {
manager.disable('ext-test', true, '*'); // Disable globally
manager.enable('ext-test', true, '/home/user/projects/');
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
true,
);
});
it('should disable a path based on a disable override rule', () => {
manager.enable('ext-test', true, '*'); // Enable globally
manager.disable('ext-test', true, '/home/user/projects/');
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
false,
);
});
it('should respect the last matching rule (enable wins)', () => {
manager.disable('ext-test', true, '/home/user/projects/');
manager.enable('ext-test', false, '/home/user/projects/my-app');
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
true,
);
});
it('should respect the last matching rule (disable wins)', () => {
manager.enable('ext-test', true, '/home/user/projects/');
manager.disable('ext-test', false, '/home/user/projects/my-app');
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
false,
);
});
});
describe('includeSubdirs', () => {
it('should add a glob when enabling with includeSubdirs', () => {
manager.enable('ext-test', true, '/path/to/dir');
const config = manager.readConfig();
expect(config['ext-test'].overrides).toContain('/path/to/dir*');
});
it('should not add a glob when enabling without includeSubdirs', () => {
manager.enable('ext-test', false, '/path/to/dir');
const config = manager.readConfig();
expect(config['ext-test'].overrides).toContain('/path/to/dir');
expect(config['ext-test'].overrides).not.toContain('/path/to/dir*');
});
it('should add a glob when disabling with includeSubdirs', () => {
manager.disable('ext-test', true, '/path/to/dir');
const config = manager.readConfig();
expect(config['ext-test'].overrides).toContain('!/path/to/dir*');
});
it('should remove conflicting glob rule when enabling without subdirs', () => {
manager.enable('ext-test', true, '/path/to/dir'); // Adds /path/to/dir*
manager.enable('ext-test', false, '/path/to/dir'); // Should remove the glob
const config = manager.readConfig();
expect(config['ext-test'].overrides).toContain('/path/to/dir');
expect(config['ext-test'].overrides).not.toContain('/path/to/dir*');
});
it('should remove conflicting non-glob rule when enabling with subdirs', () => {
manager.enable('ext-test', false, '/path/to/dir'); // Adds /path/to/dir
manager.enable('ext-test', true, '/path/to/dir'); // Should remove the non-glob
const config = manager.readConfig();
expect(config['ext-test'].overrides).toContain('/path/to/dir*');
expect(config['ext-test'].overrides).not.toContain('/path/to/dir');
});
it('should remove conflicting rules when disabling', () => {
manager.enable('ext-test', true, '/path/to/dir'); // enabled with glob
manager.disable('ext-test', false, '/path/to/dir'); // disabled without
const config = manager.readConfig();
expect(config['ext-test'].overrides).toContain('!/path/to/dir');
expect(config['ext-test'].overrides).not.toContain('/path/to/dir*');
});
it('should correctly evaluate isEnabled with subdirs', () => {
manager.disable('ext-test', true, '*');
manager.enable('ext-test', true, '/path/to/dir');
expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(true);
expect(manager.isEnabled('ext-test', '/path/to/dir/sub')).toBe(true);
expect(manager.isEnabled('ext-test', '/path/to/another')).toBe(false);
});
it('should correctly evaluate isEnabled without subdirs', () => {
manager.disable('ext-test', true, '*');
manager.enable('ext-test', false, '/path/to/dir');
expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(true);
expect(manager.isEnabled('ext-test', '/path/to/dir/sub')).toBe(false);
});
});
});
@@ -0,0 +1,158 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import path from 'node:path';
export interface ExtensionEnablementConfig {
overrides: string[];
}
export interface AllExtensionsEnablementConfig {
[extensionName: string]: ExtensionEnablementConfig;
}
/**
* Converts a glob pattern to a RegExp object.
* This is a simplified implementation that supports `*`.
*
* @param glob The glob pattern to convert.
* @returns A RegExp object.
*/
function globToRegex(glob: string): RegExp {
const regexString = glob
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex characters
.replace(/\*/g, '.*'); // Convert * to .*
return new RegExp(`^${regexString}$`);
}
/**
* Determines if an extension is enabled based on the configuration and current path.
* The last matching rule in the overrides list wins.
*
* @param config The enablement configuration for a single extension.
* @param currentPath The absolute path of the current working directory.
* @returns True if the extension is enabled, false otherwise.
*/
export class ExtensionEnablementManager {
private configFilePath: string;
private configDir: string;
constructor(configDir: string) {
this.configDir = configDir;
this.configFilePath = path.join(configDir, 'extension-enablement.json');
}
isEnabled(extensionName: string, currentPath: string): boolean {
const config = this.readConfig();
const extensionConfig = config[extensionName];
// Extensions are enabled by default.
let enabled = true;
for (const rule of extensionConfig?.overrides ?? []) {
const isDisableRule = rule.startsWith('!');
const globPattern = isDisableRule ? rule.substring(1) : rule;
const regex = globToRegex(globPattern);
if (regex.test(currentPath)) {
enabled = !isDisableRule;
}
}
return enabled;
}
readConfig(): AllExtensionsEnablementConfig {
try {
const content = fs.readFileSync(this.configFilePath, 'utf-8');
return JSON.parse(content);
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
return {};
}
console.error('Error reading extension enablement config:', error);
return {};
}
}
writeConfig(config: AllExtensionsEnablementConfig): void {
fs.mkdirSync(this.configDir, { recursive: true });
fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2));
}
enable(
extensionName: string,
includeSubdirs: boolean,
scopePath: string,
): void {
const config = this.readConfig();
if (!config[extensionName]) {
config[extensionName] = { overrides: [] };
}
const pathWithGlob = `${scopePath}*`;
const pathWithoutGlob = scopePath;
const newPath = includeSubdirs ? pathWithGlob : pathWithoutGlob;
const conflictingPath = includeSubdirs ? pathWithoutGlob : pathWithGlob;
config[extensionName].overrides = config[extensionName].overrides.filter(
(rule) =>
rule !== conflictingPath &&
rule !== `!${conflictingPath}` &&
rule !== `!${newPath}`,
);
if (!config[extensionName].overrides.includes(newPath)) {
config[extensionName].overrides.push(newPath);
}
this.writeConfig(config);
}
disable(
extensionName: string,
includeSubdirs: boolean,
scopePath: string,
): void {
const config = this.readConfig();
if (!config[extensionName]) {
config[extensionName] = { overrides: [] };
}
const pathWithGlob = `${scopePath}*`;
const pathWithoutGlob = scopePath;
const targetPath = includeSubdirs ? pathWithGlob : pathWithoutGlob;
const newRule = `!${targetPath}`;
const conflictingPath = includeSubdirs ? pathWithoutGlob : pathWithGlob;
config[extensionName].overrides = config[extensionName].overrides.filter(
(rule) =>
rule !== conflictingPath &&
rule !== `!${conflictingPath}` &&
rule !== targetPath,
);
if (!config[extensionName].overrides.includes(newRule)) {
config[extensionName].overrides.push(newRule);
}
this.writeConfig(config);
}
remove(extensionName: string): void {
const config = this.readConfig();
if (config[extensionName]) {
delete config[extensionName];
this.writeConfig(config);
}
}
}
+125
View File
@@ -49,6 +49,7 @@ import {
import * as fs from 'node:fs'; // fs will be mocked separately import * as fs from 'node:fs'; // fs will be mocked separately
import stripJsonComments from 'strip-json-comments'; // Will be mocked separately import stripJsonComments from 'strip-json-comments'; // Will be mocked separately
import { isWorkspaceTrusted } from './trustedFolders.js'; import { isWorkspaceTrusted } from './trustedFolders.js';
import { disableExtension } from './extension.js';
// These imports will get the versions from the vi.mock('./settings.js', ...) factory. // These imports will get the versions from the vi.mock('./settings.js', ...) factory.
import { import {
@@ -61,6 +62,8 @@ import {
needsMigration, needsMigration,
type Settings, type Settings,
loadEnvironment, loadEnvironment,
migrateDeprecatedSettings,
SettingScope,
} from './settings.js'; } from './settings.js';
import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core'; import { FatalConfigError, GEMINI_DIR } from '@google/gemini-cli-core';
@@ -90,6 +93,10 @@ vi.mock('fs', async (importOriginal) => {
}; };
}); });
vi.mock('./extension.js', () => ({
disableExtension: vi.fn(),
}));
vi.mock('strip-json-comments', () => ({ vi.mock('strip-json-comments', () => ({
default: vi.fn((content) => content), default: vi.fn((content) => content),
})); }));
@@ -2311,4 +2318,122 @@ describe('Settings Loading and Merging', () => {
expect(needsMigration(settings)).toBe(false); expect(needsMigration(settings)).toBe(false);
}); });
}); });
describe('migrateDeprecatedSettings', () => {
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
let mockFsReadFileSync: Mocked<typeof fs.readFileSync>;
let mockDisableExtension: Mocked<typeof disableExtension>;
beforeEach(() => {
vi.resetAllMocks();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockFsReadFileSync = vi.mocked(fs.readFileSync);
mockDisableExtension = vi.mocked(disableExtension);
(mockFsExistsSync as Mock).mockReturnValue(true);
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should migrate disabled extensions from user and workspace settings', () => {
const userSettingsContent = {
extensions: {
disabled: ['user-ext-1', 'shared-ext'],
},
};
const workspaceSettingsContent = {
extensions: {
disabled: ['workspace-ext-1', 'shared-ext'],
},
};
(mockFsReadFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR);
// Check user settings migration
expect(mockDisableExtension).toHaveBeenCalledWith(
'user-ext-1',
SettingScope.User,
MOCK_WORKSPACE_DIR,
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'shared-ext',
SettingScope.User,
MOCK_WORKSPACE_DIR,
);
// Check workspace settings migration
expect(mockDisableExtension).toHaveBeenCalledWith(
'workspace-ext-1',
SettingScope.Workspace,
MOCK_WORKSPACE_DIR,
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'shared-ext',
SettingScope.Workspace,
MOCK_WORKSPACE_DIR,
);
// Check that setValue was called to remove the deprecated setting
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'extensions',
{
disabled: undefined,
},
);
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.Workspace,
'extensions',
{
disabled: undefined,
},
);
});
it('should not do anything if there are no deprecated settings', () => {
const userSettingsContent = {
extensions: {
enabled: ['user-ext-1'],
},
};
const workspaceSettingsContent = {
someOtherSetting: 'value',
};
(mockFsReadFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
const setValueSpy = vi.spyOn(loadedSettings, 'setValue');
migrateDeprecatedSettings(loadedSettings, MOCK_WORKSPACE_DIR);
expect(mockDisableExtension).not.toHaveBeenCalled();
expect(setValueSpy).not.toHaveBeenCalled();
});
});
}); });
+26
View File
@@ -30,6 +30,7 @@ import {
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { customDeepMerge } from '../utils/deepMerge.js'; import { customDeepMerge } from '../utils/deepMerge.js';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
import { disableExtension } from './extension.js';
function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined {
let current: SettingDefinition | undefined = undefined; let current: SettingDefinition | undefined = undefined;
@@ -707,6 +708,31 @@ export function loadSettings(
); );
} }
export function migrateDeprecatedSettings(
loadedSettings: LoadedSettings,
workspaceDir: string = process.cwd(),
): void {
const processScope = (scope: SettingScope) => {
const settings = loadedSettings.forScope(scope).settings;
if (settings.extensions?.disabled) {
console.log(
`Migrating deprecated extensions.disabled settings from ${scope} settings...`,
);
for (const extension of settings.extensions.disabled ?? []) {
disableExtension(extension, scope, workspaceDir);
}
const newExtensionsValue = { ...settings.extensions };
newExtensionsValue.disabled = undefined;
loadedSettings.setValue(scope, 'extensions', newExtensionsValue);
}
};
processScope(SettingScope.User);
processScope(SettingScope.Workspace);
}
export function saveSettings(settingsFile: SettingsFile): void { export function saveSettings(settingsFile: SettingsFile): void {
try { try {
// Ensure the directory exists // Ensure the directory exists
+1
View File
@@ -214,6 +214,7 @@ describe('gemini.tsx main function kitty protocol', () => {
ui: {}, ui: {},
}, },
setValue: vi.fn(), setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
} as never); } as never);
vi.mocked(parseArguments).mockResolvedValue({ vi.mocked(parseArguments).mockResolvedValue({
model: undefined, model: undefined,
+6 -2
View File
@@ -15,7 +15,11 @@ import dns from 'node:dns';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { start_sandbox } from './utils/sandbox.js'; import { start_sandbox } from './utils/sandbox.js';
import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js';
import { loadSettings, SettingScope } from './config/settings.js'; import {
loadSettings,
migrateDeprecatedSettings,
SettingScope,
} from './config/settings.js';
import { themeManager } from './ui/themes/theme-manager.js'; import { themeManager } from './ui/themes/theme-manager.js';
import { getStartupWarnings } from './utils/startupWarnings.js'; import { getStartupWarnings } from './utils/startupWarnings.js';
import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
@@ -196,7 +200,7 @@ export async function startInteractiveUI(
export async function main() { export async function main() {
setupUnhandledRejectionHandler(); setupUnhandledRejectionHandler();
const settings = loadSettings(); const settings = loadSettings();
migrateDeprecatedSettings(settings);
await cleanupCheckpoints(); await cleanupCheckpoints();
const argv = await parseArguments(settings.merged); const argv = await parseArguments(settings.merged);