mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
Update extension enablement logic (#8544)
This commit is contained in:
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user