Improve extensions consent flow, command formatting, github-release behavior (#9121)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
christine betts
2025-09-22 19:50:12 -04:00
committed by GitHub
parent 4cdf9207f3
commit 570b0086b6
4 changed files with 49 additions and 32 deletions
@@ -57,7 +57,7 @@ export async function handleInstall(args: InstallArgs) {
} }
export const installCommand: CommandModule = { export const installCommand: CommandModule = {
command: 'install [source]', command: 'install [<source>] [--path] [--ref] [--auto-update]',
describe: 'Installs an extension from a git repository URL or a local path.', describe: 'Installs an extension from a git repository URL or a local path.',
builder: (yargs) => builder: (yargs) =>
yargs yargs
+7 -12
View File
@@ -789,16 +789,11 @@ describe('extension tests', () => {
).resolves.toBe('my-local-extension'); ).resolves.toBe('my-local-extension');
expect(consoleInfoSpy).toHaveBeenCalledWith( expect(consoleInfoSpy).toHaveBeenCalledWith(
'This extension will run the following MCP servers: ', `Extensions may introduce unexpected behavior.
); Ensure you have investigated the extension source and trust the author.
expect(consoleInfoSpy).toHaveBeenCalledWith( This extension will run the following MCP servers:
' * test-server (local): a local mcp server', * test-server (local): node server.js
); * test-server-2 (remote): https://google.com`,
expect(consoleInfoSpy).toHaveBeenCalledWith(
' * test-server-2 (remote): a remote mcp server',
);
expect(consoleInfoSpy).toHaveBeenCalledWith(
'The extension will append info to your gemini.md context',
); );
}); });
@@ -822,7 +817,7 @@ describe('extension tests', () => {
).resolves.toBe('my-local-extension'); ).resolves.toBe('my-local-extension');
expect(mockQuestion).toHaveBeenCalledWith( expect(mockQuestion).toHaveBeenCalledWith(
expect.stringContaining('Do you want to continue? (y/n)'), expect.stringContaining('Do you want to continue? [Y/n]: '),
expect.any(Function), expect.any(Function),
); );
}); });
@@ -847,7 +842,7 @@ describe('extension tests', () => {
).rejects.toThrow('Installation cancelled by user.'); ).rejects.toThrow('Installation cancelled by user.');
expect(mockQuestion).toHaveBeenCalledWith( expect(mockQuestion).toHaveBeenCalledWith(
expect.stringContaining('Do you want to continue? (y/n)'), expect.stringContaining('Do you want to continue? [Y/n]: '),
expect.any(Function), expect.any(Function),
); );
}); });
+32 -16
View File
@@ -355,7 +355,7 @@ export function annotateActiveExtensions(
/** /**
* Asks users a prompt and awaits for a y/n response * Asks users a prompt and awaits for a y/n response
* @param prompt A yes/no prompt to ask the user * @param prompt A yes/no prompt to ask the user
* @returns Whether or not the user answers 'y' (yes) * @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
*/ */
async function promptForContinuation(prompt: string): Promise<boolean> { async function promptForContinuation(prompt: string): Promise<boolean> {
const readline = await import('node:readline'); const readline = await import('node:readline');
@@ -367,7 +367,7 @@ async function promptForContinuation(prompt: string): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
rl.question(prompt, (answer) => { rl.question(prompt, (answer) => {
rl.close(); rl.close();
resolve(answer.toLowerCase() === 'y'); resolve(['y', ''].includes(answer.trim().toLowerCase()));
}); });
}); });
} }
@@ -407,12 +407,12 @@ export async function installExtension(
) { ) {
tempDir = await ExtensionStorage.createTmpDir(); tempDir = await ExtensionStorage.createTmpDir();
try { try {
const tagName = await downloadFromGitHubRelease( const result = await downloadFromGitHubRelease(
installMetadata, installMetadata,
tempDir, tempDir,
); );
installMetadata.type = 'github-release'; installMetadata.type = result.type;
installMetadata.releaseTag = tagName; installMetadata.releaseTag = result.tagName;
} catch (_error) { } catch (_error) {
await cloneFromGit(installMetadata, tempDir); await cloneFromGit(installMetadata, tempDir);
installMetadata.type = 'git'; installMetadata.type = 'git';
@@ -511,23 +511,39 @@ export async function installExtension(
} }
async function requestConsent(extensionConfig: ExtensionConfig) { async function requestConsent(extensionConfig: ExtensionConfig) {
const output: string[] = [];
const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {}); const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {});
output.push('Extensions may introduce unexpected behavior.');
output.push(
'Ensure you have investigated the extension source and trust the author.',
);
if (mcpServerEntries.length) { if (mcpServerEntries.length) {
console.info('This extension will run the following MCP servers: '); output.push('This extension will run the following MCP servers:');
for (const [key, mcpServer] of mcpServerEntries) { for (const [key, mcpServer] of mcpServerEntries) {
const isLocal = !!mcpServer.command; const isLocal = !!mcpServer.command;
console.info( const source =
` * ${key} (${isLocal ? 'local' : 'remote'}): ${mcpServer.description}`, mcpServer.httpUrl ??
); `${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`;
output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`);
} }
console.info('The extension will append info to your gemini.md context'); }
if (extensionConfig.contextFileName) {
const shouldContinue = await promptForContinuation( output.push(
'Do you want to continue? (y/n): ', `This extension will append info to your gemini.md context using ${extensionConfig.contextFileName}`,
); );
if (!shouldContinue) { }
throw new Error('Installation cancelled by user.'); if (extensionConfig.excludeTools) {
} output.push(
`This extension will exclude the following core tools: ${extensionConfig.excludeTools}`,
);
}
console.info(output.join('\n'));
const shouldContinue = await promptForContinuation(
'Do you want to continue? [Y/n]: ',
);
if (!shouldContinue) {
throw new Error('Installation cancelled by user.');
} }
} }
+9 -3
View File
@@ -220,11 +220,14 @@ export async function checkForExtensionUpdate(
return; return;
} }
} }
export interface GitHubDownloadResult {
tagName: string;
type: 'git' | 'github-release';
}
export async function downloadFromGitHubRelease( export async function downloadFromGitHubRelease(
installMetadata: ExtensionInstallMetadata, installMetadata: ExtensionInstallMetadata,
destination: string, destination: string,
): Promise<string> { ): Promise<GitHubDownloadResult> {
const { source, ref } = installMetadata; const { source, ref } = installMetadata;
const { owner, repo } = parseGitHubRepoForReleases(source); const { owner, repo } = parseGitHubRepoForReleases(source);
@@ -289,7 +292,10 @@ export async function downloadFromGitHubRelease(
} }
await fs.promises.unlink(downloadedAssetPath); await fs.promises.unlink(downloadedAssetPath);
return releaseData.tag_name; return {
tagName: releaseData.tag_name,
type: 'github-release',
};
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`, `Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`,