Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Action } from '../../../../base/common/actions.js';
7
import { CancellationToken } from '../../../../base/common/cancellation.js';
8
import { Codicon } from '../../../../base/common/codicons.js';
9
import { CancellationError } from '../../../../base/common/errors.js';
10
import { URI } from '../../../../base/common/uri.js';
11
import { localize } from '../../../../nls.js';
12
import { ICommandService } from '../../../../platform/commands/common/commands.js';
13
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
14
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
15
import { IFileService } from '../../../../platform/files/common/files.js';
16
import { ILogService } from '../../../../platform/log/common/log.js';
17
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
18
import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js';
19
import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
20
import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js';
21
import { ChatConfiguration } from '../common/constants.js';
22
import { IPluginInstallService, IInstallPluginFromSourceOptions, IInstallPluginFromSourceResult, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../common/plugins/pluginInstallService.js';
23
import { IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, MarketplaceReferenceKind, MarketplaceType, hasSourceChanged, parseMarketplaceReference, parseMarketplaceReferences, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js';
24
25
export class PluginInstallService implements IPluginInstallService {
26
declare readonly _serviceBrand: undefined;
27
28
constructor(
29
@IAgentPluginRepositoryService private readonly _pluginRepositoryService: IAgentPluginRepositoryService,
30
@IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService,
31
@IFileService private readonly _fileService: IFileService,
32
@INotificationService private readonly _notificationService: INotificationService,
33
@IDialogService private readonly _dialogService: IDialogService,
34
@ILogService private readonly _logService: ILogService,
35
@IProgressService private readonly _progressService: IProgressService,
36
@ICommandService private readonly _commandService: ICommandService,
37
@IQuickInputService private readonly _quickInputService: IQuickInputService,
38
@IConfigurationService private readonly _configurationService: IConfigurationService,
39
) { }
40
41
async installPlugin(plugin: IMarketplacePlugin): Promise<void> {
42
if (!await this._ensureMarketplaceTrusted(plugin)) {
43
throw new CancellationError();
44
}
45
46
const kind = plugin.sourceDescriptor.kind;
47
48
if (kind === PluginSourceKind.RelativePath) {
49
return this._installRelativePathPlugin(plugin);
50
}
51
52
if (kind === PluginSourceKind.Npm || kind === PluginSourceKind.Pip) {
53
await this._installPackagePlugin(plugin);
54
return;
55
}
56
57
// GitHub / GitUrl
58
return this._installGitPlugin(plugin);
59
}
60
61
async installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise<void> {
62
const reference = parseMarketplaceReference(source);
63
if (!reference) {
64
this._notificationService.notify({
65
severity: Severity.Error,
66
message: localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo) or a git clone URL.", source),
67
});
68
return;
69
}
70
71
if (reference.kind === MarketplaceReferenceKind.LocalFileUri) {
72
this._notificationService.notify({
73
severity: Severity.Error,
74
message: localize('localSourceNotSupported', "Local file paths are not supported. Enter a GitHub repository (owner/repo) or a git clone URL."),
75
});
76
return;
77
}
78
79
const result = await this._doInstallFromSource(reference, options);
80
if (!result.success && result.message) {
81
this._notificationService.notify({
82
severity: Severity.Error,
83
message: result.message,
84
});
85
}
86
}
87
88
validatePluginSource(source: string): string | undefined {
89
const reference = parseMarketplaceReference(source);
90
if (!reference) {
91
return localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo) or a git clone URL.", source);
92
}
93
if (reference.kind === MarketplaceReferenceKind.LocalFileUri) {
94
return localize('localSourceNotSupported', "Local file paths are not supported. Enter a GitHub repository (owner/repo) or a git clone URL.");
95
}
96
return undefined;
97
}
98
99
async installPluginFromValidatedSource(source: string, options?: IInstallPluginFromSourceOptions): Promise<IInstallPluginFromSourceResult> {
100
const reference = parseMarketplaceReference(source);
101
if (!reference) {
102
return {
103
success: false,
104
message: localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo) or a git clone URL.", source),
105
};
106
}
107
if (reference.kind === MarketplaceReferenceKind.LocalFileUri) {
108
return {
109
success: false,
110
message: localize('localSourceNotSupported', "Local file paths are not supported. Enter a GitHub repository (owner/repo) or a git clone URL."),
111
};
112
}
113
114
return this._doInstallFromSource(reference, options);
115
}
116
117
private async _doInstallFromSource(reference: IMarketplaceReference, options?: IInstallPluginFromSourceOptions): Promise<IInstallPluginFromSourceResult> {
118
// Build a source descriptor for the git clone.
119
const sourceDescriptor = reference.kind === MarketplaceReferenceKind.GitHubShorthand
120
? { kind: PluginSourceKind.GitHub as const, repo: reference.githubRepo! }
121
: { kind: PluginSourceKind.GitUrl as const, url: reference.cloneUrl };
122
123
// Build a temporary plugin object for the trust gate and clone step.
124
const tempPlugin: IMarketplacePlugin = {
125
name: reference.displayLabel,
126
description: '',
127
version: '',
128
source: '',
129
sourceDescriptor,
130
marketplace: reference.displayLabel,
131
marketplaceReference: reference,
132
marketplaceType: MarketplaceType.OpenPlugin,
133
};
134
135
if (!await this._ensureMarketplaceTrusted(tempPlugin)) {
136
return { success: false };
137
}
138
139
// Clone the repository.
140
let repoDir: URI;
141
try {
142
repoDir = await this._pluginRepositoryService.ensurePluginSource(tempPlugin, {
143
progressTitle: localize('cloningSource', "Cloning plugin source '{0}'...", reference.displayLabel),
144
failureLabel: reference.displayLabel,
145
marketplaceType: MarketplaceType.OpenPlugin,
146
});
147
} catch (e) {
148
const detail = e instanceof Error ? e.message : String(e);
149
return {
150
success: false,
151
message: localize('cloneFailedDetail', "Failed to clone plugin source '{0}': {1}", reference.displayLabel, detail),
152
};
153
}
154
155
const repoExists = await this._fileService.exists(repoDir);
156
if (!repoExists) {
157
return {
158
success: false,
159
message: localize('cloneFailed', "Failed to clone plugin source '{0}'.", reference.displayLabel),
160
};
161
}
162
163
// Scan for marketplace.json to discover plugins.
164
const discoveredPlugins = await this._pluginMarketplaceService.readPluginsFromDirectory(repoDir, reference);
165
166
if (discoveredPlugins.length === 0) {
167
void this._pluginRepositoryService.cleanupPluginSource(tempPlugin);
168
return {
169
success: false,
170
message: localize('noPluginsFound', "No plugins found in '{0}'. This does not appear to be a valid plugin marketplace.", reference.displayLabel),
171
};
172
}
173
174
// When targeting a specific plugin, find it, register it, and return.
175
if (options?.plugin) {
176
const matchedPlugin = discoveredPlugins.find(p => p.name === options.plugin);
177
if (!matchedPlugin) {
178
return {
179
success: false,
180
message: localize('pluginNotFound', "Plugin '{0}' not found in '{1}'.", options.plugin, reference.displayLabel),
181
};
182
}
183
await this._addMarketplaceToConfig(reference);
184
await this.installPlugin(matchedPlugin);
185
return { success: true, matchedPlugin };
186
}
187
188
if (discoveredPlugins.length === 1) {
189
await this._addMarketplaceToConfig(reference);
190
await this.installPlugin(discoveredPlugins[0]);
191
return { success: true };
192
}
193
194
// Multiple plugins — let the user choose.
195
const picks: (IQuickPickItem & { plugin: IMarketplacePlugin })[] = discoveredPlugins.map(p => ({
196
label: p.name,
197
description: p.description,
198
plugin: p,
199
}));
200
201
const selected = await this._quickInputService.pick(picks, {
202
placeHolder: localize('selectPlugin', "Select a plugin to install from '{0}'", reference.displayLabel),
203
canPickMany: false,
204
});
205
206
if (!selected) {
207
return { success: false };
208
}
209
210
await this._addMarketplaceToConfig(reference);
211
await this.installPlugin(selected.plugin);
212
213
return { success: true };
214
}
215
216
private _addMarketplaceToConfig(reference: IMarketplaceReference) {
217
const currentValues = this._configurationService.getValue<unknown[]>(ChatConfiguration.PluginMarketplaces) ?? [];
218
const existingRefs = parseMarketplaceReferences(currentValues);
219
if (existingRefs.some(r => r.canonicalId === reference.canonicalId)) {
220
return;
221
}
222
return this._configurationService.updateValue(ChatConfiguration.PluginMarketplaces, [...currentValues, reference.rawValue]);
223
}
224
225
async updatePlugin(plugin: IMarketplacePlugin, silent?: boolean): Promise<boolean> {
226
const kind = plugin.sourceDescriptor.kind;
227
228
if (kind === PluginSourceKind.Npm || kind === PluginSourceKind.Pip) {
229
// Package-manager "update" re-runs install via terminal
230
return this._installPackagePlugin(plugin, silent);
231
}
232
233
// For relative-path and git sources, delegate to repository service
234
return this._pluginRepositoryService.updatePluginSource(plugin, {
235
pluginName: plugin.name,
236
failureLabel: plugin.name,
237
marketplaceType: plugin.marketplaceType,
238
});
239
}
240
241
async updateAllPlugins(options: IUpdateAllPluginsOptions, token: CancellationToken): Promise<IUpdateAllPluginsResult> {
242
const installed = this._pluginMarketplaceService.installedPlugins.get();
243
if (installed.length === 0) {
244
return { updatedNames: [], failedNames: [] };
245
}
246
247
const updatedNames: string[] = [];
248
const failedNames: string[] = [];
249
250
const doUpdate = async () => {
251
const gitTasks: Promise<void>[] = [];
252
const packagePlugins: { installed: IMarketplacePlugin; marketplace: IMarketplacePlugin }[] = [];
253
254
// 1. Pull each unique marketplace repository first (handles all
255
// relative-path plugins and ensures the marketplace index on
256
// disk is up-to-date before we re-read it).
257
const seenMarketplaces = new Set<string>();
258
for (const entry of installed) {
259
const ref = entry.plugin.marketplaceReference;
260
if (seenMarketplaces.has(ref.canonicalId)) {
261
continue;
262
}
263
seenMarketplaces.add(ref.canonicalId);
264
gitTasks.push((async () => {
265
if (token.isCancellationRequested) {
266
return;
267
}
268
269
try {
270
const changed = await this._pluginRepositoryService.pullRepository(ref, {
271
pluginName: ref.displayLabel,
272
failureLabel: ref.displayLabel,
273
marketplaceType: entry.plugin.marketplaceType,
274
silent: options.silent,
275
});
276
if (changed) {
277
updatedNames.push(ref.displayLabel);
278
}
279
} catch (err) {
280
this._logService.error(`[PluginInstallService] Failed to pull marketplace '${ref.displayLabel}':`, err);
281
failedNames.push(ref.displayLabel);
282
}
283
})());
284
}
285
286
await Promise.all(gitTasks);
287
288
// 2. Re-fetch marketplace data *after* pulling so we see any
289
// updated plugin descriptors (new versions, refs, etc.).
290
const marketplacePlugins = await this._pluginMarketplaceService.fetchMarketplacePlugins(token);
291
const marketplaceByKey = new Map<string, IMarketplacePlugin>();
292
for (const mp of marketplacePlugins) {
293
marketplaceByKey.set(`${mp.marketplaceReference.canonicalId}::${mp.name}`, mp);
294
}
295
296
// 3. Update non-relative-path plugins individually.
297
const independentGitTasks: Promise<void>[] = [];
298
for (const entry of installed) {
299
if (entry.plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) {
300
continue;
301
}
302
303
const livePlugin = marketplaceByKey.get(`${entry.plugin.marketplaceReference.canonicalId}::${entry.plugin.name}`);
304
if (!livePlugin || !hasSourceChanged(entry.plugin.sourceDescriptor, livePlugin.sourceDescriptor)) {
305
continue;
306
}
307
308
const desc = livePlugin.sourceDescriptor;
309
if (desc.kind === PluginSourceKind.Npm || desc.kind === PluginSourceKind.Pip) {
310
if (!options.force && !desc.version) {
311
continue;
312
}
313
packagePlugins.push({ installed: entry.plugin, marketplace: livePlugin });
314
continue;
315
}
316
317
independentGitTasks.push((async () => {
318
if (token.isCancellationRequested) {
319
return;
320
}
321
322
try {
323
const changed = await this._pluginRepositoryService.updatePluginSource(livePlugin, {
324
pluginName: livePlugin.name,
325
failureLabel: livePlugin.name,
326
marketplaceType: livePlugin.marketplaceType,
327
silent: options.silent,
328
});
329
if (changed) {
330
updatedNames.push(livePlugin.name);
331
this._pluginMarketplaceService.addInstalledPlugin(entry.pluginUri, livePlugin);
332
}
333
} catch (err) {
334
this._logService.error(`[PluginInstallService] Failed to update plugin '${livePlugin.name}':`, err);
335
failedNames.push(livePlugin.name);
336
}
337
})());
338
}
339
340
await Promise.all(independentGitTasks);
341
342
for (const { installed: _installed, marketplace } of packagePlugins) {
343
if (token.isCancellationRequested) {
344
return;
345
}
346
347
try {
348
const changed = await this.updatePlugin(marketplace, options?.silent);
349
if (changed) {
350
updatedNames.push(marketplace.name);
351
const pluginUri = this._pluginRepositoryService.getPluginSourceInstallUri(marketplace.sourceDescriptor);
352
this._pluginMarketplaceService.addInstalledPlugin(pluginUri, marketplace);
353
}
354
} catch (err) {
355
this._logService.error(`[PluginInstallService] Failed to update plugin '${marketplace.name}':`, err);
356
failedNames.push(marketplace.name);
357
}
358
}
359
};
360
361
if (options.silent) {
362
await doUpdate();
363
} else {
364
await this._progressService.withProgress(
365
{
366
location: ProgressLocation.Notification,
367
title: localize('updatingAllPlugins', "Updating plugins..."),
368
},
369
doUpdate,
370
);
371
}
372
373
if (failedNames.length > 0) {
374
this._notificationService.notify({
375
severity: Severity.Error,
376
message: localize('updateAllFailed', "Failed to update: {0}", failedNames.join(', ')),
377
actions: {
378
primary: [new Action('showGitOutput', localize('showOutput', "Show Output"), undefined, true, () => {
379
this._commandService.executeCommand('git.showOutput');
380
})],
381
},
382
});
383
} else if (updatedNames.length > 0) {
384
this._pluginMarketplaceService.clearUpdatesAvailable();
385
this._notificationService.notify({
386
severity: Severity.Info,
387
message: localize('updateAllSuccess', "Updated plugins: {0}", updatedNames.join(', ')),
388
});
389
} else if (!token.isCancellationRequested) {
390
this._pluginMarketplaceService.clearUpdatesAvailable();
391
}
392
393
return { updatedNames, failedNames };
394
}
395
396
getPluginInstallUri(plugin: IMarketplacePlugin): URI {
397
if (plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) {
398
return this._pluginRepositoryService.getPluginInstallUri(plugin);
399
}
400
return this._pluginRepositoryService.getPluginSourceInstallUri(plugin.sourceDescriptor);
401
}
402
403
// --- Trust gate -------------------------------------------------------------
404
405
private async _ensureMarketplaceTrusted(plugin: IMarketplacePlugin): Promise<boolean> {
406
if (this._pluginMarketplaceService.isMarketplaceTrusted(plugin.marketplaceReference)) {
407
return true;
408
}
409
410
const { confirmed } = await this._dialogService.confirm({
411
type: 'question',
412
message: localize('trustMarketplace', "Trust Plugins from '{0}'?", plugin.marketplaceReference.displayLabel),
413
detail: localize('trustMarketplaceDetail', "Plugins can run code on your machine. Only install plugins from sources you trust.\n\nSource: {0}", plugin.marketplaceReference.rawValue),
414
primaryButton: localize({ key: 'trustAndInstall', comment: ['&& denotes a mnemonic'] }, "&&Trust"),
415
custom: {
416
icon: Codicon.shield,
417
},
418
});
419
420
if (!confirmed) {
421
return false;
422
}
423
424
this._pluginMarketplaceService.trustMarketplace(plugin.marketplaceReference);
425
return true;
426
}
427
428
// --- Relative-path source (existing git-based flow) -----------------------
429
430
private async _installRelativePathPlugin(plugin: IMarketplacePlugin): Promise<void> {
431
try {
432
await this._pluginRepositoryService.ensureRepository(plugin.marketplaceReference, {
433
progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name),
434
failureLabel: plugin.name,
435
marketplaceType: plugin.marketplaceType,
436
});
437
} catch {
438
return;
439
}
440
441
let pluginDir: URI;
442
try {
443
pluginDir = this._pluginRepositoryService.getPluginInstallUri(plugin);
444
} catch {
445
this._notificationService.notify({
446
severity: Severity.Error,
447
message: localize('pluginDirInvalid', "Plugin source directory '{0}' is invalid for repository '{1}'.", plugin.source, plugin.marketplace),
448
});
449
return;
450
}
451
452
const pluginExists = await this._fileService.exists(pluginDir);
453
if (!pluginExists) {
454
this._notificationService.notify({
455
severity: Severity.Error,
456
message: localize('pluginDirNotFound', "Plugin source directory '{0}' not found in repository '{1}'.", plugin.source, plugin.marketplace),
457
});
458
return;
459
}
460
461
this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin);
462
}
463
464
// --- GitHub / Git URL source (independent clone) --------------------------
465
466
private async _installGitPlugin(plugin: IMarketplacePlugin): Promise<void> {
467
const repo = this._pluginRepositoryService.getPluginSource(plugin.sourceDescriptor.kind);
468
let pluginDir: URI;
469
try {
470
pluginDir = await this._pluginRepositoryService.ensurePluginSource(plugin, {
471
progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name),
472
failureLabel: plugin.name,
473
marketplaceType: plugin.marketplaceType,
474
});
475
} catch {
476
return;
477
}
478
479
const pluginExists = await this._fileService.exists(pluginDir);
480
if (!pluginExists) {
481
this._notificationService.notify({
482
severity: Severity.Error,
483
message: localize('pluginSourceNotFound', "Plugin source '{0}' not found after cloning.", repo.getLabel(plugin.sourceDescriptor)),
484
});
485
return;
486
}
487
488
this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin);
489
}
490
491
// --- Package-manager sources (npm / pip) ----------------------------------
492
493
private async _installPackagePlugin(plugin: IMarketplacePlugin, silent?: boolean): Promise<boolean> {
494
const repo = this._pluginRepositoryService.getPluginSource(plugin.sourceDescriptor.kind);
495
if (!repo.runInstall) {
496
this._logService.error(`[PluginInstallService] Expected package repository for kind '${plugin.sourceDescriptor.kind}'`);
497
return false;
498
}
499
500
// Ensure the parent cache directory exists (returns npm/<pkg> or pip/<pkg>)
501
const installDir = await this._pluginRepositoryService.ensurePluginSource(plugin);
502
// The actual plugin content location (e.g. npm/<pkg>/node_modules/<pkg>)
503
const pluginDir = this._pluginRepositoryService.getPluginSourceInstallUri(plugin.sourceDescriptor);
504
505
const result = await repo.runInstall(installDir, pluginDir, plugin, { silent });
506
if (!result) {
507
return false;
508
}
509
510
this._pluginMarketplaceService.addInstalledPlugin(result.pluginDir, plugin);
511
return true;
512
}
513
}
514
515