plugins.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683
  1. const path = require('path')
  2. const fs = require('fs-extra')
  3. const LRU = require('lru-cache')
  4. const { chalk } = require('@vue/cli-shared-utils')
  5. // Context
  6. const getContext = require('../context')
  7. // Subs
  8. const channels = require('../channels')
  9. // Connectors
  10. const cwd = require('./cwd')
  11. const folders = require('./folders')
  12. const prompts = require('./prompts')
  13. const progress = require('./progress')
  14. const logs = require('./logs')
  15. const clientAddons = require('./client-addons')
  16. const views = require('./views')
  17. const locales = require('./locales')
  18. const sharedData = require('./shared-data')
  19. const suggestions = require('./suggestions')
  20. const dependencies = require('./dependencies')
  21. // Api
  22. const PluginApi = require('../api/PluginApi')
  23. // Utils
  24. const {
  25. isPlugin,
  26. isOfficialPlugin,
  27. getPluginLink,
  28. resolveModule,
  29. loadModule,
  30. clearModule,
  31. execa
  32. } = require('@vue/cli-shared-utils')
  33. const { progress: installProgress } = require('@vue/cli/lib/util/executeCommand')
  34. const PackageManager = require('@vue/cli/lib/util/ProjectPackageManager')
  35. const ipc = require('../util/ipc')
  36. const { log } = require('../util/logger')
  37. const { notify } = require('../util/notification')
  38. const PROGRESS_ID = 'plugin-installation'
  39. const CLI_SERVICE = '@vue/cli-service'
  40. // Caches
  41. const logoCache = new LRU({
  42. max: 50
  43. })
  44. // Local
  45. let currentPluginId
  46. let eventsInstalled = false
  47. let installationStep
  48. const pluginsStore = new Map()
  49. const pluginApiInstances = new Map()
  50. const pkgStore = new Map()
  51. async function list (file, context, { resetApi = true, lightApi = false, autoLoadApi = true } = {}) {
  52. let pkg = folders.readPackage(file, context)
  53. let pkgContext = cwd.get()
  54. // Custom package.json location
  55. if (pkg.vuePlugins && pkg.vuePlugins.resolveFrom) {
  56. pkgContext = path.resolve(cwd.get(), pkg.vuePlugins.resolveFrom)
  57. pkg = folders.readPackage(pkgContext, context)
  58. }
  59. pkgStore.set(file, { pkgContext, pkg })
  60. let plugins = []
  61. plugins = plugins.concat(findPlugins(pkg.devDependencies || {}, file))
  62. plugins = plugins.concat(findPlugins(pkg.dependencies || {}, file))
  63. // Put cli service at the top
  64. const index = plugins.findIndex(p => p.id === CLI_SERVICE)
  65. if (index !== -1) {
  66. const service = plugins[index]
  67. plugins.splice(index, 1)
  68. plugins.unshift(service)
  69. }
  70. pluginsStore.set(file, plugins)
  71. log('Plugins found:', plugins.length, chalk.grey(file))
  72. if (resetApi || (autoLoadApi && !pluginApiInstances.has(file))) {
  73. await resetPluginApi({ file, lightApi }, context)
  74. }
  75. return plugins
  76. }
  77. function findOne ({ id, file }, context) {
  78. const plugins = getPlugins(file)
  79. const plugin = plugins.find(
  80. p => p.id === id
  81. )
  82. if (!plugin) log('Plugin Not found', id, chalk.grey(file))
  83. return plugin
  84. }
  85. function findPlugins (deps, file) {
  86. return Object.keys(deps).filter(
  87. id => isPlugin(id) || id === CLI_SERVICE
  88. ).map(
  89. id => ({
  90. id,
  91. versionRange: deps[id],
  92. official: isOfficialPlugin(id) || id === CLI_SERVICE,
  93. installed: fs.existsSync(dependencies.getPath({ id, file })),
  94. website: getLink(id),
  95. baseDir: file
  96. })
  97. )
  98. }
  99. function getLink (id) {
  100. if (id === CLI_SERVICE) return 'https://cli.vuejs.org/'
  101. return getPluginLink(id)
  102. }
  103. function getPlugins (file) {
  104. const plugins = pluginsStore.get(file)
  105. if (!plugins) return []
  106. return plugins
  107. }
  108. function resetPluginApi ({ file, lightApi }, context) {
  109. return new Promise((resolve, reject) => {
  110. log('Plugin API reloading...', chalk.grey(file))
  111. const widgets = require('./widgets')
  112. let pluginApi = pluginApiInstances.get(file)
  113. let projectId
  114. // Clean up
  115. if (pluginApi) {
  116. projectId = pluginApi.project.id
  117. pluginApi.views.forEach(r => views.remove(r.id, context))
  118. pluginApi.ipcHandlers.forEach(fn => ipc.off(fn))
  119. }
  120. if (!lightApi) {
  121. if (projectId) sharedData.unWatchAll({ projectId }, context)
  122. clientAddons.clear(context)
  123. suggestions.clear(context)
  124. widgets.reset(context)
  125. }
  126. // Cyclic dependency with projects connector
  127. setTimeout(async () => {
  128. const projects = require('./projects')
  129. const project = projects.findByPath(file, context)
  130. if (!project) {
  131. resolve(false)
  132. return
  133. }
  134. const plugins = getPlugins(file)
  135. if (project && projects.getType(project, context) !== 'vue') {
  136. resolve(false)
  137. return
  138. }
  139. pluginApi = new PluginApi({
  140. plugins,
  141. file,
  142. project,
  143. lightMode: lightApi
  144. }, context)
  145. pluginApiInstances.set(file, pluginApi)
  146. // Run Plugin API
  147. runPluginApi(path.resolve(__dirname, '../../'), pluginApi, context, 'ui-defaults')
  148. plugins.forEach(plugin => runPluginApi(plugin.id, pluginApi, context))
  149. // Local plugins
  150. const { pkg, pkgContext } = pkgStore.get(file)
  151. if (pkg.vuePlugins && pkg.vuePlugins.ui) {
  152. const files = pkg.vuePlugins.ui
  153. if (Array.isArray(files)) {
  154. for (const file of files) {
  155. runPluginApi(pkgContext, pluginApi, context, file)
  156. }
  157. }
  158. }
  159. // Add client addons
  160. pluginApi.clientAddons.forEach(options => {
  161. clientAddons.add(options, context)
  162. })
  163. // Add views
  164. for (const view of pluginApi.views) {
  165. await views.add({ view, project }, context)
  166. }
  167. // Register widgets
  168. for (const definition of pluginApi.widgetDefs) {
  169. await widgets.registerDefinition({ definition, project }, context)
  170. }
  171. if (lightApi) {
  172. resolve(true)
  173. return
  174. }
  175. if (projectId !== project.id) {
  176. callHook({
  177. id: 'projectOpen',
  178. args: [project, projects.getLast(context)],
  179. file
  180. }, context)
  181. } else {
  182. callHook({
  183. id: 'pluginReload',
  184. args: [project],
  185. file
  186. }, context)
  187. // View open hook
  188. const currentView = views.getCurrent()
  189. if (currentView) views.open(currentView.id)
  190. }
  191. // Load widgets for current project
  192. widgets.load(context)
  193. resolve(true)
  194. })
  195. })
  196. }
  197. function runPluginApi (id, pluginApi, context, filename = 'ui') {
  198. const name = filename !== 'ui' ? `${id}/${filename}` : id
  199. let module
  200. try {
  201. module = loadModule(`${id}/${filename}`, pluginApi.cwd, true)
  202. } catch (e) {
  203. if (process.env.VUE_CLI_DEBUG) {
  204. console.error(e)
  205. }
  206. }
  207. if (module) {
  208. if (typeof module !== 'function') {
  209. log(`${chalk.red('ERROR')} while loading plugin API: no function exported, for`, name, chalk.grey(pluginApi.cwd))
  210. logs.add({
  211. type: 'error',
  212. message: `An error occurred while loading ${name}: no function exported`
  213. })
  214. } else {
  215. pluginApi.pluginId = id
  216. try {
  217. module(pluginApi)
  218. log('Plugin API loaded for', name, chalk.grey(pluginApi.cwd))
  219. } catch (e) {
  220. log(`${chalk.red('ERROR')} while loading plugin API for ${name}:`, e)
  221. logs.add({
  222. type: 'error',
  223. message: `An error occurred while loading ${name}: ${e.message}`
  224. })
  225. }
  226. pluginApi.pluginId = null
  227. }
  228. }
  229. // Locales
  230. try {
  231. const folder = fs.existsSync(id) ? id : dependencies.getPath({ id, file: pluginApi.cwd })
  232. locales.loadFolder(folder, context)
  233. } catch (e) {}
  234. }
  235. function getApi (folder) {
  236. const pluginApi = pluginApiInstances.get(folder)
  237. return pluginApi
  238. }
  239. function callHook ({ id, args, file }, context) {
  240. const pluginApi = getApi(file)
  241. if (!pluginApi) return
  242. const fns = pluginApi.hooks[id]
  243. log(`Hook ${id}`, fns.length, 'handlers')
  244. fns.forEach(fn => fn(...args))
  245. }
  246. async function getLogo (plugin, context) {
  247. const { id, baseDir } = plugin
  248. const cached = logoCache.get(id)
  249. if (cached) {
  250. return cached
  251. }
  252. const folder = dependencies.getPath({ id, file: baseDir })
  253. const file = path.join(folder, 'logo.png')
  254. if (fs.existsSync(file)) {
  255. const data = `/_plugin-logo/${encodeURIComponent(id)}`
  256. logoCache.set(id, data)
  257. return data
  258. }
  259. return null
  260. }
  261. function getInstallation (context) {
  262. if (!eventsInstalled) {
  263. eventsInstalled = true
  264. // Package installation progress events
  265. installProgress.on('progress', value => {
  266. if (progress.get(PROGRESS_ID)) {
  267. progress.set({ id: PROGRESS_ID, progress: value }, context)
  268. }
  269. })
  270. installProgress.on('log', message => {
  271. if (progress.get(PROGRESS_ID)) {
  272. progress.set({ id: PROGRESS_ID, info: message }, context)
  273. }
  274. })
  275. }
  276. return {
  277. id: 'plugin-install',
  278. pluginId: currentPluginId,
  279. step: installationStep,
  280. prompts: prompts.list()
  281. }
  282. }
  283. function install (id, context) {
  284. return progress.wrap(PROGRESS_ID, context, async setProgress => {
  285. setProgress({
  286. status: 'plugin-install',
  287. args: [id]
  288. })
  289. currentPluginId = id
  290. installationStep = 'install'
  291. if (process.env.VUE_CLI_DEBUG && isOfficialPlugin(id)) {
  292. mockInstall(id, context)
  293. } else {
  294. const pm = new PackageManager({ context: cwd.get() })
  295. await pm.add(id)
  296. }
  297. await initPrompts(id, context)
  298. installationStep = 'config'
  299. notify({
  300. title: 'Plugin installed',
  301. message: `Plugin ${id} installed, next step is configuration`,
  302. icon: 'done'
  303. })
  304. return getInstallation(context)
  305. })
  306. }
  307. function mockInstall (id, context) {
  308. const pkg = folders.readPackage(cwd.get(), context, true)
  309. pkg.devDependencies[id] = '*'
  310. folders.writePackage({ file: cwd.get(), data: pkg }, context)
  311. return true
  312. }
  313. function installLocal (context) {
  314. const projects = require('./projects')
  315. const folder = cwd.get()
  316. cwd.set(projects.getCurrent(context).path, context)
  317. return progress.wrap(PROGRESS_ID, context, async setProgress => {
  318. const pkg = loadModule(path.resolve(folder, 'package.json'), cwd.get(), true)
  319. const id = pkg.name
  320. setProgress({
  321. status: 'plugin-install',
  322. args: [id]
  323. })
  324. currentPluginId = id
  325. installationStep = 'install'
  326. // Update package.json
  327. {
  328. const pkgFile = path.resolve(cwd.get(), 'package.json')
  329. const pkg = await fs.readJson(pkgFile)
  330. if (!pkg.devDependencies) pkg.devDependencies = {}
  331. pkg.devDependencies[id] = `file:${folder}`
  332. await fs.writeJson(pkgFile, pkg, {
  333. spaces: 2
  334. })
  335. }
  336. const from = path.resolve(cwd.get(), folder)
  337. const to = path.resolve(cwd.get(), 'node_modules', ...id.split('/'))
  338. console.log('copying from', from, 'to', to)
  339. await fs.copy(from, to)
  340. await initPrompts(id, context)
  341. installationStep = 'config'
  342. notify({
  343. title: 'Plugin installed',
  344. message: `Plugin ${id} installed, next step is configuration`,
  345. icon: 'done'
  346. })
  347. return getInstallation(context)
  348. })
  349. }
  350. function uninstall (id, context) {
  351. return progress.wrap(PROGRESS_ID, context, async setProgress => {
  352. setProgress({
  353. status: 'plugin-uninstall',
  354. args: [id]
  355. })
  356. installationStep = 'uninstall'
  357. currentPluginId = id
  358. if (process.env.VUE_CLI_DEBUG && isOfficialPlugin(id)) {
  359. mockUninstall(id, context)
  360. } else {
  361. const pm = new PackageManager({ context: cwd.get() })
  362. await pm.remove(id)
  363. }
  364. currentPluginId = null
  365. installationStep = null
  366. notify({
  367. title: 'Plugin uninstalled',
  368. message: `Plugin ${id} uninstalled`,
  369. icon: 'done'
  370. })
  371. return getInstallation(context)
  372. })
  373. }
  374. function mockUninstall (id, context) {
  375. const pkg = folders.readPackage(cwd.get(), context, true)
  376. delete pkg.devDependencies[id]
  377. folders.writePackage({ file: cwd.get(), data: pkg }, context)
  378. return true
  379. }
  380. function runInvoke (id, context) {
  381. return progress.wrap(PROGRESS_ID, context, async setProgress => {
  382. setProgress({
  383. status: 'plugin-invoke',
  384. args: [id]
  385. })
  386. clearModule('@vue/cli-service/webpack.config.js', cwd.get())
  387. currentPluginId = id
  388. // Allow plugins that don't have a generator
  389. if (resolveModule(`${id}/generator`, cwd.get())) {
  390. const child = execa('vue', [
  391. 'invoke',
  392. id,
  393. '--$inlineOptions',
  394. JSON.stringify(prompts.getAnswers())
  395. ], {
  396. cwd: cwd.get(),
  397. stdio: ['inherit', 'pipe', 'inherit']
  398. })
  399. const onData = buffer => {
  400. const text = buffer.toString().trim()
  401. if (text) {
  402. setProgress({
  403. info: text
  404. })
  405. logs.add({
  406. type: 'info',
  407. message: text
  408. }, context)
  409. }
  410. }
  411. child.stdout.on('data', onData)
  412. await child
  413. }
  414. // Run plugin api
  415. runPluginApi(id, getApi(cwd.get()), context)
  416. installationStep = 'diff'
  417. notify({
  418. title: 'Plugin invoked successfully',
  419. message: `Plugin ${id} invoked successfully`,
  420. icon: 'done'
  421. })
  422. return getInstallation(context)
  423. })
  424. }
  425. function finishInstall (context) {
  426. installationStep = null
  427. currentPluginId = null
  428. return getInstallation(context)
  429. }
  430. async function initPrompts (id, context) {
  431. await prompts.reset()
  432. try {
  433. let data = require(path.join(dependencies.getPath({ id, file: cwd.get() }), 'prompts'))
  434. if (typeof data === 'function') {
  435. data = await data()
  436. }
  437. data.forEach(prompts.add)
  438. } catch (e) {
  439. console.warn(`No prompts found for ${id}`)
  440. }
  441. await prompts.start()
  442. }
  443. function update ({ id, full }, context) {
  444. return progress.wrap('plugin-update', context, async setProgress => {
  445. setProgress({
  446. status: 'plugin-update',
  447. args: [id]
  448. })
  449. currentPluginId = id
  450. const plugin = findOne({ id, file: cwd.get() }, context)
  451. const { current, wanted, localPath } = await dependencies.getVersion(plugin, context)
  452. if (localPath) {
  453. await updateLocalPackage({ cwd: cwd.get(), id, localPath, full }, context)
  454. } else {
  455. const pm = new PackageManager({ context: cwd.get() })
  456. await pm.upgrade(id)
  457. }
  458. logs.add({
  459. message: `Plugin ${id} updated from ${current} to ${wanted}`,
  460. type: 'info'
  461. }, context)
  462. notify({
  463. title: 'Plugin updated',
  464. message: `Plugin ${id} was successfully updated`,
  465. icon: 'done'
  466. })
  467. await resetPluginApi({ file: cwd.get() }, context)
  468. dependencies.invalidatePackage({ id }, context)
  469. currentPluginId = null
  470. return findOne({ id, file: cwd.get() }, context)
  471. })
  472. }
  473. async function updateLocalPackage ({ id, cwd, localPath, full = true }, context) {
  474. const from = path.resolve(cwd, localPath)
  475. const to = path.resolve(cwd, 'node_modules', ...id.split('/'))
  476. let filterRegEx
  477. if (full) {
  478. await fs.remove(to)
  479. filterRegEx = /\.git/
  480. } else {
  481. filterRegEx = /(\.git|node_modules)/
  482. }
  483. await fs.copy(from, to, {
  484. filter: (file) => !file.match(filterRegEx)
  485. })
  486. }
  487. async function updateAll (context) {
  488. return progress.wrap('plugins-update', context, async setProgress => {
  489. const plugins = await list(cwd.get(), context, { resetApi: false })
  490. const updatedPlugins = []
  491. for (const plugin of plugins) {
  492. const version = await dependencies.getVersion(plugin, context)
  493. if (version.current !== version.wanted) {
  494. updatedPlugins.push(plugin)
  495. dependencies.invalidatePackage({ id: plugin.id }, context)
  496. }
  497. }
  498. if (!updatedPlugins.length) {
  499. notify({
  500. title: 'No updates available',
  501. message: 'No plugin to update in the version ranges declared in package.json',
  502. icon: 'done'
  503. })
  504. return []
  505. }
  506. setProgress({
  507. status: 'plugins-update',
  508. args: [updatedPlugins.length]
  509. })
  510. const pm = new PackageManager({ context: cwd.get() })
  511. await pm.upgrade(updatedPlugins.map(p => p.id).join(' '))
  512. notify({
  513. title: 'Plugins updated',
  514. message: `${updatedPlugins.length} plugin(s) were successfully updated`,
  515. icon: 'done'
  516. })
  517. await resetPluginApi({ file: cwd.get() }, context)
  518. return updatedPlugins
  519. })
  520. }
  521. async function callAction ({ id, params, file = cwd.get() }, context) {
  522. const pluginApi = getApi(file)
  523. context.pubsub.publish(channels.PLUGIN_ACTION_CALLED, {
  524. pluginActionCalled: { id, params }
  525. })
  526. log('PluginAction called', id, params)
  527. const results = []
  528. const errors = []
  529. const list = pluginApi.actions.get(id)
  530. if (list) {
  531. for (const cb of list) {
  532. let result = null
  533. let error = null
  534. try {
  535. result = await cb(params)
  536. } catch (e) {
  537. error = e
  538. }
  539. results.push(result)
  540. errors.push(error)
  541. }
  542. }
  543. context.pubsub.publish(channels.PLUGIN_ACTION_RESOLVED, {
  544. pluginActionResolved: { id, params, results, errors }
  545. })
  546. log('PluginAction resolved', id, params, 'results:', results, 'errors:', errors)
  547. return { id, params, results, errors }
  548. }
  549. function serveFile ({ pluginId, projectId = null, file }, res) {
  550. let baseFile = cwd.get()
  551. if (projectId) {
  552. const projects = require('./projects')
  553. const project = projects.findOne(projectId, getContext())
  554. if (project) {
  555. baseFile = project.path
  556. }
  557. }
  558. if (pluginId) {
  559. const basePath = pluginId === '.' ? baseFile : dependencies.getPath({ id: decodeURIComponent(pluginId), file: baseFile })
  560. if (basePath) {
  561. res.sendFile(path.join(basePath, file))
  562. return
  563. }
  564. } else {
  565. console.log('serve issue', 'pluginId:', pluginId, 'projectId:', projectId, 'file:', file)
  566. }
  567. res.status(404)
  568. res.send('Addon not found in loaded addons. Try opening a vue-cli project first?')
  569. }
  570. function serve (req, res) {
  571. const { id: pluginId, 0: file } = req.params
  572. serveFile({ pluginId, file: path.join('ui-public', file) }, res)
  573. }
  574. function serveLogo (req, res) {
  575. const { id: pluginId } = req.params
  576. const { project: projectId } = req.query
  577. serveFile({ pluginId, projectId, file: 'logo.png' }, res)
  578. }
  579. module.exports = {
  580. list,
  581. findOne,
  582. getLogo,
  583. getInstallation,
  584. install,
  585. installLocal,
  586. uninstall,
  587. update,
  588. updateAll,
  589. runInvoke,
  590. resetPluginApi,
  591. getApi,
  592. finishInstall,
  593. callAction,
  594. callHook,
  595. serve,
  596. serveLogo
  597. }