mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
feat(extension) - Add permission prompt for when user installs a local extension with mcpservers (#8208)
Co-authored-by: Shi Shu <shii@google.com>
This commit is contained in:
@@ -94,6 +94,15 @@ vi.mock('child_process', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockQuestion = vi.hoisted(() => vi.fn());
|
||||||
|
const mockClose = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('node:readline', () => ({
|
||||||
|
createInterface: vi.fn(() => ({
|
||||||
|
question: mockQuestion,
|
||||||
|
close: mockClose,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
||||||
|
|
||||||
describe('loadExtensions', () => {
|
describe('loadExtensions', () => {
|
||||||
@@ -244,6 +253,7 @@ describe('loadExtensions', () => {
|
|||||||
source: sourceExtDir,
|
source: sourceExtDir,
|
||||||
type: 'link',
|
type: 'link',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(extensionName).toEqual('my-linked-extension');
|
expect(extensionName).toEqual('my-linked-extension');
|
||||||
const extensions = loadExtensions();
|
const extensions = loadExtensions();
|
||||||
expect(extensions).toHaveLength(1);
|
expect(extensions).toHaveLength(1);
|
||||||
@@ -434,6 +444,7 @@ describe('installExtension', () => {
|
|||||||
let userExtensionsDir: string;
|
let userExtensionsDir: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mockQuestion.mockImplementation((_query, callback) => callback('y'));
|
||||||
tempHomeDir = fs.mkdtempSync(
|
tempHomeDir = fs.mkdtempSync(
|
||||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||||
);
|
);
|
||||||
@@ -448,6 +459,8 @@ describe('installExtension', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
mockQuestion.mockClear();
|
||||||
|
mockClose.mockClear();
|
||||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||||
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
@@ -565,6 +578,56 @@ describe('installExtension', () => {
|
|||||||
const logger = ClearcutLogger.getInstance({} as Config);
|
const logger = ClearcutLogger.getInstance({} as Config);
|
||||||
expect(logger?.logExtensionInstallEvent).toHaveBeenCalled();
|
expect(logger?.logExtensionInstallEvent).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should continue installation if user accepts prompt for local extension with mcp servers', async () => {
|
||||||
|
const sourceExtDir = createExtension({
|
||||||
|
extensionsDir: tempHomeDir,
|
||||||
|
name: 'my-local-extension',
|
||||||
|
version: '1.0.0',
|
||||||
|
mcpServers: {
|
||||||
|
'test-server': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mockQuestion.mockImplementation((_query, callback) => callback('y'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
installExtension({ source: sourceExtDir, type: 'local' }),
|
||||||
|
).resolves.toBe('my-local-extension');
|
||||||
|
|
||||||
|
expect(mockQuestion).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Do you want to continue? (y/n)'),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel installation if user declines prompt for local extension with mcp servers', async () => {
|
||||||
|
const sourceExtDir = createExtension({
|
||||||
|
extensionsDir: tempHomeDir,
|
||||||
|
name: 'my-local-extension',
|
||||||
|
version: '1.0.0',
|
||||||
|
mcpServers: {
|
||||||
|
'test-server': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mockQuestion.mockImplementation((_query, callback) => callback('n'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
installExtension({ source: sourceExtDir, type: 'local' }),
|
||||||
|
).rejects.toThrow('Installation cancelled by user.');
|
||||||
|
|
||||||
|
expect(mockQuestion).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Do you want to continue? (y/n)'),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('uninstallExtension', () => {
|
describe('uninstallExtension', () => {
|
||||||
@@ -807,6 +870,7 @@ describe('updateExtension', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
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 () => {
|
||||||
|
|||||||
@@ -372,6 +372,26 @@ async function cloneFromGit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks users a prompt and awaits for a y/n response
|
||||||
|
* @param prompt A yes/no prompt to ask the user
|
||||||
|
* @returns Whether or not the user answers 'y' (yes)
|
||||||
|
*/
|
||||||
|
async function promptForContinuation(prompt: string): Promise<boolean> {
|
||||||
|
const readline = await import('node:readline');
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(prompt, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(answer.toLowerCase() === 'y');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function installExtension(
|
export async function installExtension(
|
||||||
installMetadata: ExtensionInstallMetadata,
|
installMetadata: ExtensionInstallMetadata,
|
||||||
cwd: string = process.cwd(),
|
cwd: string = process.cwd(),
|
||||||
@@ -443,6 +463,26 @@ export async function installExtension(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mcpServerEntries = Object.entries(
|
||||||
|
newExtensionConfig.mcpServers || {},
|
||||||
|
);
|
||||||
|
if (mcpServerEntries.length) {
|
||||||
|
console.info('This extension will run the following MCP servers: ');
|
||||||
|
for (const [key, value] of mcpServerEntries) {
|
||||||
|
console.info(` * ${key}: ${value.description}`);
|
||||||
|
}
|
||||||
|
console.info(
|
||||||
|
'The extension will append info to your gemini.md context',
|
||||||
|
);
|
||||||
|
|
||||||
|
const shouldContinue = await promptForContinuation(
|
||||||
|
'Do you want to continue? (y/n): ',
|
||||||
|
);
|
||||||
|
if (!shouldContinue) {
|
||||||
|
throw new Error('Installation cancelled by user.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||||
|
|
||||||
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
|
if (installMetadata.type === 'local' || installMetadata.type === 'git') {
|
||||||
@@ -599,7 +639,6 @@ export async function updateExtension(
|
|||||||
await copyExtension(extension.path, tempDir);
|
await copyExtension(extension.path, tempDir);
|
||||||
await uninstallExtension(extension.config.name, cwd);
|
await uninstallExtension(extension.config.name, cwd);
|
||||||
await installExtension(extension.installMetadata, cwd);
|
await installExtension(extension.installMetadata, cwd);
|
||||||
|
|
||||||
const updatedExtensionStorage = new ExtensionStorage(extension.config.name);
|
const updatedExtensionStorage = new ExtensionStorage(extension.config.name);
|
||||||
const updatedExtension = loadExtension(
|
const updatedExtension = loadExtension(
|
||||||
updatedExtensionStorage.getExtensionDir(),
|
updatedExtensionStorage.getExtensionDir(),
|
||||||
|
|||||||
Reference in New Issue
Block a user