run-task.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. /**
  2. * @module run-task
  3. * @author Toru Nagashima
  4. * @copyright 2015 Toru Nagashima. All rights reserved.
  5. * See LICENSE file in root directory for full license.
  6. */
  7. 'use strict'
  8. // ------------------------------------------------------------------------------
  9. // Requirements
  10. // ------------------------------------------------------------------------------
  11. const fs = require('fs')
  12. const path = require('path')
  13. const ansiStylesPromise = import('ansi-styles')
  14. const parseArgs = require('shell-quote').parse
  15. const which = require('which')
  16. const createHeader = require('./create-header')
  17. const createPrefixTransform = require('./create-prefix-transform-stream')
  18. const spawn = require('./spawn')
  19. // ------------------------------------------------------------------------------
  20. // Helpers
  21. // ------------------------------------------------------------------------------
  22. const colors = ['cyan', 'green', 'magenta', 'yellow', 'red']
  23. let colorIndex = 0
  24. const taskNamesToColors = new Map()
  25. /**
  26. * Select a color from given task name.
  27. *
  28. * @param {string} taskName - The task name.
  29. * @returns {function} A colorize function that provided by `chalk`
  30. */
  31. function selectColor (taskName) {
  32. let color = taskNamesToColors.get(taskName)
  33. if (!color) {
  34. color = colors[colorIndex]
  35. colorIndex = (colorIndex + 1) % colors.length
  36. taskNamesToColors.set(taskName, color)
  37. }
  38. return color
  39. }
  40. /**
  41. * Wraps stdout/stderr with a transform stream to add the task name as prefix.
  42. *
  43. * @param {string} taskName - The task name.
  44. * @param {stream.Writable} source - An output stream to be wrapped.
  45. * @param {object} labelState - An label state for the transform stream.
  46. * @returns {stream.Writable} `source` or the created wrapped stream.
  47. */
  48. function wrapLabeling (taskName, source, labelState, ansiStyles) {
  49. if (source == null || !labelState.enabled) {
  50. return source
  51. }
  52. const label = taskName.padEnd(labelState.width)
  53. const color = source.isTTY ? ansiStyles[selectColor(taskName)] : { open: '', close: '' }
  54. const prefix = `${color.open}[${label}]${color.close} `
  55. const stream = createPrefixTransform(prefix, labelState)
  56. stream.pipe(source)
  57. return stream
  58. }
  59. /**
  60. * Converts a given stream to an option for `child_process.spawn`.
  61. *
  62. * @param {stream.Readable|stream.Writable|null} stream - An original stream to convert.
  63. * @param {process.stdin|process.stdout|process.stderr} std - A standard stream for this option.
  64. * @returns {string|stream.Readable|stream.Writable} An option for `child_process.spawn`.
  65. */
  66. function detectStreamKind (stream, std) {
  67. return (
  68. stream == null
  69. ? 'ignore' // `|| !std.isTTY` is needed for the workaround of https://github.com/nodejs/node/issues/5620
  70. : stream !== std || !std.isTTY
  71. ? 'pipe'
  72. : stream
  73. )
  74. }
  75. /**
  76. * Ensure the output of shell-quote's `parse()` is acceptable input to npm-cli.
  77. *
  78. * The `parse()` method of shell-quote sometimes returns special objects in its
  79. * output array, e.g. if it thinks some elements should be globbed. But npm-cli
  80. * only accepts strings and will throw an error otherwise.
  81. *
  82. * See https://github.com/substack/node-shell-quote#parsecmd-env
  83. *
  84. * @param {object|string} arg - Item in the output of shell-quote's `parse()`.
  85. * @returns {string} A valid argument for npm-cli.
  86. */
  87. function cleanTaskArg (arg) {
  88. return arg.pattern || arg.op || arg
  89. }
  90. // ------------------------------------------------------------------------------
  91. // Interface
  92. // ------------------------------------------------------------------------------
  93. /**
  94. * Run a npm-script of a given name.
  95. * The return value is a promise which has an extra method: `abort()`.
  96. * The `abort()` kills the child process to run the npm-script.
  97. *
  98. * @param {string} task - A npm-script name to run.
  99. * @param {object} options - An option object.
  100. * @param {stream.Readable|null} options.stdin -
  101. * A readable stream to send messages to stdin of child process.
  102. * If this is `null`, ignores it.
  103. * If this is `process.stdin`, inherits it.
  104. * Otherwise, makes a pipe.
  105. * @param {stream.Writable|null} options.stdout -
  106. * A writable stream to receive messages from stdout of child process.
  107. * If this is `null`, cannot send.
  108. * If this is `process.stdout`, inherits it.
  109. * Otherwise, makes a pipe.
  110. * @param {stream.Writable|null} options.stderr -
  111. * A writable stream to receive messages from stderr of child process.
  112. * If this is `null`, cannot send.
  113. * If this is `process.stderr`, inherits it.
  114. * Otherwise, makes a pipe.
  115. * @param {string[]} options.prefixOptions -
  116. * An array of options which are inserted before the task name.
  117. * @param {object} options.labelState - A state object for printing labels.
  118. * @param {boolean} options.printName - The flag to print task names before running each task.
  119. * @param {object} options.packageInfo - A package.json's information.
  120. * @param {object} options.packageInfo.body - A package.json's JSON object.
  121. * @param {string} options.packageInfo.path - A package.json's file path.
  122. * @returns {Promise}
  123. * A promise object which becomes fullfilled when the npm-script is completed.
  124. * This promise object has an extra method: `abort()`.
  125. * @private
  126. */
  127. module.exports = function runTask (task, options) {
  128. let cp = null
  129. async function asyncRunTask () {
  130. const { default: ansiStyles } = await ansiStylesPromise
  131. const stdin = options.stdin
  132. const stdout = wrapLabeling(task, options.stdout, options.labelState, ansiStyles)
  133. const stderr = wrapLabeling(task, options.stderr, options.labelState, ansiStyles)
  134. const stdinKind = detectStreamKind(stdin, process.stdin)
  135. const stdoutKind = detectStreamKind(stdout, process.stdout)
  136. const stderrKind = detectStreamKind(stderr, process.stderr)
  137. const spawnOptions = { stdio: [stdinKind, stdoutKind, stderrKind] }
  138. // Print task name.
  139. if (options.printName && stdout != null) {
  140. stdout.write(createHeader(
  141. task,
  142. options.packageInfo,
  143. options.stdout.isTTY,
  144. ansiStyles
  145. ))
  146. }
  147. // Execute.
  148. let npmPath = options.npmPath
  149. if (!npmPath && process.env.npm_execpath) {
  150. const basename = path.basename(process.env.npm_execpath)
  151. let newBasename = basename
  152. if (basename.startsWith('npx')) {
  153. newBasename = basename.replace('npx', 'npm')
  154. } else if (basename.startsWith('pnpx')) {
  155. newBasename = basename.replace('pnpx', 'pnpm')
  156. }
  157. npmPath = newBasename !== basename
  158. ? path.join(path.dirname(process.env.npm_execpath), newBasename)
  159. : process.env.npm_execpath
  160. }
  161. const npmPathIsJs = typeof npmPath === 'string' && /\.(c|m)?js/.test(path.extname(npmPath))
  162. let execPath = (npmPathIsJs ? process.execPath : npmPath || 'npm')
  163. if (!npmPath && !process.env.npm_execpath) {
  164. // When a script is being run via pnpm, npmPath and npm_execpath will be null or undefined
  165. // Attempt to figure out whether we're running via pnpm
  166. const projectRoot = path.dirname(options.packageInfo.path)
  167. const hasPnpmLockfile = fs.existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))
  168. const whichPnpmResults = await which('pnpm', { nothrow: true })
  169. const pnpmFound = whichPnpmResults?.status
  170. const pnpmWhichOutput = whichPnpmResults?.output
  171. if (hasPnpmLockfile && __dirname.split(path.delimiter).includes('.pnpm') && pnpmFound) {
  172. execPath = pnpmWhichOutput
  173. }
  174. }
  175. const isYarn = process.env.npm_config_user_agent && process.env.npm_config_user_agent.startsWith('yarn')
  176. const isPnpm = Boolean(process.env.PNPM_SCRIPT_SRC_DIR)
  177. const isNpm = !isYarn && !isPnpm
  178. const spawnArgs = ['run']
  179. if (npmPathIsJs) {
  180. spawnArgs.unshift(npmPath)
  181. }
  182. if (isNpm) {
  183. Array.prototype.push.apply(spawnArgs, options.prefixOptions)
  184. } else if (options.prefixOptions.indexOf('--silent') !== -1) {
  185. spawnArgs.push('--silent')
  186. }
  187. Array.prototype.push.apply(spawnArgs, parseArgs(task).map(cleanTaskArg))
  188. cp = spawn(execPath, spawnArgs, spawnOptions)
  189. // Piping stdio.
  190. if (stdinKind === 'pipe') {
  191. stdin.pipe(cp.stdin)
  192. }
  193. if (stdoutKind === 'pipe') {
  194. cp.stdout.pipe(stdout, { end: false })
  195. }
  196. if (stderrKind === 'pipe') {
  197. cp.stderr.pipe(stderr, { end: false })
  198. }
  199. return new Promise((resolve, reject) => {
  200. // Register
  201. cp.on('error', (err) => {
  202. cp = null
  203. reject(err)
  204. })
  205. cp.on('close', (code, signal) => {
  206. cp = null
  207. resolve({ task, code, signal })
  208. })
  209. })
  210. }
  211. const promise = asyncRunTask()
  212. promise.abort = function abort () {
  213. if (cp != null) {
  214. cp.kill()
  215. cp = null
  216. }
  217. }
  218. return promise
  219. }