Files
atom/src/package-manager.js
David Wilson 86b8ead239 👕
2018-08-07 16:09:34 -07:00

876 lines
27 KiB
JavaScript

const path = require('path')
let normalizePackageData = null
const _ = require('underscore-plus')
const {Emitter} = require('event-kit')
const fs = require('fs-plus')
const CSON = require('season')
const ServiceHub = require('service-hub')
const Package = require('./package')
const ThemePackage = require('./theme-package')
const {isDeprecatedPackage, getDeprecatedPackageMetadata} = require('./deprecated-packages')
const packageJSON = require('../package.json')
// Extended: Package manager for coordinating the lifecycle of Atom packages.
//
// An instance of this class is always available as the `atom.packages` global.
//
// Packages can be loaded, activated, and deactivated, and unloaded:
// * Loading a package reads and parses the package's metadata and resources
// such as keymaps, menus, stylesheets, etc.
// * Activating a package registers the loaded resources and calls `activate()`
// on the package's main module.
// * Deactivating a package unregisters the package's resources and calls
// `deactivate()` on the package's main module.
// * Unloading a package removes it completely from the package manager.
//
// Packages can be enabled/disabled via the `core.disabledPackages` config
// settings and also by calling `enablePackage()/disablePackage()`.
module.exports = class PackageManager {
constructor (params) {
({
config: this.config, styleManager: this.styleManager, notificationManager: this.notificationManager, keymapManager: this.keymapManager,
commandRegistry: this.commandRegistry, grammarRegistry: this.grammarRegistry, deserializerManager: this.deserializerManager, viewRegistry: this.viewRegistry,
uriHandlerRegistry: this.uriHandlerRegistry
} = params)
this.emitter = new Emitter()
this.activationHookEmitter = new Emitter()
this.packageDirPaths = []
this.deferredActivationHooks = []
this.triggeredActivationHooks = new Set()
this.packagesCache = packageJSON._atomPackages != null ? packageJSON._atomPackages : {}
this.packageDependencies = packageJSON.packageDependencies != null ? packageJSON.packageDependencies : {}
this.initialPackagesLoaded = false
this.initialPackagesActivated = false
this.preloadedPackages = {}
this.loadedPackages = {}
this.activePackages = {}
this.activatingPackages = {}
this.packageStates = {}
this.serviceHub = new ServiceHub()
this.packageActivators = []
this.registerPackageActivator(this, ['atom', 'textmate'])
}
initialize (params) {
this.devMode = params.devMode
this.resourcePath = params.resourcePath
if (params.configDirPath != null && !params.safeMode) {
if (this.devMode) {
this.packageDirPaths.push(path.join(params.configDirPath, 'dev', 'packages'))
if (process.env.ATOM_DEV_RESOURCE_PATH) {
this.packageDirPaths.push(path.join(process.env.ATOM_DEV_RESOURCE_PATH, 'packages'))
}
}
this.packageDirPaths.push(path.join(params.configDirPath, 'packages'))
}
}
setContextMenuManager (contextMenuManager) {
this.contextMenuManager = contextMenuManager
}
setMenuManager (menuManager) {
this.menuManager = menuManager
}
setThemeManager (themeManager) {
this.themeManager = themeManager
}
async reset () {
this.serviceHub.clear()
await this.deactivatePackages()
this.loadedPackages = {}
this.preloadedPackages = {}
this.packageStates = {}
this.packagesCache = packageJSON._atomPackages != null ? packageJSON._atomPackages : {}
this.packageDependencies = packageJSON.packageDependencies != null ? packageJSON.packageDependencies : {}
this.triggeredActivationHooks.clear()
}
/*
Section: Event Subscription
*/
// Public: Invoke the given callback when all packages have been loaded.
//
// * `callback` {Function}
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidLoadInitialPackages (callback) {
return this.emitter.on('did-load-initial-packages', callback)
}
// Public: Invoke the given callback when all packages have been activated.
//
// * `callback` {Function}
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidActivateInitialPackages (callback) {
return this.emitter.on('did-activate-initial-packages', callback)
}
// Public: Invoke the given callback when a package is activated.
//
// * `callback` A {Function} to be invoked when a package is activated.
// * `package` The {Package} that was activated.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidActivatePackage (callback) {
return this.emitter.on('did-activate-package', callback)
}
// Public: Invoke the given callback when a package is deactivated.
//
// * `callback` A {Function} to be invoked when a package is deactivated.
// * `package` The {Package} that was deactivated.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidDeactivatePackage (callback) {
return this.emitter.on('did-deactivate-package', callback)
}
// Public: Invoke the given callback when a package is loaded.
//
// * `callback` A {Function} to be invoked when a package is loaded.
// * `package` The {Package} that was loaded.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidLoadPackage (callback) {
return this.emitter.on('did-load-package', callback)
}
// Public: Invoke the given callback when a package is unloaded.
//
// * `callback` A {Function} to be invoked when a package is unloaded.
// * `package` The {Package} that was unloaded.
//
// Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
onDidUnloadPackage (callback) {
return this.emitter.on('did-unload-package', callback)
}
/*
Section: Package system data
*/
// Public: Get the path to the apm command.
//
// Uses the value of the `core.apmPath` config setting if it exists.
//
// Return a {String} file path to apm.
getApmPath () {
const configPath = atom.config.get('core.apmPath')
if (configPath || this.apmPath) {
return configPath || this.apmPath
}
const commandName = process.platform === 'win32' ? 'apm.cmd' : 'apm'
const apmRoot = path.join(process.resourcesPath, 'app', 'apm')
this.apmPath = path.join(apmRoot, 'bin', commandName)
if (!fs.isFileSync(this.apmPath)) {
this.apmPath = path.join(apmRoot, 'node_modules', 'atom-package-manager', 'bin', commandName)
}
return this.apmPath
}
// Public: Get the paths being used to look for packages.
//
// Returns an {Array} of {String} directory paths.
getPackageDirPaths () {
return _.clone(this.packageDirPaths)
}
/*
Section: General package data
*/
// Public: Resolve the given package name to a path on disk.
//
// * `name` - The {String} package name.
//
// Return a {String} folder path or undefined if it could not be resolved.
resolvePackagePath (name) {
if (fs.isDirectorySync(name)) {
return name
}
let packagePath = fs.resolve(...this.packageDirPaths, name)
if (fs.isDirectorySync(packagePath)) {
return packagePath
}
packagePath = path.join(this.resourcePath, 'node_modules', name)
if (this.hasAtomEngine(packagePath)) {
return packagePath
}
return null
}
// Public: Is the package with the given name bundled with Atom?
//
// * `name` - The {String} package name.
//
// Returns a {Boolean}.
isBundledPackage (name) {
return this.getPackageDependencies().hasOwnProperty(name)
}
isDeprecatedPackage (name, version) {
return isDeprecatedPackage(name, version)
}
getDeprecatedPackageMetadata (name) {
return getDeprecatedPackageMetadata(name)
}
/*
Section: Enabling and disabling packages
*/
// Public: Enable the package with the given name.
//
// * `name` - The {String} package name.
//
// Returns the {Package} that was enabled or null if it isn't loaded.
enablePackage (name) {
const pack = this.loadPackage(name)
if (pack != null) {
pack.enable()
}
return pack
}
// Public: Disable the package with the given name.
//
// * `name` - The {String} package name.
//
// Returns the {Package} that was disabled or null if it isn't loaded.
disablePackage (name) {
const pack = this.loadPackage(name)
if (!this.isPackageDisabled(name) && pack != null) {
pack.disable()
}
return pack
}
// Public: Is the package with the given name disabled?
//
// * `name` - The {String} package name.
//
// Returns a {Boolean}.
isPackageDisabled (name) {
return _.include(this.config.get('core.disabledPackages') || [], name)
}
/*
Section: Accessing active packages
*/
// Public: Get an {Array} of all the active {Package}s.
getActivePackages () {
return _.values(this.activePackages)
}
// Public: Get the active {Package} with the given name.
//
// * `name` - The {String} package name.
//
// Returns a {Package} or undefined.
getActivePackage (name) {
return this.activePackages[name]
}
// Public: Is the {Package} with the given name active?
//
// * `name` - The {String} package name.
//
// Returns a {Boolean}.
isPackageActive (name) {
return (this.getActivePackage(name) != null)
}
// Public: Returns a {Boolean} indicating whether package activation has occurred.
hasActivatedInitialPackages () {
return this.initialPackagesActivated
}
/*
Section: Accessing loaded packages
*/
// Public: Get an {Array} of all the loaded {Package}s
getLoadedPackages () {
return _.values(this.loadedPackages)
}
// Get packages for a certain package type
//
// * `types` an {Array} of {String}s like ['atom', 'textmate'].
getLoadedPackagesForTypes (types) {
return this.getLoadedPackages().filter(p => types.includes(p.getType()))
}
// Public: Get the loaded {Package} with the given name.
//
// * `name` - The {String} package name.
//
// Returns a {Package} or undefined.
getLoadedPackage (name) {
return this.loadedPackages[name]
}
// Public: Is the package with the given name loaded?
//
// * `name` - The {String} package name.
//
// Returns a {Boolean}.
isPackageLoaded (name) {
return this.getLoadedPackage(name) != null
}
// Public: Returns a {Boolean} indicating whether package loading has occurred.
hasLoadedInitialPackages () {
return this.initialPackagesLoaded
}
/*
Section: Accessing available packages
*/
// Public: Returns an {Array} of {String}s of all the available package paths.
getAvailablePackagePaths () {
return this.getAvailablePackages().map(a => a.path)
}
// Public: Returns an {Array} of {String}s of all the available package names.
getAvailablePackageNames () {
return this.getAvailablePackages().map(a => a.name)
}
// Public: Returns an {Array} of {String}s of all the available package metadata.
getAvailablePackageMetadata () {
const packages = []
for (const pack of this.getAvailablePackages()) {
const loadedPackage = this.getLoadedPackage(pack.name)
const metadata = loadedPackage != null ? loadedPackage.metadata : this.loadPackageMetadata(pack, true)
packages.push(metadata)
}
return packages
}
getAvailablePackages () {
const packages = []
const packagesByName = new Set()
for (const packageDirPath of this.packageDirPaths) {
if (fs.isDirectorySync(packageDirPath)) {
for (let packagePath of fs.readdirSync(packageDirPath)) {
packagePath = path.join(packageDirPath, packagePath)
const packageName = path.basename(packagePath)
if (!packageName.startsWith('.') && !packagesByName.has(packageName) && fs.isDirectorySync(packagePath)) {
packages.push({
name: packageName,
path: packagePath,
isBundled: false
})
packagesByName.add(packageName)
}
}
}
}
for (const packageName in this.packageDependencies) {
if (!packagesByName.has(packageName)) {
packages.push({
name: packageName,
path: path.join(this.resourcePath, 'node_modules', packageName),
isBundled: true
})
}
}
return packages.sort((a, b) => a.name.localeCompare(b.name))
}
/*
Section: Private
*/
getPackageState (name) {
return this.packageStates[name]
}
setPackageState (name, state) {
this.packageStates[name] = state
}
getPackageDependencies () {
return this.packageDependencies
}
hasAtomEngine (packagePath) {
const metadata = this.loadPackageMetadata(packagePath, true)
return metadata != null && metadata.engines != null && metadata.engines.atom != null
}
unobserveDisabledPackages () {
if (this.disabledPackagesSubscription != null) {
this.disabledPackagesSubscription.dispose()
}
this.disabledPackagesSubscription = null
}
observeDisabledPackages () {
if (this.disabledPackagesSubscription != null) {
return
}
this.disabledPackagesSubscription = this.config.onDidChange('core.disabledPackages', ({newValue, oldValue}) => {
const packagesToEnable = _.difference(oldValue, newValue)
const packagesToDisable = _.difference(newValue, oldValue)
packagesToDisable.forEach(name => { if (this.getActivePackage(name)) this.deactivatePackage(name) })
packagesToEnable.forEach(name => this.activatePackage(name))
return null
})
}
unobservePackagesWithKeymapsDisabled () {
if (this.packagesWithKeymapsDisabledSubscription != null) {
this.packagesWithKeymapsDisabledSubscription.dispose()
}
this.packagesWithKeymapsDisabledSubscription = null
}
observePackagesWithKeymapsDisabled () {
if (this.packagesWithKeymapsDisabledSubscription != null) {
return
}
const performOnLoadedActivePackages = (packageNames, disabledPackageNames, action) => {
for (const packageName of packageNames) {
if (!disabledPackageNames.has(packageName)) {
var pack = this.getLoadedPackage(packageName)
if (pack != null) {
action(pack)
}
}
}
}
this.packagesWithKeymapsDisabledSubscription = this.config.onDidChange('core.packagesWithKeymapsDisabled', ({newValue, oldValue}) => {
const keymapsToEnable = _.difference(oldValue, newValue)
const keymapsToDisable = _.difference(newValue, oldValue)
const disabledPackageNames = new Set(this.config.get('core.disabledPackages'))
performOnLoadedActivePackages(keymapsToDisable, disabledPackageNames, p => p.deactivateKeymaps())
performOnLoadedActivePackages(keymapsToEnable, disabledPackageNames, p => p.activateKeymaps())
return null
})
}
preloadPackages () {
const result = []
for (const packageName in this.packagesCache) {
result.push(this.preloadPackage(packageName, this.packagesCache[packageName]))
}
return result
}
preloadPackage (packageName, pack) {
const metadata = pack.metadata || {}
if (typeof metadata.name !== 'string' || metadata.name.length < 1) {
metadata.name = packageName
}
if (metadata.repository != null && metadata.repository.type === 'git' && typeof metadata.repository.url === 'string') {
metadata.repository.url = metadata.repository.url.replace(/(^git\+)|(\.git$)/g, '')
}
const options = {
path: pack.rootDirPath,
name: packageName,
preloadedPackage: true,
bundledPackage: true,
metadata,
packageManager: this,
config: this.config,
styleManager: this.styleManager,
commandRegistry: this.commandRegistry,
keymapManager: this.keymapManager,
notificationManager: this.notificationManager,
grammarRegistry: this.grammarRegistry,
themeManager: this.themeManager,
menuManager: this.menuManager,
contextMenuManager: this.contextMenuManager,
deserializerManager: this.deserializerManager,
viewRegistry: this.viewRegistry
}
pack = metadata.theme ? new ThemePackage(options) : new Package(options)
pack.preload()
this.preloadedPackages[packageName] = pack
return pack
}
loadPackages () {
// Ensure atom exports is already in the require cache so the load time
// of the first package isn't skewed by being the first to require atom
require('../exports/atom')
const disabledPackageNames = new Set(this.config.get('core.disabledPackages'))
this.config.transact(() => {
for (const pack of this.getAvailablePackages()) {
this.loadAvailablePackage(pack, disabledPackageNames)
}
})
this.initialPackagesLoaded = true
this.emitter.emit('did-load-initial-packages')
}
loadPackage (nameOrPath) {
if (path.basename(nameOrPath)[0].match(/^\./)) { // primarily to skip .git folder
return null
}
const pack = this.getLoadedPackage(nameOrPath)
if (pack) {
return pack
}
const packagePath = this.resolvePackagePath(nameOrPath)
if (packagePath) {
const name = path.basename(nameOrPath)
return this.loadAvailablePackage({name, path: packagePath, isBundled: this.isBundledPackagePath(packagePath)})
}
console.warn(`Could not resolve '${nameOrPath}' to a package path`)
return null
}
loadAvailablePackage (availablePackage, disabledPackageNames) {
const preloadedPackage = this.preloadedPackages[availablePackage.name]
if (disabledPackageNames != null && disabledPackageNames.has(availablePackage.name)) {
if (preloadedPackage != null) {
preloadedPackage.deactivate()
delete preloadedPackage[availablePackage.name]
}
return null
}
const loadedPackage = this.getLoadedPackage(availablePackage.name)
if (loadedPackage != null) {
return loadedPackage
}
if (preloadedPackage != null) {
if (availablePackage.isBundled) {
preloadedPackage.finishLoading()
this.loadedPackages[availablePackage.name] = preloadedPackage
return preloadedPackage
} else {
preloadedPackage.deactivate()
delete preloadedPackage[availablePackage.name]
}
}
let metadata
try {
metadata = this.loadPackageMetadata(availablePackage) || {}
} catch (error) {
this.handleMetadataError(error, availablePackage.path)
return null
}
if (!availablePackage.isBundled && this.isDeprecatedPackage(metadata.name, metadata.version)) {
console.warn(`Could not load ${metadata.name}@${metadata.version} because it uses deprecated APIs that have been removed.`)
return null
}
const options = {
path: availablePackage.path,
name: availablePackage.name,
metadata,
bundledPackage: availablePackage.isBundled,
packageManager: this,
config: this.config,
styleManager: this.styleManager,
commandRegistry: this.commandRegistry,
keymapManager: this.keymapManager,
notificationManager: this.notificationManager,
grammarRegistry: this.grammarRegistry,
themeManager: this.themeManager,
menuManager: this.menuManager,
contextMenuManager: this.contextMenuManager,
deserializerManager: this.deserializerManager,
viewRegistry: this.viewRegistry
}
const pack = metadata.theme ? new ThemePackage(options) : new Package(options)
pack.load()
this.loadedPackages[pack.name] = pack
this.emitter.emit('did-load-package', pack)
return pack
}
unloadPackages () {
_.keys(this.loadedPackages).forEach(name => this.unloadPackage(name))
}
unloadPackage (name) {
if (this.isPackageActive(name)) {
throw new Error(`Tried to unload active package '${name}'`)
}
const pack = this.getLoadedPackage(name)
if (pack) {
delete this.loadedPackages[pack.name]
this.emitter.emit('did-unload-package', pack)
} else {
throw new Error(`No loaded package for name '${name}'`)
}
}
// Activate all the packages that should be activated.
activate () {
let promises = []
for (let [activator, types] of this.packageActivators) {
const packages = this.getLoadedPackagesForTypes(types)
promises = promises.concat(activator.activatePackages(packages))
}
return Promise.all(promises).then(() => {
this.triggerDeferredActivationHooks()
this.initialPackagesActivated = true
this.emitter.emit('did-activate-initial-packages')
})
}
registerURIHandlerForPackage (packageName, handler) {
return this.uriHandlerRegistry.registerHostHandler(packageName, handler)
}
// another type of package manager can handle other package types.
// See ThemeManager
registerPackageActivator (activator, types) {
this.packageActivators.push([activator, types])
}
activatePackages (packages) {
const promises = []
this.config.transactAsync(() => {
for (const pack of packages) {
const promise = this.activatePackage(pack.name)
if (!pack.activationShouldBeDeferred()) {
promises.push(promise)
}
}
return Promise.all(promises)
})
this.observeDisabledPackages()
this.observePackagesWithKeymapsDisabled()
return promises
}
// Activate a single package by name
activatePackage (name) {
let pack = this.getActivePackage(name)
if (pack) {
return Promise.resolve(pack)
}
pack = this.loadPackage(name)
if (!pack) {
return Promise.reject(new Error(`Failed to load package '${name}'`))
}
this.activatingPackages[pack.name] = pack
const activationPromise = pack.activate().then(() => {
if (this.activatingPackages[pack.name] != null) {
delete this.activatingPackages[pack.name]
this.activePackages[pack.name] = pack
this.emitter.emit('did-activate-package', pack)
}
return pack
})
if (this.deferredActivationHooks == null) {
this.triggeredActivationHooks.forEach(hook => this.activationHookEmitter.emit(hook))
}
return activationPromise
}
triggerDeferredActivationHooks () {
if (this.deferredActivationHooks == null) {
return
}
for (const hook of this.deferredActivationHooks) {
this.activationHookEmitter.emit(hook)
}
this.deferredActivationHooks = null
}
triggerActivationHook (hook) {
if (hook == null || !_.isString(hook) || hook.length <= 0) {
return new Error('Cannot trigger an empty activation hook')
}
this.triggeredActivationHooks.add(hook)
if (this.deferredActivationHooks != null) {
this.deferredActivationHooks.push(hook)
} else {
this.activationHookEmitter.emit(hook)
}
}
onDidTriggerActivationHook (hook, callback) {
if (hook == null || !_.isString(hook) || hook.length <= 0) {
return
}
return this.activationHookEmitter.on(hook, callback)
}
serialize () {
for (const pack of this.getActivePackages()) {
this.serializePackage(pack)
}
return this.packageStates
}
serializePackage (pack) {
if (typeof pack.serialize === 'function') {
this.setPackageState(pack.name, pack.serialize())
}
}
// Deactivate all packages
async deactivatePackages () {
await this.config.transactAsync(() =>
Promise.all(this.getLoadedPackages().map(pack => this.deactivatePackage(pack.name, true)))
)
this.unobserveDisabledPackages()
this.unobservePackagesWithKeymapsDisabled()
}
// Deactivate the package with the given name
async deactivatePackage (name, suppressSerialization) {
const pack = this.getLoadedPackage(name)
if (pack == null) {
return
}
if (!suppressSerialization && this.isPackageActive(pack.name)) {
this.serializePackage(pack)
}
const deactivationResult = pack.deactivate()
if (deactivationResult && typeof deactivationResult.then === 'function') {
await deactivationResult
}
delete this.activePackages[pack.name]
delete this.activatingPackages[pack.name]
this.emitter.emit('did-deactivate-package', pack)
}
handleMetadataError (error, packagePath) {
const metadataPath = path.join(packagePath, 'package.json')
const detail = `${error.message} in ${metadataPath}`
const stack = `${error.stack}\n at ${metadataPath}:1:1`
const message = `Failed to load the ${path.basename(packagePath)} package`
this.notificationManager.addError(message, {stack, detail, packageName: path.basename(packagePath), dismissable: true})
}
uninstallDirectory (directory) {
const symlinkPromise = new Promise(resolve => fs.isSymbolicLink(directory, isSymLink => resolve(isSymLink)))
const dirPromise = new Promise(resolve => fs.isDirectory(directory, isDir => resolve(isDir)))
return Promise.all([symlinkPromise, dirPromise]).then(values => {
const [isSymLink, isDir] = values
if (!isSymLink && isDir) {
return fs.remove(directory, function () {})
}
})
}
reloadActivePackageStyleSheets () {
for (const pack of this.getActivePackages()) {
if (pack.getType() !== 'theme' && typeof pack.reloadStylesheets === 'function') {
pack.reloadStylesheets()
}
}
}
isBundledPackagePath (packagePath) {
if (this.devMode && !this.resourcePath.startsWith(`${process.resourcesPath}${path.sep}`)) {
return false
}
if (this.resourcePathWithTrailingSlash == null) {
this.resourcePathWithTrailingSlash = `${this.resourcePath}${path.sep}`
}
return packagePath != null && packagePath.startsWith(this.resourcePathWithTrailingSlash)
}
loadPackageMetadata (packagePathOrAvailablePackage, ignoreErrors = false) {
let isBundled, packageName, packagePath
if (typeof packagePathOrAvailablePackage === 'object') {
const availablePackage = packagePathOrAvailablePackage
packageName = availablePackage.name
packagePath = availablePackage.path
isBundled = availablePackage.isBundled
} else {
packagePath = packagePathOrAvailablePackage
packageName = path.basename(packagePath)
isBundled = this.isBundledPackagePath(packagePath)
}
let metadata
if (isBundled && this.packagesCache[packageName] != null) {
metadata = this.packagesCache[packageName].metadata
}
if (metadata == null) {
const metadataPath = CSON.resolve(path.join(packagePath, 'package'))
if (metadataPath) {
try {
metadata = CSON.readFileSync(metadataPath)
this.normalizePackageMetadata(metadata)
} catch (error) {
if (!ignoreErrors) { throw error }
}
}
}
if (metadata == null) {
metadata = {}
}
if (typeof metadata.name !== 'string' || metadata.name.length <= 0) {
metadata.name = packageName
}
if (metadata.repository && metadata.repository.type === 'git' && typeof metadata.repository.url === 'string') {
metadata.repository.url = metadata.repository.url.replace(/(^git\+)|(\.git$)/g, '')
}
return metadata
}
normalizePackageMetadata (metadata) {
if (metadata != null) {
normalizePackageData = normalizePackageData || require('normalize-package-data')
normalizePackageData(metadata)
}
}
}