index.js 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. /**
  2. * Copyright (c) 2015-present, Facebook, Inc.
  3. *
  4. * This source code is licensed under the MIT license found in the
  5. * LICENSE file at
  6. * https://github.com/facebookincubator/create-react-app/blob/master/LICENSE
  7. *
  8. * Modified by Yuxi Evan You
  9. */
  10. const fs = require('fs')
  11. const os = require('os')
  12. const path = require('path')
  13. const colors = require('picocolors')
  14. const childProcess = require('child_process')
  15. const guessEditor = require('./guess')
  16. const getArgumentsForPosition = require('./get-args')
  17. function wrapErrorCallback(cb) {
  18. return (fileName, errorMessage) => {
  19. console.log()
  20. console.log(colors.red('Could not open ' + path.basename(fileName) + ' in the editor.'))
  21. if (errorMessage) {
  22. if (errorMessage[errorMessage.length - 1] !== '.') {
  23. errorMessage += '.'
  24. }
  25. console.log(colors.red('The editor process exited with an error: ' + errorMessage))
  26. }
  27. console.log()
  28. if (cb) cb(fileName, errorMessage)
  29. }
  30. }
  31. function isTerminalEditor(editor) {
  32. switch (editor) {
  33. case 'vim':
  34. case 'emacs':
  35. case 'nano':
  36. return true
  37. }
  38. return false
  39. }
  40. const positionRE = /:(\d+)(:(\d+))?$/
  41. function parseFile(file) {
  42. // support `file://` protocol
  43. if (file.startsWith('file://')) {
  44. file = require('url').fileURLToPath(file)
  45. }
  46. const fileName = file.replace(positionRE, '')
  47. const match = file.match(positionRE)
  48. const lineNumber = match && match[1]
  49. const columnNumber = match && match[3]
  50. return {
  51. fileName,
  52. lineNumber,
  53. columnNumber,
  54. }
  55. }
  56. let currentChildProcess = null
  57. function launchEditor(file, specifiedEditor, onErrorCallback) {
  58. const parsed = parseFile(file)
  59. let { fileName } = parsed
  60. const { lineNumber, columnNumber } = parsed
  61. if (process.platform === 'win32' && path.resolve(fileName).startsWith('\\\\')) {
  62. return onErrorCallback(
  63. fileName,
  64. 'UNC paths are not supported on Windows to avoid security issues. ' +
  65. 'See https://github.com/vitejs/launch-editor/tree/main/packages/launch-editor#unc-paths-on-windows for details.',
  66. )
  67. }
  68. if (!fs.existsSync(fileName)) {
  69. return
  70. }
  71. if (typeof specifiedEditor === 'function') {
  72. onErrorCallback = specifiedEditor
  73. specifiedEditor = undefined
  74. }
  75. onErrorCallback = wrapErrorCallback(onErrorCallback)
  76. const [editor, ...args] = guessEditor(specifiedEditor)
  77. if (!editor) {
  78. onErrorCallback(fileName, null)
  79. return
  80. }
  81. if (
  82. process.platform === 'linux' &&
  83. fileName.startsWith('/mnt/') &&
  84. /Microsoft/i.test(os.release())
  85. ) {
  86. // Assume WSL / "Bash on Ubuntu on Windows" is being used, and
  87. // that the file exists on the Windows file system.
  88. // `os.release()` is "4.4.0-43-Microsoft" in the current release
  89. // build of WSL, see: https://github.com/Microsoft/BashOnWindows/issues/423#issuecomment-221627364
  90. // When a Windows editor is specified, interop functionality can
  91. // handle the path translation, but only if a relative path is used.
  92. fileName = path.relative('', fileName)
  93. }
  94. if (lineNumber) {
  95. const extraArgs = getArgumentsForPosition(editor, fileName, lineNumber, columnNumber)
  96. args.push.apply(args, extraArgs)
  97. } else {
  98. args.push(fileName)
  99. }
  100. if (currentChildProcess && isTerminalEditor(editor)) {
  101. // There's an existing editor process already and it's attached
  102. // to the terminal, so go kill it. Otherwise two separate editor
  103. // instances attach to the stdin/stdout which gets confusing.
  104. currentChildProcess.kill('SIGKILL')
  105. }
  106. if (process.platform === 'win32') {
  107. // On Windows, we need to use `exec` with the `shell: true` option,
  108. // and some more sanitization is required.
  109. // However, CMD.exe on Windows is vulnerable to RCE attacks given a file name of the
  110. // form "C:\Users\myusername\Downloads\& curl 172.21.93.52".
  111. // `create-react-app` used a safe file name pattern to validate user-provided file names:
  112. // - https://github.com/facebook/create-react-app/pull/4866
  113. // - https://github.com/facebook/create-react-app/pull/5431
  114. // But that's not a viable solution for this package because
  115. // it's depended on by so many meta frameworks that heavily rely on
  116. // special characters in file names for filesystem-based routing.
  117. // We need to at least:
  118. // - Support `+` because it's used in SvelteKit and Vike
  119. // - Support `$` because it's used in Remix
  120. // - Support `(` and `)` because they are used in Analog, SolidStart, and Vike
  121. // - Support `@` because it's used in Vike
  122. // - Support `[` and `]` because they are widely used for [slug]
  123. // So here we choose to use `^` to escape special characters instead.
  124. // According to https://ss64.com/nt/syntax-esc.html,
  125. // we can use `^` to escape `&`, `<`, `>`, `|`, `%`, and `^`
  126. // I'm not sure if we have to escape all of these, but let's do it anyway
  127. function escapeCmdArgs(cmdArgs) {
  128. return cmdArgs.replace(/([&|<>,;=^])/g, '^$1')
  129. }
  130. // Need to double quote the editor path in case it contains spaces;
  131. // If the fileName contains spaces, we also need to double quote it in the arguments
  132. // However, there's a case that it's concatenated with line number and column number
  133. // which is separated by `:`. We need to double quote the whole string in this case.
  134. // Also, if the string contains the escape character `^`, it needs to be quoted, too.
  135. function doubleQuoteIfNeeded(str) {
  136. if (str.includes('^')) {
  137. // If a string includes an escaped character, not only does it need to be quoted,
  138. // but the quotes need to be escaped too.
  139. return `^"${str}^"`
  140. } else if (str.includes(' ')) {
  141. return `"${str}"`
  142. }
  143. return str
  144. }
  145. const launchCommand = [editor, ...args.map(escapeCmdArgs)].map(doubleQuoteIfNeeded).join(' ')
  146. currentChildProcess = childProcess.exec(launchCommand, {
  147. stdio: 'inherit',
  148. shell: true,
  149. })
  150. } else {
  151. currentChildProcess = childProcess.spawn(editor, args, { stdio: 'inherit' })
  152. }
  153. currentChildProcess.on('exit', function (errorCode) {
  154. currentChildProcess = null
  155. if (errorCode) {
  156. onErrorCallback(fileName, '(code ' + errorCode + ')')
  157. }
  158. })
  159. currentChildProcess.on('error', function (error) {
  160. let { code, message } = error
  161. if ('ENOENT' === code) {
  162. message = `${message} ('${editor}' command does not exist in 'PATH')`
  163. }
  164. onErrorCallback(fileName, message)
  165. })
  166. }
  167. module.exports = launchEditor