Path: blob/main/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Action } from '../../../../base/common/actions.js';6import { CancellationToken } from '../../../../base/common/cancellation.js';7import { Codicon } from '../../../../base/common/codicons.js';8import { CancellationError } from '../../../../base/common/errors.js';9import { URI } from '../../../../base/common/uri.js';10import { localize } from '../../../../nls.js';11import { ICommandService } from '../../../../platform/commands/common/commands.js';12import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';13import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';14import { IFileService } from '../../../../platform/files/common/files.js';15import { ILogService } from '../../../../platform/log/common/log.js';16import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';17import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js';18import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';19import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js';20import { ChatConfiguration } from '../common/constants.js';21import { IPluginInstallService, IInstallPluginFromSourceOptions, IInstallPluginFromSourceResult, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../common/plugins/pluginInstallService.js';22import { IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, MarketplaceReferenceKind, MarketplaceType, hasSourceChanged, parseMarketplaceReference, parseMarketplaceReferences, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js';2324export class PluginInstallService implements IPluginInstallService {25declare readonly _serviceBrand: undefined;2627constructor(28@IAgentPluginRepositoryService private readonly _pluginRepositoryService: IAgentPluginRepositoryService,29@IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService,30@IFileService private readonly _fileService: IFileService,31@INotificationService private readonly _notificationService: INotificationService,32@IDialogService private readonly _dialogService: IDialogService,33@ILogService private readonly _logService: ILogService,34@IProgressService private readonly _progressService: IProgressService,35@ICommandService private readonly _commandService: ICommandService,36@IQuickInputService private readonly _quickInputService: IQuickInputService,37@IConfigurationService private readonly _configurationService: IConfigurationService,38) { }3940async installPlugin(plugin: IMarketplacePlugin): Promise<void> {41if (!await this._ensureMarketplaceTrusted(plugin)) {42throw new CancellationError();43}4445const kind = plugin.sourceDescriptor.kind;4647if (kind === PluginSourceKind.RelativePath) {48return this._installRelativePathPlugin(plugin);49}5051if (kind === PluginSourceKind.Npm || kind === PluginSourceKind.Pip) {52await this._installPackagePlugin(plugin);53return;54}5556// GitHub / GitUrl57return this._installGitPlugin(plugin);58}5960async installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise<void> {61const reference = parseMarketplaceReference(source);62if (!reference) {63this._notificationService.notify({64severity: Severity.Error,65message: localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo) or a git clone URL.", source),66});67return;68}6970if (reference.kind === MarketplaceReferenceKind.LocalFileUri) {71this._notificationService.notify({72severity: Severity.Error,73message: localize('localSourceNotSupported', "Local file paths are not supported. Enter a GitHub repository (owner/repo) or a git clone URL."),74});75return;76}7778const result = await this._doInstallFromSource(reference, options);79if (!result.success && result.message) {80this._notificationService.notify({81severity: Severity.Error,82message: result.message,83});84}85}8687validatePluginSource(source: string): string | undefined {88const reference = parseMarketplaceReference(source);89if (!reference) {90return localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo) or a git clone URL.", source);91}92if (reference.kind === MarketplaceReferenceKind.LocalFileUri) {93return localize('localSourceNotSupported', "Local file paths are not supported. Enter a GitHub repository (owner/repo) or a git clone URL.");94}95return undefined;96}9798async installPluginFromValidatedSource(source: string, options?: IInstallPluginFromSourceOptions): Promise<IInstallPluginFromSourceResult> {99const reference = parseMarketplaceReference(source);100if (!reference) {101return {102success: false,103message: localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo) or a git clone URL.", source),104};105}106if (reference.kind === MarketplaceReferenceKind.LocalFileUri) {107return {108success: false,109message: localize('localSourceNotSupported', "Local file paths are not supported. Enter a GitHub repository (owner/repo) or a git clone URL."),110};111}112113return this._doInstallFromSource(reference, options);114}115116private async _doInstallFromSource(reference: IMarketplaceReference, options?: IInstallPluginFromSourceOptions): Promise<IInstallPluginFromSourceResult> {117// Build a source descriptor for the git clone.118const sourceDescriptor = reference.kind === MarketplaceReferenceKind.GitHubShorthand119? { kind: PluginSourceKind.GitHub as const, repo: reference.githubRepo! }120: { kind: PluginSourceKind.GitUrl as const, url: reference.cloneUrl };121122// Build a temporary plugin object for the trust gate and clone step.123const tempPlugin: IMarketplacePlugin = {124name: reference.displayLabel,125description: '',126version: '',127source: '',128sourceDescriptor,129marketplace: reference.displayLabel,130marketplaceReference: reference,131marketplaceType: MarketplaceType.OpenPlugin,132};133134if (!await this._ensureMarketplaceTrusted(tempPlugin)) {135return { success: false };136}137138// Clone the repository.139let repoDir: URI;140try {141repoDir = await this._pluginRepositoryService.ensurePluginSource(tempPlugin, {142progressTitle: localize('cloningSource', "Cloning plugin source '{0}'...", reference.displayLabel),143failureLabel: reference.displayLabel,144marketplaceType: MarketplaceType.OpenPlugin,145});146} catch (e) {147const detail = e instanceof Error ? e.message : String(e);148return {149success: false,150message: localize('cloneFailedDetail', "Failed to clone plugin source '{0}': {1}", reference.displayLabel, detail),151};152}153154const repoExists = await this._fileService.exists(repoDir);155if (!repoExists) {156return {157success: false,158message: localize('cloneFailed', "Failed to clone plugin source '{0}'.", reference.displayLabel),159};160}161162// Scan for marketplace.json to discover plugins.163const discoveredPlugins = await this._pluginMarketplaceService.readPluginsFromDirectory(repoDir, reference);164165if (discoveredPlugins.length === 0) {166void this._pluginRepositoryService.cleanupPluginSource(tempPlugin);167return {168success: false,169message: localize('noPluginsFound', "No plugins found in '{0}'. This does not appear to be a valid plugin marketplace.", reference.displayLabel),170};171}172173// When targeting a specific plugin, find it, register it, and return.174if (options?.plugin) {175const matchedPlugin = discoveredPlugins.find(p => p.name === options.plugin);176if (!matchedPlugin) {177return {178success: false,179message: localize('pluginNotFound', "Plugin '{0}' not found in '{1}'.", options.plugin, reference.displayLabel),180};181}182await this._addMarketplaceToConfig(reference);183await this.installPlugin(matchedPlugin);184return { success: true, matchedPlugin };185}186187if (discoveredPlugins.length === 1) {188await this._addMarketplaceToConfig(reference);189await this.installPlugin(discoveredPlugins[0]);190return { success: true };191}192193// Multiple plugins — let the user choose.194const picks: (IQuickPickItem & { plugin: IMarketplacePlugin })[] = discoveredPlugins.map(p => ({195label: p.name,196description: p.description,197plugin: p,198}));199200const selected = await this._quickInputService.pick(picks, {201placeHolder: localize('selectPlugin', "Select a plugin to install from '{0}'", reference.displayLabel),202canPickMany: false,203});204205if (!selected) {206return { success: false };207}208209await this._addMarketplaceToConfig(reference);210await this.installPlugin(selected.plugin);211212return { success: true };213}214215private _addMarketplaceToConfig(reference: IMarketplaceReference) {216const currentValues = this._configurationService.getValue<unknown[]>(ChatConfiguration.PluginMarketplaces) ?? [];217const existingRefs = parseMarketplaceReferences(currentValues);218if (existingRefs.some(r => r.canonicalId === reference.canonicalId)) {219return;220}221return this._configurationService.updateValue(ChatConfiguration.PluginMarketplaces, [...currentValues, reference.rawValue]);222}223224async updatePlugin(plugin: IMarketplacePlugin, silent?: boolean): Promise<boolean> {225const kind = plugin.sourceDescriptor.kind;226227if (kind === PluginSourceKind.Npm || kind === PluginSourceKind.Pip) {228// Package-manager "update" re-runs install via terminal229return this._installPackagePlugin(plugin, silent);230}231232// For relative-path and git sources, delegate to repository service233return this._pluginRepositoryService.updatePluginSource(plugin, {234pluginName: plugin.name,235failureLabel: plugin.name,236marketplaceType: plugin.marketplaceType,237});238}239240async updateAllPlugins(options: IUpdateAllPluginsOptions, token: CancellationToken): Promise<IUpdateAllPluginsResult> {241const installed = this._pluginMarketplaceService.installedPlugins.get();242if (installed.length === 0) {243return { updatedNames: [], failedNames: [] };244}245246const updatedNames: string[] = [];247const failedNames: string[] = [];248249const doUpdate = async () => {250const gitTasks: Promise<void>[] = [];251const packagePlugins: { installed: IMarketplacePlugin; marketplace: IMarketplacePlugin }[] = [];252253// 1. Pull each unique marketplace repository first (handles all254// relative-path plugins and ensures the marketplace index on255// disk is up-to-date before we re-read it).256const seenMarketplaces = new Set<string>();257for (const entry of installed) {258const ref = entry.plugin.marketplaceReference;259if (seenMarketplaces.has(ref.canonicalId)) {260continue;261}262seenMarketplaces.add(ref.canonicalId);263gitTasks.push((async () => {264if (token.isCancellationRequested) {265return;266}267268try {269const changed = await this._pluginRepositoryService.pullRepository(ref, {270pluginName: ref.displayLabel,271failureLabel: ref.displayLabel,272marketplaceType: entry.plugin.marketplaceType,273silent: options.silent,274});275if (changed) {276updatedNames.push(ref.displayLabel);277}278} catch (err) {279this._logService.error(`[PluginInstallService] Failed to pull marketplace '${ref.displayLabel}':`, err);280failedNames.push(ref.displayLabel);281}282})());283}284285await Promise.all(gitTasks);286287// 2. Re-fetch marketplace data *after* pulling so we see any288// updated plugin descriptors (new versions, refs, etc.).289const marketplacePlugins = await this._pluginMarketplaceService.fetchMarketplacePlugins(token);290const marketplaceByKey = new Map<string, IMarketplacePlugin>();291for (const mp of marketplacePlugins) {292marketplaceByKey.set(`${mp.marketplaceReference.canonicalId}::${mp.name}`, mp);293}294295// 3. Update non-relative-path plugins individually.296const independentGitTasks: Promise<void>[] = [];297for (const entry of installed) {298if (entry.plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) {299continue;300}301302const livePlugin = marketplaceByKey.get(`${entry.plugin.marketplaceReference.canonicalId}::${entry.plugin.name}`);303if (!livePlugin || !hasSourceChanged(entry.plugin.sourceDescriptor, livePlugin.sourceDescriptor)) {304continue;305}306307const desc = livePlugin.sourceDescriptor;308if (desc.kind === PluginSourceKind.Npm || desc.kind === PluginSourceKind.Pip) {309if (!options.force && !desc.version) {310continue;311}312packagePlugins.push({ installed: entry.plugin, marketplace: livePlugin });313continue;314}315316independentGitTasks.push((async () => {317if (token.isCancellationRequested) {318return;319}320321try {322const changed = await this._pluginRepositoryService.updatePluginSource(livePlugin, {323pluginName: livePlugin.name,324failureLabel: livePlugin.name,325marketplaceType: livePlugin.marketplaceType,326silent: options.silent,327});328if (changed) {329updatedNames.push(livePlugin.name);330this._pluginMarketplaceService.addInstalledPlugin(entry.pluginUri, livePlugin);331}332} catch (err) {333this._logService.error(`[PluginInstallService] Failed to update plugin '${livePlugin.name}':`, err);334failedNames.push(livePlugin.name);335}336})());337}338339await Promise.all(independentGitTasks);340341for (const { installed: _installed, marketplace } of packagePlugins) {342if (token.isCancellationRequested) {343return;344}345346try {347const changed = await this.updatePlugin(marketplace, options?.silent);348if (changed) {349updatedNames.push(marketplace.name);350const pluginUri = this._pluginRepositoryService.getPluginSourceInstallUri(marketplace.sourceDescriptor);351this._pluginMarketplaceService.addInstalledPlugin(pluginUri, marketplace);352}353} catch (err) {354this._logService.error(`[PluginInstallService] Failed to update plugin '${marketplace.name}':`, err);355failedNames.push(marketplace.name);356}357}358};359360if (options.silent) {361await doUpdate();362} else {363await this._progressService.withProgress(364{365location: ProgressLocation.Notification,366title: localize('updatingAllPlugins', "Updating plugins..."),367},368doUpdate,369);370}371372if (failedNames.length > 0) {373this._notificationService.notify({374severity: Severity.Error,375message: localize('updateAllFailed', "Failed to update: {0}", failedNames.join(', ')),376actions: {377primary: [new Action('showGitOutput', localize('showOutput', "Show Output"), undefined, true, () => {378this._commandService.executeCommand('git.showOutput');379})],380},381});382} else if (updatedNames.length > 0) {383this._pluginMarketplaceService.clearUpdatesAvailable();384this._notificationService.notify({385severity: Severity.Info,386message: localize('updateAllSuccess', "Updated plugins: {0}", updatedNames.join(', ')),387});388} else if (!token.isCancellationRequested) {389this._pluginMarketplaceService.clearUpdatesAvailable();390}391392return { updatedNames, failedNames };393}394395getPluginInstallUri(plugin: IMarketplacePlugin): URI {396if (plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) {397return this._pluginRepositoryService.getPluginInstallUri(plugin);398}399return this._pluginRepositoryService.getPluginSourceInstallUri(plugin.sourceDescriptor);400}401402// --- Trust gate -------------------------------------------------------------403404private async _ensureMarketplaceTrusted(plugin: IMarketplacePlugin): Promise<boolean> {405if (this._pluginMarketplaceService.isMarketplaceTrusted(plugin.marketplaceReference)) {406return true;407}408409const { confirmed } = await this._dialogService.confirm({410type: 'question',411message: localize('trustMarketplace', "Trust Plugins from '{0}'?", plugin.marketplaceReference.displayLabel),412detail: localize('trustMarketplaceDetail', "Plugins can run code on your machine. Only install plugins from sources you trust.\n\nSource: {0}", plugin.marketplaceReference.rawValue),413primaryButton: localize({ key: 'trustAndInstall', comment: ['&& denotes a mnemonic'] }, "&&Trust"),414custom: {415icon: Codicon.shield,416},417});418419if (!confirmed) {420return false;421}422423this._pluginMarketplaceService.trustMarketplace(plugin.marketplaceReference);424return true;425}426427// --- Relative-path source (existing git-based flow) -----------------------428429private async _installRelativePathPlugin(plugin: IMarketplacePlugin): Promise<void> {430try {431await this._pluginRepositoryService.ensureRepository(plugin.marketplaceReference, {432progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name),433failureLabel: plugin.name,434marketplaceType: plugin.marketplaceType,435});436} catch {437return;438}439440let pluginDir: URI;441try {442pluginDir = this._pluginRepositoryService.getPluginInstallUri(plugin);443} catch {444this._notificationService.notify({445severity: Severity.Error,446message: localize('pluginDirInvalid', "Plugin source directory '{0}' is invalid for repository '{1}'.", plugin.source, plugin.marketplace),447});448return;449}450451const pluginExists = await this._fileService.exists(pluginDir);452if (!pluginExists) {453this._notificationService.notify({454severity: Severity.Error,455message: localize('pluginDirNotFound', "Plugin source directory '{0}' not found in repository '{1}'.", plugin.source, plugin.marketplace),456});457return;458}459460this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin);461}462463// --- GitHub / Git URL source (independent clone) --------------------------464465private async _installGitPlugin(plugin: IMarketplacePlugin): Promise<void> {466const repo = this._pluginRepositoryService.getPluginSource(plugin.sourceDescriptor.kind);467let pluginDir: URI;468try {469pluginDir = await this._pluginRepositoryService.ensurePluginSource(plugin, {470progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name),471failureLabel: plugin.name,472marketplaceType: plugin.marketplaceType,473});474} catch {475return;476}477478const pluginExists = await this._fileService.exists(pluginDir);479if (!pluginExists) {480this._notificationService.notify({481severity: Severity.Error,482message: localize('pluginSourceNotFound', "Plugin source '{0}' not found after cloning.", repo.getLabel(plugin.sourceDescriptor)),483});484return;485}486487this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin);488}489490// --- Package-manager sources (npm / pip) ----------------------------------491492private async _installPackagePlugin(plugin: IMarketplacePlugin, silent?: boolean): Promise<boolean> {493const repo = this._pluginRepositoryService.getPluginSource(plugin.sourceDescriptor.kind);494if (!repo.runInstall) {495this._logService.error(`[PluginInstallService] Expected package repository for kind '${plugin.sourceDescriptor.kind}'`);496return false;497}498499// Ensure the parent cache directory exists (returns npm/<pkg> or pip/<pkg>)500const installDir = await this._pluginRepositoryService.ensurePluginSource(plugin);501// The actual plugin content location (e.g. npm/<pkg>/node_modules/<pkg>)502const pluginDir = this._pluginRepositoryService.getPluginSourceInstallUri(plugin.sourceDescriptor);503504const result = await repo.runInstall(installDir, pluginDir, plugin, { silent });505if (!result) {506return false;507}508509this._pluginMarketplaceService.addInstalledPlugin(result.pluginDir, plugin);510return true;511}512}513514515