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 }