Add experimental in-CLI extension install and uninstall subcommands (#15178)

Co-authored-by: Christine Betts <chrstn@google.com>
This commit is contained in:
christine betts
2025-12-23 13:48:27 -06:00
committed by GitHub
parent 5f28614760
commit 563d81e08e
6 changed files with 435 additions and 49 deletions

View File

@@ -4,11 +4,23 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, type MockInstance, type Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
type Mock,
} from 'vitest';
import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs';
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import type { ExtensionManager } from '../../config/extension-manager.js';
import type {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import type { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import type * as fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
@@ -20,12 +32,15 @@ const mockRequestConsentNonInteractive: Mock<
typeof requestConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
() => vi.fn(),
);
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
}));
vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
@@ -34,6 +49,7 @@ vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
installOrUpdateExtension: mockInstallOrUpdateExtension,
loadExtensions: vi.fn(),
})),
inferInstallMetadata: mockInferInstallMetadata,
};
});
@@ -72,12 +88,31 @@ describe('handleInstall', () => {
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockInferInstallMetadata.mockImplementation(async (source, args) => {
if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
) {
return {
source,
type: 'git',
ref: args?.ref,
autoUpdate: args?.autoUpdate,
allowPreRelease: args?.allowPreRelease,
};
}
return { source, type: 'local' };
});
});
afterEach(() => {
mockInstallOrUpdateExtension.mockClear();
mockRequestConsentNonInteractive.mockClear();
mockStat.mockClear();
mockInferInstallMetadata.mockClear();
vi.clearAllMocks();
});
@@ -124,7 +159,9 @@ describe('handleInstall', () => {
});
it('throws an error from an unknown source', async () => {
mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'));
mockInferInstallMetadata.mockRejectedValue(
new Error('Install source not found.'),
);
await handleInstall({
source: 'test://google.com',
});

View File

@@ -5,17 +5,16 @@
*/
import type { CommandModule } from 'yargs';
import {
debugLogger,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import { debugLogger } from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { stat } from 'node:fs/promises';
import {
INSTALL_WARNING_MESSAGE,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
@@ -30,37 +29,12 @@ interface InstallArgs {
export async function handleInstall(args: InstallArgs) {
try {
let installMetadata: ExtensionInstallMetadata;
const { source } = args;
if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
) {
installMetadata = {
source,
type: 'git',
ref: args.ref,
autoUpdate: args.autoUpdate,
allowPreRelease: args.allowPreRelease,
};
} else {
if (args.ref || args.autoUpdate) {
throw new Error(
'--ref and --auto-update are not applicable for local extensions.',
);
}
try {
await stat(source);
installMetadata = {
source,
type: 'local',
};
} catch {
throw new Error('Install source not found.');
}
}
const installMetadata = await inferInstallMetadata(source, {
ref: args.ref,
autoUpdate: args.autoUpdate,
allowPreRelease: args.allowPreRelease,
});
const requestConsent = args.consent
? () => Promise.resolve(true)

View File

@@ -5,7 +5,15 @@
*/
import * as fs from 'node:fs';
import { describe, it, expect, vi, type MockInstance } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import { handleValidate, validateCommand } from './validate.js';
import yargs from 'yargs';
import { createExtension } from '../../test-utils/createExtension.js';