mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-17 07:13:45 +01:00
chat: add support for agent plugin sources (#299081)
* chat: add support for agent plugin sources - Adds support for agent plugins to reference sources as specified in PLUGIN_SOURCES.md, enabling installation from GitHub, npm, pip, and other package registries - Integrates source parsing and validation into the plugin installation service and repository service - Adds comprehensive test coverage for plugin source handling and installation from various sources - Creates PLUGIN_SOURCES.md documentation describing how to specify plugin source configurations (Commit message generated by Copilot) * comments * windows fixes and fault handling * fix tests
This commit is contained in:
@@ -1040,19 +1040,29 @@ export class CommandCenter {
|
||||
}
|
||||
|
||||
@command('_git.cloneRepository')
|
||||
async cloneRepository(url: string, parentPath: string): Promise<void> {
|
||||
async cloneRepository(url: string, localPath: string, ref?: string): Promise<void> {
|
||||
const opts = {
|
||||
location: ProgressLocation.Notification,
|
||||
title: l10n.t('Cloning git repository "{0}"...', url),
|
||||
cancellable: true
|
||||
};
|
||||
|
||||
const parentPath = path.dirname(localPath);
|
||||
const targetName = path.basename(localPath);
|
||||
|
||||
await window.withProgress(
|
||||
opts,
|
||||
(progress, token) => this.model.git.clone(url, { parentPath, progress }, token)
|
||||
(progress, token) => this.model.git.clone(url, { parentPath, targetName, progress, ref }, token)
|
||||
);
|
||||
}
|
||||
|
||||
@command('_git.checkout')
|
||||
async checkoutRepository(repositoryPath: string, treeish: string, detached?: boolean): Promise<void> {
|
||||
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);
|
||||
const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger);
|
||||
await repo.checkout(treeish, [], detached ? { detached: true } : {});
|
||||
}
|
||||
|
||||
@command('_git.pull')
|
||||
async pullRepository(repositoryPath: string): Promise<void> {
|
||||
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);
|
||||
|
||||
@@ -378,6 +378,7 @@ const STASH_FORMAT = '%H%n%P%n%gd%n%gs%n%at%n%ct';
|
||||
|
||||
export interface ICloneOptions {
|
||||
readonly parentPath: string;
|
||||
readonly targetName?: string;
|
||||
readonly progress: Progress<{ increment: number }>;
|
||||
readonly recursive?: boolean;
|
||||
readonly ref?: string;
|
||||
@@ -433,14 +434,16 @@ export class Git {
|
||||
}
|
||||
|
||||
async clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise<string> {
|
||||
const baseFolderName = decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository';
|
||||
const baseFolderName = options.targetName || decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository';
|
||||
let folderName = baseFolderName;
|
||||
let folderPath = path.join(options.parentPath, folderName);
|
||||
let count = 1;
|
||||
|
||||
while (count < 20 && await new Promise(c => exists(folderPath, c))) {
|
||||
folderName = `${baseFolderName}-${count++}`;
|
||||
folderPath = path.join(options.parentPath, folderName);
|
||||
if (!options.targetName) {
|
||||
while (count < 20 && await new Promise(c => exists(folderPath, c))) {
|
||||
folderName = `${baseFolderName}-${count++}`;
|
||||
folderPath = path.join(options.parentPath, folderName);
|
||||
}
|
||||
}
|
||||
|
||||
await mkdirp(options.parentPath);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../base/
|
||||
import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';
|
||||
import { Schemas, matchesScheme } from '../../../../../base/common/network.js';
|
||||
import { autorun } from '../../../../../base/common/observable.js';
|
||||
import { basename, dirname, joinPath } from '../../../../../base/common/resources.js';
|
||||
import { dirname, joinPath } from '../../../../../base/common/resources.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { generateUuid } from '../../../../../base/common/uuid.js';
|
||||
import { TokenizationRegistry } from '../../../../../editor/common/languages.js';
|
||||
@@ -202,6 +202,7 @@ export class AgentPluginEditor extends EditorPane {
|
||||
description: item.description,
|
||||
version: '',
|
||||
source: item.source,
|
||||
sourceDescriptor: item.sourceDescriptor,
|
||||
marketplace: item.marketplace,
|
||||
marketplaceReference: item.marketplaceReference,
|
||||
marketplaceType: item.marketplaceType,
|
||||
@@ -222,6 +223,7 @@ export class AgentPluginEditor extends EditorPane {
|
||||
name: item.name,
|
||||
description: mp.description,
|
||||
source: mp.source,
|
||||
sourceDescriptor: mp.sourceDescriptor,
|
||||
marketplace: mp.marketplace,
|
||||
marketplaceReference: mp.marketplaceReference,
|
||||
marketplaceType: mp.marketplaceType,
|
||||
@@ -267,7 +269,7 @@ export class AgentPluginEditor extends EditorPane {
|
||||
}
|
||||
|
||||
private installedPluginToItem(plugin: IAgentPlugin): IInstalledPluginItem {
|
||||
const name = basename(plugin.uri);
|
||||
const name = plugin.label;
|
||||
const description = plugin.fromMarketplace?.description ?? this.labelService.getUriLabel(dirname(plugin.uri), { relative: true });
|
||||
const marketplace = plugin.fromMarketplace?.marketplace;
|
||||
return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin };
|
||||
@@ -517,6 +519,7 @@ class InstallPluginEditorAction extends Action {
|
||||
description: this.item.description,
|
||||
version: '',
|
||||
source: this.item.source,
|
||||
sourceDescriptor: this.item.sourceDescriptor,
|
||||
marketplace: this.item.marketplace,
|
||||
marketplaceReference: this.item.marketplaceReference,
|
||||
marketplaceType: this.item.marketplaceType,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import type { IAgentPlugin } from '../../common/plugins/agentPluginService.js';
|
||||
import type { IMarketplaceReference, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js';
|
||||
import type { IMarketplaceReference, IPluginSourceDescriptor, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js';
|
||||
|
||||
export const enum AgentPluginItemKind {
|
||||
Installed = 'installed',
|
||||
@@ -25,6 +25,7 @@ export interface IMarketplacePluginItem {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly source: string;
|
||||
readonly sourceDescriptor: IPluginSourceDescriptor;
|
||||
readonly marketplace: string;
|
||||
readonly marketplaceReference: IMarketplaceReference;
|
||||
readonly marketplaceType: MarketplaceType;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { IProgressService, ProgressLocation } from '../../../../platform/progres
|
||||
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
|
||||
import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js';
|
||||
import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js';
|
||||
import { IMarketplacePlugin, IMarketplaceReference, MarketplaceReferenceKind, MarketplaceType } from '../common/plugins/pluginMarketplaceService.js';
|
||||
import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js';
|
||||
|
||||
const MARKETPLACE_INDEX_STORAGE_KEY = 'chat.plugins.marketplaces.index.v1';
|
||||
|
||||
@@ -176,7 +176,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
|
||||
this._storageService.store(MARKETPLACE_INDEX_STORAGE_KEY, JSON.stringify(serialized), StorageScope.APPLICATION, StorageTarget.MACHINE);
|
||||
}
|
||||
|
||||
private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string): Promise<void> {
|
||||
private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string, ref?: string): Promise<void> {
|
||||
try {
|
||||
await this._progressService.withProgress(
|
||||
{
|
||||
@@ -186,7 +186,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
|
||||
},
|
||||
async () => {
|
||||
await this._fileService.createFolder(dirname(repoDir));
|
||||
await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, dirname(repoDir).fsPath);
|
||||
await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, repoDir.fsPath, ref);
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -212,4 +212,178 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi
|
||||
}
|
||||
return pluginDir;
|
||||
}
|
||||
|
||||
getPluginSourceInstallUri(sourceDescriptor: IPluginSourceDescriptor): URI {
|
||||
switch (sourceDescriptor.kind) {
|
||||
case PluginSourceKind.RelativePath:
|
||||
throw new Error('Use getPluginInstallUri() for relative-path sources');
|
||||
case PluginSourceKind.GitHub: {
|
||||
const [owner, repo] = sourceDescriptor.repo.split('/');
|
||||
return joinPath(this._cacheRoot, 'github.com', owner, repo, ...this._getSourceRevisionCacheSuffix(sourceDescriptor));
|
||||
}
|
||||
case PluginSourceKind.GitUrl: {
|
||||
const segments = this._gitUrlCacheSegments(sourceDescriptor.url, sourceDescriptor.ref, sourceDescriptor.sha);
|
||||
return joinPath(this._cacheRoot, ...segments);
|
||||
}
|
||||
case PluginSourceKind.Npm:
|
||||
return joinPath(this._cacheRoot, 'npm', sanitizePackageName(sourceDescriptor.package), 'node_modules', sourceDescriptor.package);
|
||||
case PluginSourceKind.Pip:
|
||||
return joinPath(this._cacheRoot, 'pip', sanitizePackageName(sourceDescriptor.package));
|
||||
}
|
||||
}
|
||||
|
||||
async ensurePluginSource(plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise<URI> {
|
||||
const descriptor = plugin.sourceDescriptor;
|
||||
switch (descriptor.kind) {
|
||||
case PluginSourceKind.RelativePath:
|
||||
return this.ensureRepository(plugin.marketplaceReference, options);
|
||||
case PluginSourceKind.GitHub: {
|
||||
const cloneUrl = `https://github.com/${descriptor.repo}.git`;
|
||||
const repoDir = this.getPluginSourceInstallUri(descriptor);
|
||||
const repoExists = await this._fileService.exists(repoDir);
|
||||
if (repoExists) {
|
||||
await this._checkoutPluginSourceRevision(repoDir, descriptor, options?.failureLabel ?? descriptor.repo);
|
||||
return repoDir;
|
||||
}
|
||||
const progressTitle = options?.progressTitle ?? localize('cloningPluginSource', "Cloning plugin source '{0}'...", descriptor.repo);
|
||||
const failureLabel = options?.failureLabel ?? descriptor.repo;
|
||||
await this._cloneRepository(repoDir, cloneUrl, progressTitle, failureLabel, descriptor.ref);
|
||||
await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel);
|
||||
return repoDir;
|
||||
}
|
||||
case PluginSourceKind.GitUrl: {
|
||||
const repoDir = this.getPluginSourceInstallUri(descriptor);
|
||||
const repoExists = await this._fileService.exists(repoDir);
|
||||
if (repoExists) {
|
||||
await this._checkoutPluginSourceRevision(repoDir, descriptor, options?.failureLabel ?? descriptor.url);
|
||||
return repoDir;
|
||||
}
|
||||
const progressTitle = options?.progressTitle ?? localize('cloningPluginSourceUrl', "Cloning plugin source '{0}'...", descriptor.url);
|
||||
const failureLabel = options?.failureLabel ?? descriptor.url;
|
||||
await this._cloneRepository(repoDir, descriptor.url, progressTitle, failureLabel, descriptor.ref);
|
||||
await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel);
|
||||
return repoDir;
|
||||
}
|
||||
case PluginSourceKind.Npm: {
|
||||
// npm/pip install directories are managed by the install service.
|
||||
// Return the expected install URI without performing installation.
|
||||
return joinPath(this._cacheRoot, 'npm', sanitizePackageName(descriptor.package));
|
||||
}
|
||||
case PluginSourceKind.Pip: {
|
||||
return joinPath(this._cacheRoot, 'pip', sanitizePackageName(descriptor.package));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updatePluginSource(plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise<void> {
|
||||
const descriptor = plugin.sourceDescriptor;
|
||||
if (descriptor.kind !== PluginSourceKind.GitHub && descriptor.kind !== PluginSourceKind.GitUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const repoDir = this.getPluginSourceInstallUri(descriptor);
|
||||
const repoExists = await this._fileService.exists(repoDir);
|
||||
if (!repoExists) {
|
||||
this._logService.warn(`[AgentPluginRepositoryService] Cannot update plugin '${options?.pluginName ?? plugin.name}': source repository not cloned`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateLabel = options?.pluginName ?? plugin.name;
|
||||
const failureLabel = options?.failureLabel ?? updateLabel;
|
||||
|
||||
try {
|
||||
await this._progressService.withProgress(
|
||||
{
|
||||
location: ProgressLocation.Notification,
|
||||
title: localize('updatingPluginSource', "Updating plugin '{0}'...", updateLabel),
|
||||
cancellable: false,
|
||||
},
|
||||
async () => {
|
||||
await this._commandService.executeCommand('git.openRepository', repoDir.fsPath);
|
||||
if (descriptor.sha) {
|
||||
await this._commandService.executeCommand('git.fetch', repoDir.fsPath);
|
||||
} else {
|
||||
await this._commandService.executeCommand('_git.pull', repoDir.fsPath);
|
||||
}
|
||||
await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel);
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
this._logService.error(`[AgentPluginRepositoryService] Failed to update plugin source ${updateLabel}:`, err);
|
||||
this._notificationService.notify({
|
||||
severity: Severity.Error,
|
||||
message: localize('pullPluginSourceFailed', "Failed to update plugin '{0}': {1}", failureLabel, err?.message ?? String(err)),
|
||||
actions: {
|
||||
primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => {
|
||||
this._commandService.executeCommand('git.showOutput');
|
||||
})],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _gitUrlCacheSegments(url: string, ref?: string, sha?: string): string[] {
|
||||
try {
|
||||
const parsed = URI.parse(url);
|
||||
const authority = (parsed.authority || 'unknown').replace(/[\\/:*?"<>|]/g, '_').toLowerCase();
|
||||
const pathPart = parsed.path.replace(/^\/+/, '').replace(/\.git$/i, '').replace(/\/+$/g, '');
|
||||
const segments = pathPart.split('/').map(s => s.replace(/[\\/:*?"<>|]/g, '_'));
|
||||
return [authority, ...segments, ...this._getSourceRevisionCacheSuffix(ref, sha)];
|
||||
} catch {
|
||||
return ['git', url.replace(/[\\/:*?"<>|]/g, '_'), ...this._getSourceRevisionCacheSuffix(ref, sha)];
|
||||
}
|
||||
}
|
||||
|
||||
private _getSourceRevisionCacheSuffix(descriptorOrRef: IPluginSourceDescriptor | string | undefined, sha?: string): string[] {
|
||||
if (typeof descriptorOrRef === 'object' && descriptorOrRef) {
|
||||
if (descriptorOrRef.kind === PluginSourceKind.GitHub || descriptorOrRef.kind === PluginSourceKind.GitUrl) {
|
||||
return this._getSourceRevisionCacheSuffix(descriptorOrRef.ref, descriptorOrRef.sha);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const ref = descriptorOrRef;
|
||||
if (sha) {
|
||||
return [`sha_${sanitizePackageName(sha)}`];
|
||||
}
|
||||
if (ref) {
|
||||
return [`ref_${sanitizePackageName(ref)}`];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private async _checkoutPluginSourceRevision(repoDir: URI, descriptor: IPluginSourceDescriptor, failureLabel: string): Promise<void> {
|
||||
if (descriptor.kind !== PluginSourceKind.GitHub && descriptor.kind !== PluginSourceKind.GitUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!descriptor.sha && !descriptor.ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (descriptor.sha) {
|
||||
await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, descriptor.sha, true);
|
||||
return;
|
||||
}
|
||||
|
||||
await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, descriptor.ref);
|
||||
} catch (err) {
|
||||
this._logService.error(`[AgentPluginRepositoryService] Failed to checkout plugin source revision for ${failureLabel}:`, err);
|
||||
this._notificationService.notify({
|
||||
severity: Severity.Error,
|
||||
message: localize('checkoutPluginSourceFailed', "Failed to checkout plugin '{0}' to requested revision: {1}", failureLabel, err?.message ?? String(err)),
|
||||
actions: {
|
||||
primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => {
|
||||
this._commandService.executeCommand('git.showOutput');
|
||||
})],
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizePackageName(name: string): string {
|
||||
return name.replace(/[\\/:*?"<>|]/g, '_');
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Disposable, DisposableStore, disposeIfDisposable, IDisposable, isDispos
|
||||
import { ThemeIcon } from '../../../../base/common/themables.js';
|
||||
import { autorun } from '../../../../base/common/observable.js';
|
||||
import { IPagedModel, PagedModel } from '../../../../base/common/paging.js';
|
||||
import { basename, dirname, joinPath } from '../../../../base/common/resources.js';
|
||||
import { dirname, joinPath } from '../../../../base/common/resources.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { localize, localize2 } from '../../../../nls.js';
|
||||
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
|
||||
@@ -56,7 +56,7 @@ export const InstalledAgentPluginsViewId = 'workbench.views.agentPlugins.install
|
||||
//#region Item model
|
||||
|
||||
function installedPluginToItem(plugin: IAgentPlugin, labelService: ILabelService): IInstalledPluginItem {
|
||||
const name = basename(plugin.uri);
|
||||
const name = plugin.label;
|
||||
const description = plugin.fromMarketplace?.description ?? labelService.getUriLabel(dirname(plugin.uri), { relative: true });
|
||||
const marketplace = plugin.fromMarketplace?.marketplace;
|
||||
return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin };
|
||||
@@ -68,6 +68,7 @@ function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePlugin
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
source: plugin.source,
|
||||
sourceDescriptor: plugin.sourceDescriptor,
|
||||
marketplace: plugin.marketplace,
|
||||
marketplaceReference: plugin.marketplaceReference,
|
||||
marketplaceType: plugin.marketplaceType,
|
||||
@@ -95,6 +96,7 @@ class InstallPluginAction extends Action {
|
||||
description: this.item.description,
|
||||
version: '',
|
||||
source: this.item.source,
|
||||
sourceDescriptor: this.item.sourceDescriptor,
|
||||
marketplace: this.item.marketplace,
|
||||
marketplaceReference: this.item.marketplaceReference,
|
||||
marketplaceType: this.item.marketplaceType,
|
||||
@@ -518,6 +520,7 @@ export class AgentPluginsListView extends AbstractExtensionsListView<IAgentPlugi
|
||||
description: m.description,
|
||||
version: '',
|
||||
source: m.source,
|
||||
sourceDescriptor: m.sourceDescriptor,
|
||||
marketplace: m.marketplace,
|
||||
marketplaceReference: m.marketplaceReference,
|
||||
marketplaceType: m.marketplaceType,
|
||||
|
||||
@@ -3,13 +3,21 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { CancelablePromise, timeout } from '../../../../base/common/async.js';
|
||||
import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { isWindows } from '../../../../base/common/platform.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
|
||||
import { IFileService } from '../../../../platform/files/common/files.js';
|
||||
import { ILogService } from '../../../../platform/log/common/log.js';
|
||||
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
|
||||
import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js';
|
||||
import { TerminalCapability, type ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js';
|
||||
import { ITerminalInstance, ITerminalService } from '../../terminal/browser/terminal.js';
|
||||
import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js';
|
||||
import { IPluginInstallService } from '../common/plugins/pluginInstallService.js';
|
||||
import { IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js';
|
||||
import { getPluginSourceLabel, IMarketplacePlugin, INpmPluginSource, IPipPluginSource, IPluginMarketplaceService, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js';
|
||||
|
||||
export class PluginInstallService implements IPluginInstallService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
@@ -19,9 +27,58 @@ export class PluginInstallService implements IPluginInstallService {
|
||||
@IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService,
|
||||
@IFileService private readonly _fileService: IFileService,
|
||||
@INotificationService private readonly _notificationService: INotificationService,
|
||||
@IDialogService private readonly _dialogService: IDialogService,
|
||||
@ITerminalService private readonly _terminalService: ITerminalService,
|
||||
@IProgressService private readonly _progressService: IProgressService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
) { }
|
||||
|
||||
async installPlugin(plugin: IMarketplacePlugin): Promise<void> {
|
||||
switch (plugin.sourceDescriptor.kind) {
|
||||
case PluginSourceKind.RelativePath:
|
||||
return this._installRelativePathPlugin(plugin);
|
||||
case PluginSourceKind.GitHub:
|
||||
case PluginSourceKind.GitUrl:
|
||||
return this._installGitPlugin(plugin);
|
||||
case PluginSourceKind.Npm:
|
||||
return this._installNpmPlugin(plugin, plugin.sourceDescriptor);
|
||||
case PluginSourceKind.Pip:
|
||||
return this._installPipPlugin(plugin, plugin.sourceDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePlugin(plugin: IMarketplacePlugin): Promise<void> {
|
||||
switch (plugin.sourceDescriptor.kind) {
|
||||
case PluginSourceKind.RelativePath:
|
||||
return this._pluginRepositoryService.pullRepository(plugin.marketplaceReference, {
|
||||
pluginName: plugin.name,
|
||||
failureLabel: plugin.name,
|
||||
marketplaceType: plugin.marketplaceType,
|
||||
});
|
||||
case PluginSourceKind.GitHub:
|
||||
case PluginSourceKind.GitUrl:
|
||||
return this._pluginRepositoryService.updatePluginSource(plugin, {
|
||||
pluginName: plugin.name,
|
||||
failureLabel: plugin.name,
|
||||
marketplaceType: plugin.marketplaceType,
|
||||
});
|
||||
case PluginSourceKind.Npm:
|
||||
return this._installNpmPlugin(plugin, plugin.sourceDescriptor);
|
||||
case PluginSourceKind.Pip:
|
||||
return this._installPipPlugin(plugin, plugin.sourceDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
getPluginInstallUri(plugin: IMarketplacePlugin): URI {
|
||||
if (plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) {
|
||||
return this._pluginRepositoryService.getPluginInstallUri(plugin);
|
||||
}
|
||||
return this._pluginRepositoryService.getPluginSourceInstallUri(plugin.sourceDescriptor);
|
||||
}
|
||||
|
||||
// --- Relative-path source (existing git-based flow) -----------------------
|
||||
|
||||
private async _installRelativePathPlugin(plugin: IMarketplacePlugin): Promise<void> {
|
||||
try {
|
||||
await this._pluginRepositoryService.ensureRepository(plugin.marketplaceReference, {
|
||||
progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name),
|
||||
@@ -55,15 +112,212 @@ export class PluginInstallService implements IPluginInstallService {
|
||||
this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin);
|
||||
}
|
||||
|
||||
async updatePlugin(plugin: IMarketplacePlugin): Promise<void> {
|
||||
return this._pluginRepositoryService.pullRepository(plugin.marketplaceReference, {
|
||||
pluginName: plugin.name,
|
||||
failureLabel: plugin.name,
|
||||
marketplaceType: plugin.marketplaceType,
|
||||
// --- GitHub / Git URL source (independent clone) --------------------------
|
||||
|
||||
private async _installGitPlugin(plugin: IMarketplacePlugin): Promise<void> {
|
||||
let pluginDir: URI;
|
||||
try {
|
||||
pluginDir = await this._pluginRepositoryService.ensurePluginSource(plugin, {
|
||||
progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name),
|
||||
failureLabel: plugin.name,
|
||||
marketplaceType: plugin.marketplaceType,
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginExists = await this._fileService.exists(pluginDir);
|
||||
if (!pluginExists) {
|
||||
this._notificationService.notify({
|
||||
severity: Severity.Error,
|
||||
message: localize('pluginSourceNotFound', "Plugin source '{0}' not found after cloning.", getPluginSourceLabel(plugin.sourceDescriptor)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin);
|
||||
}
|
||||
|
||||
// --- npm source -----------------------------------------------------------
|
||||
|
||||
private async _installNpmPlugin(plugin: IMarketplacePlugin, source: INpmPluginSource): Promise<void> {
|
||||
const packageSpec = source.version ? `${source.package}@${source.version}` : source.package;
|
||||
const installDir = await this._pluginRepositoryService.ensurePluginSource(plugin);
|
||||
const args = ['npm', 'install', '--prefix', installDir.fsPath, packageSpec];
|
||||
if (source.registry) {
|
||||
args.push('--registry', source.registry);
|
||||
}
|
||||
const command = this._formatShellCommand(args);
|
||||
|
||||
const confirmed = await this._confirmTerminalCommand(plugin.name, command);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { success, terminal } = await this._runTerminalCommand(
|
||||
command,
|
||||
localize('installingNpmPlugin', "Installing npm plugin '{0}'...", plugin.name),
|
||||
);
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginDir = this._pluginRepositoryService.getPluginSourceInstallUri(source);
|
||||
const pluginExists = await this._fileService.exists(pluginDir);
|
||||
if (!pluginExists) {
|
||||
this._notificationService.notify({
|
||||
severity: Severity.Error,
|
||||
message: localize('npmPluginNotFound', "npm package '{0}' was not found after installation.", source.package),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
terminal?.dispose();
|
||||
this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin);
|
||||
}
|
||||
|
||||
// --- pip source -----------------------------------------------------------
|
||||
|
||||
private async _installPipPlugin(plugin: IMarketplacePlugin, source: IPipPluginSource): Promise<void> {
|
||||
const packageSpec = source.version ? `${source.package}==${source.version}` : source.package;
|
||||
const installDir = await this._pluginRepositoryService.ensurePluginSource(plugin);
|
||||
const args = ['pip', 'install', '--target', installDir.fsPath, packageSpec];
|
||||
if (source.registry) {
|
||||
args.push('--index-url', source.registry);
|
||||
}
|
||||
const command = this._formatShellCommand(args);
|
||||
|
||||
const confirmed = await this._confirmTerminalCommand(plugin.name, command);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { success, terminal } = await this._runTerminalCommand(
|
||||
command,
|
||||
localize('installingPipPlugin', "Installing pip plugin '{0}'...", plugin.name),
|
||||
);
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginDir = this._pluginRepositoryService.getPluginSourceInstallUri(source);
|
||||
const pluginExists = await this._fileService.exists(pluginDir);
|
||||
if (!pluginExists) {
|
||||
this._notificationService.notify({
|
||||
severity: Severity.Error,
|
||||
message: localize('pipPluginNotFound', "pip package '{0}' was not found after installation.", source.package),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
terminal?.dispose();
|
||||
this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin);
|
||||
}
|
||||
|
||||
// --- Helpers --------------------------------------------------------------
|
||||
|
||||
private async _confirmTerminalCommand(pluginName: string, command: string): Promise<boolean> {
|
||||
const { confirmed } = await this._dialogService.confirm({
|
||||
type: 'question',
|
||||
message: localize('confirmPluginInstall', "Install Plugin '{0}'?", pluginName),
|
||||
detail: localize('confirmPluginInstallDetail', "This will run the following command in a terminal:\n\n{0}", command),
|
||||
primaryButton: localize({ key: 'confirmInstall', comment: ['&& denotes a mnemonic'] }, "&&Install"),
|
||||
});
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
private async _runTerminalCommand(command: string, progressTitle: string) {
|
||||
let terminal: ITerminalInstance | undefined;
|
||||
try {
|
||||
await this._progressService.withProgress(
|
||||
{
|
||||
location: ProgressLocation.Notification,
|
||||
title: progressTitle,
|
||||
cancellable: false,
|
||||
},
|
||||
async () => {
|
||||
terminal = await this._terminalService.createTerminal({
|
||||
config: {
|
||||
name: localize('pluginInstallTerminal', "Plugin Install"),
|
||||
forceShellIntegration: true,
|
||||
isTransient: true,
|
||||
isFeatureTerminal: true,
|
||||
},
|
||||
});
|
||||
|
||||
await terminal.processReady;
|
||||
this._terminalService.setActiveInstance(terminal);
|
||||
|
||||
const commandResultPromise = this._waitForTerminalCommandCompletion(terminal);
|
||||
await terminal.runCommand(command, true);
|
||||
const exitCode = await commandResultPromise;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(localize('terminalCommandExitCode', "Command exited with code {0}", exitCode));
|
||||
}
|
||||
}
|
||||
);
|
||||
return { success: true, terminal };
|
||||
} catch (err) {
|
||||
this._logService.error('[PluginInstallService] Terminal command failed:', err);
|
||||
this._notificationService.notify({
|
||||
severity: Severity.Error,
|
||||
message: localize('terminalCommandFailed', "Plugin installation command failed: {0}", err?.message ?? String(err)),
|
||||
});
|
||||
return { success: false, terminal };
|
||||
}
|
||||
}
|
||||
|
||||
private _waitForTerminalCommandCompletion(terminal: ITerminalInstance): Promise<number | undefined> {
|
||||
return new Promise<number | undefined>(resolve => {
|
||||
const disposables = new DisposableStore();
|
||||
let isResolved = false;
|
||||
|
||||
const resolveAndDispose = (exitCode: number | undefined): void => {
|
||||
if (isResolved) {
|
||||
return;
|
||||
}
|
||||
isResolved = true;
|
||||
disposables.dispose();
|
||||
resolve(exitCode);
|
||||
};
|
||||
|
||||
const attachCommandFinishedListener = (): void => {
|
||||
const commandDetection = terminal.capabilities.get(TerminalCapability.CommandDetection);
|
||||
if (!commandDetection) {
|
||||
return;
|
||||
}
|
||||
|
||||
disposables.add(commandDetection.onCommandFinished((command: ITerminalCommand) => {
|
||||
resolveAndDispose(command.exitCode ?? 0);
|
||||
}));
|
||||
};
|
||||
|
||||
attachCommandFinishedListener();
|
||||
disposables.add(terminal.capabilities.onDidAddCommandDetectionCapability(() => attachCommandFinishedListener()));
|
||||
|
||||
const timeoutHandle: CancelablePromise<void> = timeout(120_000);
|
||||
disposables.add(toDisposable(() => timeoutHandle.cancel()));
|
||||
void timeoutHandle.then(() => {
|
||||
if (isResolved) {
|
||||
return;
|
||||
}
|
||||
this._logService.warn('[PluginInstallService] Terminal command completion timed out');
|
||||
resolveAndDispose(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getPluginInstallUri(plugin: IMarketplacePlugin): URI {
|
||||
return this._pluginRepositoryService.getPluginInstallUri(plugin);
|
||||
private _formatShellCommand(args: readonly string[]): string {
|
||||
const [command, ...rest] = args;
|
||||
return [command, ...rest.map(arg => this._shellEscapeArg(arg))].join(' ');
|
||||
}
|
||||
|
||||
private _shellEscapeArg(value: string): string {
|
||||
if (isWindows) {
|
||||
// PowerShell: use double quotes, escape backticks, dollar signs, and double quotes
|
||||
return `"${value.replace(/[`$"]/g, '`$&')}"`;
|
||||
}
|
||||
// POSIX shells: use single quotes, escape by ending quote, adding escaped quote, reopening
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
|
||||
import { IMarketplacePlugin, IMarketplaceReference, MarketplaceType } from './pluginMarketplaceService.js';
|
||||
import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceType } from './pluginMarketplaceService.js';
|
||||
|
||||
export const IAgentPluginRepositoryService = createDecorator<IAgentPluginRepositoryService>('agentPluginRepositoryService');
|
||||
|
||||
@@ -61,4 +61,26 @@ export interface IAgentPluginRepositoryService {
|
||||
* Pulls latest changes for a cloned marketplace repository.
|
||||
*/
|
||||
pullRepository(marketplace: IMarketplaceReference, options?: IPullRepositoryOptions): Promise<void>;
|
||||
|
||||
/**
|
||||
* Returns the local install URI for a plugin based on its
|
||||
* {@link IPluginSourceDescriptor}. For non-relative-path sources
|
||||
* (github, url, npm, pip), this computes a cache location independent
|
||||
* of the marketplace repository.
|
||||
*/
|
||||
getPluginSourceInstallUri(sourceDescriptor: IPluginSourceDescriptor): URI;
|
||||
|
||||
/**
|
||||
* Ensures the plugin source is available locally. For github/url sources
|
||||
* this clones the repository into the cache. For npm/pip sources this is
|
||||
* a no-op (installation via terminal is handled by the install service).
|
||||
*/
|
||||
ensurePluginSource(plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise<URI>;
|
||||
|
||||
/**
|
||||
* Updates a plugin source that is stored outside the marketplace repository.
|
||||
* For github/url sources this pulls latest changes and reapplies pinned
|
||||
* ref/sha checkout. For npm/pip sources this is a no-op.
|
||||
*/
|
||||
updatePluginSource(plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface IAgentPluginMcpServerDefinition {
|
||||
|
||||
export interface IAgentPlugin {
|
||||
readonly uri: URI;
|
||||
/** Human-readable display name for the plugin. */
|
||||
readonly label: string;
|
||||
readonly enabled: IObservable<boolean>;
|
||||
setEnabled(enabled: boolean): void;
|
||||
/** Removes this plugin from its discovery source (config or installed storage). */
|
||||
|
||||
@@ -416,6 +416,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements
|
||||
|
||||
const plugin: PluginEntry = {
|
||||
uri,
|
||||
label: fromMarketplace?.name ?? basename(uri),
|
||||
enabled,
|
||||
setEnabled: setEnabledCallback,
|
||||
remove: removeCallback,
|
||||
|
||||
@@ -45,12 +45,64 @@ export interface IMarketplaceReference {
|
||||
readonly localRepositoryUri?: URI;
|
||||
}
|
||||
|
||||
export const enum PluginSourceKind {
|
||||
RelativePath = 'relativePath',
|
||||
GitHub = 'github',
|
||||
GitUrl = 'url',
|
||||
Npm = 'npm',
|
||||
Pip = 'pip',
|
||||
}
|
||||
|
||||
export interface IRelativePathPluginSource {
|
||||
readonly kind: PluginSourceKind.RelativePath;
|
||||
/** Resolved relative path within the marketplace repository. */
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
export interface IGitHubPluginSource {
|
||||
readonly kind: PluginSourceKind.GitHub;
|
||||
readonly repo: string;
|
||||
readonly ref?: string;
|
||||
readonly sha?: string;
|
||||
}
|
||||
|
||||
export interface IGitUrlPluginSource {
|
||||
readonly kind: PluginSourceKind.GitUrl;
|
||||
/** Full git repository URL (must end with .git). */
|
||||
readonly url: string;
|
||||
readonly ref?: string;
|
||||
readonly sha?: string;
|
||||
}
|
||||
|
||||
export interface INpmPluginSource {
|
||||
readonly kind: PluginSourceKind.Npm;
|
||||
readonly package: string;
|
||||
readonly version?: string;
|
||||
readonly registry?: string;
|
||||
}
|
||||
|
||||
export interface IPipPluginSource {
|
||||
readonly kind: PluginSourceKind.Pip;
|
||||
readonly package: string;
|
||||
readonly version?: string;
|
||||
readonly registry?: string;
|
||||
}
|
||||
|
||||
export type IPluginSourceDescriptor =
|
||||
| IRelativePathPluginSource
|
||||
| IGitHubPluginSource
|
||||
| IGitUrlPluginSource
|
||||
| INpmPluginSource
|
||||
| IPipPluginSource;
|
||||
|
||||
export interface IMarketplacePlugin {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly version: string;
|
||||
/** Subdirectory within the repository where the plugin lives. */
|
||||
/** Subdirectory within the repository where the plugin lives (for relative-path sources). */
|
||||
readonly source: string;
|
||||
/** Structured source descriptor indicating how the plugin should be fetched/installed. */
|
||||
readonly sourceDescriptor: IPluginSourceDescriptor;
|
||||
/** Marketplace label shown in UI and plugin provenance. */
|
||||
readonly marketplace: string;
|
||||
/** Canonical reference for clone/update/install location resolution. */
|
||||
@@ -60,6 +112,18 @@ export interface IMarketplacePlugin {
|
||||
readonly readmeUri?: URI;
|
||||
}
|
||||
|
||||
/** Raw JSON shape of a remote plugin source object in marketplace.json. */
|
||||
interface IJsonPluginSource {
|
||||
readonly source: string;
|
||||
readonly repo?: string;
|
||||
readonly url?: string;
|
||||
readonly package?: string;
|
||||
readonly ref?: string;
|
||||
readonly sha?: string;
|
||||
readonly version?: string;
|
||||
readonly registry?: string;
|
||||
}
|
||||
|
||||
interface IMarketplaceJson {
|
||||
readonly metadata?: {
|
||||
readonly pluginRoot?: string;
|
||||
@@ -68,7 +132,7 @@ interface IMarketplaceJson {
|
||||
readonly name?: string;
|
||||
readonly description?: string;
|
||||
readonly version?: string;
|
||||
readonly source?: string;
|
||||
readonly source?: string | IJsonPluginSource;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -118,6 +182,23 @@ interface IStoredInstalledPlugin {
|
||||
readonly enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that an {@link IMarketplacePlugin} loaded from storage has a
|
||||
* {@link IMarketplacePlugin.sourceDescriptor sourceDescriptor}. Plugins
|
||||
* persisted before the sourceDescriptor field was introduced will only
|
||||
* have the legacy `source` string — this function synthesises a
|
||||
* {@link PluginSourceKind.RelativePath} descriptor from it.
|
||||
*/
|
||||
function ensureSourceDescriptor(plugin: IMarketplacePlugin): IMarketplacePlugin {
|
||||
if (plugin.sourceDescriptor) {
|
||||
return plugin;
|
||||
}
|
||||
return {
|
||||
...plugin,
|
||||
sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: plugin.source },
|
||||
};
|
||||
}
|
||||
|
||||
const installedPluginsMemento = observableMemento<readonly IStoredInstalledPlugin[]>({
|
||||
defaultValue: [],
|
||||
key: 'chat.plugins.installed.v1',
|
||||
@@ -151,7 +232,12 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
|
||||
installedPluginsMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService)
|
||||
);
|
||||
|
||||
this.installedPlugins = this._installedPluginsStore.map(s => revive(s));
|
||||
this.installedPlugins = this._installedPluginsStore.map(s =>
|
||||
(revive(s) as readonly IMarketplaceInstalledPlugin[]).map(e => ({
|
||||
...e,
|
||||
plugin: ensureSourceDescriptor(e.plugin),
|
||||
}))
|
||||
);
|
||||
|
||||
this.onDidChangeMarketplaces = Event.filter(
|
||||
_configurationService.onDidChangeConfiguration,
|
||||
@@ -213,21 +299,27 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
|
||||
continue;
|
||||
}
|
||||
const plugins = json.plugins
|
||||
.filter((p): p is { name: string; description?: string; version?: string; source?: string } =>
|
||||
.filter((p): p is { name: string; description?: string; version?: string; source?: string | IJsonPluginSource } =>
|
||||
typeof p.name === 'string' && !!p.name
|
||||
)
|
||||
.flatMap(p => {
|
||||
const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? '');
|
||||
if (source === undefined) {
|
||||
this._logService.warn(`[PluginMarketplaceService] Skipping plugin '${p.name}' in ${repo}: invalid source path '${p.source ?? ''}' with pluginRoot '${json.metadata?.pluginRoot ?? ''}'`);
|
||||
const sourceDescriptor = parsePluginSource(p.source, json.metadata?.pluginRoot, {
|
||||
pluginName: p.name,
|
||||
logService: this._logService,
|
||||
logPrefix: `[PluginMarketplaceService]`,
|
||||
});
|
||||
if (!sourceDescriptor) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const source = sourceDescriptor.kind === PluginSourceKind.RelativePath ? sourceDescriptor.path : '';
|
||||
|
||||
return [{
|
||||
name: p.name,
|
||||
description: p.description ?? '',
|
||||
version: p.version ?? '',
|
||||
source,
|
||||
sourceDescriptor,
|
||||
marketplace: reference.displayLabel,
|
||||
marketplaceReference: reference,
|
||||
marketplaceType: def.type,
|
||||
@@ -293,7 +385,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
|
||||
continue;
|
||||
}
|
||||
|
||||
const plugins = entry.plugins.map(plugin => ({
|
||||
const plugins = entry.plugins.map(plugin => ensureSourceDescriptor({
|
||||
...plugin,
|
||||
marketplace: reference.displayLabel,
|
||||
marketplaceReference: reference,
|
||||
@@ -344,9 +436,11 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
|
||||
addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void {
|
||||
const current = this.installedPlugins.get();
|
||||
if (current.some(e => isEqual(e.pluginUri, pluginUri))) {
|
||||
return;
|
||||
// Still update to trigger watchers to re-check, something might have happened that we want to know about
|
||||
this._installedPluginsStore.set([...current], undefined);
|
||||
} else {
|
||||
this._installedPluginsStore.set([...current, { pluginUri, plugin, enabled: true }], undefined);
|
||||
}
|
||||
this._installedPluginsStore.set([...current, { pluginUri, plugin, enabled: true }], undefined);
|
||||
}
|
||||
|
||||
removeInstalledPlugin(pluginUri: URI): void {
|
||||
@@ -391,21 +485,27 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke
|
||||
}
|
||||
|
||||
return json.plugins
|
||||
.filter((p): p is { name: string; description?: string; version?: string; source?: string } =>
|
||||
.filter((p): p is { name: string; description?: string; version?: string; source?: string | IJsonPluginSource } =>
|
||||
typeof p.name === 'string' && !!p.name
|
||||
)
|
||||
.flatMap(p => {
|
||||
const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? '');
|
||||
if (source === undefined) {
|
||||
this._logService.warn(`[PluginMarketplaceService] Skipping plugin '${p.name}' in ${reference.rawValue}: invalid source path '${p.source ?? ''}' with pluginRoot '${json.metadata?.pluginRoot ?? ''}'`);
|
||||
const sourceDescriptor = parsePluginSource(p.source, json.metadata?.pluginRoot, {
|
||||
pluginName: p.name,
|
||||
logService: this._logService,
|
||||
logPrefix: `[PluginMarketplaceService]`,
|
||||
});
|
||||
if (!sourceDescriptor) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const source = sourceDescriptor.kind === PluginSourceKind.RelativePath ? sourceDescriptor.path : '';
|
||||
|
||||
return [{
|
||||
name: p.name,
|
||||
description: p.description ?? '',
|
||||
version: p.version ?? '',
|
||||
source,
|
||||
sourceDescriptor,
|
||||
marketplace: reference.displayLabel,
|
||||
marketplaceReference: reference,
|
||||
marketplaceType: def.type,
|
||||
@@ -597,6 +697,158 @@ function resolvePluginSource(pluginRoot: string | undefined, source: string): st
|
||||
return relativePath(repoRoot, resolvedUri) ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw `source` field from marketplace.json into a structured
|
||||
* {@link IPluginSourceDescriptor}. Accepts either a relative-path string
|
||||
* or a JSON object with a `source` discriminant indicating the kind.
|
||||
*/
|
||||
export function parsePluginSource(
|
||||
rawSource: string | IJsonPluginSource | undefined,
|
||||
pluginRoot: string | undefined,
|
||||
logContext: { pluginName: string; logService: ILogService; logPrefix: string },
|
||||
): IPluginSourceDescriptor | undefined {
|
||||
if (rawSource === undefined || rawSource === null) {
|
||||
// Treat missing source the same as empty string → pluginRoot or repo root.
|
||||
const resolved = resolvePluginSource(pluginRoot, '');
|
||||
if (resolved === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { kind: PluginSourceKind.RelativePath, path: resolved };
|
||||
}
|
||||
|
||||
// String source → legacy relative-path behaviour.
|
||||
if (typeof rawSource === 'string') {
|
||||
const resolved = resolvePluginSource(pluginRoot, rawSource);
|
||||
if (resolved === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { kind: PluginSourceKind.RelativePath, path: resolved };
|
||||
}
|
||||
|
||||
// Object source → discriminated by `rawSource.source`.
|
||||
if (typeof rawSource !== 'object' || typeof rawSource.source !== 'string') {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': source object is missing a 'source' discriminant`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (rawSource.source) {
|
||||
case 'github': {
|
||||
if (typeof rawSource.repo !== 'string' || !rawSource.repo) {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source is missing required 'repo' field`);
|
||||
return undefined;
|
||||
}
|
||||
if (!isValidGitHubRepo(rawSource.repo)) {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source repo must be in 'owner/repo' format`);
|
||||
return undefined;
|
||||
}
|
||||
if (!isOptionalString(rawSource.ref)) {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'ref' must be a string when provided`);
|
||||
return undefined;
|
||||
}
|
||||
if (!isOptionalGitSha(rawSource.sha)) {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'sha' must be a full 40-character commit hash when provided`);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: PluginSourceKind.GitHub,
|
||||
repo: rawSource.repo,
|
||||
ref: rawSource.ref,
|
||||
sha: rawSource.sha,
|
||||
};
|
||||
}
|
||||
case 'url': {
|
||||
if (typeof rawSource.url !== 'string' || !rawSource.url) {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source is missing required 'url' field`);
|
||||
return undefined;
|
||||
}
|
||||
if (!rawSource.url.toLowerCase().endsWith('.git')) {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source must end with '.git'`);
|
||||
return undefined;
|
||||
}
|
||||
if (!isOptionalString(rawSource.ref)) {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source 'ref' must be a string when provided`);
|
||||
return undefined;
|
||||
}
|
||||
if (!isOptionalGitSha(rawSource.sha)) {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source 'sha' must be a full 40-character commit hash when provided`);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: PluginSourceKind.GitUrl,
|
||||
url: rawSource.url,
|
||||
ref: rawSource.ref,
|
||||
sha: rawSource.sha,
|
||||
};
|
||||
}
|
||||
case 'npm': {
|
||||
if (typeof rawSource.package !== 'string' || !rawSource.package) {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': npm source is missing required 'package' field`);
|
||||
return undefined;
|
||||
}
|
||||
if (!isOptionalString(rawSource.version) || !isOptionalString(rawSource.registry)) {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': npm source 'version' and 'registry' must be strings when provided`);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: PluginSourceKind.Npm,
|
||||
package: rawSource.package,
|
||||
version: rawSource.version,
|
||||
registry: rawSource.registry,
|
||||
};
|
||||
}
|
||||
case 'pip': {
|
||||
if (typeof rawSource.package !== 'string' || !rawSource.package) {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': pip source is missing required 'package' field`);
|
||||
return undefined;
|
||||
}
|
||||
if (!isOptionalString(rawSource.version) || !isOptionalString(rawSource.registry)) {
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': pip source 'version' and 'registry' must be strings when provided`);
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
kind: PluginSourceKind.Pip,
|
||||
package: rawSource.package,
|
||||
version: rawSource.version,
|
||||
registry: rawSource.registry,
|
||||
};
|
||||
}
|
||||
default:
|
||||
logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': unknown source kind '${rawSource.source}'`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isOptionalString(value: unknown): value is string | undefined {
|
||||
return value === undefined || typeof value === 'string';
|
||||
}
|
||||
|
||||
function isOptionalGitSha(value: unknown): value is string | undefined {
|
||||
return value === undefined || (typeof value === 'string' && /^[0-9a-fA-F]{40}$/.test(value));
|
||||
}
|
||||
|
||||
function isValidGitHubRepo(repo: string): boolean {
|
||||
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable label for a plugin source descriptor,
|
||||
* suitable for error messages and UI display.
|
||||
*/
|
||||
export function getPluginSourceLabel(descriptor: IPluginSourceDescriptor): string {
|
||||
switch (descriptor.kind) {
|
||||
case PluginSourceKind.RelativePath:
|
||||
return descriptor.path || '.';
|
||||
case PluginSourceKind.GitHub:
|
||||
return descriptor.repo;
|
||||
case PluginSourceKind.GitUrl:
|
||||
return descriptor.url;
|
||||
case PluginSourceKind.Npm:
|
||||
return descriptor.version ? `${descriptor.package}@${descriptor.version}` : descriptor.package;
|
||||
case PluginSourceKind.Pip:
|
||||
return descriptor.version ? `${descriptor.package}==${descriptor.version}` : descriptor.package;
|
||||
}
|
||||
}
|
||||
|
||||
function getMarketplaceReadmeUri(repo: string, source: string): URI {
|
||||
const normalizedSource = source.trim().replace(/^\.?\/+|\/+$/g, '');
|
||||
const readmePath = normalizedSource ? `${normalizedSource}/README.md` : 'README.md';
|
||||
|
||||
@@ -15,7 +15,7 @@ import { INotificationService } from '../../../../../../platform/notification/co
|
||||
import { IProgressService } from '../../../../../../platform/progress/common/progress.js';
|
||||
import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';
|
||||
import { AgentPluginRepositoryService } from '../../../browser/agentPluginRepositoryService.js';
|
||||
import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference } from '../../../common/plugins/pluginMarketplaceService.js';
|
||||
import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js';
|
||||
|
||||
suite('AgentPluginRepositoryService', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
@@ -32,6 +32,7 @@ suite('AgentPluginRepositoryService', () => {
|
||||
description: '',
|
||||
version: '',
|
||||
source,
|
||||
sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: source },
|
||||
marketplace: marketplaceReference.displayLabel,
|
||||
marketplaceReference,
|
||||
marketplaceType: MarketplaceType.Copilot,
|
||||
@@ -40,7 +41,7 @@ suite('AgentPluginRepositoryService', () => {
|
||||
|
||||
function createService(
|
||||
onExists?: (resource: URI) => Promise<boolean>,
|
||||
onExecuteCommand?: (id: string) => void,
|
||||
onExecuteCommand?: (id: string, ...args: unknown[]) => void,
|
||||
): AgentPluginRepositoryService {
|
||||
const instantiationService = store.add(new TestInstantiationService());
|
||||
|
||||
@@ -53,8 +54,8 @@ suite('AgentPluginRepositoryService', () => {
|
||||
} as unknown as IProgressService;
|
||||
|
||||
instantiationService.stub(ICommandService, {
|
||||
executeCommand: async (id: string) => {
|
||||
onExecuteCommand?.(id);
|
||||
executeCommand: async (id: string, ...args: unknown[]) => {
|
||||
onExecuteCommand?.(id, ...args);
|
||||
return undefined;
|
||||
},
|
||||
} as unknown as ICommandService);
|
||||
@@ -170,4 +171,43 @@ suite('AgentPluginRepositoryService', () => {
|
||||
assert.strictEqual(uri.path, '/tmp/marketplace-repo');
|
||||
assert.strictEqual(commandInvocationCount, 0);
|
||||
});
|
||||
|
||||
test('builds revision-aware install URI for github plugin sources', () => {
|
||||
const service = createService();
|
||||
const uri = service.getPluginSourceInstallUri({
|
||||
kind: PluginSourceKind.GitHub,
|
||||
repo: 'owner/repo',
|
||||
ref: 'release/v1',
|
||||
});
|
||||
|
||||
assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/owner/repo/ref_release_v1');
|
||||
});
|
||||
|
||||
test('updates git plugin source by pulling and checking out requested revision', async () => {
|
||||
const commands: string[] = [];
|
||||
const service = createService(async () => true, (id: string) => {
|
||||
commands.push(id);
|
||||
});
|
||||
|
||||
await service.updatePluginSource({
|
||||
name: 'my-plugin',
|
||||
description: '',
|
||||
version: '',
|
||||
source: '',
|
||||
sourceDescriptor: {
|
||||
kind: PluginSourceKind.GitHub,
|
||||
repo: 'owner/repo',
|
||||
sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0',
|
||||
},
|
||||
marketplace: 'owner/repo',
|
||||
marketplaceReference: parseMarketplaceReference('owner/repo')!,
|
||||
marketplaceType: MarketplaceType.Copilot,
|
||||
}, {
|
||||
pluginName: 'my-plugin',
|
||||
failureLabel: 'my-plugin',
|
||||
marketplaceType: MarketplaceType.Copilot,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(commands, ['git.openRepository', 'git.fetch', '_git.checkout']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import assert from 'assert';
|
||||
import { URI } from '../../../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
|
||||
import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js';
|
||||
import { IFileService } from '../../../../../../platform/files/common/files.js';
|
||||
import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
||||
import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js';
|
||||
import { INotificationService } from '../../../../../../platform/notification/common/notification.js';
|
||||
import { IProgressService } from '../../../../../../platform/progress/common/progress.js';
|
||||
import { ITerminalService } from '../../../../terminal/browser/terminal.js';
|
||||
import { PluginInstallService } from '../../../browser/pluginInstallService.js';
|
||||
import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../../../common/plugins/agentPluginRepositoryService.js';
|
||||
import { IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, IPluginSourceDescriptor, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js';
|
||||
|
||||
suite('PluginInstallService', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
// --- Factory helpers -------------------------------------------------------
|
||||
|
||||
function makeMarketplaceRef(marketplace: string): IMarketplaceReference {
|
||||
const ref = parseMarketplaceReference(marketplace);
|
||||
assert.ok(ref);
|
||||
return ref!;
|
||||
}
|
||||
|
||||
function createPlugin(overrides: Partial<IMarketplacePlugin> & { sourceDescriptor: IPluginSourceDescriptor }): IMarketplacePlugin {
|
||||
return {
|
||||
name: overrides.name ?? 'test-plugin',
|
||||
description: overrides.description ?? '',
|
||||
version: overrides.version ?? '',
|
||||
source: overrides.source ?? '',
|
||||
sourceDescriptor: overrides.sourceDescriptor,
|
||||
marketplace: overrides.marketplace ?? 'microsoft/vscode',
|
||||
marketplaceReference: overrides.marketplaceReference ?? makeMarketplaceRef('microsoft/vscode'),
|
||||
marketplaceType: overrides.marketplaceType ?? MarketplaceType.Copilot,
|
||||
readmeUri: overrides.readmeUri,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Mock tracking types ---------------------------------------------------
|
||||
|
||||
interface MockState {
|
||||
notifications: { severity: number; message: string }[];
|
||||
addedPlugins: { uri: string; plugin: IMarketplacePlugin }[];
|
||||
dialogConfirmResult: boolean;
|
||||
fileExistsResult: boolean | ((uri: URI) => Promise<boolean>);
|
||||
ensureRepositoryResult: URI;
|
||||
ensurePluginSourceResult: URI;
|
||||
/** Plugin source install URI, per kind */
|
||||
pluginSourceInstallUris: Map<string, URI>;
|
||||
/** The commands that were sent to the terminal */
|
||||
terminalCommands: string[];
|
||||
/** Simulated exit code from terminal */
|
||||
terminalExitCode: number;
|
||||
/** Whether the terminal resolves the command completion at all */
|
||||
terminalCompletes: boolean;
|
||||
pullRepositoryCalls: { marketplace: IMarketplaceReference; options?: IPullRepositoryOptions }[];
|
||||
updatePluginSourceCalls: { plugin: IMarketplacePlugin; options?: IPullRepositoryOptions }[];
|
||||
}
|
||||
|
||||
function createDefaults(): MockState {
|
||||
return {
|
||||
notifications: [],
|
||||
addedPlugins: [],
|
||||
dialogConfirmResult: true,
|
||||
fileExistsResult: true,
|
||||
ensureRepositoryResult: URI.file('/cache/agentPlugins/github.com/microsoft/vscode'),
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-package'),
|
||||
pluginSourceInstallUris: new Map(),
|
||||
terminalCommands: [],
|
||||
terminalExitCode: 0,
|
||||
terminalCompletes: true,
|
||||
pullRepositoryCalls: [],
|
||||
updatePluginSourceCalls: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createService(stateOverrides?: Partial<MockState>): { service: PluginInstallService; state: MockState } {
|
||||
const state: MockState = { ...createDefaults(), ...stateOverrides };
|
||||
const instantiationService = store.add(new TestInstantiationService());
|
||||
|
||||
// IFileService
|
||||
instantiationService.stub(IFileService, {
|
||||
exists: async (resource: URI) => {
|
||||
if (typeof state.fileExistsResult === 'function') {
|
||||
return state.fileExistsResult(resource);
|
||||
}
|
||||
return state.fileExistsResult;
|
||||
},
|
||||
} as unknown as IFileService);
|
||||
|
||||
// INotificationService
|
||||
instantiationService.stub(INotificationService, {
|
||||
notify: (notification: { severity: number; message: string }) => {
|
||||
state.notifications.push({ severity: notification.severity, message: notification.message });
|
||||
return undefined;
|
||||
},
|
||||
} as unknown as INotificationService);
|
||||
|
||||
// IDialogService
|
||||
instantiationService.stub(IDialogService, {
|
||||
confirm: async () => ({ confirmed: state.dialogConfirmResult }),
|
||||
} as unknown as IDialogService);
|
||||
|
||||
// ITerminalService — the mock coordinates runCommand and onCommandFinished
|
||||
// so the command ID matches, just like a real terminal would.
|
||||
instantiationService.stub(ITerminalService, {
|
||||
createTerminal: async () => {
|
||||
let finishedCallback: ((cmd: { id: string; exitCode: number }) => void) | undefined;
|
||||
return {
|
||||
processReady: Promise.resolve(),
|
||||
dispose: () => { },
|
||||
runCommand: (command: string, _addNewLine?: boolean) => {
|
||||
state.terminalCommands.push(command);
|
||||
// Simulate command completing after runCommand is called
|
||||
if (finishedCallback) {
|
||||
finishedCallback({ id: 'command', exitCode: state.terminalExitCode });
|
||||
}
|
||||
},
|
||||
capabilities: {
|
||||
get: () => state.terminalCompletes ? {
|
||||
onCommandFinished: (callback: (cmd: { id: string; exitCode: number }) => void) => {
|
||||
finishedCallback = callback;
|
||||
return { dispose() { } };
|
||||
},
|
||||
} : undefined,
|
||||
onDidAddCommandDetectionCapability: () => ({ dispose() { } }),
|
||||
},
|
||||
};
|
||||
},
|
||||
setActiveInstance: () => { },
|
||||
} as unknown as ITerminalService);
|
||||
|
||||
// IProgressService
|
||||
instantiationService.stub(IProgressService, {
|
||||
withProgress: async (_options: unknown, callback: (...args: unknown[]) => Promise<unknown>) => callback(),
|
||||
} as unknown as IProgressService);
|
||||
|
||||
// ILogService
|
||||
instantiationService.stub(ILogService, new NullLogService());
|
||||
|
||||
// IAgentPluginRepositoryService
|
||||
instantiationService.stub(IAgentPluginRepositoryService, {
|
||||
getPluginInstallUri: (plugin: IMarketplacePlugin) => {
|
||||
return URI.joinPath(state.ensureRepositoryResult, plugin.source);
|
||||
},
|
||||
getRepositoryUri: () => state.ensureRepositoryResult,
|
||||
ensureRepository: async (_marketplace: IMarketplaceReference, _options?: IEnsureRepositoryOptions) => {
|
||||
return state.ensureRepositoryResult;
|
||||
},
|
||||
pullRepository: async (marketplace: IMarketplaceReference, options?: IPullRepositoryOptions) => {
|
||||
state.pullRepositoryCalls.push({ marketplace, options });
|
||||
},
|
||||
getPluginSourceInstallUri: (descriptor: IPluginSourceDescriptor) => {
|
||||
const key = descriptor.kind;
|
||||
return state.pluginSourceInstallUris.get(key) ?? URI.file(`/cache/agentPlugins/${key}/default`);
|
||||
},
|
||||
ensurePluginSource: async () => state.ensurePluginSourceResult,
|
||||
updatePluginSource: async (plugin: IMarketplacePlugin, options?: IPullRepositoryOptions) => {
|
||||
state.updatePluginSourceCalls.push({ plugin, options });
|
||||
},
|
||||
} as unknown as IAgentPluginRepositoryService);
|
||||
|
||||
// IPluginMarketplaceService
|
||||
instantiationService.stub(IPluginMarketplaceService, {
|
||||
addInstalledPlugin: (uri: URI, plugin: IMarketplacePlugin) => {
|
||||
state.addedPlugins.push({ uri: uri.toString(), plugin });
|
||||
},
|
||||
} as unknown as IPluginMarketplaceService);
|
||||
|
||||
const service = instantiationService.createInstance(PluginInstallService);
|
||||
return { service, state };
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// getPluginInstallUri
|
||||
// =========================================================================
|
||||
|
||||
suite('getPluginInstallUri', () => {
|
||||
|
||||
test('delegates to getPluginInstallUri for relative-path plugins', () => {
|
||||
const { service } = createService();
|
||||
const plugin = createPlugin({
|
||||
source: 'plugins/myPlugin',
|
||||
sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' },
|
||||
});
|
||||
const uri = service.getPluginInstallUri(plugin);
|
||||
assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/microsoft/vscode/plugins/myPlugin');
|
||||
});
|
||||
|
||||
test('delegates to getPluginSourceInstallUri for npm plugins', () => {
|
||||
const npmUri = URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg');
|
||||
const { service } = createService({
|
||||
pluginSourceInstallUris: new Map([['npm', npmUri]]),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' },
|
||||
});
|
||||
const uri = service.getPluginInstallUri(plugin);
|
||||
assert.strictEqual(uri.path, npmUri.path);
|
||||
});
|
||||
|
||||
test('delegates to getPluginSourceInstallUri for pip plugins', () => {
|
||||
const pipUri = URI.file('/cache/agentPlugins/pip/my-pkg');
|
||||
const { service } = createService({
|
||||
pluginSourceInstallUris: new Map([['pip', pipUri]]),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' },
|
||||
});
|
||||
const uri = service.getPluginInstallUri(plugin);
|
||||
assert.strictEqual(uri.path, pipUri.path);
|
||||
});
|
||||
|
||||
test('delegates to getPluginSourceInstallUri for github plugins', () => {
|
||||
const ghUri = URI.file('/cache/agentPlugins/github.com/owner/repo');
|
||||
const { service } = createService({
|
||||
pluginSourceInstallUris: new Map([['github', ghUri]]),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' },
|
||||
});
|
||||
const uri = service.getPluginInstallUri(plugin);
|
||||
assert.strictEqual(uri.path, ghUri.path);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// installPlugin — relative path
|
||||
// =========================================================================
|
||||
|
||||
suite('installPlugin — relative path', () => {
|
||||
|
||||
test('installs a relative-path plugin when directory exists', async () => {
|
||||
const { service, state } = createService();
|
||||
const plugin = createPlugin({
|
||||
source: 'plugins/myPlugin',
|
||||
sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.addedPlugins.length, 1);
|
||||
assert.ok(state.addedPlugins[0].uri.includes('plugins/myPlugin'));
|
||||
assert.strictEqual(state.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('notifies error when plugin directory does not exist', async () => {
|
||||
const { service, state } = createService({ fileExistsResult: false });
|
||||
const plugin = createPlugin({
|
||||
source: 'plugins/missing',
|
||||
sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/missing' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.addedPlugins.length, 0);
|
||||
assert.strictEqual(state.notifications.length, 1);
|
||||
assert.ok(state.notifications[0].message.includes('not found'));
|
||||
});
|
||||
|
||||
test('does not install when ensureRepository throws', async () => {
|
||||
const { state } = createService();
|
||||
// Override ensureRepository to throw
|
||||
const instantiationService = store.add(new TestInstantiationService());
|
||||
const repoService = {
|
||||
ensureRepository: async () => { throw new Error('clone failed'); },
|
||||
getPluginInstallUri: () => URI.file('/x'),
|
||||
getPluginSourceInstallUri: () => URI.file('/x'),
|
||||
};
|
||||
instantiationService.stub(IAgentPluginRepositoryService, repoService as unknown as IAgentPluginRepositoryService);
|
||||
instantiationService.stub(IFileService, { exists: async () => true } as unknown as IFileService);
|
||||
instantiationService.stub(INotificationService, { notify: (n: { severity: number; message: string }) => { state.notifications.push(n); } } as unknown as INotificationService);
|
||||
instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }) } as unknown as IDialogService);
|
||||
instantiationService.stub(ITerminalService, {} as unknown as ITerminalService);
|
||||
instantiationService.stub(IProgressService, { withProgress: async (_o: unknown, cb: () => Promise<unknown>) => cb() } as unknown as IProgressService);
|
||||
instantiationService.stub(ILogService, new NullLogService());
|
||||
instantiationService.stub(IPluginMarketplaceService, { addInstalledPlugin: () => { } } as unknown as IPluginMarketplaceService);
|
||||
const svc = instantiationService.createInstance(PluginInstallService);
|
||||
|
||||
const plugin = createPlugin({
|
||||
source: 'plugins/myPlugin',
|
||||
sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' },
|
||||
});
|
||||
await svc.installPlugin(plugin);
|
||||
|
||||
// Should return without installing or crashing
|
||||
assert.strictEqual(state.addedPlugins.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// installPlugin — GitHub / GitUrl
|
||||
// =========================================================================
|
||||
|
||||
suite('installPlugin — git sources', () => {
|
||||
|
||||
test('installs a GitHub plugin when source exists after clone', async () => {
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/github.com/owner/repo'),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.addedPlugins.length, 1);
|
||||
assert.strictEqual(state.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('installs a GitUrl plugin when source exists after clone', async () => {
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/example.com/repo'),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.addedPlugins.length, 1);
|
||||
assert.strictEqual(state.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('notifies error when cloned directory does not exist', async () => {
|
||||
const { service, state } = createService({
|
||||
fileExistsResult: false,
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/github.com/owner/repo'),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.addedPlugins.length, 0);
|
||||
assert.strictEqual(state.notifications.length, 1);
|
||||
assert.ok(state.notifications[0].message.includes('not found'));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// installPlugin — npm
|
||||
// =========================================================================
|
||||
|
||||
suite('installPlugin — npm', () => {
|
||||
|
||||
test('runs npm install and registers plugin on success', async () => {
|
||||
const npmInstallUri = URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg');
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'),
|
||||
pluginSourceInstallUris: new Map([['npm', npmInstallUri]]),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.terminalCommands.length, 1);
|
||||
assert.ok(state.terminalCommands[0].includes('npm'));
|
||||
assert.ok(state.terminalCommands[0].includes('install'));
|
||||
assert.ok(state.terminalCommands[0].includes('my-pkg'));
|
||||
assert.strictEqual(state.addedPlugins.length, 1);
|
||||
assert.strictEqual(state.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('includes version in npm install command', async () => {
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'),
|
||||
pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg', version: '1.2.3' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.terminalCommands.length, 1);
|
||||
assert.ok(state.terminalCommands[0].includes('my-pkg@1.2.3'));
|
||||
});
|
||||
|
||||
test('includes registry in npm install command', async () => {
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'),
|
||||
pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg', registry: 'https://custom.registry.com' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.terminalCommands.length, 1);
|
||||
assert.ok(state.terminalCommands[0].includes('--registry'));
|
||||
assert.ok(state.terminalCommands[0].includes('https://custom.registry.com'));
|
||||
});
|
||||
|
||||
test('does not install when user declines confirmation', async () => {
|
||||
const { service, state } = createService({ dialogConfirmResult: false });
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.terminalCommands.length, 0);
|
||||
assert.strictEqual(state.addedPlugins.length, 0);
|
||||
});
|
||||
|
||||
test('notifies error when npm package directory not found after install', async () => {
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'),
|
||||
// exists returns true for ensurePluginSource but false for the final check
|
||||
fileExistsResult: false,
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.addedPlugins.length, 0);
|
||||
assert.strictEqual(state.notifications.length, 1);
|
||||
assert.ok(state.notifications[0].message.includes('not found'));
|
||||
});
|
||||
|
||||
test('notifies error when terminal command fails with non-zero exit code', async () => {
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'),
|
||||
terminalExitCode: 1,
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.addedPlugins.length, 0);
|
||||
assert.strictEqual(state.notifications.length, 1);
|
||||
assert.ok(state.notifications[0].message.includes('failed'));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// installPlugin — pip
|
||||
// =========================================================================
|
||||
|
||||
suite('installPlugin — pip', () => {
|
||||
|
||||
test('runs pip install and registers plugin on success', async () => {
|
||||
const pipInstallUri = URI.file('/cache/agentPlugins/pip/my-pkg');
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'),
|
||||
pluginSourceInstallUris: new Map([['pip', pipInstallUri]]),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.terminalCommands.length, 1);
|
||||
assert.ok(state.terminalCommands[0].includes('pip'));
|
||||
assert.ok(state.terminalCommands[0].includes('install'));
|
||||
assert.ok(state.terminalCommands[0].includes('my-pkg'));
|
||||
assert.strictEqual(state.addedPlugins.length, 1);
|
||||
assert.strictEqual(state.notifications.length, 0);
|
||||
});
|
||||
|
||||
test('includes version with == syntax in pip install command', async () => {
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'),
|
||||
pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg', version: '2.0.0' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.terminalCommands.length, 1);
|
||||
assert.ok(state.terminalCommands[0].includes('my-pkg==2.0.0'));
|
||||
});
|
||||
|
||||
test('includes registry with --index-url in pip install command', async () => {
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'),
|
||||
pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg', registry: 'https://pypi.custom.com/simple' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.terminalCommands.length, 1);
|
||||
assert.ok(state.terminalCommands[0].includes('--index-url'));
|
||||
assert.ok(state.terminalCommands[0].includes('https://pypi.custom.com/simple'));
|
||||
});
|
||||
|
||||
test('does not install when user declines confirmation', async () => {
|
||||
const { service, state } = createService({ dialogConfirmResult: false });
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.terminalCommands.length, 0);
|
||||
assert.strictEqual(state.addedPlugins.length, 0);
|
||||
});
|
||||
|
||||
test('notifies error when pip package directory not found after install', async () => {
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'),
|
||||
fileExistsResult: false,
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' },
|
||||
});
|
||||
|
||||
await service.installPlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.addedPlugins.length, 0);
|
||||
assert.strictEqual(state.notifications.length, 1);
|
||||
assert.ok(state.notifications[0].message.includes('not found'));
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// updatePlugin
|
||||
// =========================================================================
|
||||
|
||||
suite('updatePlugin', () => {
|
||||
|
||||
test('calls pullRepository for relative-path plugins', async () => {
|
||||
const { service, state } = createService();
|
||||
const plugin = createPlugin({
|
||||
source: 'plugins/myPlugin',
|
||||
sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' },
|
||||
});
|
||||
|
||||
await service.updatePlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.pullRepositoryCalls.length, 1);
|
||||
assert.strictEqual(state.updatePluginSourceCalls.length, 0);
|
||||
});
|
||||
|
||||
test('calls updatePluginSource for GitHub plugins', async () => {
|
||||
const { service, state } = createService();
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' },
|
||||
});
|
||||
|
||||
await service.updatePlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.updatePluginSourceCalls.length, 1);
|
||||
assert.strictEqual(state.pullRepositoryCalls.length, 0);
|
||||
});
|
||||
|
||||
test('calls updatePluginSource for GitUrl plugins', async () => {
|
||||
const { service, state } = createService();
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' },
|
||||
});
|
||||
|
||||
await service.updatePlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.updatePluginSourceCalls.length, 1);
|
||||
assert.strictEqual(state.pullRepositoryCalls.length, 0);
|
||||
});
|
||||
|
||||
test('re-installs for npm plugin updates', async () => {
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'),
|
||||
pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' },
|
||||
});
|
||||
|
||||
await service.updatePlugin(plugin);
|
||||
|
||||
// npm update goes through the same install flow
|
||||
assert.strictEqual(state.terminalCommands.length, 1);
|
||||
assert.ok(state.terminalCommands[0].includes('npm'));
|
||||
});
|
||||
|
||||
test('re-installs for pip plugin updates', async () => {
|
||||
const { service, state } = createService({
|
||||
ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'),
|
||||
pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]),
|
||||
});
|
||||
const plugin = createPlugin({
|
||||
sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' },
|
||||
});
|
||||
|
||||
await service.updatePlugin(plugin);
|
||||
|
||||
assert.strictEqual(state.terminalCommands.length, 1);
|
||||
assert.ok(state.terminalCommands[0].includes('pip'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import { IStorageService, InMemoryStorageService } from '../../../../../../platf
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
|
||||
import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPluginRepositoryService.js';
|
||||
import { ChatConfiguration } from '../../../common/constants.js';
|
||||
import { MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, parseMarketplaceReference, parseMarketplaceReferences } from '../../../common/plugins/pluginMarketplaceService.js';
|
||||
import { MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource } from '../../../common/plugins/pluginMarketplaceService.js';
|
||||
|
||||
suite('PluginMarketplaceService', () => {
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
@@ -142,6 +142,7 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => {
|
||||
description: 'A test plugin',
|
||||
version: '2.0.0',
|
||||
source: 'plugins/my-plugin',
|
||||
sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/my-plugin' } as const,
|
||||
marketplace: marketplaceRef.displayLabel,
|
||||
marketplaceReference: marketplaceRef,
|
||||
marketplaceType: MarketplaceType.Copilot,
|
||||
@@ -165,3 +166,152 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => {
|
||||
assert.strictEqual(result, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
suite('parsePluginSource', () => {
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
const logContext = {
|
||||
pluginName: 'test',
|
||||
logService: new NullLogService(),
|
||||
logPrefix: '[test]',
|
||||
};
|
||||
|
||||
test('parses string source as RelativePath', () => {
|
||||
const result = parsePluginSource('./my-plugin', undefined, logContext);
|
||||
assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'my-plugin' });
|
||||
});
|
||||
|
||||
test('parses string source with pluginRoot', () => {
|
||||
const result = parsePluginSource('sub', 'plugins', logContext);
|
||||
assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'plugins/sub' });
|
||||
});
|
||||
|
||||
test('parses undefined source as RelativePath using pluginRoot', () => {
|
||||
const result = parsePluginSource(undefined, 'root', logContext);
|
||||
assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'root' });
|
||||
});
|
||||
|
||||
test('parses empty string source as RelativePath using pluginRoot', () => {
|
||||
const result = parsePluginSource('', 'base', logContext);
|
||||
assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'base' });
|
||||
});
|
||||
|
||||
test('returns undefined for empty source without pluginRoot', () => {
|
||||
assert.strictEqual(parsePluginSource('', undefined, logContext), undefined);
|
||||
});
|
||||
|
||||
test('parses github object source', () => {
|
||||
const result = parsePluginSource({ source: 'github', repo: 'owner/repo' }, undefined, logContext);
|
||||
assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: undefined, sha: undefined });
|
||||
});
|
||||
|
||||
test('parses github object source with ref and sha', () => {
|
||||
const result = parsePluginSource({ source: 'github', repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }, undefined, logContext);
|
||||
assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' });
|
||||
});
|
||||
|
||||
test('returns undefined for github source missing repo', () => {
|
||||
assert.strictEqual(parsePluginSource({ source: 'github' }, undefined, logContext), undefined);
|
||||
});
|
||||
|
||||
test('returns undefined for github source with invalid repo format', () => {
|
||||
assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner' }, undefined, logContext), undefined);
|
||||
});
|
||||
|
||||
test('returns undefined for github source with invalid sha', () => {
|
||||
assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner/repo', sha: 'abc123' }, undefined, logContext), undefined);
|
||||
});
|
||||
|
||||
test('parses url object source', () => {
|
||||
const result = parsePluginSource({ source: 'url', url: 'https://gitlab.com/team/plugin.git' }, undefined, logContext);
|
||||
assert.deepStrictEqual(result, { kind: PluginSourceKind.GitUrl, url: 'https://gitlab.com/team/plugin.git', ref: undefined, sha: undefined });
|
||||
});
|
||||
|
||||
test('returns undefined for url source missing url field', () => {
|
||||
assert.strictEqual(parsePluginSource({ source: 'url' }, undefined, logContext), undefined);
|
||||
});
|
||||
|
||||
test('returns undefined for url source not ending in .git', () => {
|
||||
assert.strictEqual(parsePluginSource({ source: 'url', url: 'https://gitlab.com/team/plugin' }, undefined, logContext), undefined);
|
||||
});
|
||||
|
||||
test('parses npm object source', () => {
|
||||
const result = parsePluginSource({ source: 'npm', package: '@acme/claude-plugin' }, undefined, logContext);
|
||||
assert.deepStrictEqual(result, { kind: PluginSourceKind.Npm, package: '@acme/claude-plugin', version: undefined, registry: undefined });
|
||||
});
|
||||
|
||||
test('parses npm object source with version and registry', () => {
|
||||
const result = parsePluginSource({ source: 'npm', package: '@acme/claude-plugin', version: '2.1.0', registry: 'https://npm.example.com' }, undefined, logContext);
|
||||
assert.deepStrictEqual(result, { kind: PluginSourceKind.Npm, package: '@acme/claude-plugin', version: '2.1.0', registry: 'https://npm.example.com' });
|
||||
});
|
||||
|
||||
test('returns undefined for npm source missing package', () => {
|
||||
assert.strictEqual(parsePluginSource({ source: 'npm' }, undefined, logContext), undefined);
|
||||
});
|
||||
|
||||
test('returns undefined for npm source with non-string version', () => {
|
||||
assert.strictEqual(parsePluginSource({ source: 'npm', package: '@acme/claude-plugin', version: 123 } as never, undefined, logContext), undefined);
|
||||
});
|
||||
|
||||
test('parses pip object source', () => {
|
||||
const result = parsePluginSource({ source: 'pip', package: 'my-plugin' }, undefined, logContext);
|
||||
assert.deepStrictEqual(result, { kind: PluginSourceKind.Pip, package: 'my-plugin', version: undefined, registry: undefined });
|
||||
});
|
||||
|
||||
test('parses pip object source with version and registry', () => {
|
||||
const result = parsePluginSource({ source: 'pip', package: 'my-plugin', version: '1.0.0', registry: 'https://pypi.example.com' }, undefined, logContext);
|
||||
assert.deepStrictEqual(result, { kind: PluginSourceKind.Pip, package: 'my-plugin', version: '1.0.0', registry: 'https://pypi.example.com' });
|
||||
});
|
||||
|
||||
test('returns undefined for pip source missing package', () => {
|
||||
assert.strictEqual(parsePluginSource({ source: 'pip' }, undefined, logContext), undefined);
|
||||
});
|
||||
|
||||
test('returns undefined for pip source with non-string registry', () => {
|
||||
assert.strictEqual(parsePluginSource({ source: 'pip', package: 'my-plugin', registry: 42 } as never, undefined, logContext), undefined);
|
||||
});
|
||||
|
||||
test('returns undefined for unknown source kind', () => {
|
||||
assert.strictEqual(parsePluginSource({ source: 'unknown' }, undefined, logContext), undefined);
|
||||
});
|
||||
|
||||
test('returns undefined for object source without source discriminant', () => {
|
||||
assert.strictEqual(parsePluginSource({ package: 'test' } as never, undefined, logContext), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
suite('getPluginSourceLabel', () => {
|
||||
ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('formats relative path', () => {
|
||||
assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.RelativePath, path: 'plugins/foo' }), 'plugins/foo');
|
||||
});
|
||||
|
||||
test('formats empty relative path', () => {
|
||||
assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.RelativePath, path: '' }), '.');
|
||||
});
|
||||
|
||||
test('formats github source', () => {
|
||||
assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitHub, repo: 'owner/repo' }), 'owner/repo');
|
||||
});
|
||||
|
||||
test('formats url source', () => {
|
||||
assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }), 'https://example.com/repo.git');
|
||||
});
|
||||
|
||||
test('formats npm source without version', () => {
|
||||
assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Npm, package: '@acme/plugin' }), '@acme/plugin');
|
||||
});
|
||||
|
||||
test('formats npm source with version', () => {
|
||||
assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Npm, package: '@acme/plugin', version: '1.0.0' }), '@acme/plugin@1.0.0');
|
||||
});
|
||||
|
||||
test('formats pip source without version', () => {
|
||||
assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Pip, package: 'my-plugin' }), 'my-plugin');
|
||||
});
|
||||
|
||||
test('formats pip source with version', () => {
|
||||
assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Pip, package: 'my-plugin', version: '2.0' }), 'my-plugin==2.0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { match } from '../../../../../../../base/common/glob.js';
|
||||
import { ResourceSet } from '../../../../../../../base/common/map.js';
|
||||
import { Schemas } from '../../../../../../../base/common/network.js';
|
||||
import { ISettableObservable, observableValue } from '../../../../../../../base/common/observable.js';
|
||||
import { relativePath } from '../../../../../../../base/common/resources.js';
|
||||
import { basename, relativePath } from '../../../../../../../base/common/resources.js';
|
||||
import { URI } from '../../../../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
|
||||
import { Range } from '../../../../../../../editor/common/core/range.js';
|
||||
@@ -3505,6 +3505,7 @@ suite('PromptsService', () => {
|
||||
return {
|
||||
plugin: {
|
||||
uri: URI.file(path),
|
||||
label: basename(URI.file(path)),
|
||||
enabled,
|
||||
setEnabled: () => { },
|
||||
remove: () => { },
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Disposable, DisposableResourceMap } from '../../../../../base/common/li
|
||||
import { ResourceSet } from '../../../../../base/common/map.js';
|
||||
import { Schemas } from '../../../../../base/common/network.js';
|
||||
import { autorun } from '../../../../../base/common/observable.js';
|
||||
import { basename } from '../../../../../base/common/resources.js';
|
||||
import { isDefined } from '../../../../../base/common/types.js';
|
||||
import { URI } from '../../../../../base/common/uri.js';
|
||||
import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js';
|
||||
@@ -61,7 +60,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery {
|
||||
const collectionId = `plugin.${plugin.uri}`;
|
||||
return this._mcpRegistry.registerCollection({
|
||||
id: collectionId,
|
||||
label: `${basename(plugin.uri)} (Agent Plugin)`,
|
||||
label: `${plugin.label} (Agent Plugin)`,
|
||||
remoteAuthority: plugin.uri.scheme === Schemas.vscodeRemote ? plugin.uri.authority : null,
|
||||
configTarget: ConfigurationTarget.USER,
|
||||
scope: StorageScope.PROFILE,
|
||||
|
||||
Reference in New Issue
Block a user