Path: blob/main/src/vs/workbench/contrib/chat/browser/pluginSources.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 { CancelablePromise, timeout } from '../../../../base/common/async.js';7import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';8import { Event } from '../../../../base/common/event.js';9import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';10import { isWindows } from '../../../../base/common/platform.js';11import { dirname, isEqualOrParent, joinPath } from '../../../../base/common/resources.js';12import { URI } from '../../../../base/common/uri.js';13import { localize } from '../../../../nls.js';14import { ICommandService } from '../../../../platform/commands/common/commands.js';15import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';16import { IFileService } from '../../../../platform/files/common/files.js';17import { ILogService } from '../../../../platform/log/common/log.js';18import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';19import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js';20import { TerminalCapability, type ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js';21import { ITerminalInstance, ITerminalService } from '../../terminal/browser/terminal.js';22import { IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js';23import { IGitHubPluginSource, IGitUrlPluginSource, IMarketplacePlugin, INpmPluginSource, IPipPluginSource, IPluginSourceDescriptor, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js';24import { IPluginSource } from '../common/plugins/pluginSource.js';25import { IPluginGitService } from '../common/plugins/pluginGitService.js';2627// ---------------------------------------------------------------------------28// Shared helpers29// ---------------------------------------------------------------------------3031function sanitizeCacheSegment(name: string): string {32return name.replace(/[\\/:*?"<>|]/g, '_');33}3435function gitRevisionCacheSuffix(ref?: string, sha?: string): string[] {36if (sha) {37return [`sha_${sanitizeCacheSegment(sha)}`];38}39if (ref) {40return [`ref_${sanitizeCacheSegment(ref)}`];41}42return [];43}4445function shellEscapeArg(value: string): string {46if (isWindows) {47return `"${value.replace(/[`$"]/g, '`$&')}"`;48}49return `'${value.replace(/'/g, `'\\''`)}'`;50}5152function formatShellCommand(args: readonly string[]): string {53const [command, ...rest] = args;54return [command, ...rest.map(arg => shellEscapeArg(arg))].join(' ');55}5657// ---------------------------------------------------------------------------58// Base for git-based sources (GitHub shorthand & arbitrary Git URL)59// ---------------------------------------------------------------------------6061abstract class AbstractGitPluginSource implements IPluginSource {62abstract readonly kind: PluginSourceKind;63constructor(64@ICommandService protected readonly _commandService: ICommandService,65@IFileService protected readonly _fileService: IFileService,66@ILogService protected readonly _logService: ILogService,67@INotificationService protected readonly _notificationService: INotificationService,68@IPluginGitService protected readonly _pluginGit: IPluginGitService,69@IProgressService protected readonly _progressService: IProgressService,70) { }7172abstract getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI;73abstract getLabel(descriptor: IPluginSourceDescriptor): string;74protected abstract _cloneUrl(descriptor: IPluginSourceDescriptor): string;75protected abstract _displayLabel(descriptor: IPluginSourceDescriptor): string;7677getCleanupTarget(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI | undefined {78return this._getRepoDir(cacheRoot, descriptor);79}8081/**82* Returns the on-disk directory of the cloned repository. Subclasses that83* support a sub-path within a repository should override this to return the84* repository root, while {@link getInstallUri} returns root + sub-path.85*/86protected _getRepoDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI {87return this.getInstallUri(cacheRoot, descriptor);88}8990async ensure(cacheRoot: URI, plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise<URI> {91const descriptor = plugin.sourceDescriptor;92const repoDir = this._getRepoDir(cacheRoot, descriptor);93const repoExists = await this._fileService.exists(repoDir);94const label = this._displayLabel(descriptor);9596if (repoExists) {97await this._checkoutRevision(repoDir, descriptor, options?.failureLabel ?? label);98return this.getInstallUri(cacheRoot, descriptor);99}100101const progressTitle = options?.progressTitle ?? localize('cloningPluginSource', "Cloning plugin source '{0}'...", label);102const failureLabel = options?.failureLabel ?? label;103const ref = (descriptor as IGitHubPluginSource | IGitUrlPluginSource).ref;104105await this._cloneRepository(repoDir, this._cloneUrl(descriptor), progressTitle, failureLabel, ref);106await this._checkoutRevision(repoDir, descriptor, failureLabel);107return this.getInstallUri(cacheRoot, descriptor);108}109110async update(cacheRoot: URI, plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise<boolean> {111const descriptor = plugin.sourceDescriptor;112const repoDir = this._getRepoDir(cacheRoot, descriptor);113const repoExists = await this._fileService.exists(repoDir);114if (!repoExists) {115this._logService.warn(`[${this.kind}] Cannot update plugin '${options?.pluginName ?? plugin.name}': source repository not cloned`);116return false;117}118119const updateLabel = options?.pluginName ?? plugin.name;120const failureLabel = options?.failureLabel ?? updateLabel;121122try {123const doUpdate = async (cts?: CancellationTokenSource) => {124const git = descriptor as IGitHubPluginSource | IGitUrlPluginSource;125let changed: boolean;126if (git.sha) {127const headBefore = await this._pluginGit.revParse(repoDir, 'HEAD').catch(() => undefined);128await this._pluginGit.fetch(repoDir, cts?.token);129await this._checkoutRevision(repoDir, descriptor, failureLabel, cts?.token);130const headAfter = await this._pluginGit.revParse(repoDir, 'HEAD').catch(() => undefined);131changed = headBefore !== headAfter;132} else {133changed = await this._pluginGit.pull(repoDir, cts?.token);134await this._checkoutRevision(repoDir, descriptor, failureLabel, cts?.token);135}136return changed;137};138139if (options?.silent) {140return await doUpdate();141}142143const cts = new CancellationTokenSource();144try {145return await this._progressService.withProgress(146{147location: ProgressLocation.Notification,148title: localize('updatingPluginSource', "Updating plugin '{0}'...", updateLabel),149cancellable: true,150},151() => doUpdate(cts),152() => cts.dispose(true),153);154} finally {155cts.dispose();156}157} catch (err) {158this._logService.error(`[${this.kind}] Failed to update plugin source '${updateLabel}':`, err);159if (!options?.silent) {160this._notificationService.notify({161severity: Severity.Error,162message: localize('pullPluginSourceFailed', "Failed to update plugin '{0}': {1}", failureLabel, err?.message ?? String(err)),163});164}165throw err;166}167}168169// -- internal helpers ---170171private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string, ref?: string): Promise<void> {172const cts = new CancellationTokenSource();173try {174await this._progressService.withProgress(175{176location: ProgressLocation.Notification,177title: progressTitle,178cancellable: true,179},180async () => {181await this._fileService.createFolder(dirname(repoDir));182await this._pluginGit.cloneRepository(cloneUrl, repoDir, ref, cts.token);183},184() => cts.dispose(true),185);186} catch (err) {187this._logService.error(`[${this.kind}] Failed to clone ${cloneUrl}:`, err);188this._notificationService.notify({189severity: Severity.Error,190message: localize('cloneFailed', "Failed to install plugin '{0}': {1}", failureLabel, err?.message ?? String(err)),191});192throw err;193} finally {194cts.dispose();195}196}197198private async _checkoutRevision(repoDir: URI, descriptor: IPluginSourceDescriptor, failureLabel: string, token?: CancellationToken): Promise<void> {199const git = descriptor as IGitHubPluginSource | IGitUrlPluginSource;200if (!git.sha && !git.ref) {201return;202}203204try {205if (git.sha) {206await this._pluginGit.checkout(repoDir, git.sha, true, token);207return;208}209// git.ref is guaranteed non-nullish by the guard above210await this._pluginGit.checkout(repoDir, git.ref!, undefined, token);211} catch (err) {212this._logService.error(`[${this.kind}] Failed to checkout revision for '${failureLabel}':`, err);213this._notificationService.notify({214severity: Severity.Error,215message: localize('checkoutPluginSourceFailed', "Failed to checkout plugin '{0}' to requested revision: {1}", failureLabel, err?.message ?? String(err)),216});217throw err;218}219}220}221222// ---------------------------------------------------------------------------223// RelativePath — plugin lives inside a shared marketplace repository224// ---------------------------------------------------------------------------225226export class RelativePathPluginSource implements IPluginSource {227readonly kind = PluginSourceKind.RelativePath;228229getInstallUri(_cacheRoot: URI, _descriptor: IPluginSourceDescriptor): URI {230throw new Error('Use getPluginInstallUri() for relative-path sources');231}232233async ensure(_cacheRoot: URI, _plugin: IMarketplacePlugin, _options?: IEnsureRepositoryOptions): Promise<URI> {234throw new Error('Use ensureRepository() for relative-path sources');235}236237async update(_cacheRoot: URI, _plugin: IMarketplacePlugin, _options?: IPullRepositoryOptions): Promise<boolean> {238throw new Error('Use pullRepository() for relative-path sources');239}240241getCleanupTarget(_cacheRoot: URI, _descriptor: IPluginSourceDescriptor): URI | undefined {242return undefined;243}244245getLabel(descriptor: IPluginSourceDescriptor): string {246return (descriptor as { path: string }).path || '.';247}248}249250// ---------------------------------------------------------------------------251// GitHub — `{ source: "github", repo: "owner/repo" }`252// ---------------------------------------------------------------------------253254export class GitHubPluginSource extends AbstractGitPluginSource {255readonly kind = PluginSourceKind.GitHub;256257/** Returns the URI where the plugin content lives (repo root + optional sub-path). */258getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI {259const repoDir = this._getRepoDir(cacheRoot, descriptor);260const gh = descriptor as IGitHubPluginSource;261if (gh.path) {262const normalizedPath = gh.path.trim().replace(/^\.?\/+|\/+$/g, '');263if (normalizedPath) {264const target = joinPath(repoDir, normalizedPath);265if (isEqualOrParent(target, repoDir)) {266return target;267}268}269}270return repoDir;271}272273/** Returns the cloned repository root (without sub-path). */274protected override _getRepoDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI {275const gh = descriptor as IGitHubPluginSource;276const [owner, repo] = gh.repo.split('/');277return joinPath(cacheRoot, 'github.com', owner, repo, ...gitRevisionCacheSuffix(gh.ref, gh.sha));278}279280getLabel(descriptor: IPluginSourceDescriptor): string {281const gh = descriptor as IGitHubPluginSource;282return gh.path ? `${gh.repo}/${gh.path}` : gh.repo;283}284285protected _cloneUrl(descriptor: IPluginSourceDescriptor): string {286return `https://github.com/${(descriptor as IGitHubPluginSource).repo}.git`;287}288289protected _displayLabel(descriptor: IPluginSourceDescriptor): string {290return (descriptor as IGitHubPluginSource).repo;291}292}293294// ---------------------------------------------------------------------------295// GitUrl — `{ source: "url", url: "https://…/repo.git" }`296// ---------------------------------------------------------------------------297298export class GitUrlPluginSource extends AbstractGitPluginSource {299readonly kind = PluginSourceKind.GitUrl;300301getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI {302const git = descriptor as IGitUrlPluginSource;303const segments = this._gitUrlCacheSegments(git.url, git.ref, git.sha);304return joinPath(cacheRoot, ...segments);305}306307getLabel(descriptor: IPluginSourceDescriptor): string {308return (descriptor as IGitUrlPluginSource).url;309}310311protected _cloneUrl(descriptor: IPluginSourceDescriptor): string {312return (descriptor as IGitUrlPluginSource).url;313}314315protected _displayLabel(descriptor: IPluginSourceDescriptor): string {316return (descriptor as IGitUrlPluginSource).url;317}318319private _gitUrlCacheSegments(url: string, ref?: string, sha?: string): string[] {320try {321const parsed = URI.parse(url);322const authority = (parsed.authority || 'unknown').replace(/[\\/:*?"<>|]/g, '_').toLowerCase();323const pathPart = parsed.path.replace(/^\/+/, '').replace(/\.git$/i, '').replace(/\/+$/g, '');324const segments = pathPart.split('/').map(s => s.replace(/[\\/:*?"<>|]/g, '_'));325return [authority, ...segments, ...gitRevisionCacheSuffix(ref, sha)];326} catch {327return ['git', url.replace(/[\\/:*?"<>|]/g, '_'), ...gitRevisionCacheSuffix(ref, sha)];328}329}330}331332// ---------------------------------------------------------------------------333// Base for package-manager-based sources (npm, pip)334// ---------------------------------------------------------------------------335336export abstract class AbstractPackagePluginSource implements IPluginSource {337abstract readonly kind: PluginSourceKind;338constructor(339@IDialogService protected readonly _dialogService: IDialogService,340@IFileService protected readonly _fileService: IFileService,341@ILogService protected readonly _logService: ILogService,342@INotificationService protected readonly _notificationService: INotificationService,343@IProgressService protected readonly _progressService: IProgressService,344@ITerminalService protected readonly _terminalService: ITerminalService,345) { }346347abstract getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI;348abstract getLabel(descriptor: IPluginSourceDescriptor): string;349350getCleanupTarget(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI | undefined {351return this._getCacheDir(cacheRoot, descriptor);352}353354/**355* Return the parent directory (prefix / target) where the package356* manager installs into. This is above the actual plugin content dir.357*/358protected abstract _getCacheDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI;359360/** Build the terminal command args for install. */361protected abstract _buildInstallArgs(installDir: URI, plugin: IMarketplacePlugin): string[];362363/** Human-readable package manager name for messages. */364protected abstract get _managerName(): string;365366async ensure(cacheRoot: URI, plugin: IMarketplacePlugin, _options?: IEnsureRepositoryOptions): Promise<URI> {367const cacheDir = this._getCacheDir(cacheRoot, plugin.sourceDescriptor);368await this._fileService.createFolder(cacheDir);369return cacheDir;370}371372async update(cacheRoot: URI, plugin: IMarketplacePlugin, _options?: IPullRepositoryOptions): Promise<boolean> {373// For package-manager sources, "update" re-runs install.374const installDir = this._getCacheDir(cacheRoot, plugin.sourceDescriptor);375const pluginDir = this.getInstallUri(cacheRoot, plugin.sourceDescriptor);376await this.runInstall(installDir, pluginDir, plugin, { silent: _options?.silent });377return true;378}379380async runInstall(installDir: URI, pluginDir: URI, plugin: IMarketplacePlugin, options?: { silent?: boolean }): Promise<{ pluginDir: URI } | undefined> {381const args = this._buildInstallArgs(installDir, plugin);382const command = formatShellCommand(args);383const confirmed = await this._confirmTerminalCommand(plugin.name, command, options?.silent);384if (!confirmed) {385return undefined;386}387388const progressTitle = localize('installingPackagePlugin', "Installing {0} plugin '{1}'...", this._managerName, plugin.name);389const { success, terminal } = await this._runTerminalCommand(command, progressTitle);390if (!success) {391return undefined;392}393394const exists = await this._fileService.exists(pluginDir);395if (!exists) {396this._notificationService.notify({397severity: Severity.Error,398message: localize('packagePluginNotFound', "{0} package '{1}' was not found after installation.", this._managerName, this.getLabel(plugin.sourceDescriptor)),399});400return undefined;401}402403terminal?.dispose();404return { pluginDir };405}406407// -- terminal helpers (moved from PluginInstallService) ---408409private async _confirmTerminalCommand(pluginName: string, command: string, silent?: boolean): Promise<boolean> {410if (silent) {411return new Promise<boolean>(resolve => {412const n = this._notificationService.notify({413severity: Severity.Info,414message: localize('confirmPluginInstallNotification', "Plugin '{0}' wants to run: {1}", pluginName, command),415actions: {416primary: [417new Action('installPlugin', localize('install', "Install"), undefined, true, async () => resolve(true)),418],419},420});421422Event.once(n.onDidClose)(() => resolve(false));423});424}425426const { confirmed } = await this._dialogService.confirm({427type: 'question',428message: localize('confirmPluginInstall', "Install Plugin '{0}'?", pluginName),429detail: localize('confirmPluginInstallDetail', "This will run the following command in a terminal:\n\n{0}", command),430primaryButton: localize({ key: 'confirmInstall', comment: ['&& denotes a mnemonic'] }, "&&Install"),431});432return confirmed;433}434435private async _runTerminalCommand(command: string, progressTitle: string) {436let terminal: ITerminalInstance | undefined;437try {438await this._progressService.withProgress(439{440location: ProgressLocation.Notification,441title: progressTitle,442cancellable: false,443},444async () => {445terminal = await this._terminalService.createTerminal({446config: {447name: localize('pluginInstallTerminal', "Plugin Install"),448forceShellIntegration: true,449isTransient: true,450isFeatureTerminal: true,451},452});453await terminal.processReady;454this._terminalService.setActiveInstance(terminal);455456const commandResultPromise = this._waitForTerminalCommandCompletion(terminal);457await terminal.runCommand(command, true);458const exitCode = await commandResultPromise;459if (exitCode !== 0) {460throw new Error(localize('terminalCommandExitCode', "Command exited with code {0}", exitCode));461}462}463);464return { success: true, terminal };465} catch (err) {466this._logService.error(`[${this.kind}] Terminal command failed:`, err);467this._notificationService.notify({468severity: Severity.Error,469message: localize('terminalCommandFailed', "Plugin installation command failed: {0}", err?.message ?? String(err)),470});471return { success: false, terminal };472}473}474475private _waitForTerminalCommandCompletion(terminal: ITerminalInstance): Promise<number | undefined> {476return new Promise<number | undefined>(resolve => {477const disposables = new DisposableStore();478let isResolved = false;479480const resolveAndDispose = (exitCode: number | undefined): void => {481if (isResolved) {482return;483}484isResolved = true;485disposables.dispose();486resolve(exitCode);487};488489const attachCommandFinishedListener = (): void => {490const commandDetection = terminal.capabilities.get(TerminalCapability.CommandDetection);491if (!commandDetection) {492return;493}494disposables.add(commandDetection.onCommandFinished((command: ITerminalCommand) => {495resolveAndDispose(command.exitCode ?? 0);496}));497};498499attachCommandFinishedListener();500disposables.add(terminal.capabilities.onDidAddCommandDetectionCapability(() => attachCommandFinishedListener()));501502const timeoutHandle: CancelablePromise<void> = timeout(120_000);503disposables.add(toDisposable(() => timeoutHandle.cancel()));504void timeoutHandle.then(() => {505if (isResolved) {506return;507}508this._logService.warn(`[${this.kind}] Terminal command completion timed out`);509resolveAndDispose(undefined);510});511});512}513}514515// ---------------------------------------------------------------------------516// npm — `{ source: "npm", package: "@org/plugin" }`517// ---------------------------------------------------------------------------518519export class NpmPluginSource extends AbstractPackagePluginSource {520readonly kind = PluginSourceKind.Npm;521protected readonly _managerName = 'npm';522523getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI {524const npm = descriptor as INpmPluginSource;525return joinPath(cacheRoot, 'npm', sanitizeCacheSegment(npm.package), 'node_modules', npm.package);526}527528getLabel(descriptor: IPluginSourceDescriptor): string {529const npm = descriptor as INpmPluginSource;530return npm.version ? `${npm.package}@${npm.version}` : npm.package;531}532533protected _getCacheDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI {534const npm = descriptor as INpmPluginSource;535return joinPath(cacheRoot, 'npm', sanitizeCacheSegment(npm.package));536}537538protected _buildInstallArgs(installDir: URI, plugin: IMarketplacePlugin): string[] {539const npm = plugin.sourceDescriptor as INpmPluginSource;540const packageSpec = npm.version ? `${npm.package}@${npm.version}` : npm.package;541const args = ['npm', 'install', '--prefix', installDir.fsPath, packageSpec];542if (npm.registry) {543args.push('--registry', npm.registry);544}545return args;546}547}548549// ---------------------------------------------------------------------------550// pip — `{ source: "pip", package: "my-plugin" }`551// ---------------------------------------------------------------------------552553export class PipPluginSource extends AbstractPackagePluginSource {554readonly kind = PluginSourceKind.Pip;555protected readonly _managerName = 'pip';556557getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI {558const pip = descriptor as IPipPluginSource;559return joinPath(cacheRoot, 'pip', sanitizeCacheSegment(pip.package));560}561562getLabel(descriptor: IPluginSourceDescriptor): string {563const pip = descriptor as IPipPluginSource;564return pip.version ? `${pip.package}==${pip.version}` : pip.package;565}566567protected _getCacheDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI {568const pip = descriptor as IPipPluginSource;569return joinPath(cacheRoot, 'pip', sanitizeCacheSegment(pip.package));570}571572protected _buildInstallArgs(installDir: URI, plugin: IMarketplacePlugin): string[] {573const pip = plugin.sourceDescriptor as IPipPluginSource;574const packageSpec = pip.version ? `${pip.package}==${pip.version}` : pip.package;575const args = ['pip', 'install', '--target', installDir.fsPath, packageSpec];576if (pip.registry) {577args.push('--index-url', pip.registry);578}579return args;580}581}582583584