tasks.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. const { chalk, execa } = require('@vue/cli-shared-utils')
  2. // Subs
  3. const channels = require('../channels')
  4. // Connectors
  5. const cwd = require('./cwd')
  6. const folders = require('./folders')
  7. const logs = require('./logs')
  8. const plugins = require('./plugins')
  9. const prompts = require('./prompts')
  10. const views = require('./views')
  11. const projects = require('./projects')
  12. // Utils
  13. const { log } = require('../util/logger')
  14. const { notify } = require('../util/notification')
  15. const { terminate } = require('../util/terminate')
  16. const { parseArgs } = require('../util/parse-args')
  17. const MAX_LOGS = 2000
  18. const VIEW_ID = 'vue-project-tasks'
  19. const WIN_ENOENT_THRESHOLD = 500 // ms
  20. const tasks = new Map()
  21. function getTasks (file = null) {
  22. if (!file) file = cwd.get()
  23. let list = tasks.get(file)
  24. if (!list) {
  25. list = []
  26. tasks.set(file, list)
  27. }
  28. return list
  29. }
  30. async function list ({ file = null, api = true } = {}, context) {
  31. if (!file) file = cwd.get()
  32. let list = getTasks(file)
  33. const pkg = folders.readPackage(file, context)
  34. if (pkg.scripts) {
  35. const existing = new Map()
  36. if (projects.getType(file, context) === 'vue') {
  37. await plugins.list(file, context, { resetApi: false, lightApi: true })
  38. }
  39. const pluginApi = api && plugins.getApi(file)
  40. // Get current valid tasks in project `package.json`
  41. const scriptKeys = Object.keys(pkg.scripts)
  42. let currentTasks = scriptKeys.map(
  43. name => {
  44. const id = `${file}:${name}`
  45. existing.set(id, true)
  46. const command = pkg.scripts[name]
  47. const moreData = pluginApi ? pluginApi.getDescribedTask(command) : null
  48. return {
  49. id,
  50. name,
  51. command,
  52. index: list.findIndex(t => t.id === id),
  53. prompts: [],
  54. views: [],
  55. path: file,
  56. ...moreData
  57. }
  58. }
  59. )
  60. if (api && pluginApi) {
  61. currentTasks = currentTasks.concat(plugins.getApi(file).addedTasks.map(
  62. task => {
  63. const id = `${file}:${task.name}`
  64. existing.set(id, true)
  65. return {
  66. id,
  67. index: list.findIndex(t => t.id === id),
  68. prompts: [],
  69. views: [],
  70. path: file,
  71. uiOnly: true,
  72. ...task
  73. }
  74. }
  75. ))
  76. }
  77. // Process existing tasks
  78. const existingTasks = currentTasks.filter(
  79. task => task.index !== -1
  80. )
  81. // Update tasks data
  82. existingTasks.forEach(task => {
  83. Object.assign(list[task.index], task)
  84. })
  85. // Process removed tasks
  86. const removedTasks = list.filter(
  87. t => currentTasks.findIndex(c => c.id === t.id) === -1
  88. )
  89. // Remove badges
  90. removedTasks.forEach(task => {
  91. updateViewBadges({ task }, context)
  92. })
  93. // Process new tasks
  94. const newTasks = currentTasks.filter(
  95. task => task.index === -1
  96. ).map(
  97. task => ({
  98. ...task,
  99. status: 'idle',
  100. child: null,
  101. logs: []
  102. })
  103. )
  104. // Keep existing running tasks
  105. list = list.filter(
  106. task => existing.get(task.id) ||
  107. task.status === 'running'
  108. )
  109. // Add the new tasks
  110. list = list.concat(newTasks)
  111. // Sort
  112. const getSortScore = task => {
  113. const index = scriptKeys.indexOf(task.name)
  114. if (index !== -1) return index
  115. return Infinity
  116. }
  117. list.sort((a, b) => getSortScore(a) - getSortScore(b))
  118. tasks.set(file, list)
  119. }
  120. return list
  121. }
  122. function findOne (id, context) {
  123. for (const [, list] of tasks) {
  124. const result = list.find(t => t.id === id)
  125. if (result) return result
  126. }
  127. }
  128. function getSavedData (id, context) {
  129. let data = context.db.get('tasks').find({
  130. id
  131. }).value()
  132. // Clone
  133. if (data != null) data = JSON.parse(JSON.stringify(data))
  134. return data
  135. }
  136. function updateSavedData (data, context) {
  137. if (getSavedData(data.id, context)) {
  138. context.db.get('tasks').find({ id: data.id }).assign(data).write()
  139. } else {
  140. context.db.get('tasks').push(data).write()
  141. }
  142. }
  143. function getPrompts (id, context) {
  144. return restoreParameters({ id }, context)
  145. }
  146. function updateOne (data, context) {
  147. const task = findOne(data.id)
  148. if (task) {
  149. if (task.status !== data.status) {
  150. updateViewBadges({
  151. task,
  152. data
  153. }, context)
  154. }
  155. Object.assign(task, data)
  156. context.pubsub.publish(channels.TASK_CHANGED, {
  157. taskChanged: task
  158. })
  159. }
  160. return task
  161. }
  162. function updateViewBadges ({ task, data }, context) {
  163. const viewId = VIEW_ID
  164. // New badges
  165. if (data) {
  166. if (data.status === 'error') {
  167. views.addBadge({
  168. viewId,
  169. badge: {
  170. id: 'vue-task-error',
  171. type: 'error',
  172. label: 'org.vue.components.view-badge.labels.tasks.error',
  173. priority: 3
  174. }
  175. }, context)
  176. } else if (data.status === 'running') {
  177. views.addBadge({
  178. viewId,
  179. badge: {
  180. id: 'vue-task-running',
  181. type: 'info',
  182. label: 'org.vue.components.view-badge.labels.tasks.running',
  183. priority: 2
  184. }
  185. }, context)
  186. } else if (data.status === 'done') {
  187. views.addBadge({
  188. viewId,
  189. badge: {
  190. id: 'vue-task-done',
  191. type: 'success',
  192. label: 'org.vue.components.view-badge.labels.tasks.done',
  193. priority: 1,
  194. hidden: true
  195. }
  196. }, context)
  197. }
  198. }
  199. // Remove previous badges
  200. if (task.status === 'error') {
  201. views.removeBadge({ viewId, badgeId: 'vue-task-error' }, context)
  202. } else if (task.status === 'running') {
  203. views.removeBadge({ viewId, badgeId: 'vue-task-running' }, context)
  204. } else if (task.status === 'done') {
  205. views.removeBadge({ viewId, badgeId: 'vue-task-done' }, context)
  206. }
  207. }
  208. async function run (id, context) {
  209. const task = findOne(id, context)
  210. if (task && task.status !== 'running') {
  211. task._terminating = false
  212. // Answers
  213. const answers = prompts.getAnswers()
  214. let [command, ...args] = parseArgs(task.command)
  215. // Output colors
  216. // See: https://www.npmjs.com/package/supports-color
  217. process.env.FORCE_COLOR = 1
  218. // Plugin API
  219. if (task.onBeforeRun) {
  220. if (!answers.$_overrideArgs) {
  221. const origPush = args.push.bind(args)
  222. args.push = (...items) => {
  223. if (items.length && args.indexOf(items[0]) !== -1) return items.length
  224. return origPush(...items)
  225. }
  226. }
  227. await task.onBeforeRun({
  228. answers,
  229. args
  230. })
  231. }
  232. // Deduplicate arguments
  233. const dedupedArgs = []
  234. for (let i = args.length - 1; i >= 0; i--) {
  235. const arg = args[i]
  236. if (typeof arg === 'string' && arg.indexOf('--') === 0) {
  237. if (dedupedArgs.indexOf(arg) === -1) {
  238. dedupedArgs.push(arg)
  239. } else {
  240. const value = args[i + 1]
  241. if (value && value.indexOf('--') !== 0) {
  242. dedupedArgs.pop()
  243. }
  244. }
  245. } else {
  246. dedupedArgs.push(arg)
  247. }
  248. }
  249. args = dedupedArgs.reverse()
  250. if (command === 'npm') {
  251. args.splice(0, 0, '--')
  252. }
  253. log('Task run', command, args)
  254. updateOne({
  255. id: task.id,
  256. status: 'running'
  257. }, context)
  258. logs.add({
  259. message: `Task ${task.id} started`,
  260. type: 'info'
  261. }, context)
  262. addLog({
  263. taskId: task.id,
  264. type: 'stdout',
  265. text: chalk.grey(`$ ${command} ${args.join(' ')}`)
  266. }, context)
  267. task.time = Date.now()
  268. // Task env
  269. process.env.VUE_CLI_CONTEXT = cwd.get()
  270. process.env.VUE_CLI_PROJECT_ID = projects.getCurrent(context).id
  271. const nodeEnv = process.env.NODE_ENV
  272. delete process.env.NODE_ENV
  273. const child = execa(command, args, {
  274. cwd: cwd.get(),
  275. stdio: ['inherit', 'pipe', 'pipe'],
  276. shell: true
  277. })
  278. if (typeof nodeEnv !== 'undefined') {
  279. process.env.NODE_ENV = nodeEnv
  280. }
  281. task.child = child
  282. const outPipe = logPipe(queue => {
  283. addLog({
  284. taskId: task.id,
  285. type: 'stdout',
  286. text: queue
  287. }, context)
  288. })
  289. child.stdout.on('data', buffer => {
  290. outPipe.add(buffer.toString())
  291. })
  292. const errPipe = logPipe(queue => {
  293. addLog({
  294. taskId: task.id,
  295. type: 'stderr',
  296. text: queue
  297. }, context)
  298. })
  299. child.stderr.on('data', buffer => {
  300. errPipe.add(buffer.toString())
  301. })
  302. const onExit = async (code, signal) => {
  303. outPipe.flush()
  304. errPipe.flush()
  305. log('Task exit', command, args, 'code:', code, 'signal:', signal)
  306. const duration = Date.now() - task.time
  307. const seconds = Math.round(duration / 10) / 100
  308. addLog({
  309. taskId: task.id,
  310. type: 'stdout',
  311. text: chalk.grey(`Total task duration: ${seconds}s`)
  312. }, context)
  313. // Plugin API
  314. if (task.onExit) {
  315. await task.onExit({
  316. args,
  317. child,
  318. cwd: cwd.get(),
  319. code,
  320. signal
  321. })
  322. }
  323. if (code === null || task._terminating) {
  324. updateOne({
  325. id: task.id,
  326. status: 'terminated'
  327. }, context)
  328. logs.add({
  329. message: `Task ${task.id} was terminated`,
  330. type: 'info'
  331. }, context)
  332. } else if (code !== 0) {
  333. updateOne({
  334. id: task.id,
  335. status: 'error'
  336. }, context)
  337. logs.add({
  338. message: `Task ${task.id} ended with error code ${code}`,
  339. type: 'error'
  340. }, context)
  341. notify({
  342. title: 'Task error',
  343. message: `Task ${task.id} ended with error code ${code}`,
  344. icon: 'error'
  345. })
  346. } else {
  347. updateOne({
  348. id: task.id,
  349. status: 'done'
  350. }, context)
  351. logs.add({
  352. message: `Task ${task.id} completed`,
  353. type: 'done'
  354. }, context)
  355. notify({
  356. title: 'Task completed',
  357. message: `Task ${task.id} completed in ${seconds}s.`,
  358. icon: 'done'
  359. })
  360. }
  361. plugins.callHook({
  362. id: 'taskExit',
  363. args: [{
  364. task,
  365. args,
  366. child,
  367. cwd: cwd.get(),
  368. signal,
  369. code
  370. }],
  371. file: cwd.get()
  372. }, context)
  373. }
  374. child.on('exit', onExit)
  375. child.on('error', error => {
  376. const duration = Date.now() - task.time
  377. // hackish workaround for https://github.com/vuejs/vue-cli/issues/2096
  378. if (process.platform === 'win32' && error.code === 'ENOENT' && duration > WIN_ENOENT_THRESHOLD) {
  379. return onExit(null)
  380. }
  381. updateOne({
  382. id: task.id,
  383. status: 'error'
  384. }, context)
  385. logs.add({
  386. message: `Error while running task ${task.id} with message'${error.message}'`,
  387. type: 'error'
  388. }, context)
  389. notify({
  390. title: 'Task error',
  391. message: `Error while running task ${task.id} with message'${error.message}'`,
  392. icon: 'error'
  393. })
  394. addLog({
  395. taskId: task.id,
  396. type: 'stdout',
  397. text: chalk.red(`Error while running task ${task.id} with message '${error.message}'`)
  398. }, context)
  399. console.error(error)
  400. })
  401. // Plugin API
  402. if (task.onRun) {
  403. await task.onRun({
  404. args,
  405. child,
  406. cwd: cwd.get()
  407. })
  408. }
  409. plugins.callHook({
  410. id: 'taskRun',
  411. args: [{
  412. task,
  413. args,
  414. child,
  415. cwd: cwd.get()
  416. }],
  417. file: cwd.get()
  418. }, context)
  419. }
  420. return task
  421. }
  422. async function stop (id, context) {
  423. const task = findOne(id, context)
  424. if (task && task.status === 'running' && task.child) {
  425. task._terminating = true
  426. try {
  427. const { success, error } = await terminate(task.child, cwd.get())
  428. if (success) {
  429. updateOne({
  430. id: task.id,
  431. status: 'terminated'
  432. }, context)
  433. } else if (error) {
  434. throw error
  435. } else {
  436. throw new Error('Unknown error')
  437. }
  438. } catch (e) {
  439. console.log(chalk.red(`Can't terminate process ${task.child.pid}`))
  440. console.error(e)
  441. }
  442. }
  443. return task
  444. }
  445. function addLog (log, context) {
  446. const task = findOne(log.taskId, context)
  447. if (task) {
  448. if (task.logs.length === MAX_LOGS) {
  449. task.logs.shift()
  450. }
  451. task.logs.push(log)
  452. context.pubsub.publish(channels.TASK_LOG_ADDED, {
  453. taskLogAdded: log
  454. })
  455. }
  456. }
  457. function clearLogs (id, context) {
  458. const task = findOne(id, context)
  459. if (task) {
  460. task.logs = []
  461. }
  462. return task
  463. }
  464. function open (id, context) {
  465. const task = findOne(id, context)
  466. plugins.callHook({
  467. id: 'taskOpen',
  468. args: [{
  469. task,
  470. cwd: cwd.get()
  471. }],
  472. file: cwd.get()
  473. }, context)
  474. return true
  475. }
  476. function logPipe (action) {
  477. const maxTime = 300
  478. let queue = ''
  479. let size = 0
  480. let time = Date.now()
  481. let timeout
  482. const add = (string) => {
  483. queue += string
  484. size++
  485. if (size === 50 || Date.now() > time + maxTime) {
  486. flush()
  487. } else {
  488. clearTimeout(timeout)
  489. timeout = setTimeout(flush, maxTime)
  490. }
  491. }
  492. const flush = () => {
  493. clearTimeout(timeout)
  494. if (!size) return
  495. action(queue)
  496. queue = ''
  497. size = 0
  498. time = Date.now()
  499. }
  500. return {
  501. add,
  502. flush
  503. }
  504. }
  505. function saveParameters ({ id }, context) {
  506. // Answers
  507. const answers = prompts.getAnswers()
  508. // Save parameters
  509. updateSavedData({
  510. id,
  511. answers
  512. }, context)
  513. return prompts.list()
  514. }
  515. async function restoreParameters ({ id }, context) {
  516. const task = findOne(id, context)
  517. if (task) {
  518. await prompts.reset()
  519. if (task.prompts.length) {
  520. prompts.add({
  521. name: '$_overrideArgs',
  522. type: 'confirm',
  523. default: false,
  524. message: 'org.vue.views.project-task-details.override-args.message',
  525. description: 'org.vue.views.project-task-details.override-args.description'
  526. })
  527. }
  528. task.prompts.forEach(prompts.add)
  529. const data = getSavedData(id, context)
  530. if (data) {
  531. await prompts.setAnswers(data.answers)
  532. }
  533. await prompts.start()
  534. }
  535. return prompts.list()
  536. }
  537. module.exports = {
  538. list,
  539. findOne,
  540. getPrompts,
  541. run,
  542. stop,
  543. updateOne,
  544. clearLogs,
  545. open,
  546. saveParameters,
  547. restoreParameters
  548. }