/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ 'use strict'; import nls = require('vs/nls'); import * as path from 'path'; import * as pfs from 'vs/base/node/pfs'; import * as errors from 'vs/base/common/errors'; import { assign } from 'vs/base/common/objects'; import { IDisposable, dispose } from 'vs/base/common/lifecycle'; import { flatten } from 'vs/base/common/arrays'; import { extract, buffer } from 'vs/base/node/zip'; import { Promise, TPromise } from 'vs/base/common/winjs.base'; import { IExtensionManagementService, IExtensionGalleryService, ILocalExtension, IGalleryExtension, IExtensionIdentity, IExtensionManifest, IGalleryMetadata, InstallExtensionEvent, DidInstallExtensionEvent, LocalExtensionType } from 'vs/platform/extensionManagement/common/extensionManagement'; import { localizeManifest } from '../common/extensionNls'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { Limiter } from 'vs/base/common/async'; import Event, { Emitter } from 'vs/base/common/event'; import * as semver from 'semver'; import { groupBy, values } from 'vs/base/common/collections'; import URI from 'vs/base/common/uri'; import { IChoiceService, Severity } from 'vs/platform/message/common/message'; const SystemExtensionsRoot = path.normalize(path.join(URI.parse(require.toUrl('')).fsPath, '..', 'extensions')); function parseManifest(raw: string): TPromise<{ manifest: IExtensionManifest; metadata: IGalleryMetadata; }> { return new Promise((c, e) => { try { const manifest = JSON.parse(raw); const metadata = manifest.__metadata || null; delete manifest.__metadata; c({ manifest, metadata }); } catch (err) { e(new Error(nls.localize('invalidManifest', "Extension invalid: package.json is not a JSON file."))); } }); } function validate(zipPath: string, extension?: IExtensionIdentity, version?: string): TPromise { return buffer(zipPath, 'extension/package.json') .then(buffer => parseManifest(buffer.toString('utf8'))) .then(({ manifest }) => { if (extension) { if (extension.name !== manifest.name) { return Promise.wrapError(Error(nls.localize('invalidName', "Extension invalid: manifest name mismatch."))); } if (extension.publisher !== manifest.publisher) { return Promise.wrapError(Error(nls.localize('invalidPublisher', "Extension invalid: manifest publisher mismatch."))); } if (version !== manifest.version) { return Promise.wrapError(Error(nls.localize('invalidVersion', "Extension invalid: manifest version mismatch."))); } } return TPromise.as(manifest); }); } function readManifest(extensionPath: string): TPromise<{ manifest: IExtensionManifest; metadata: IGalleryMetadata; }> { const promises = [ pfs.readFile(path.join(extensionPath, 'package.json'), 'utf8') .then(raw => parseManifest(raw)), pfs.readFile(path.join(extensionPath, 'package.nls.json'), 'utf8') .then(null, err => err.code !== 'ENOENT' ? TPromise.wrapError(err) : '{}') .then(raw => JSON.parse(raw)) ]; return TPromise.join(promises).then(([{ manifest, metadata }, translations]) => { return { manifest: localizeManifest(manifest, translations), metadata }; }); } function getExtensionId(extension: IExtensionIdentity, version: string): string { return `${extension.publisher}.${extension.name}-${version}`; } export class ExtensionManagementService implements IExtensionManagementService { _serviceBrand: any; private extensionsPath: string; private obsoletePath: string; private obsoleteFileLimiter: Limiter; private disposables: IDisposable[]; private _onInstallExtension = new Emitter(); onInstallExtension: Event = this._onInstallExtension.event; private _onDidInstallExtension = new Emitter(); onDidInstallExtension: Event = this._onDidInstallExtension.event; private _onUninstallExtension = new Emitter(); onUninstallExtension: Event = this._onUninstallExtension.event; private _onDidUninstallExtension = new Emitter(); onDidUninstallExtension: Event = this._onDidUninstallExtension.event; constructor( @IEnvironmentService private environmentService: IEnvironmentService, @IChoiceService private choiceService: IChoiceService, @IExtensionGalleryService private galleryService: IExtensionGalleryService ) { this.extensionsPath = environmentService.extensionsPath; this.obsoletePath = path.join(this.extensionsPath, '.obsolete'); this.obsoleteFileLimiter = new Limiter(1); } install(zipPath: string): TPromise { zipPath = path.resolve(zipPath); return validate(zipPath).then(manifest => { const id = getExtensionId(manifest, manifest.version); return this.isObsolete(id).then(isObsolete => { if (isObsolete) { return TPromise.wrapError(new Error(nls.localize('restartCode', "Please restart Code before reinstalling {0}.", manifest.displayName || manifest.name))); } this._onInstallExtension.fire({ id, zipPath }); return this.installExtension(zipPath, id) .then( local => this._onDidInstallExtension.fire({ id, zipPath, local }), error => { this._onDidInstallExtension.fire({ id, zipPath, error }); return TPromise.wrapError(error); } ); }); }); } installFromGallery(extension: IGalleryExtension, promptToInstallDependencies: boolean = true): TPromise { const id = getExtensionId(extension, extension.version); return this.isObsolete(id).then(isObsolete => { if (isObsolete) { return TPromise.wrapError(new Error(nls.localize('restartCode', "Please restart Code before reinstalling {0}.", extension.displayName || extension.name))); } this._onInstallExtension.fire({ id, gallery: extension }); return this.installCompatibleVersion(extension, true, promptToInstallDependencies) .then( local => this._onDidInstallExtension.fire({ id, local, gallery: extension }), error => { this._onDidInstallExtension.fire({ id, gallery: extension, error }); return TPromise.wrapError(error); } ); }); } private installCompatibleVersion(extension: IGalleryExtension, installDependencies: boolean, promptToInstallDependencies: boolean): TPromise { return this.galleryService.loadCompatibleVersion(extension) .then(compatibleVersion => this.getDependenciesToInstall(extension, installDependencies) .then(dependencies => { if (!dependencies.length) { return this.downloadAndInstall(compatibleVersion); } if (promptToInstallDependencies) { const message = nls.localize('installDependeciesConfirmation', "Installing '{0}' also installs its dependencies. Would you like to continue?", extension.displayName); const options = [ nls.localize('install', "Yes"), nls.localize('doNotInstall', "No") ]; return this.choiceService.choose(Severity.Info, message, options) .then(value => { if (value === 0) { return this.installWithDependencies(compatibleVersion); } return TPromise.wrapError(errors.canceled()); }, error => TPromise.wrapError(errors.canceled())); } else { return this.installWithDependencies(compatibleVersion); } }) ); } private getDependenciesToInstall(extension: IGalleryExtension, checkDependecies: boolean): TPromise { if (!checkDependecies) { return TPromise.wrap([]); } // Filter out self const extensionName = `${extension.publisher}.${extension.name}`; const dependencies = extension.properties.dependencies ? extension.properties.dependencies.filter(name => name !== extensionName) : []; if (!dependencies.length) { return TPromise.wrap([]); } // Filter out installed dependencies return this.getInstalled().then(installed => { return dependencies.filter(dep => installed.every(i => `${i.manifest.publisher}.${i.manifest.name}` !== dep)); }); } private installWithDependencies(extension: IGalleryExtension): TPromise { return this.galleryService.getAllDependencies(extension) .then(allDependencies => this.filterDependenciesToInstall(extension, allDependencies)) .then(toInstall => this.filterObsolete(...toInstall.map(i => getExtensionId(i, i.version))) .then((obsolete) => { if (obsolete.length) { return TPromise.wrapError(new Error(nls.localize('restartCode', "Please restart Code before reinstalling {0}.", extension.displayName || extension.name))); } return this.bulkInstallWithDependencies(extension, toInstall); }) ); } private bulkInstallWithDependencies(extension: IGalleryExtension, dependecies: IGalleryExtension[]): TPromise { for (const dependency of dependecies) { const id = getExtensionId(dependency, dependency.version); this._onInstallExtension.fire({ id, gallery: dependency }); } return this.downloadAndInstall(extension) .then(localExtension => { return TPromise.join(dependecies.map((dep) => this.installCompatibleVersion(dep, false, false))) .then(installedLocalExtensions => { for (const installedLocalExtension of installedLocalExtensions) { const gallery = this.getGalleryExtensionForLocalExtension(dependecies, installedLocalExtension); this._onDidInstallExtension.fire({ id: installedLocalExtension.id, local: installedLocalExtension, gallery }); } return localExtension; }, error => { return this.rollback(localExtension, dependecies).then(() => { return TPromise.wrapError(Array.isArray(error) ? error[error.length - 1] : error); }); }); }) .then(localExtension => localExtension, error => { for (const dependency of dependecies) { this._onDidInstallExtension.fire({ id: getExtensionId(dependency, dependency.version), gallery: dependency, error }); } return TPromise.wrapError(error); }); } private rollback(localExtension: ILocalExtension, dependecies: IGalleryExtension[]): TPromise { return this.uninstall(localExtension) .then(() => this.filterOutUninstalled(dependecies)) .then(installed => TPromise.join(installed.map((i) => this.uninstall(i)))) .then(() => null); } private filterDependenciesToInstall(extension: IGalleryExtension, dependencies: IGalleryExtension[]): TPromise { return this.getInstalled() .then(local => { return dependencies.filter(d => { if (extension.id === d.id) { return false; } const extensionId = getExtensionId(d, d.version); return local.every(local => local.id !== extensionId); }); }); } private filterOutUninstalled(extensions: IGalleryExtension[]): TPromise { return this.getInstalled() .then(installed => installed.filter(local => !!this.getGalleryExtensionForLocalExtension(extensions, local))); } private getGalleryExtensionForLocalExtension(galleryExtensions: IGalleryExtension[], localExtension: ILocalExtension): IGalleryExtension { const filtered = galleryExtensions.filter(galleryExtension => getExtensionId(galleryExtension, galleryExtension.version) === localExtension.id); return filtered.length ? filtered[0] : null; } private downloadAndInstall(extension: IGalleryExtension): TPromise { const id = getExtensionId(extension, extension.version); const metadata = { id: extension.id, publisherId: extension.publisherId, publisherDisplayName: extension.publisherDisplayName, }; return this.galleryService.download(extension) .then(zipPath => validate(zipPath).then(() => zipPath)) .then(zipPath => this.installExtension(zipPath, id, metadata)); } private installExtension(zipPath: string, id: string, metadata: IGalleryMetadata = null): TPromise { const extensionPath = path.join(this.extensionsPath, id); return extract(zipPath, extensionPath, { sourcePath: 'extension', overwrite: true }) .then(() => readManifest(extensionPath)) .then(({ manifest }) => { return pfs.readdir(extensionPath).then(children => { const readme = children.filter(child => /^readme(\.txt|\.md|)$/i.test(child))[0]; const readmeUrl = readme ? URI.file(path.join(extensionPath, readme)).toString() : null; const changelog = children.filter(child => /^changelog(\.txt|\.md|)$/i.test(child))[0]; const changelogUrl = changelog ? URI.file(path.join(extensionPath, changelog)).toString() : null; const type = LocalExtensionType.User; const local: ILocalExtension = { type, id, manifest, metadata, path: extensionPath, readmeUrl, changelogUrl }; const manifestPath = path.join(extensionPath, 'package.json'); return pfs.readFile(manifestPath, 'utf8') .then(raw => parseManifest(raw)) .then(({ manifest }) => assign(manifest, { __metadata: metadata })) .then(manifest => pfs.writeFile(manifestPath, JSON.stringify(manifest, null, '\t'))) .then(() => local); }); }); } uninstall(extension: ILocalExtension): TPromise { return this.removeOutdatedExtensions().then(() => { return this.scanUserExtensions().then(installed => { const promises = installed .filter(e => e.manifest.publisher === extension.manifest.publisher && e.manifest.name === extension.manifest.name) .map(({ id }) => this.uninstallExtension(id)); return TPromise.join(promises); }); }); } private uninstallExtension(id: string): TPromise { const extensionPath = path.join(this.extensionsPath, id); return pfs.exists(extensionPath) .then(exists => exists ? null : Promise.wrapError(new Error(nls.localize('notExists', "Could not find extension")))) .then(() => this._onUninstallExtension.fire(id)) .then(() => this.setObsolete(id)) .then(() => pfs.rimraf(extensionPath)) .then(() => this.unsetObsolete(id)) .then(() => this._onDidUninstallExtension.fire(id)); } getInstalled(type: LocalExtensionType = null): TPromise { const promises = []; if (type === null || type === LocalExtensionType.System) { promises.push(this.scanSystemExtensions()); } if (type === null || type === LocalExtensionType.User) { promises.push(this.scanUserExtensions()); } return TPromise.join(promises).then(flatten); } private scanSystemExtensions(): TPromise { return this.scanExtensions(SystemExtensionsRoot, LocalExtensionType.System); } private scanUserExtensions(): TPromise { return this.scanExtensions(this.extensionsPath, LocalExtensionType.User).then(extensions => { const byId = values(groupBy(extensions, p => `${p.manifest.publisher}.${p.manifest.name}`)); return byId.map(p => p.sort((a, b) => semver.rcompare(a.manifest.version, b.manifest.version))[0]); }); } private scanExtensions(root: string, type: LocalExtensionType): TPromise { const limiter = new Limiter(10); return this.getObsoleteExtensions() .then(obsolete => { return pfs.readdir(root) .then(extensions => extensions.filter(id => !obsolete[id])) .then(extensionIds => Promise.join(extensionIds.map(id => { const extensionPath = path.join(root, id); const each = () => pfs.readdir(extensionPath).then(children => { const readme = children.filter(child => /^readme(\.txt|\.md|)$/i.test(child))[0]; const readmeUrl = readme ? URI.file(path.join(extensionPath, readme)).toString() : null; const changelog = children.filter(child => /^changelog(\.txt|\.md|)$/i.test(child))[0]; const changelogUrl = changelog ? URI.file(path.join(extensionPath, changelog)).toString() : null; return readManifest(extensionPath) .then(({ manifest, metadata }) => ({ type, id, manifest, metadata, path: extensionPath, readmeUrl, changelogUrl })); }).then(null, () => null); return limiter.queue(each); }))) .then(result => result.filter(a => !!a)); }); } removeDeprecatedExtensions(): TPromise { return TPromise.join([ this.removeOutdatedExtensions(), this.removeObsoleteExtensions() ]); } private removeOutdatedExtensions(): TPromise { return this.getOutdatedExtensionIds() .then(extensions => extensions.map(e => getExtensionId(e.manifest, e.manifest.version))) .then(extensionIds => this.removeExtensions(extensionIds)); } private removeObsoleteExtensions(): TPromise { return this.getObsoleteExtensions() .then(obsolete => Object.keys(obsolete)) .then(extensionIds => this.removeExtensions(extensionIds)); } private removeExtensions(extensionsIds: string[]): TPromise { return TPromise.join(extensionsIds.map(id => { return pfs.rimraf(path.join(this.extensionsPath, id)) .then(() => this.withObsoleteExtensions(obsolete => delete obsolete[id])); })); } private getOutdatedExtensionIds(): TPromise { return this.scanExtensions(this.extensionsPath, LocalExtensionType.User) .then(extensions => values(groupBy(extensions, p => `${p.manifest.publisher}.${p.manifest.name}`))) .then(versions => flatten(versions.map(p => p.sort((a, b) => semver.rcompare(a.manifest.version, b.manifest.version)).slice(1)))); } private isObsolete(id: string): TPromise { return this.filterObsolete(id).then(obsolete => obsolete.length === 1); } private filterObsolete(...ids: string[]): TPromise { return this.withObsoleteExtensions(allObsolete => { const obsolete = []; for (const id of ids) { if (!!allObsolete[id]) { obsolete.push(id); } } return obsolete; }); } private setObsolete(id: string): TPromise { return this.withObsoleteExtensions(obsolete => assign(obsolete, { [id]: true })); } private unsetObsolete(id: string): TPromise { return this.withObsoleteExtensions(obsolete => delete obsolete[id]); } private getObsoleteExtensions(): TPromise<{ [id: string]: boolean; }> { return this.withObsoleteExtensions(obsolete => obsolete); } private withObsoleteExtensions(fn: (obsolete: { [id: string]: boolean; }) => T): TPromise { return this.obsoleteFileLimiter.queue(() => { let result: T = null; return pfs.readFile(this.obsoletePath, 'utf8') .then(null, err => err.code === 'ENOENT' ? TPromise.as('{}') : TPromise.wrapError(err)) .then<{ [id: string]: boolean }>(raw => { try { return JSON.parse(raw); } catch (e) { return {}; } }) .then(obsolete => { result = fn(obsolete); return obsolete; }) .then(obsolete => { if (Object.keys(obsolete).length === 0) { return pfs.rimraf(this.obsoletePath); } else { const raw = JSON.stringify(obsolete); return pfs.writeFile(this.obsoletePath, raw); } }) .then(() => result); }); } dispose() { this.disposables = dispose(this.disposables); } }