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