mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-30 06:54:15 -07:00
Add experimental in-CLI extension install and uninstall subcommands (#15178)
Co-authored-by: Christine Betts <chrstn@google.com>
This commit is contained in:
@@ -4,11 +4,23 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* 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 { handleInstall, installCommand } from './install.js';
|
||||||
import yargs from 'yargs';
|
import yargs from 'yargs';
|
||||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
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 { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||||
import type * as fs from 'node:fs/promises';
|
import type * as fs from 'node:fs/promises';
|
||||||
import type { Stats } from 'node:fs';
|
import type { Stats } from 'node:fs';
|
||||||
@@ -20,12 +32,15 @@ const mockRequestConsentNonInteractive: Mock<
|
|||||||
typeof requestConsentNonInteractive
|
typeof requestConsentNonInteractive
|
||||||
> = vi.hoisted(() => vi.fn());
|
> = vi.hoisted(() => vi.fn());
|
||||||
const mockStat: Mock<typeof fs.stat> = 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', () => ({
|
vi.mock('../../config/extensions/consent.js', () => ({
|
||||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
|
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
await importOriginal<typeof import('../../config/extension-manager.js')>();
|
await importOriginal<typeof import('../../config/extension-manager.js')>();
|
||||||
return {
|
return {
|
||||||
@@ -34,6 +49,7 @@ vi.mock('../../config/extension-manager.ts', async (importOriginal) => {
|
|||||||
installOrUpdateExtension: mockInstallOrUpdateExtension,
|
installOrUpdateExtension: mockInstallOrUpdateExtension,
|
||||||
loadExtensions: vi.fn(),
|
loadExtensions: vi.fn(),
|
||||||
})),
|
})),
|
||||||
|
inferInstallMetadata: mockInferInstallMetadata,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,12 +88,31 @@ describe('handleInstall', () => {
|
|||||||
processSpy = vi
|
processSpy = vi
|
||||||
.spyOn(process, 'exit')
|
.spyOn(process, 'exit')
|
||||||
.mockImplementation(() => undefined as never);
|
.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(() => {
|
afterEach(() => {
|
||||||
mockInstallOrUpdateExtension.mockClear();
|
mockInstallOrUpdateExtension.mockClear();
|
||||||
mockRequestConsentNonInteractive.mockClear();
|
mockRequestConsentNonInteractive.mockClear();
|
||||||
mockStat.mockClear();
|
mockStat.mockClear();
|
||||||
|
mockInferInstallMetadata.mockClear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,7 +159,9 @@ describe('handleInstall', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('throws an error from an unknown source', async () => {
|
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({
|
await handleInstall({
|
||||||
source: 'test://google.com',
|
source: 'test://google.com',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,17 +5,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CommandModule } from 'yargs';
|
import type { CommandModule } from 'yargs';
|
||||||
import {
|
import { debugLogger } from '@google/gemini-cli-core';
|
||||||
debugLogger,
|
|
||||||
type ExtensionInstallMetadata,
|
|
||||||
} from '@google/gemini-cli-core';
|
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
import { stat } from 'node:fs/promises';
|
|
||||||
import {
|
import {
|
||||||
INSTALL_WARNING_MESSAGE,
|
INSTALL_WARNING_MESSAGE,
|
||||||
requestConsentNonInteractive,
|
requestConsentNonInteractive,
|
||||||
} from '../../config/extensions/consent.js';
|
} 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 { loadSettings } from '../../config/settings.js';
|
||||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||||
import { exitCli } from '../utils.js';
|
import { exitCli } from '../utils.js';
|
||||||
@@ -30,37 +29,12 @@ interface InstallArgs {
|
|||||||
|
|
||||||
export async function handleInstall(args: InstallArgs) {
|
export async function handleInstall(args: InstallArgs) {
|
||||||
try {
|
try {
|
||||||
let installMetadata: ExtensionInstallMetadata;
|
|
||||||
const { source } = args;
|
const { source } = args;
|
||||||
if (
|
const installMetadata = await inferInstallMetadata(source, {
|
||||||
source.startsWith('http://') ||
|
ref: args.ref,
|
||||||
source.startsWith('https://') ||
|
autoUpdate: args.autoUpdate,
|
||||||
source.startsWith('git@') ||
|
allowPreRelease: args.allowPreRelease,
|
||||||
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 requestConsent = args.consent
|
const requestConsent = args.consent
|
||||||
? () => Promise.resolve(true)
|
? () => Promise.resolve(true)
|
||||||
|
|||||||
@@ -5,7 +5,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'node:fs';
|
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 { handleValidate, validateCommand } from './validate.js';
|
||||||
import yargs from 'yargs';
|
import yargs from 'yargs';
|
||||||
import { createExtension } from '../../test-utils/createExtension.js';
|
import { createExtension } from '../../test-utils/createExtension.js';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||||
import { type Settings, SettingScope } from './settings.js';
|
import { type Settings, SettingScope } from './settings.js';
|
||||||
@@ -198,7 +199,9 @@ export class ExtensionManager extends ExtensionLoader {
|
|||||||
installMetadata.type === 'git') ||
|
installMetadata.type === 'git') ||
|
||||||
// Otherwise ask the user if they would like to try a git clone.
|
// Otherwise ask the user if they would like to try a git clone.
|
||||||
(await this.requestConsent(
|
(await this.requestConsent(
|
||||||
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.\n\nWould you like to attempt to install via "git clone" instead?`,
|
`Error downloading github release for ${installMetadata.source} with the following error: ${result.errorMessage}.
|
||||||
|
|
||||||
|
Would you like to attempt to install via "git clone" instead?`,
|
||||||
))
|
))
|
||||||
) {
|
) {
|
||||||
await cloneFromGit(installMetadata, tempDir);
|
await cloneFromGit(installMetadata, tempDir);
|
||||||
@@ -797,7 +800,46 @@ function validateName(name: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExtensionId(
|
export async function inferInstallMetadata(
|
||||||
|
source: string,
|
||||||
|
args: {
|
||||||
|
ref?: string;
|
||||||
|
autoUpdate?: boolean;
|
||||||
|
allowPreRelease?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Promise<ExtensionInstallMetadata> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (args.ref || args.autoUpdate) {
|
||||||
|
throw new Error(
|
||||||
|
'--ref and --auto-update are not applicable for local extensions.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await stat(source);
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
type: 'local',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new Error('Install source not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExtensionId(
|
||||||
config: ExtensionConfig,
|
config: ExtensionConfig,
|
||||||
installMetadata?: ExtensionInstallMetadata,
|
installMetadata?: ExtensionInstallMetadata,
|
||||||
): string {
|
): string {
|
||||||
|
|||||||
@@ -26,9 +26,21 @@ import {
|
|||||||
type MockedFunction,
|
type MockedFunction,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { type ExtensionUpdateAction } from '../state/extensions.js';
|
import { type ExtensionUpdateAction } from '../state/extensions.js';
|
||||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
import {
|
||||||
|
ExtensionManager,
|
||||||
|
inferInstallMetadata,
|
||||||
|
} from '../../config/extension-manager.js';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
|
|
||||||
|
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('../../config/extension-manager.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
inferInstallMetadata: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
import open from 'open';
|
import open from 'open';
|
||||||
|
|
||||||
vi.mock('open', () => ({
|
vi.mock('open', () => ({
|
||||||
@@ -42,6 +54,8 @@ vi.mock('../../config/extensions/update.js', () => ({
|
|||||||
|
|
||||||
const mockDisableExtension = vi.fn();
|
const mockDisableExtension = vi.fn();
|
||||||
const mockEnableExtension = vi.fn();
|
const mockEnableExtension = vi.fn();
|
||||||
|
const mockInstallExtension = vi.fn();
|
||||||
|
const mockUninstallExtension = vi.fn();
|
||||||
const mockGetExtensions = vi.fn();
|
const mockGetExtensions = vi.fn();
|
||||||
|
|
||||||
const inactiveExt: GeminiCLIExtension = {
|
const inactiveExt: GeminiCLIExtension = {
|
||||||
@@ -102,6 +116,8 @@ describe('extensionsCommand', () => {
|
|||||||
Object.assign(actual, {
|
Object.assign(actual, {
|
||||||
enableExtension: mockEnableExtension,
|
enableExtension: mockEnableExtension,
|
||||||
disableExtension: mockDisableExtension,
|
disableExtension: mockDisableExtension,
|
||||||
|
installOrUpdateExtension: mockInstallExtension,
|
||||||
|
uninstallExtension: mockUninstallExtension,
|
||||||
getExtensions: mockGetExtensions,
|
getExtensions: mockGetExtensions,
|
||||||
});
|
});
|
||||||
return actual;
|
return actual;
|
||||||
@@ -477,29 +493,189 @@ describe('extensionsCommand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('when enableExtensionReloading is true', () => {
|
describe('when enableExtensionReloading is true', () => {
|
||||||
it('should include enable and disable subcommands', () => {
|
it('should include enable, disable, install, and uninstall subcommands', () => {
|
||||||
const command = extensionsCommand(true);
|
const command = extensionsCommand(true);
|
||||||
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
||||||
expect(subCommandNames).toContain('enable');
|
expect(subCommandNames).toContain('enable');
|
||||||
expect(subCommandNames).toContain('disable');
|
expect(subCommandNames).toContain('disable');
|
||||||
|
expect(subCommandNames).toContain('install');
|
||||||
|
expect(subCommandNames).toContain('uninstall');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when enableExtensionReloading is false', () => {
|
describe('when enableExtensionReloading is false', () => {
|
||||||
it('should not include enable and disable subcommands', () => {
|
it('should not include enable, disable, install, and uninstall subcommands', () => {
|
||||||
const command = extensionsCommand(false);
|
const command = extensionsCommand(false);
|
||||||
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
||||||
expect(subCommandNames).not.toContain('enable');
|
expect(subCommandNames).not.toContain('enable');
|
||||||
expect(subCommandNames).not.toContain('disable');
|
expect(subCommandNames).not.toContain('disable');
|
||||||
|
expect(subCommandNames).not.toContain('install');
|
||||||
|
expect(subCommandNames).not.toContain('uninstall');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when enableExtensionReloading is not provided', () => {
|
describe('when enableExtensionReloading is not provided', () => {
|
||||||
it('should not include enable and disable subcommands by default', () => {
|
it('should not include enable, disable, install, and uninstall subcommands by default', () => {
|
||||||
const command = extensionsCommand();
|
const command = extensionsCommand();
|
||||||
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
||||||
expect(subCommandNames).not.toContain('enable');
|
expect(subCommandNames).not.toContain('enable');
|
||||||
expect(subCommandNames).not.toContain('disable');
|
expect(subCommandNames).not.toContain('disable');
|
||||||
|
expect(subCommandNames).not.toContain('install');
|
||||||
|
expect(subCommandNames).not.toContain('uninstall');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('install', () => {
|
||||||
|
let installAction: SlashCommand['action'];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
installAction = extensionsCommand(true).subCommands?.find(
|
||||||
|
(cmd) => cmd.name === 'install',
|
||||||
|
)?.action;
|
||||||
|
|
||||||
|
expect(installAction).not.toBeNull();
|
||||||
|
|
||||||
|
mockContext.invocation!.name = 'install';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show usage if no extension name is provided', async () => {
|
||||||
|
await installAction!(mockContext, '');
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: 'Usage: /extensions install <source>',
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(mockInstallExtension).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call installExtension and show success message', async () => {
|
||||||
|
const packageName = 'test-extension-package';
|
||||||
|
vi.mocked(inferInstallMetadata).mockResolvedValue({
|
||||||
|
source: packageName,
|
||||||
|
type: 'git',
|
||||||
|
});
|
||||||
|
mockInstallExtension.mockResolvedValue({ name: packageName });
|
||||||
|
await installAction!(mockContext, packageName);
|
||||||
|
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
|
||||||
|
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||||
|
source: packageName,
|
||||||
|
type: 'git',
|
||||||
|
});
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Installing extension from "${packageName}"...`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Extension "${packageName}" installed successfully.`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error message on installation failure', async () => {
|
||||||
|
const packageName = 'failed-extension';
|
||||||
|
const errorMessage = 'install failed';
|
||||||
|
vi.mocked(inferInstallMetadata).mockResolvedValue({
|
||||||
|
source: packageName,
|
||||||
|
type: 'git',
|
||||||
|
});
|
||||||
|
mockInstallExtension.mockRejectedValue(new Error(errorMessage));
|
||||||
|
|
||||||
|
await installAction!(mockContext, packageName);
|
||||||
|
expect(inferInstallMetadata).toHaveBeenCalledWith(packageName);
|
||||||
|
expect(mockInstallExtension).toHaveBeenCalledWith({
|
||||||
|
source: packageName,
|
||||||
|
type: 'git',
|
||||||
|
});
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Failed to install extension from "${packageName}": ${errorMessage}`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error message for invalid source', async () => {
|
||||||
|
const invalidSource = 'a;b';
|
||||||
|
await installAction!(mockContext, invalidSource);
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Invalid source: ${invalidSource}`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(mockInstallExtension).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uninstall', () => {
|
||||||
|
let uninstallAction: SlashCommand['action'];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
uninstallAction = extensionsCommand(true).subCommands?.find(
|
||||||
|
(cmd) => cmd.name === 'uninstall',
|
||||||
|
)?.action;
|
||||||
|
|
||||||
|
expect(uninstallAction).not.toBeNull();
|
||||||
|
|
||||||
|
mockContext.invocation!.name = 'uninstall';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show usage if no extension name is provided', async () => {
|
||||||
|
await uninstallAction!(mockContext, '');
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: 'Usage: /extensions uninstall <extension-name>',
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(mockUninstallExtension).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call uninstallExtension and show success message', async () => {
|
||||||
|
const extensionName = 'test-extension';
|
||||||
|
await uninstallAction!(mockContext, extensionName);
|
||||||
|
expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false);
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Uninstalling extension "${extensionName}"...`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Extension "${extensionName}" uninstalled successfully.`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error message on uninstallation failure', async () => {
|
||||||
|
const extensionName = 'failed-extension';
|
||||||
|
const errorMessage = 'uninstall failed';
|
||||||
|
mockUninstallExtension.mockRejectedValue(new Error(errorMessage));
|
||||||
|
|
||||||
|
await uninstallAction!(mockContext, extensionName);
|
||||||
|
expect(mockUninstallExtension).toHaveBeenCalledWith(extensionName, false);
|
||||||
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Failed to uninstall extension "${extensionName}": ${errorMessage}`,
|
||||||
|
},
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import {
|
|||||||
} from './types.js';
|
} from './types.js';
|
||||||
import open from 'open';
|
import open from 'open';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
import {
|
||||||
|
ExtensionManager,
|
||||||
|
inferInstallMetadata,
|
||||||
|
} from '../../config/extension-manager.js';
|
||||||
import { SettingScope } from '../../config/settings.js';
|
import { SettingScope } from '../../config/settings.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
|
|
||||||
@@ -429,6 +432,135 @@ async function enableAction(context: CommandContext, args: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function installAction(context: CommandContext, args: string) {
|
||||||
|
const extensionLoader = context.services.config?.getExtensionLoader();
|
||||||
|
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||||
|
debugLogger.error(
|
||||||
|
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = args.trim();
|
||||||
|
if (!source) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Usage: /extensions install <source>`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the source is either a valid URL or a valid file path.
|
||||||
|
let isValid = false;
|
||||||
|
try {
|
||||||
|
// Check if it's a valid URL.
|
||||||
|
new URL(source);
|
||||||
|
isValid = true;
|
||||||
|
} catch {
|
||||||
|
// If not a URL, check for characters that are disallowed in file paths
|
||||||
|
// and could be used for command injection.
|
||||||
|
if (!/[;&|`'"]/.test(source)) {
|
||||||
|
isValid = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Invalid source: ${source}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Installing extension from "${source}"...`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const installMetadata = await inferInstallMetadata(source);
|
||||||
|
const extension =
|
||||||
|
await extensionLoader.installOrUpdateExtension(installMetadata);
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Extension "${extension.name}" installed successfully.`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Failed to install extension from "${source}": ${getErrorMessage(
|
||||||
|
error,
|
||||||
|
)}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uninstallAction(context: CommandContext, args: string) {
|
||||||
|
const extensionLoader = context.services.config?.getExtensionLoader();
|
||||||
|
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||||
|
debugLogger.error(
|
||||||
|
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = args.trim();
|
||||||
|
if (!name) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Usage: /extensions uninstall <extension-name>`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Uninstalling extension "${name}"...`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await extensionLoader.uninstallExtension(name, false);
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: `Extension "${name}" uninstalled successfully.`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
context.ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: `Failed to uninstall extension "${name}": ${getErrorMessage(
|
||||||
|
error,
|
||||||
|
)}`,
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exported for testing.
|
* Exported for testing.
|
||||||
*/
|
*/
|
||||||
@@ -505,6 +637,23 @@ const enableCommand: SlashCommand = {
|
|||||||
completion: completeExtensionsAndScopes,
|
completion: completeExtensionsAndScopes,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const installCommand: SlashCommand = {
|
||||||
|
name: 'install',
|
||||||
|
description: 'Install an extension from a git repo or local path',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
autoExecute: false,
|
||||||
|
action: installAction,
|
||||||
|
};
|
||||||
|
|
||||||
|
const uninstallCommand: SlashCommand = {
|
||||||
|
name: 'uninstall',
|
||||||
|
description: 'Uninstall an extension',
|
||||||
|
kind: CommandKind.BUILT_IN,
|
||||||
|
autoExecute: false,
|
||||||
|
action: uninstallAction,
|
||||||
|
completion: completeExtensions,
|
||||||
|
};
|
||||||
|
|
||||||
const exploreExtensionsCommand: SlashCommand = {
|
const exploreExtensionsCommand: SlashCommand = {
|
||||||
name: 'explore',
|
name: 'explore',
|
||||||
description: 'Open extensions page in your browser',
|
description: 'Open extensions page in your browser',
|
||||||
@@ -526,7 +675,7 @@ export function extensionsCommand(
|
|||||||
enableExtensionReloading?: boolean,
|
enableExtensionReloading?: boolean,
|
||||||
): SlashCommand {
|
): SlashCommand {
|
||||||
const conditionalCommands = enableExtensionReloading
|
const conditionalCommands = enableExtensionReloading
|
||||||
? [disableCommand, enableCommand]
|
? [disableCommand, enableCommand, installCommand, uninstallCommand]
|
||||||
: [];
|
: [];
|
||||||
return {
|
return {
|
||||||
name: 'extensions',
|
name: 'extensions',
|
||||||
|
|||||||
Reference in New Issue
Block a user