Files
vue-cli/packages/@vue/cli-ui/apollo-server/connectors/plugins.js
T

684 lines
18 KiB
JavaScript

const path = require('path')
const fs = require('fs-extra')
const LRU = require('lru-cache')
const { chalk } = require('@vue/cli-shared-utils')
// Context
const getContext = require('../context')
// Subs
const channels = require('../channels')
// Connectors
const cwd = require('./cwd')
const folders = require('./folders')
const prompts = require('./prompts')
const progress = require('./progress')
const logs = require('./logs')
const clientAddons = require('./client-addons')
const views = require('./views')
const locales = require('./locales')
const sharedData = require('./shared-data')
const suggestions = require('./suggestions')
const dependencies = require('./dependencies')
// Api
const PluginApi = require('../api/PluginApi')
// Utils
const {
isPlugin,
isOfficialPlugin,
getPluginLink,
resolveModule,
loadModule,
clearModule,
execa
} = require('@vue/cli-shared-utils')
const { progress: installProgress } = require('@vue/cli/lib/util/executeCommand')
const PackageManager = require('@vue/cli/lib/util/ProjectPackageManager')
const ipc = require('../util/ipc')
const { log } = require('../util/logger')
const { notify } = require('../util/notification')
const PROGRESS_ID = 'plugin-installation'
const CLI_SERVICE = '@vue/cli-service'
// Caches
const logoCache = new LRU({
max: 50
})
// Local
let currentPluginId
let eventsInstalled = false
let installationStep
const pluginsStore = new Map()
const pluginApiInstances = new Map()
const pkgStore = new Map()
async function list (file, context, { resetApi = true, lightApi = false, autoLoadApi = true } = {}) {
let pkg = folders.readPackage(file, context)
let pkgContext = cwd.get()
// Custom package.json location
if (pkg.vuePlugins && pkg.vuePlugins.resolveFrom) {
pkgContext = path.resolve(cwd.get(), pkg.vuePlugins.resolveFrom)
pkg = folders.readPackage(pkgContext, context)
}
pkgStore.set(file, { pkgContext, pkg })
let plugins = []
plugins = plugins.concat(findPlugins(pkg.devDependencies || {}, file))
plugins = plugins.concat(findPlugins(pkg.dependencies || {}, file))
// Put cli service at the top
const index = plugins.findIndex(p => p.id === CLI_SERVICE)
if (index !== -1) {
const service = plugins[index]
plugins.splice(index, 1)
plugins.unshift(service)
}
pluginsStore.set(file, plugins)
log('Plugins found:', plugins.length, chalk.grey(file))
if (resetApi || (autoLoadApi && !pluginApiInstances.has(file))) {
await resetPluginApi({ file, lightApi }, context)
}
return plugins
}
function findOne ({ id, file }, context) {
const plugins = getPlugins(file)
const plugin = plugins.find(
p => p.id === id
)
if (!plugin) log('Plugin Not found', id, chalk.grey(file))
return plugin
}
function findPlugins (deps, file) {
return Object.keys(deps).filter(
id => isPlugin(id) || id === CLI_SERVICE
).map(
id => ({
id,
versionRange: deps[id],
official: isOfficialPlugin(id) || id === CLI_SERVICE,
installed: fs.existsSync(dependencies.getPath({ id, file })),
website: getLink(id),
baseDir: file
})
)
}
function getLink (id) {
if (id === CLI_SERVICE) return 'https://cli.vuejs.org/'
return getPluginLink(id)
}
function getPlugins (file) {
const plugins = pluginsStore.get(file)
if (!plugins) return []
return plugins
}
function resetPluginApi ({ file, lightApi }, context) {
return new Promise((resolve, reject) => {
log('Plugin API reloading...', chalk.grey(file))
const widgets = require('./widgets')
let pluginApi = pluginApiInstances.get(file)
let projectId
// Clean up
if (pluginApi) {
projectId = pluginApi.project.id
pluginApi.views.forEach(r => views.remove(r.id, context))
pluginApi.ipcHandlers.forEach(fn => ipc.off(fn))
}
if (!lightApi) {
if (projectId) sharedData.unWatchAll({ projectId }, context)
clientAddons.clear(context)
suggestions.clear(context)
widgets.reset(context)
}
// Cyclic dependency with projects connector
setTimeout(async () => {
const projects = require('./projects')
const project = projects.findByPath(file, context)
if (!project) {
resolve(false)
return
}
const plugins = getPlugins(file)
if (project && projects.getType(project, context) !== 'vue') {
resolve(false)
return
}
pluginApi = new PluginApi({
plugins,
file,
project,
lightMode: lightApi
}, context)
pluginApiInstances.set(file, pluginApi)
// Run Plugin API
runPluginApi(path.resolve(__dirname, '../../'), pluginApi, context, 'ui-defaults')
plugins.forEach(plugin => runPluginApi(plugin.id, pluginApi, context))
// Local plugins
const { pkg, pkgContext } = pkgStore.get(file)
if (pkg.vuePlugins && pkg.vuePlugins.ui) {
const files = pkg.vuePlugins.ui
if (Array.isArray(files)) {
for (const file of files) {
runPluginApi(pkgContext, pluginApi, context, file)
}
}
}
// Add client addons
pluginApi.clientAddons.forEach(options => {
clientAddons.add(options, context)
})
// Add views
for (const view of pluginApi.views) {
await views.add({ view, project }, context)
}
// Register widgets
for (const definition of pluginApi.widgetDefs) {
await widgets.registerDefinition({ definition, project }, context)
}
if (lightApi) {
resolve(true)
return
}
if (projectId !== project.id) {
callHook({
id: 'projectOpen',
args: [project, projects.getLast(context)],
file
}, context)
} else {
callHook({
id: 'pluginReload',
args: [project],
file
}, context)
// View open hook
const currentView = views.getCurrent()
if (currentView) views.open(currentView.id)
}
// Load widgets for current project
widgets.load(context)
resolve(true)
})
})
}
function runPluginApi (id, pluginApi, context, filename = 'ui') {
const name = filename !== 'ui' ? `${id}/${filename}` : id
let module
try {
module = loadModule(`${id}/${filename}`, pluginApi.cwd, true)
} catch (e) {
if (process.env.VUE_CLI_DEBUG) {
console.error(e)
}
}
if (module) {
if (typeof module !== 'function') {
log(`${chalk.red('ERROR')} while loading plugin API: no function exported, for`, name, chalk.grey(pluginApi.cwd))
logs.add({
type: 'error',
message: `An error occurred while loading ${name}: no function exported`
})
} else {
pluginApi.pluginId = id
try {
module(pluginApi)
log('Plugin API loaded for', name, chalk.grey(pluginApi.cwd))
} catch (e) {
log(`${chalk.red('ERROR')} while loading plugin API for ${name}:`, e)
logs.add({
type: 'error',
message: `An error occurred while loading ${name}: ${e.message}`
})
}
pluginApi.pluginId = null
}
}
// Locales
try {
const folder = fs.existsSync(id) ? id : dependencies.getPath({ id, file: pluginApi.cwd })
locales.loadFolder(folder, context)
} catch (e) {}
}
function getApi (folder) {
const pluginApi = pluginApiInstances.get(folder)
return pluginApi
}
function callHook ({ id, args, file }, context) {
const pluginApi = getApi(file)
if (!pluginApi) return
const fns = pluginApi.hooks[id]
log(`Hook ${id}`, fns.length, 'handlers')
fns.forEach(fn => fn(...args))
}
async function getLogo (plugin, context) {
const { id, baseDir } = plugin
const cached = logoCache.get(id)
if (cached) {
return cached
}
const folder = dependencies.getPath({ id, file: baseDir })
const file = path.join(folder, 'logo.png')
if (fs.existsSync(file)) {
const data = `/_plugin-logo/${encodeURIComponent(id)}`
logoCache.set(id, data)
return data
}
return null
}
function getInstallation (context) {
if (!eventsInstalled) {
eventsInstalled = true
// Package installation progress events
installProgress.on('progress', value => {
if (progress.get(PROGRESS_ID)) {
progress.set({ id: PROGRESS_ID, progress: value }, context)
}
})
installProgress.on('log', message => {
if (progress.get(PROGRESS_ID)) {
progress.set({ id: PROGRESS_ID, info: message }, context)
}
})
}
return {
id: 'plugin-install',
pluginId: currentPluginId,
step: installationStep,
prompts: prompts.list()
}
}
function install (id, context) {
return progress.wrap(PROGRESS_ID, context, async setProgress => {
setProgress({
status: 'plugin-install',
args: [id]
})
currentPluginId = id
installationStep = 'install'
if (process.env.VUE_CLI_DEBUG && isOfficialPlugin(id)) {
mockInstall(id, context)
} else {
const pm = new PackageManager({ context: cwd.get() })
await pm.add(id)
}
await initPrompts(id, context)
installationStep = 'config'
notify({
title: 'Plugin installed',
message: `Plugin ${id} installed, next step is configuration`,
icon: 'done'
})
return getInstallation(context)
})
}
function mockInstall (id, context) {
const pkg = folders.readPackage(cwd.get(), context, true)
pkg.devDependencies[id] = '*'
folders.writePackage({ file: cwd.get(), data: pkg }, context)
return true
}
function installLocal (context) {
const projects = require('./projects')
const folder = cwd.get()
cwd.set(projects.getCurrent(context).path, context)
return progress.wrap(PROGRESS_ID, context, async setProgress => {
const pkg = loadModule(path.resolve(folder, 'package.json'), cwd.get(), true)
const id = pkg.name
setProgress({
status: 'plugin-install',
args: [id]
})
currentPluginId = id
installationStep = 'install'
// Update package.json
{
const pkgFile = path.resolve(cwd.get(), 'package.json')
const pkg = await fs.readJson(pkgFile)
if (!pkg.devDependencies) pkg.devDependencies = {}
pkg.devDependencies[id] = `file:${folder}`
await fs.writeJson(pkgFile, pkg, {
spaces: 2
})
}
const from = path.resolve(cwd.get(), folder)
const to = path.resolve(cwd.get(), 'node_modules', ...id.split('/'))
console.log('copying from', from, 'to', to)
await fs.copy(from, to)
await initPrompts(id, context)
installationStep = 'config'
notify({
title: 'Plugin installed',
message: `Plugin ${id} installed, next step is configuration`,
icon: 'done'
})
return getInstallation(context)
})
}
function uninstall (id, context) {
return progress.wrap(PROGRESS_ID, context, async setProgress => {
setProgress({
status: 'plugin-uninstall',
args: [id]
})
installationStep = 'uninstall'
currentPluginId = id
if (process.env.VUE_CLI_DEBUG && isOfficialPlugin(id)) {
mockUninstall(id, context)
} else {
const pm = new PackageManager({ context: cwd.get() })
await pm.remove(id)
}
currentPluginId = null
installationStep = null
notify({
title: 'Plugin uninstalled',
message: `Plugin ${id} uninstalled`,
icon: 'done'
})
return getInstallation(context)
})
}
function mockUninstall (id, context) {
const pkg = folders.readPackage(cwd.get(), context, true)
delete pkg.devDependencies[id]
folders.writePackage({ file: cwd.get(), data: pkg }, context)
return true
}
function runInvoke (id, context) {
return progress.wrap(PROGRESS_ID, context, async setProgress => {
setProgress({
status: 'plugin-invoke',
args: [id]
})
clearModule('@vue/cli-service/webpack.config.js', cwd.get())
currentPluginId = id
// Allow plugins that don't have a generator
if (resolveModule(`${id}/generator`, cwd.get())) {
const child = execa('vue', [
'invoke',
id,
'--$inlineOptions',
JSON.stringify(prompts.getAnswers())
], {
cwd: cwd.get(),
stdio: ['inherit', 'pipe', 'inherit']
})
const onData = buffer => {
const text = buffer.toString().trim()
if (text) {
setProgress({
info: text
})
logs.add({
type: 'info',
message: text
}, context)
}
}
child.stdout.on('data', onData)
await child
}
// Run plugin api
runPluginApi(id, getApi(cwd.get()), context)
installationStep = 'diff'
notify({
title: 'Plugin invoked successfully',
message: `Plugin ${id} invoked successfully`,
icon: 'done'
})
return getInstallation(context)
})
}
function finishInstall (context) {
installationStep = null
currentPluginId = null
return getInstallation(context)
}
async function initPrompts (id, context) {
await prompts.reset()
try {
let data = require(path.join(dependencies.getPath({ id, file: cwd.get() }), 'prompts'))
if (typeof data === 'function') {
data = await data()
}
data.forEach(prompts.add)
} catch (e) {
console.warn(`No prompts found for ${id}`)
}
await prompts.start()
}
function update ({ id, full }, context) {
return progress.wrap('plugin-update', context, async setProgress => {
setProgress({
status: 'plugin-update',
args: [id]
})
currentPluginId = id
const plugin = findOne({ id, file: cwd.get() }, context)
const { current, wanted, localPath } = await dependencies.getVersion(plugin, context)
if (localPath) {
await updateLocalPackage({ cwd: cwd.get(), id, localPath, full }, context)
} else {
const pm = new PackageManager({ context: cwd.get() })
await pm.upgrade(id)
}
logs.add({
message: `Plugin ${id} updated from ${current} to ${wanted}`,
type: 'info'
}, context)
notify({
title: 'Plugin updated',
message: `Plugin ${id} was successfully updated`,
icon: 'done'
})
await resetPluginApi({ file: cwd.get() }, context)
dependencies.invalidatePackage({ id }, context)
currentPluginId = null
return findOne({ id, file: cwd.get() }, context)
})
}
async function updateLocalPackage ({ id, cwd, localPath, full = true }, context) {
const from = path.resolve(cwd, localPath)
const to = path.resolve(cwd, 'node_modules', ...id.split('/'))
let filterRegEx
if (full) {
await fs.remove(to)
filterRegEx = /\.git/
} else {
filterRegEx = /(\.git|node_modules)/
}
await fs.copy(from, to, {
filter: (file) => !file.match(filterRegEx)
})
}
async function updateAll (context) {
return progress.wrap('plugins-update', context, async setProgress => {
const plugins = await list(cwd.get(), context, { resetApi: false })
const updatedPlugins = []
for (const plugin of plugins) {
const version = await dependencies.getVersion(plugin, context)
if (version.current !== version.wanted) {
updatedPlugins.push(plugin)
dependencies.invalidatePackage({ id: plugin.id }, context)
}
}
if (!updatedPlugins.length) {
notify({
title: 'No updates available',
message: 'No plugin to update in the version ranges declared in package.json',
icon: 'done'
})
return []
}
setProgress({
status: 'plugins-update',
args: [updatedPlugins.length]
})
const pm = new PackageManager({ context: cwd.get() })
await pm.upgrade(updatedPlugins.map(p => p.id).join(' '))
notify({
title: 'Plugins updated',
message: `${updatedPlugins.length} plugin(s) were successfully updated`,
icon: 'done'
})
await resetPluginApi({ file: cwd.get() }, context)
return updatedPlugins
})
}
async function callAction ({ id, params, file = cwd.get() }, context) {
const pluginApi = getApi(file)
context.pubsub.publish(channels.PLUGIN_ACTION_CALLED, {
pluginActionCalled: { id, params }
})
log('PluginAction called', id, params)
const results = []
const errors = []
const list = pluginApi.actions.get(id)
if (list) {
for (const cb of list) {
let result = null
let error = null
try {
result = await cb(params)
} catch (e) {
error = e
}
results.push(result)
errors.push(error)
}
}
context.pubsub.publish(channels.PLUGIN_ACTION_RESOLVED, {
pluginActionResolved: { id, params, results, errors }
})
log('PluginAction resolved', id, params, 'results:', results, 'errors:', errors)
return { id, params, results, errors }
}
function serveFile ({ pluginId, projectId = null, file }, res) {
let baseFile = cwd.get()
if (projectId) {
const projects = require('./projects')
const project = projects.findOne(projectId, getContext())
if (project) {
baseFile = project.path
}
}
if (pluginId) {
const basePath = pluginId === '.' ? baseFile : dependencies.getPath({ id: decodeURIComponent(pluginId), file: baseFile })
if (basePath) {
res.sendFile(path.join(basePath, file))
return
}
} else {
console.log('serve issue', 'pluginId:', pluginId, 'projectId:', projectId, 'file:', file)
}
res.status(404)
res.send('Addon not found in loaded addons. Try opening a vue-cli project first?')
}
function serve (req, res) {
const { id: pluginId, 0: file } = req.params
serveFile({ pluginId, file: path.join('ui-public', file) }, res)
}
function serveLogo (req, res) {
const { id: pluginId } = req.params
const { project: projectId } = req.query
serveFile({ pluginId, projectId, file: 'logo.png' }, res)
}
module.exports = {
list,
findOne,
getLogo,
getInstallation,
install,
installLocal,
uninstall,
update,
updateAll,
runInvoke,
resetPluginApi,
getApi,
finishInstall,
callAction,
callHook,
serve,
serveLogo
}